diff --git a/README.md b/README.md index 96f8a9e..a7e63bc 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ php artisan turbo:install --jet --stimulus The package ships with a middleware which applies some conventions on your redirects, specially around how failed validations are handled automatically by Laravel. Read more about this in the [Conventions](#conventions) section of the documentation. -You may add the middleware to the "web" route group on your HTTP Kernel: +**The middleware is automatically prepended to your web route group middleware stack**. You may want to add the middleware to other groups, when doing so, make sure it's at the top of the middleware stack: ```php \Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware::class, @@ -90,8 +90,8 @@ class Kernel extends HttpKernel { protected $middlewareGroups = [ 'web' => [ - // ... \Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware::class, + // other middlewares... ], ]; } diff --git a/src/Facades/Turbo.php b/src/Facades/Turbo.php index cf4c682..ed974e3 100644 --- a/src/Facades/Turbo.php +++ b/src/Facades/Turbo.php @@ -17,6 +17,8 @@ * @method static bool shouldBroadcastToOthers * @method static string domId(Model $model, string $prefix = "") * @method static Broadcaster broadcaster() + * @method static self withoutRegisteringMiddleware() + * @method static bool shouldRegisterMiddleware() */ class Turbo extends Facade { diff --git a/src/Http/Middleware/RouteRedirectGuesser.php b/src/Http/Middleware/RouteRedirectGuesser.php index 6bba7c1..81e731a 100644 --- a/src/Http/Middleware/RouteRedirectGuesser.php +++ b/src/Http/Middleware/RouteRedirectGuesser.php @@ -6,8 +6,12 @@ class RouteRedirectGuesser { - public function guess(string $routeName): string + public function guess(string $routeName): ?string { + if (! Str::endsWith($routeName, '.store') && ! Str::endsWith($routeName, '.update')) { + return null; + } + $creating = Str::endsWith($routeName, '.store'); $lookFor = $creating diff --git a/src/Http/Middleware/TurboMiddleware.php b/src/Http/Middleware/TurboMiddleware.php index 5dde21f..2015979 100644 --- a/src/Http/Middleware/TurboMiddleware.php +++ b/src/Http/Middleware/TurboMiddleware.php @@ -3,11 +3,16 @@ namespace Tonysm\TurboLaravel\Http\Middleware; use Closure; +use Illuminate\Contracts\Http\Kernel; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\Cookie; use Tonysm\TurboLaravel\Facades\Turbo as TurboFacade; use Tonysm\TurboLaravel\Turbo; @@ -16,6 +21,13 @@ class TurboMiddleware /** @var \Tonysm\TurboLaravel\Http\Middleware\RouteRedirectGuesser */ private $redirectGuesser; + /** + * Encrypted cookies to be added to the internal requests following redirects. + * + * @var array + */ + private array $encryptedCookies; + public function __construct(RouteRedirectGuesser $redirectGuesser) { $this->redirectGuesser = $redirectGuesser; @@ -28,6 +40,8 @@ public function __construct(RouteRedirectGuesser $redirectGuesser) */ public function handle($request, Closure $next) { + $this->encryptedCookies = $request->cookies->all(); + if ($this->turboNativeVisit($request)) { TurboFacade::setVisitingFromTurboNative(); } @@ -59,21 +73,79 @@ private function turboResponse($response, Request $request) return $response; } - // Turbo expects a 303 redirect. We are also changing the default behavior of Laravel's failed - // validation redirection to send the user to a page where the form of the current resource - // is rendered (instead of just "back"), since Frames could have been used in many pages. + // We get the response's encrypted cookies and merge them with the + // encrypted cookies of the first request to make sure that are + // sub-sequent request will use the most up-to-date values. + + $responseCookies = collect($response->headers->getCookies()) + ->mapWithKeys(fn (Cookie $cookie) => [$cookie->getName() => $cookie->getValue()]) + ->all(); + + $this->encryptedCookies = array_replace_recursive($this->encryptedCookies, $responseCookies); + + // When throwing a ValidationException and the app uses named routes convention, we can guess + // the form route for the current endpoint, make an internal request there, and return the + // response body with the form over a 422 status code, which is better for Turbo Native. + + if ($response->exception instanceof ValidationException && ($formRedirectUrl = $this->getRedirectUrl($request, $response))) { + $response->setTargetUrl($formRedirectUrl); + + return tap($this->handleRedirectInternally($request, $response), function () use ($request) { + App::instance('request', $request); + Facade::clearResolvedInstance('request'); + }); + } + + return $response->setStatusCode(303); + } + + private function getRedirectUrl($request, $response) + { + if ($response->exception->redirectTo) { + return $response->exception->redirectTo; + } + + return $this->guessFormRedirectUrl($request); + } - $response->setStatusCode(303); + private function kernel(): Kernel + { + return App::make(Kernel::class); + } - if ($response->exception instanceof ValidationException && ! $response->exception->redirectTo) { - $response->setTargetUrl( - $this->guessRedirectingRoute($request) ?: $response->getTargetUrl() + /** + * @param Request $request + * @param Response $response + * + * @return Response + */ + private function handleRedirectInternally($request, $response) + { + $kernel = $this->kernel(); + + do { + $response = $kernel->handle( + $request = $this->createRequestFrom($response->headers->get('Location'), $request) ); + } while ($response->isRedirect()); + + if ($response->isOk()) { + $response->setStatusCode(422); } return $response; } + private function createRequestFrom(string $url, Request $baseRequest) + { + $request = Request::create($url, 'GET'); + + $request->headers->replace($baseRequest->headers->all()); + $request->cookies->replace($this->encryptedCookies); + + return $request; + } + /** * @param \Illuminate\Http\Request $request * @return bool @@ -86,7 +158,7 @@ private function turboVisit($request) /** * @param \Illuminate\Http\Request $request */ - private function guessRedirectingRoute($request) + private function guessFormRedirectUrl($request) { $route = $request->route(); $name = optional($route)->getName(); @@ -99,7 +171,7 @@ private function guessRedirectingRoute($request) // If the guessed route doesn't exist, send it back to wherever Laravel defaults to. - if (! Route::has($formRouteName)) { + if (! $formRouteName || ! Route::has($formRouteName)) { return null; } diff --git a/src/Turbo.php b/src/Turbo.php index 4e65834..6f3bcc9 100644 --- a/src/Turbo.php +++ b/src/Turbo.php @@ -24,6 +24,13 @@ class Turbo */ private bool $broadcastToOthersOnly = false; + /** + * Whether or not the turbo middleware should be automatically added to the "web" middleware group stack. + * + * @var bool + */ + private bool $registerMiddleware = true; + public function isTurboNativeVisit(): bool { return $this->visitFromTurboNative; @@ -36,6 +43,18 @@ public function setVisitingFromTurboNative(): self return $this; } + public function withoutRegisteringMiddleware(): self + { + $this->registerMiddleware = true; + + return $this; + } + + public function shouldRegisterMiddleware(): bool + { + return $this->registerMiddleware; + } + /** * @param bool|Closure $toOthers * diff --git a/src/TurboServiceProvider.php b/src/TurboServiceProvider.php index 00b190d..2891f24 100644 --- a/src/TurboServiceProvider.php +++ b/src/TurboServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Response; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; @@ -14,6 +15,7 @@ use Tonysm\TurboLaravel\Broadcasters\LaravelBroadcaster; use Tonysm\TurboLaravel\Commands\TurboInstallCommand; use Tonysm\TurboLaravel\Facades\Turbo as TurboFacade; +use Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware; use Tonysm\TurboLaravel\Http\MultiplePendingTurboStreamResponse; use Tonysm\TurboLaravel\Http\PendingTurboStreamResponse; use Tonysm\TurboLaravel\Http\TurboResponseFactory; @@ -43,6 +45,10 @@ public function boot() $this->bindBladeMacros(); $this->bindRequestAndResponseMacros(); $this->bindTestResponseMacros(); + + if (TurboFacade::shouldRegisterMiddleware()) { + Route::prependMiddlewareToGroup('web', TurboMiddleware::class); + } } public function register() diff --git a/tests/Http/Middleware/TurboMiddlewareTest.php b/tests/Http/Middleware/TurboMiddlewareTest.php index 11f30d1..ff08085 100644 --- a/tests/Http/Middleware/TurboMiddlewareTest.php +++ b/tests/Http/Middleware/TurboMiddlewareTest.php @@ -15,7 +15,7 @@ class TurboMiddlewareTest extends TestCase public function usesTestModelResourceRoutes() { Route::get('/test-models/create', function () { - return 'show form'; + return 'show create form' . (request()->has('frame') ? ' (frame=' . request('frame') . ')' : ''); })->name('test-models.create'); Route::post('/test-models', function () { @@ -23,7 +23,7 @@ public function usesTestModelResourceRoutes() })->name('test-models.store')->middleware(TurboMiddleware::class); Route::get('/test-models/{testModel}/edit', function () { - return 'show form'; + return 'show edit form' . (request()->has('frame') ? ' (frame=' . request('frame') . ')' : ''); })->name('test-models.edit'); Route::put('/test-models/{testModel}', function (TestModel $model) { @@ -47,14 +47,14 @@ public function doesnt_change_redirect_response_when_not_turbo_visit() * @test * @define-route usesTestModelResourceRoutes */ - public function handles_redirect_responses() + public function handles_invalid_forms_with_an_internal_redirect() { $response = $this->from('/source')->post('/test-models', [], [ 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), ]); - $response->assertRedirect('/test-models/create'); - $response->assertStatus(303); + $response->assertSee('show create form'); + $response->assertStatus(422); } public function usesTurboNativeRoute() @@ -96,7 +96,7 @@ public function usesTestModelRoutesWithCustomRedirect() })->name('somewhere-else'); Route::get('/test-models/create', function () { - return 'show form'; + return 'show create form' . (request()->has('frame') ? ' (frame=' . request('frame') . ')' : ''); })->name('test-models.create'); Route::post('/test-models', function () { @@ -114,22 +114,22 @@ public function respects_the_redirects_to_property_of_the_validation_failed_exce 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), ]); - $response->assertRedirect('/somewhere-else'); - $response->assertStatus(303); + $response->assertSee('show form'); + $response->assertStatus(422); } /** * @test * @define-route usesTestModelResourceRoutes */ - public function redirects_back_to_resource_create_routes_on_failed_validation_follows_laravel_conventions() + public function sends_an_internal_redirect_to_resource_create_routes_on_failed_validation_follows_laravel_conventions_and_returns_422_status_code() { $response = $this->from('/source')->post(route('test-models.store'), [], [ 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), ]); - $response->assertRedirect(route('test-models.create')); - $response->assertStatus(303); + $response->assertSee('show create form'); + $response->assertStatus(422); } /** @@ -144,8 +144,8 @@ public function redirects_back_to_resource_edit_routes_on_failed_validation_foll 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), ]); - $response->assertRedirect(route('test-models.edit', $testModel)); - $response->assertStatus(303); + $response->assertSee('show edit form'); + $response->assertStatus(422); } /** @@ -160,8 +160,8 @@ public function redirects_include_query_params() 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), ]); - $response->assertRedirect(route('test-models.edit', ['testModel' => $testModel, 'frame' => 'lorem'])); - $response->assertStatus(303); + $response->assertSee('show edit form (frame=lorem)'); + $response->assertStatus(422); } public function usesTestModelUpdateRouteWithoutEdit() @@ -186,4 +186,71 @@ public function lets_it_crash_when_redirect_route_does_not_exist() $response->assertRedirect('/source'); $response->assertStatus(303); } + + public function usesNonResourceRoutes() + { + Route::name('app.')->middleware(TurboMiddleware::class)->group(function () { + Route::get('login', function () { + return 'login form'; + })->name('login'); + + Route::post('login', function () { + request()->validate([ + 'email' => 'required', + 'password' => 'required', + ]); + }); + }); + } + + /** + * @test + * @define-route usesNonResourceRoutes + */ + public function only_guess_route_on_resource_routes() + { + $this->from(route('app.login')) + ->withHeaders([ + 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), + ]) + ->post('/login') + ->assertRedirect(route('app.login')) + ->assertStatus(303); + } + + public function usesRoutesWhichExceptCookies() + { + Route::get('posts/create', function () { + $firstRequestCookie = request()->cookie('my-cookie', 'no-cookie'); + + $responseCookie = request()->cookie('response-cookie', 'no-cookie'); + + return response(sprintf('Request Cookie: %s; Response Cookie: %s', $firstRequestCookie, $responseCookie)); + })->name('posts.create'); + + Route::post('posts', function () { + $exception = ValidationException::withMessages([ + 'title' => ['Title cannot be blank.'], + ]); + + $exception->response = redirect()->to('/')->withCookie('response-cookie', 'response-cookie-value'); + + throw $exception; + })->name('posts.store')->middleware(TurboMiddleware::class); + } + + /** + * @test + * @define-route usesRoutesWhichExceptCookies + */ + public function passes_the_request_cookies_to_the_internal_request() + { + $this->withHeaders([ + 'Accept' => sprintf('%s, text/html, application/xhtml+xml', Turbo::TURBO_STREAM_FORMAT), + ]) + ->withUnencryptedCookie('my-cookie', 'test-value') + ->post(route('posts.store')) + ->assertSee('Request Cookie: test-value; Response Cookie: response-cookie-value') + ->assertStatus(422); + } }