Skip to content

Commit efe6c32

Browse files
authored
fix(router): skip sessions for stateless routes (#2143)
1 parent 9ffaf1b commit efe6c32

6 files changed

Lines changed: 123 additions & 37 deletions

File tree

packages/router/src/HandleRouteExceptionMiddleware.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use Tempest\Router\Exceptions\RouteBindingFailed;
1010
use Tempest\Support\Priority;
1111

12-
#[Priority(Priority::FRAMEWORK - 10)]
12+
#[Priority(Priority::FRAMEWORK - 30)]
1313
final readonly class HandleRouteExceptionMiddleware implements HttpMiddleware
1414
{
1515
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response

packages/router/src/MatchRouteMiddleware.php

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@
44

55
use Tempest\Container\Container;
66
use Tempest\Http\GenericRequest;
7-
use Tempest\Http\Mappers\RequestToObjectMapper;
87
use Tempest\Http\Method;
98
use Tempest\Http\Request;
109
use Tempest\Http\Response;
1110
use Tempest\Http\Responses\NotFound;
1211
use Tempest\Router\Routing\Matching\RouteMatcher;
1312
use Tempest\Support\Priority;
1413

15-
use function Tempest\Mapper\map;
16-
17-
#[Priority(Priority::FRAMEWORK - 9)]
14+
#[Priority(Priority::FRAMEWORK - 29)]
1815
final readonly class MatchRouteMiddleware implements HttpMiddleware
1916
{
2017
public function __construct(
@@ -37,38 +34,6 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon
3734
// We register the matched route in the container, some internal framework components will need it
3835
$this->container->singleton(MatchedRoute::class, fn () => $matchedRoute);
3936

40-
// Convert the request to a specific request implementation, if needed
41-
$request = $this->resolveRequest($request, $matchedRoute);
42-
43-
// We register this newly created request object in the container
44-
// This makes it so that RequestInitializer is bypassed entirely when the controller action needs the request class
45-
// Making it so that we don't need to set any $_SERVER variables and stuff like that
46-
$this->container->singleton($request::class, fn () => $request);
47-
4837
return $next($request);
4938
}
50-
51-
private function resolveRequest(Request $request, MatchedRoute $matchedRoute): Request
52-
{
53-
// Let's find out if our input request data matches what the route's action needs
54-
$requestClass = GenericRequest::class;
55-
56-
// We'll loop over all the handler's parameters
57-
foreach ($matchedRoute->route->handler->getParameters() as $parameter) {
58-
// If the parameter's type is an instance of Request…
59-
if (! $parameter->getType()->matches(Request::class)) {
60-
continue;
61-
}
62-
63-
$requestClass = $parameter->getType()->getName();
64-
65-
break;
66-
}
67-
68-
if ($requestClass !== Request::class && $requestClass !== GenericRequest::class) {
69-
return map($request)->with(RequestToObjectMapper::class)->to($requestClass);
70-
}
71-
72-
return $request;
73-
}
7439
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router;
6+
7+
use Tempest\Container\Container;
8+
use Tempest\Http\GenericRequest;
9+
use Tempest\Http\Mappers\RequestToObjectMapper;
10+
use Tempest\Http\Request;
11+
use Tempest\Http\Response;
12+
use Tempest\Support\Priority;
13+
14+
use function Tempest\Mapper\map;
15+
16+
#[Priority(Priority::FRAMEWORK - 9)]
17+
final readonly class ResolveRouteRequestMiddleware implements HttpMiddleware
18+
{
19+
public function __construct(
20+
private MatchedRoute $matchedRoute,
21+
private Container $container,
22+
) {}
23+
24+
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
25+
{
26+
$request = $this->resolveRequest($request);
27+
28+
// We register this newly created request object in the container
29+
// This makes it so that RequestInitializer is bypassed entirely when the controller action needs the request class
30+
// Making it so that we don't need to set any $_SERVER variables and stuff like that
31+
$this->container->singleton($request::class, fn () => $request);
32+
33+
return $next($request);
34+
}
35+
36+
private function resolveRequest(Request $request): Request
37+
{
38+
// Let's find out if our input request data matches what the route's action needs
39+
$requestClass = GenericRequest::class;
40+
41+
// We'll loop over all the handler's parameters
42+
foreach ($this->matchedRoute
43+
->route
44+
->handler
45+
->getParameters() as $parameter) {
46+
// If the parameter's type is an instance of Request…
47+
if (! $parameter->getType()->matches(Request::class)) {
48+
continue;
49+
}
50+
51+
$requestClass = $parameter->getType()->getName();
52+
53+
break;
54+
}
55+
56+
if ($requestClass !== Request::class && $requestClass !== GenericRequest::class) {
57+
return map($request)->with(RequestToObjectMapper::class)->to($requestClass);
58+
}
59+
60+
return $request;
61+
}
62+
}

packages/router/src/RouteConfig.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function __construct(
3131
public Middleware $middleware = new Middleware(
3232
HandleRouteExceptionMiddleware::class,
3333
MatchRouteMiddleware::class,
34+
ResolveRouteRequestMiddleware::class,
3435
SetCookieHeadersMiddleware::class,
3536
HandleRouteSpecificMiddleware::class,
3637
),

packages/router/src/Stateless.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Attribute;
66
use Tempest\Http\Session\ManageSessionMiddleware;
7+
use Tempest\Http\Session\TrackPreviousUrlMiddleware;
78

89
/**
910
* Mark a route handler as stateless, causing all cookie and session-related middleware to be skipped.
@@ -17,6 +18,7 @@ public function decorate(Route $route): Route
1718
...$route->without,
1819
PreventCrossSiteRequestsMiddleware::class,
1920
ManageSessionMiddleware::class,
21+
TrackPreviousUrlMiddleware::class,
2022
SetCookieHeadersMiddleware::class,
2123
];
2224

tests/Integration/Route/RouterTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
use Laminas\Diactoros\ServerRequest;
88
use Laminas\Diactoros\Stream;
99
use Laminas\Diactoros\Uri;
10+
use Tempest\Clock\Clock;
1011
use Tempest\Database\Migrations\CreateMigrationsTable;
1112
use Tempest\Http\ContentType;
1213
use Tempest\Http\Responses\Ok;
14+
use Tempest\Http\Session\Session;
15+
use Tempest\Http\Session\SessionId;
16+
use Tempest\Http\Session\SessionManager;
1317
use Tempest\Http\Status;
1418
use Tempest\Router\GenericRouter;
1519
use Tempest\Router\RouteConfig;
@@ -260,6 +264,20 @@ public function test_stateless_decorator(): void
260264
->assertDoesNotHaveCookie('tempest_session_id');
261265
}
262266

267+
public function test_stateless_decorator_does_not_manage_session(): void
268+
{
269+
$sessionManager = new RouteTestingSessionManager($this->container->get(Clock::class));
270+
271+
$this->container->singleton(SessionManager::class, fn () => $sessionManager);
272+
273+
$this->http
274+
->get('/stateless')
275+
->assertOk();
276+
277+
$this->assertSame(0, $sessionManager->createdSessions);
278+
$this->assertSame(0, $sessionManager->savedSessions);
279+
}
280+
263281
public function test_prefix_decorator(): void
264282
{
265283
$this->http
@@ -327,3 +345,41 @@ public function test_multiple_optional_parameters(): void
327345
->assertSee('Post 789 in category tech');
328346
}
329347
}
348+
349+
final class RouteTestingSessionManager implements SessionManager
350+
{
351+
public int $createdSessions = 0;
352+
353+
public int $savedSessions = 0;
354+
355+
public function __construct(
356+
private readonly Clock $clock,
357+
) {}
358+
359+
public function getOrCreate(SessionId $id): Session
360+
{
361+
$this->createdSessions++;
362+
363+
$now = $this->clock->now();
364+
365+
return new Session(
366+
id: $id,
367+
createdAt: $now,
368+
lastActiveAt: $now,
369+
);
370+
}
371+
372+
public function save(Session $session): void
373+
{
374+
$this->savedSessions++;
375+
}
376+
377+
public function delete(Session $session): void {}
378+
379+
public function isValid(Session $session): bool
380+
{
381+
return true;
382+
}
383+
384+
public function deleteExpiredSessions(): void {}
385+
}

0 commit comments

Comments
 (0)