Skip to content

Commit 86d140d

Browse files
brendtinnocenzi
andauthored
feat(router): add #[Stateless] attribute (#1692)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 0fb628e commit 86d140d

File tree

6 files changed

+104
-3
lines changed

6 files changed

+104
-3
lines changed

docs/1-essentials/01-routing.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,23 @@ final class ErrorResponseProcessor implements ResponseProcessor
631631
}
632632
```
633633

634+
## Stateless routes
635+
636+
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:
637+
638+
```php
639+
use Tempest\Router\Stateless;
640+
use Tempest\Router\Get;
641+
642+
final readonly class JsonController
643+
{
644+
#[Stateless]
645+
#[Get('/json')]
646+
public function json(string $path): Response
647+
{ /* … */ }
648+
}
649+
```
650+
634651
## Custom route attributes
635652

636653
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.

packages/router/src/GenericRouter.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\Http\Request;
1212
use Tempest\Http\Response;
1313
use Tempest\Http\Responses\Ok;
14+
use Tempest\Http\Session\VerifyCsrfMiddleware;
1415
use Tempest\Router\Exceptions\ControllerActionHadNoReturn;
1516
use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved;
1617
use Tempest\Router\Routing\Matching\RouteMatcher;
@@ -70,12 +71,32 @@ private function getCallable(): HttpMiddlewareCallable
7071
$middlewareStack = $this->routeConfig->middleware;
7172

7273
foreach ($middlewareStack->unwrap() as $middlewareClass) {
73-
$callable = new HttpMiddlewareCallable(function (Request $request) use ($middlewareClass, $callable) {
74-
// We skip this middleware if it's ignored by the route
74+
$callable = new HttpMiddlewareCallable(closure: function (Request $request) use ($middlewareClass, $callable) {
7575
if ($this->container->has(MatchedRoute::class)) {
7676
$matchedRoute = $this->container->get(MatchedRoute::class);
7777

78-
if (in_array($middlewareClass->getName(), $matchedRoute->route->without, strict: true)) {
78+
// Skip the middleware if it's ignored by the route
79+
if (in_array(
80+
needle: $middlewareClass->getName(),
81+
haystack: $matchedRoute->route->without,
82+
strict: true,
83+
)) {
84+
return $callable($request);
85+
}
86+
87+
// Skip middleware that sets cookies or session values when the route is stateless
88+
if (
89+
$matchedRoute->route->handler->hasAttribute(Stateless::class)
90+
&& in_array(
91+
needle: $middlewareClass->getName(),
92+
haystack: [
93+
VerifyCsrfMiddleware::class,
94+
SetCurrentUrlMiddleware::class,
95+
SetCookieMiddleware::class,
96+
],
97+
strict: true,
98+
)
99+
) {
79100
return $callable($request);
80101
}
81102
}

packages/router/src/Stateless.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Tempest\Router;
4+
5+
use Attribute;
6+
7+
/**
8+
* Mark a route handler as stateless, causing all cookie- and session-related middleware to be skipped.
9+
*/
10+
#[Attribute(Attribute::TARGET_METHOD)]
11+
final class Stateless
12+
{
13+
}

src/Tempest/Framework/Testing/Http/TestResponseHelper.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ public function assertHasCookie(string $key, null|string|Closure $value = null):
184184
return $this;
185185
}
186186

187+
public function assertDoesNotHaveCookie(string $key, null|string|Closure $value = null): self
188+
{
189+
/** @var array<string,Cookie> */
190+
$cookies = Arr\map_with_keys(
191+
array: $this->response->getHeader('set-cookie')->values ?? [],
192+
map: function (string $cookie) {
193+
$cookie = Cookie::createFromString($cookie);
194+
yield $cookie->key => $cookie;
195+
},
196+
);
197+
198+
Assert::assertArrayNotHasKey(
199+
key: $key,
200+
array: $cookies,
201+
message: sprintf("A cookie was set for [%s], while it shouldn't have been", $key),
202+
);
203+
204+
return $this;
205+
}
206+
187207
public function assertHasSession(string $key, ?Closure $callback = null): self
188208
{
189209
$this->assertHasContainer();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Route\Fixtures;
4+
5+
use Tempest\Http\Responses\Ok;
6+
use Tempest\Router\Get;
7+
use Tempest\Router\Stateless;
8+
9+
final class StatelessController
10+
{
11+
#[Stateless]
12+
#[Get('/stateless')]
13+
public function __invoke(): Ok
14+
{
15+
return new Ok();
16+
}
17+
}

tests/Integration/Route/RouterTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\Database\Migrations\CreateMigrationsTable;
1212
use Tempest\Http\HttpRequestFailed;
1313
use Tempest\Http\Responses\Ok;
14+
use Tempest\Http\Session\VerifyCsrfMiddleware;
1415
use Tempest\Http\Status;
1516
use Tempest\Router\GenericRouter;
1617
use Tempest\Router\RouteConfig;
@@ -25,6 +26,7 @@
2526
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
2627
use Tests\Tempest\Integration\Route\Fixtures\HeadController;
2728
use Tests\Tempest\Integration\Route\Fixtures\Http500Controller;
29+
use Tests\Tempest\Integration\Route\Fixtures\StatelessController;
2830

2931
/**
3032
* @internal
@@ -258,4 +260,15 @@ public function test_head_requests(): void
258260
->assertOk()
259261
->assertHasHeader('x-custom');
260262
}
263+
264+
public function test_stateless_actions(): void
265+
{
266+
$this->registerRoute(StatelessController::class);
267+
268+
$this->http
269+
->get('/stateless')
270+
->assertOk()
271+
->assertDoesNotHaveCookie('tempest_session_id')
272+
->assertDoesNotHaveCookie(VerifyCsrfMiddleware::CSRF_COOKIE_KEY);
273+
}
261274
}

0 commit comments

Comments
 (0)