From 5d3784decb7547fbf260819e02aa846ee58b24ac Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sat, 9 Aug 2025 16:25:26 +0200 Subject: [PATCH 1/2] feat(router): add ability to skip middleware per route --- packages/router/src/Connect.php | 4 +++- packages/router/src/Delete.php | 4 +++- packages/router/src/GenericRouter.php | 9 +++++++++ packages/router/src/Get.php | 4 +++- packages/router/src/Head.php | 4 +++- packages/router/src/Options.php | 4 +++- packages/router/src/Patch.php | 4 +++- packages/router/src/Post.php | 4 +++- packages/router/src/Put.php | 4 +++- packages/router/src/Route.php | 5 +++++ .../src/Routing/Construction/DiscoveredRoute.php | 2 ++ packages/router/src/Trace.php | 4 +++- packages/router/tests/FakeRouteBuilder.php | 1 + .../Framework/Testing/Http/TestResponseHelper.php | 11 +++++++++++ tests/Fixtures/Controllers/TestController.php | 6 ++++++ tests/Integration/Route/RouterTest.php | 12 ++++++++++++ 16 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/router/src/Connect.php b/packages/router/src/Connect.php index 86607e983..7b055c5fb 100644 --- a/packages/router/src/Connect.php +++ b/packages/router/src/Connect.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::CONNECT; } diff --git a/packages/router/src/Delete.php b/packages/router/src/Delete.php index cdaa17ea4..7903efb60 100644 --- a/packages/router/src/Delete.php +++ b/packages/router/src/Delete.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::DELETE; } diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index 96cd0e8c2..a21974c7f 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -77,6 +77,15 @@ private function getCallable(): HttpMiddlewareCallable foreach ($middlewareStack->unwrap() as $middlewareClass) { $callable = new HttpMiddlewareCallable(function (Request $request) use ($middlewareClass, $callable) { + // We skip this middleware if it's ignored by the route + if ($this->container->has(MatchedRoute::class)) { + $matchedRoute = $this->container->get(MatchedRoute::class); + + if (in_array($middlewareClass->getName(), $matchedRoute->route->without, strict: true)) { + return $callable($request); + } + } + /** @var HttpMiddleware $middleware */ $middleware = $this->container->get($middlewareClass->getName()); diff --git a/packages/router/src/Get.php b/packages/router/src/Get.php index dba8dd748..42ec16597 100644 --- a/packages/router/src/Get.php +++ b/packages/router/src/Get.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::GET; } diff --git a/packages/router/src/Head.php b/packages/router/src/Head.php index 0856e5aca..b7792cc7f 100644 --- a/packages/router/src/Head.php +++ b/packages/router/src/Head.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::HEAD; } diff --git a/packages/router/src/Options.php b/packages/router/src/Options.php index d92327491..a1d70e458 100644 --- a/packages/router/src/Options.php +++ b/packages/router/src/Options.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::OPTIONS; } diff --git a/packages/router/src/Patch.php b/packages/router/src/Patch.php index 2e06338fc..40ef8801f 100644 --- a/packages/router/src/Patch.php +++ b/packages/router/src/Patch.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::PATCH; } diff --git a/packages/router/src/Post.php b/packages/router/src/Post.php index 85ec5393e..fdd2f4f1e 100644 --- a/packages/router/src/Post.php +++ b/packages/router/src/Post.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::POST; } diff --git a/packages/router/src/Put.php b/packages/router/src/Put.php index c73484d58..8291e9675 100644 --- a/packages/router/src/Put.php +++ b/packages/router/src/Put.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::PUT; } diff --git a/packages/router/src/Route.php b/packages/router/src/Route.php index 1b6bd6cb6..03b31da05 100644 --- a/packages/router/src/Route.php +++ b/packages/router/src/Route.php @@ -20,4 +20,9 @@ interface Route public array $middleware { get; } + + /** @var class-string[] */ + public array $without { + get; + } } diff --git a/packages/router/src/Routing/Construction/DiscoveredRoute.php b/packages/router/src/Routing/Construction/DiscoveredRoute.php index 7b6ce4532..029b35017 100644 --- a/packages/router/src/Routing/Construction/DiscoveredRoute.php +++ b/packages/router/src/Routing/Construction/DiscoveredRoute.php @@ -24,6 +24,7 @@ public static function fromRoute(Route $route, MethodReflector $methodReflector) self::getRouteParams($route->uri), $route->middleware, $methodReflector, + $route->without, ); } @@ -36,6 +37,7 @@ private function __construct( /** @var class-string<\Tempest\Router\HttpMiddleware>[] */ public array $middleware, public MethodReflector $handler, + public array $without = [], ) { $this->isDynamic = $parameters !== []; } diff --git a/packages/router/src/Trace.php b/packages/router/src/Trace.php index d80aacd08..b0166f1c8 100644 --- a/packages/router/src/Trace.php +++ b/packages/router/src/Trace.php @@ -13,11 +13,13 @@ public Method $method; /** - * @param class-string[] $middleware + * @param class-string[] $middleware Middleware specific to this route. + * @param class-string[] $without Middleware to remove from this route. */ public function __construct( public string $uri, public array $middleware = [], + public array $without = [], ) { $this->method = Method::TRACE; } diff --git a/packages/router/tests/FakeRouteBuilder.php b/packages/router/tests/FakeRouteBuilder.php index 2bb923960..309eb3a88 100644 --- a/packages/router/tests/FakeRouteBuilder.php +++ b/packages/router/tests/FakeRouteBuilder.php @@ -21,6 +21,7 @@ public function __construct( public string $uri = '/', /** @var class-string[] */ public array $middleware = [], + public array $without = [], ) { $this->handler = new MethodReflector(new ReflectionMethod($this, 'handler')); } diff --git a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php index 42fd0c0c5..c15dd53c3 100644 --- a/src/Tempest/Framework/Testing/Http/TestResponseHelper.php +++ b/src/Tempest/Framework/Testing/Http/TestResponseHelper.php @@ -53,6 +53,17 @@ public function assertHasHeader(string $name): self return $this; } + public function assertDoesNotHaveHeader(string $name): self + { + Assert::assertArrayNotHasKey( + $name, + $this->response->headers, + sprintf('Failed to assert that response does not contain header [%s].', $name), + ); + + return $this; + } + /** * Asserts that the given header contains the given value. */ diff --git a/tests/Fixtures/Controllers/TestController.php b/tests/Fixtures/Controllers/TestController.php index 5f92daf48..f3a097325 100644 --- a/tests/Fixtures/Controllers/TestController.php +++ b/tests/Fixtures/Controllers/TestController.php @@ -91,6 +91,12 @@ public function withMiddleware(): Response return new Ok(); } + #[Get(uri: '/without-middleware', without: [TestMiddleware::class])] + public function withoutMiddleware(): Response + { + return new Ok(); + } + #[Get('/view-model-with-response-data')] public function viewWithResponseData(): Response { diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 1d63e9190..80d704237 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -21,6 +21,7 @@ use Tests\Tempest\Fixtures\Controllers\EnumForController; use Tests\Tempest\Fixtures\Controllers\TestController; use Tests\Tempest\Fixtures\Controllers\TestGlobalMiddleware; +use Tests\Tempest\Fixtures\Controllers\TestMiddleware; use Tests\Tempest\Fixtures\Controllers\UriGeneratorController; use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable; use Tests\Tempest\Fixtures\Migrations\CreateBookTable; @@ -161,6 +162,17 @@ public function test_middleware(): void $this->assertEquals(['yes'], $response->getHeader('global-middleware')->values); } + public function test_skip_middleware(): void + { + $this + ->container->get(RouteConfig::class) + ->middleware->add(TestMiddleware::class); + + $this->http + ->get('/without-middleware') + ->assertDoesNotHaveHeader('middleware'); + } + public function test_trailing_slash(): void { $this->http From d2d614e7fbbc9f07094abcd66883c49735b7e251 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 13 Aug 2025 15:46:42 +0200 Subject: [PATCH 2/2] docs: document middleware exclusion --- docs/1-essentials/01-routing.md | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index 6612dc345..44b1b852d 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -374,7 +374,7 @@ Note that priority is defined using an integer. You can however use one of the b ### Middleware discovery -Global middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the `#[SkipDiscovery]` attribute: +Global middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the {b`#[Tempest\Discovery\SkipDiscovery]`} attribute: ```php use Tempest\Discovery\SkipDiscovery; @@ -384,6 +384,48 @@ final readonly class ValidateWebhook implements HttpMiddleware { /* … */ } ``` +### Excluding route middleware + +Some routes may not require specific global middleware to be applied. For instance, API routes do not need CSRF protection. You may skip specific middleware by using the `without` argument of the route attribute. + +```php app/Slack/ReceiveInteractionController.php +use Tempest\Router\Post; +use Tempest\Http\Response; + +final readonly class ReceiveInteractionController +{ + #[Post('/slack/interaction', without: [VerifyCsrfMiddleware::class, SetCookieMiddleware::class])] + public function __invoke(): Response + { + // … + } +} +``` + +### Group middleware + +While Tempest does not provide a way to group middleware, you can easily create your own route attribute that applies or excludes a set of middleware to a route. + +```php Api.php +#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] +final readonly class Api implements Route +{ + public function __construct( + public Method $method, + public string $uri, + public array $middleware = [], + public array $without = [], + ) { + $this->uri = "/api/{$uri}"; + $this->without[] = [ + ...$without, + VerifyCsrfMiddleware::class, + SetCookieMiddleware::class + ]; + } +} +``` + ## Responses All requests to a controller action expect a response to be returned to the client. This is done by returning a `{php}View` or a `{php}Response` object.