diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index b71254b258..0be4acfb51 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -440,26 +440,120 @@ final readonly class ReceiveInteractionController } ``` -### Group middleware +## Route decorators (route groups) -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. +Route decorators are Tempest's way to manage routes in bulk; it's a feature similar to route groups in other frameworks. Route decorators are attributes that implement the {b`\Tempest\Router\RouteDecorator`} interface. A route decorator's task is to make changes or add functionality to whether route it's associated with. Tempest comes with a few built-in route decorators, and you can make your own as well. -```php Api.php -#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Api implements Route +In most cases, you'll want to add route decorators to a controller class, so that they are applied to all actions of that class: + +```php +use Tempest\Router\Prefix; +use Tempest\Router\Get; + +#[Prefix('/api')] +final readonly class ApiController { - 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 - ]; + #[Get('/books')] + public function books(): Response { /* … */ } + + #[Get('/authors')] + public function authors(): Response { /* … */ } +} +``` + +However, route decorators may also be applied to individual controller actions: + +```php +use Tempest\Router\Stateless; +use Tempest\Router\Get; + +final readonly class BlogPostController +{ + #[Stateless] + #[Get('/rss')] + public function rss(): Response { /* … */ } +} +``` + +### Built-in route decorators + +These route decorators are provided by Tempest: + +#### `#[Stateless]` + +When you're building API endpoints, RSS feeds, or any other kind of page that does not require any cookie or session data, you may use the {b`#[Tempest\Router\Stateless]`} attribute, which will remove all state-related logic: + +```php +use Tempest\Router\Stateless; +use Tempest\Router\Get; + +final readonly class BlogPostController +{ + #[Stateless] + #[Get('/rss')] + public function rss(): Response { /* … */ } +} +``` + +#### `#[Prefix]` + +Adds a prefix to all associated routes. + +```php +use Tempest\Router\Prefix; +use Tempest\Router\Get; + +#[Prefix('/api')] +final readonly class ApiController +{ + #[Get('/books')] + public function books(): Response { /* … */ } + + #[Get('/authors')] + public function authors(): Response { /* … */ } +} +``` + +#### `#[WithMiddleware]` + +Adds middleware to all associated routes. + +```php +use Tempest\Router\WithMiddleware; +use Tempest\Router\Get; + +#[Middleware(AuthMiddleware::class, AdminMiddleware::class)] +final readonly class AdminController { /* … */ } +``` + +#### `#[WithoutMiddleware]` + +Explicitly removes middleware to all associated routes. + +```php +use Tempest\Router\WithoutMiddleware; +use Tempest\Router\Get; + +#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)] +final readonly class StatelessController { /* … */ } +``` + +### Custom route decorators + +Building your own route decorators is done by implementing the {b`\Tempest\Router\RouteDecorator`} interface and marking your decorator as an attribute. + +```php +use Attribute; +use Tempest\Router\RouteDecorator; + +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +final readonly class Auth implements RouteDecorator +{ + public function decorate(Route $route): Route + { + $route->middleare[] = AuthMiddleware::class; + + return $route; } } ``` @@ -631,23 +725,6 @@ final class ErrorResponseProcessor implements ResponseProcessor } ``` -## Stateless routes - -When you're building API endpoints, RSS pages, or any other kind of page that does not require any cookie or session data, you may use the `{#[Tempest\Router\Stateless]}` attribute, which will remove all state-related logic: - -```php -use Tempest\Router\Stateless; -use Tempest\Router\Get; - -final readonly class JsonController -{ - #[Stateless] - #[Get('/json')] - public function json(string $path): Response - { /* … */ } -} -``` - ## Custom route attributes It is often a requirement to have a bunch of routes following the same specifications—for instance, using the same middleware, or the same URI prefix. diff --git a/packages/router/src/Connect.php b/packages/router/src/Connect.php index 7b055c5fb1..280c5752e3 100644 --- a/packages/router/src/Connect.php +++ b/packages/router/src/Connect.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Connect implements Route +final class Connect implements Route { public Method $method; diff --git a/packages/router/src/Delete.php b/packages/router/src/Delete.php index 7903efb608..40724d91c9 100644 --- a/packages/router/src/Delete.php +++ b/packages/router/src/Delete.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Delete implements Route +final class Delete implements Route { public Method $method; diff --git a/packages/router/src/GenericRouter.php b/packages/router/src/GenericRouter.php index 974c94413c..4dd5199625 100644 --- a/packages/router/src/GenericRouter.php +++ b/packages/router/src/GenericRouter.php @@ -11,7 +11,6 @@ use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\Ok; -use Tempest\Http\Session\VerifyCsrfMiddleware; use Tempest\Router\Exceptions\ControllerActionHadNoReturn; use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved; use Tempest\Router\Routing\Matching\RouteMatcher; @@ -83,22 +82,6 @@ private function getCallable(): HttpMiddlewareCallable )) { return $callable($request); } - - // Skip middleware that sets cookies or session values when the route is stateless - if ( - $matchedRoute->route->handler->hasAttribute(Stateless::class) - && in_array( - needle: $middlewareClass->getName(), - haystack: [ - VerifyCsrfMiddleware::class, - SetCurrentUrlMiddleware::class, - SetCookieMiddleware::class, - ], - strict: true, - ) - ) { - return $callable($request); - } } /** @var HttpMiddleware $middleware */ diff --git a/packages/router/src/Get.php b/packages/router/src/Get.php index 42ec165979..e73c048136 100644 --- a/packages/router/src/Get.php +++ b/packages/router/src/Get.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Get implements Route +final class Get implements Route { public Method $method; diff --git a/packages/router/src/Head.php b/packages/router/src/Head.php index b7792cc7fb..74530c1c02 100644 --- a/packages/router/src/Head.php +++ b/packages/router/src/Head.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Head implements Route +final class Head implements Route { public Method $method; diff --git a/packages/router/src/MatchedRoute.php b/packages/router/src/MatchedRoute.php index 30e0aec04b..08a82dc326 100644 --- a/packages/router/src/MatchedRoute.php +++ b/packages/router/src/MatchedRoute.php @@ -6,7 +6,7 @@ use Tempest\Router\Routing\Construction\DiscoveredRoute; -final readonly class MatchedRoute +final class MatchedRoute { public function __construct( public DiscoveredRoute $route, diff --git a/packages/router/src/Options.php b/packages/router/src/Options.php index a1d70e458a..60d370ddfb 100644 --- a/packages/router/src/Options.php +++ b/packages/router/src/Options.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Options implements Route +final class Options implements Route { public Method $method; diff --git a/packages/router/src/Patch.php b/packages/router/src/Patch.php index 40ef8801f7..c00c875c4f 100644 --- a/packages/router/src/Patch.php +++ b/packages/router/src/Patch.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Patch implements Route +final class Patch implements Route { public Method $method; diff --git a/packages/router/src/Post.php b/packages/router/src/Post.php index fdd2f4f1e4..83711e1db8 100644 --- a/packages/router/src/Post.php +++ b/packages/router/src/Post.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Post implements Route +final class Post implements Route { public Method $method; diff --git a/packages/router/src/Prefix.php b/packages/router/src/Prefix.php new file mode 100644 index 0000000000..2d91aea54a --- /dev/null +++ b/packages/router/src/Prefix.php @@ -0,0 +1,25 @@ +uri = path($this->prefix, $route->uri)->toString(); + + return $route; + } +} diff --git a/packages/router/src/Put.php b/packages/router/src/Put.php index 8291e9675d..4e990b83b2 100644 --- a/packages/router/src/Put.php +++ b/packages/router/src/Put.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Put implements Route +final class Put implements Route { public Method $method; diff --git a/packages/router/src/Route.php b/packages/router/src/Route.php index 03b31da059..3387556c1c 100644 --- a/packages/router/src/Route.php +++ b/packages/router/src/Route.php @@ -10,19 +10,23 @@ interface Route { public Method $method { get; + set; } public string $uri { get; + set; } /** @var class-string[] */ public array $middleware { get; + set; } /** @var class-string[] */ public array $without { get; + set; } } diff --git a/packages/router/src/RouteDecorator.php b/packages/router/src/RouteDecorator.php new file mode 100644 index 0000000000..feff7a1e02 --- /dev/null +++ b/packages/router/src/RouteDecorator.php @@ -0,0 +1,8 @@ +getAttributes(Route::class); foreach ($routeAttributes as $routeAttribute) { - $this->discoveryItems->add($location, [$method, $routeAttribute]); + $decorators = [ + ...$method->getDeclaringClass()->getAttributes(RouteDecorator::class), + ...$method->getAttributes(RouteDecorator::class), + ]; + + $route = DiscoveredRoute::fromRoute($routeAttribute, $decorators, $method); + + $this->discoveryItems->add($location, $route); } } } public function apply(): void { - foreach ($this->discoveryItems as [$method, $routeAttribute]) { - $route = DiscoveredRoute::fromRoute($routeAttribute, $method); + foreach ($this->discoveryItems as $route) { $this->configurator->addRoute($route); } diff --git a/packages/router/src/Routing/Construction/DiscoveredRoute.php b/packages/router/src/Routing/Construction/DiscoveredRoute.php index 8138fdb24a..f18042ce72 100644 --- a/packages/router/src/Routing/Construction/DiscoveredRoute.php +++ b/packages/router/src/Routing/Construction/DiscoveredRoute.php @@ -8,7 +8,7 @@ use Tempest\Reflection\MethodReflector; use Tempest\Router\Route; -final readonly class DiscoveredRoute implements Route +final class DiscoveredRoute implements Route { public const string DEFAULT_MATCHING_GROUP = '[^/]++'; @@ -16,8 +16,13 @@ public const string ROUTE_PARAM_CUSTOM_REGEX = '(?::([^{}]*(?:\{(?-1)\}[^{}]*)*))?'; - public static function fromRoute(Route $route, MethodReflector $methodReflector): self + /** @param \Tempest\Router\RouteDecorator[] $decorators */ + public static function fromRoute(Route $route, array $decorators, MethodReflector $methodReflector): self { + foreach ($decorators as $decorator) { + $route = $decorator->decorate($route); + } + return new self( $route->uri, $route->method, diff --git a/packages/router/src/Stateless.php b/packages/router/src/Stateless.php index 57afbd1740..36804ea602 100644 --- a/packages/router/src/Stateless.php +++ b/packages/router/src/Stateless.php @@ -3,11 +3,23 @@ namespace Tempest\Router; use Attribute; +use Tempest\Http\Session\VerifyCsrfMiddleware; /** * Mark a route handler as stateless, causing all cookie- and session-related middleware to be skipped. */ -#[Attribute(Attribute::TARGET_METHOD)] -final class Stateless +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +final class Stateless implements RouteDecorator { + public function decorate(Route $route): Route + { + $route->without = [ + ...$route->without, + VerifyCsrfMiddleware::class, + SetCurrentUrlMiddleware::class, + SetCookieMiddleware::class, + ]; + + return $route; + } } diff --git a/packages/router/src/Trace.php b/packages/router/src/Trace.php index b0166f1c89..24b8857af1 100644 --- a/packages/router/src/Trace.php +++ b/packages/router/src/Trace.php @@ -8,7 +8,7 @@ use Tempest\Http\Method; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)] -final readonly class Trace implements Route +final class Trace implements Route { public Method $method; diff --git a/packages/router/src/WithMiddleware.php b/packages/router/src/WithMiddleware.php new file mode 100644 index 0000000000..e5d1498bbb --- /dev/null +++ b/packages/router/src/WithMiddleware.php @@ -0,0 +1,31 @@ +[] */ + private array $middleware; + + /** @param class-string ...$middleware */ + public function __construct(string ...$middleware) + { + $this->middleware = $middleware; + } + + public function decorate(Route $route): Route + { + $route->middleware = [ + ...$route->middleware, + ...$this->middleware, + ]; + + return $route; + } +} diff --git a/packages/router/src/WithoutMiddleware.php b/packages/router/src/WithoutMiddleware.php new file mode 100644 index 0000000000..e510bcf848 --- /dev/null +++ b/packages/router/src/WithoutMiddleware.php @@ -0,0 +1,31 @@ +[] */ + private array $withoutMiddleware; + + /** @param class-string ...$withoutMiddleware */ + public function __construct(string ...$withoutMiddleware) + { + $this->withoutMiddleware = $withoutMiddleware; + } + + public function decorate(Route $route): Route + { + $route->without = [ + ...$route->without, + ...$this->withoutMiddleware, + ]; + + return $route; + } +} diff --git a/packages/router/tests/FakeRouteBuilder.php b/packages/router/tests/FakeRouteBuilder.php index 309eb3a886..1897a68431 100644 --- a/packages/router/tests/FakeRouteBuilder.php +++ b/packages/router/tests/FakeRouteBuilder.php @@ -12,7 +12,7 @@ use Tempest\Router\Routing\Construction\DiscoveredRoute; use Tempest\Router\Routing\Construction\MarkedRoute; -final readonly class FakeRouteBuilder implements Route +final class FakeRouteBuilder implements Route { private MethodReflector $handler; @@ -43,7 +43,7 @@ public function asMarkedRoute(string $mark): MarkedRoute public function asDiscoveredRoute(): DiscoveredRoute { - return DiscoveredRoute::fromRoute($this, $this->handler); + return DiscoveredRoute::fromRoute($this, [], $this->handler); } public function handler(): void diff --git a/packages/upgrade/config/sets/level/up-to-tempest-28.php b/packages/upgrade/config/sets/level/up-to-tempest-28.php new file mode 100644 index 0000000000..5f9181ec2d --- /dev/null +++ b/packages/upgrade/config/sets/level/up-to-tempest-28.php @@ -0,0 +1,13 @@ +sets([ + TempestSetList::TEMPEST_20, + TempestSetList::TEMPEST_28, + ]); +}; diff --git a/packages/upgrade/config/sets/tempest28.php b/packages/upgrade/config/sets/tempest28.php new file mode 100644 index 0000000000..bf625728d3 --- /dev/null +++ b/packages/upgrade/config/sets/tempest28.php @@ -0,0 +1,11 @@ +importNames(); + $config->importShortClasses(); + + $config->rule(WriteableRouteRector::class); +}; diff --git a/packages/upgrade/src/Set/TempestLevelSetList.php b/packages/upgrade/src/Set/TempestLevelSetList.php index 335d74c4eb..0afa4288b0 100644 --- a/packages/upgrade/src/Set/TempestLevelSetList.php +++ b/packages/upgrade/src/Set/TempestLevelSetList.php @@ -7,4 +7,5 @@ final class TempestLevelSetList { public const string UP_TO_TEMPEST_20 = __DIR__ . '/../../config/sets/level/up-to-tempest-20.php'; + public const string UP_TO_TEMPEST_28 = __DIR__ . '/../../config/sets/level/up-to-tempest-28.php'; } diff --git a/packages/upgrade/src/Set/TempestSetList.php b/packages/upgrade/src/Set/TempestSetList.php index 5bff4af7e4..4769ce6b04 100644 --- a/packages/upgrade/src/Set/TempestSetList.php +++ b/packages/upgrade/src/Set/TempestSetList.php @@ -7,4 +7,5 @@ final class TempestSetList { public const string TEMPEST_20 = __DIR__ . '/../../config/sets/tempest20.php'; + public const string TEMPEST_28 = __DIR__ . '/../../config/sets/tempest28.php'; } diff --git a/packages/upgrade/src/Tempest28/WriteableRouteRector.php b/packages/upgrade/src/Tempest28/WriteableRouteRector.php new file mode 100644 index 0000000000..05d4c658f6 --- /dev/null +++ b/packages/upgrade/src/Tempest28/WriteableRouteRector.php @@ -0,0 +1,43 @@ +implements; + + $implementsRoute = array_find_key( + $implements, + static fn (Node\Name $name) => $name->toString() === Route::class, + ); + + if ($implementsRoute === null) { + return; + } + + if (! $node->isReadonly()) { + return; + } + + $node->flags &= ~Modifiers::READONLY; + } +} diff --git a/packages/upgrade/tests/Tempest28/Fixtures/CustomRoute.input.php b/packages/upgrade/tests/Tempest28/Fixtures/CustomRoute.input.php new file mode 100644 index 0000000000..28b25a8daf --- /dev/null +++ b/packages/upgrade/tests/Tempest28/Fixtures/CustomRoute.input.php @@ -0,0 +1,16 @@ + new RectorTester(__DIR__ . '/tempest28_rector.php'); + } + + public function test_writeable_routes(): void + { + $this->rector + ->runFixture(__DIR__ . '/Fixtures/CustomRoute.input.php') + ->assertContains('final class CustomRoute implements Route'); + } +} diff --git a/packages/upgrade/tests/Tempest28/tempest28_rector.php b/packages/upgrade/tests/Tempest28/tempest28_rector.php new file mode 100644 index 0000000000..e9e18da8d2 --- /dev/null +++ b/packages/upgrade/tests/Tempest28/tempest28_rector.php @@ -0,0 +1,9 @@ +withSets([ + TempestSetList::TEMPEST_28, + ]); diff --git a/tests/Fixtures/Controllers/PrefixController.php b/tests/Fixtures/Controllers/PrefixController.php new file mode 100644 index 0000000000..e61e6c8dce --- /dev/null +++ b/tests/Fixtures/Controllers/PrefixController.php @@ -0,0 +1,17 @@ +container->get(RouteConfigurator::class); + $configurator->addRoute( DiscoveredRoute::fromRoute( $reflector->getAttribute(Route::class), + [ + ...$reflector->getDeclaringClass()->getAttributes(RouteDecorator::class), + ...$reflector->getAttributes(RouteDecorator::class), + ], $reflector, ), ); diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index 3b1af2dcdc..812764a904 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -26,7 +26,6 @@ use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Route\Fixtures\HeadController; use Tests\Tempest\Integration\Route\Fixtures\Http500Controller; -use Tests\Tempest\Integration\Route\Fixtures\StatelessController; /** * @internal @@ -261,14 +260,35 @@ public function test_head_requests(): void ->assertHasHeader('x-custom'); } - public function test_stateless_actions(): void + public function test_stateless_decorator(): void { - $this->registerRoute(StatelessController::class); - $this->http ->get('/stateless') ->assertOk() ->assertDoesNotHaveCookie('tempest_session_id') ->assertDoesNotHaveCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY); } + + public function test_prefix_decorator(): void + { + $this->http + ->get('/prefix/endpoint') + ->assertOk(); + } + + public function test_with_middleware_decorator(): void + { + $this->http + ->get('/with-decorated-middleware') + ->assertOk() + ->assertHasHeader('middleware'); + } + + public function test_without_middleware_decorator(): void + { + $this->http + ->get('/without-decorated-middleware') + ->assertOk() + ->assertDoesNotHaveCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY); + } }