Skip to content

Commit 1ca856c

Browse files
loks0nclaude
andcommitted
Bundle route + matchedPath + arguments into immutable RouteMatch
The framework was juggling three separate context keys ('route', 'matchedPath', 'arguments') for state that's logically one thing: "how this request was routed and what its action received." Replace them with a single immutable RouteMatch value class set on the context as 'match'. Shape: final class RouteMatch { public function __construct( public readonly Route $route, public readonly string $matchedPath, public readonly array $arguments = [], ) {} public function withArguments(array $arguments): self; } Lifecycle: - Router::match() now returns ?RouteMatch (was ?array{Route, string}) - Http::match() stores it on the context as 'match' (or null on miss) - Http::execute() reads it, calls withArguments() once the route's params are resolved, and re-stores. Synthesizes a RouteMatch if execute() is called directly without prior match() (test path). - Wildcard branch in runInternal() builds a RouteMatch around the cloned wildcard route. - Telemetry attribution reads it once at the end of run(). Inject via ->inject('match') from any hook/action; access ->route, ->matchedPath, ->arguments. The previous separate 'route' / 'matchedPath' / 'arguments' keys are gone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f86d40 commit 1ca856c

5 files changed

Lines changed: 122 additions & 83 deletions

File tree

src/Http/Http.php

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,10 @@ public function match(Request $request, bool $fresh = true): ?Route
571571
{
572572
$context = $this->server->getContext();
573573

574-
if (!$fresh && $context->has('route')) {
575-
$cached = $context->get('route');
576-
if (null !== $cached) {
577-
return $cached;
574+
if (!$fresh && $context->has('match')) {
575+
$cached = $context->get('match');
576+
if ($cached instanceof RouteMatch) {
577+
return $cached->route;
578578
}
579579
}
580580

@@ -583,18 +583,10 @@ public function match(Request $request, bool $fresh = true): ?Route
583583
$method = $request->getMethod();
584584
$method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method;
585585

586-
$matched = Router::match($method, $url);
587-
if (null === $matched) {
588-
$context->set('route', fn() => null);
589-
$context->set('matchedPath', fn() => '');
590-
return null;
591-
}
592-
593-
[$route, $matchedPath] = $matched;
594-
$context->set('route', fn() => $route);
595-
$context->set('matchedPath', fn() => $matchedPath);
586+
$match = Router::match($method, $url);
587+
$context->set('match', fn() => $match);
596588

597-
return $route;
589+
return $match?->route;
598590
}
599591

600592
/**
@@ -606,8 +598,15 @@ public function execute(Route $route, Request $request, Response $response): sta
606598
$groups = $route->getGroups();
607599

608600
$context = $this->server->getContext();
609-
$matchedPath = $context->has('matchedPath') ? $context->get('matchedPath') : '';
610-
$preparedPath = Router::preparePath($matchedPath);
601+
$match = $context->has('match') ? $context->get('match') : null;
602+
if (!$match instanceof RouteMatch || $match->route !== $route) {
603+
// execute() called directly (e.g. from a test) without a prior
604+
// match() — synthesize a RouteMatch so shutdown / error hooks
605+
// injecting 'match' still see the route they're running under.
606+
$match = new RouteMatch($route, '');
607+
$context->set('match', fn() => $match);
608+
}
609+
$preparedPath = Router::preparePath($match->matchedPath);
611610
$pathValues = $route->getPathValues($request, $preparedPath[0]);
612611

613612
try {
@@ -632,16 +631,17 @@ public function execute(Route $route, Request $request, Response $response): sta
632631
if (!$response->isSent()) {
633632
$arguments = $this->getArguments($route, $pathValues, $request->getParams());
634633

635-
// Stash a name-keyed map of the route's resolved+validated
636-
// params on the context so shutdown / error hooks can read
637-
// the same values the action saw — e.g. for label
634+
// Update the per-request RouteMatch with the resolved+
635+
// validated argument map so shutdown / error hooks can
636+
// read the same values the action saw — e.g. for label
638637
// substitution like {request.fileId}. Race-free because
639638
// the context container is per-request.
640639
$resolved = [];
641640
foreach ($route->getParams() as $name => $param) {
642641
$resolved[$name] = $arguments[$param['order']] ?? null;
643642
}
644-
$this->setContext('arguments', fn() => $resolved);
643+
$match = $match->withArguments($resolved);
644+
$this->setContext('match', fn() => $match);
645645

646646
\call_user_func_array($route->getAction(), $arguments);
647647
}
@@ -751,11 +751,11 @@ public function run(Request $request, Response $response): static
751751

752752
$requestDuration = microtime(true) - $start;
753753
$context = $this->server->getContext();
754-
$route = $context->has('route') ? $context->get('route') : null;
754+
$match = $context->has('match') ? $context->get('match') : null;
755755
$attributes = [
756756
'url.scheme' => $request->getProtocol(),
757757
'http.request.method' => $request->getMethod(),
758-
'http.route' => $route?->getPath(),
758+
'http.route' => $match instanceof RouteMatch ? $match->route->getPath() : null,
759759
'http.response.status_code' => $response->getStatusCode(),
760760
];
761761
$this->requestDuration->record($requestDuration, $attributes);
@@ -824,8 +824,6 @@ private function runInternal(Request $request, Response $response): static
824824
$route = $this->match($request);
825825
$groups = ($route instanceof Route) ? $route->getGroups() : [];
826826

827-
$this->setContext('route', fn() => $route, []);
828-
829827
if (self::REQUEST_METHOD_HEAD === $method) {
830828
$method = self::REQUEST_METHOD_GET;
831829
$response->disablePayload();
@@ -869,7 +867,8 @@ private function runInternal(Request $request, Response $response): static
869867
$path = \is_string($path) ? ($path === '' ? '/' : $path) : '/';
870868
$route->path($path);
871869

872-
$this->setContext('route', fn() => $route);
870+
$match = new RouteMatch($route, '');
871+
$this->setContext('match', fn() => $match);
873872
}
874873
if (null !== $route) {
875874
return $this->execute($route, $request, $response);

src/Http/RouteMatch.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Utopia\Http;
6+
7+
/**
8+
* Immutable bundle of everything the framework knows about how the current
9+
* request was routed: the matched Route, the prepared-path key it was
10+
* matched under (with placeholders like ":::") and — once the action has
11+
* resolved its parameters — the name-keyed map of resolved + validated
12+
* argument values.
13+
*
14+
* Lives on the per-request context container (coroutine-local under the
15+
* Swoole adapters) under the key `'match'`. Inject it into a hook or
16+
* action with `->inject('match')`.
17+
*/
18+
final class RouteMatch
19+
{
20+
/**
21+
* @param array<string, mixed> $arguments
22+
*/
23+
public function __construct(
24+
public readonly Route $route,
25+
public readonly string $matchedPath,
26+
public readonly array $arguments = [],
27+
) {}
28+
29+
/**
30+
* Return a copy of this match with the resolved-argument map replaced.
31+
* Used by the framework once the action's parameters have been
32+
* validated, so subsequent shutdown / error hooks can read the same
33+
* values the action received.
34+
*
35+
* @param array<string, mixed> $arguments
36+
*/
37+
public function withArguments(array $arguments): self
38+
{
39+
return new self($this->route, $this->matchedPath, $arguments);
40+
}
41+
}

src/Http/Router.php

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,14 @@ public static function addRouteAlias(string $path, Route $route): void
106106
}
107107

108108
/**
109-
* Match route against the method and path.
109+
* Match a request against the registered routes.
110110
*
111-
* Returns the matched Route together with the prepared-path key it was
112-
* found under, so callers can resolve path params without mutating the
113-
* shared Route singleton.
114-
*
115-
* @return array{0: Route, 1: string}|null
111+
* Returns a RouteMatch holding the matched Route and the prepared-path
112+
* key it was found under (placeholders replaced with `:::`), so callers
113+
* can resolve path params without mutating the shared Route singleton.
114+
* Returns null when no route matches.
116115
*/
117-
public static function match(string $method, string $path): ?array
116+
public static function match(string $method, string $path): ?RouteMatch
118117
{
119118
if (!\array_key_exists($method, self::$routes)) {
120119
return null;
@@ -135,7 +134,7 @@ public static function match(string $method, string $path): ?array
135134
);
136135

137136
if (\array_key_exists($match, self::$routes[$method])) {
138-
return [self::$routes[$method][$match], $match];
137+
return new RouteMatch(self::$routes[$method][$match], $match);
139138
}
140139
}
141140

@@ -144,7 +143,7 @@ public static function match(string $method, string $path): ?array
144143
*/
145144
$match = self::WILDCARD_TOKEN;
146145
if (\array_key_exists($match, self::$routes[$method])) {
147-
return [self::$routes[$method][$match], $match];
146+
return new RouteMatch(self::$routes[$method][$match], $match);
148147
}
149148

150149
/**
@@ -154,7 +153,7 @@ public static function match(string $method, string $path): ?array
154153
$current = ($current ?? '') . "{$part}/";
155154
$match = $current . self::WILDCARD_TOKEN;
156155
if (\array_key_exists($match, self::$routes[$method])) {
157-
return [self::$routes[$method][$match], $match];
156+
return new RouteMatch(self::$routes[$method][$match], $match);
158157
}
159158
}
160159

tests/HttpTest.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,9 @@ public function testShutdownHookCanInjectResolvedArguments(): void
378378

379379
$this->http
380380
->shutdown()
381-
->inject('arguments')
382-
->action(function (array $arguments) {
383-
echo '|shutdown:fileId=' . $arguments['fileId'] . ',width=' . $arguments['width'];
381+
->inject('match')
382+
->action(function (RouteMatch $match) {
383+
echo '|shutdown:fileId=' . $match->arguments['fileId'] . ',width=' . $match->arguments['width'];
384384
});
385385

386386
$request = new UtopiaFPMRequestTest();
@@ -442,7 +442,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n
442442
$_SERVER['REQUEST_URI'] = $url;
443443

444444
$this->assertSame($expected, $this->http->match(new Request()));
445-
$this->assertSame($expected, $this->http->getResource('route'));
445+
$this->assertSame($expected, $this->http->getResource('match')?->route);
446446
}
447447

448448
public function testNoMismatchRoute(): void
@@ -471,7 +471,7 @@ public function testNoMismatchRoute(): void
471471
$route = $this->http->match(new Request(), fresh: true);
472472

473473
$this->assertNull($route);
474-
$this->assertNull($this->http->getResource('route'));
474+
$this->assertNull($this->http->getResource('match')?->route);
475475
}
476476
}
477477

@@ -486,20 +486,20 @@ public function testCanMatchFreshRoute(): void
486486
$_SERVER['REQUEST_URI'] = '/path1';
487487
$matched = $this->http->match(new Request());
488488
$this->assertSame($route1, $matched);
489-
$this->assertSame($route1, $this->http->getResource('route'));
489+
$this->assertSame($route1, $this->http->getResource('match')?->route);
490490

491491
// Second request match returns cached route
492492
$_SERVER['REQUEST_METHOD'] = 'HEAD';
493493
$_SERVER['REQUEST_URI'] = '/path2';
494494
$request2 = new Request();
495495
$matched = $this->http->match($request2, fresh: false);
496496
$this->assertSame($route1, $matched);
497-
$this->assertSame($route1, $this->http->getResource('route'));
497+
$this->assertSame($route1, $this->http->getResource('match')?->route);
498498

499499
// Fresh match returns new route
500500
$matched = $this->http->match($request2, fresh: true);
501501
$this->assertSame($route2, $matched);
502-
$this->assertSame($route2, $this->http->getResource('route'));
502+
$this->assertSame($route2, $this->http->getResource('match')?->route);
503503
} catch (\Exception $e) {
504504
$this->fail($e->getMessage());
505505
}
@@ -513,7 +513,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void
513513
$_SERVER['REQUEST_URI'] = 'https://example.com?x=1';
514514

515515
$this->assertSame($route, $this->http->match(new Request()));
516-
$this->assertSame($route, $this->http->getResource('route'));
516+
$this->assertSame($route, $this->http->getResource('match')?->route);
517517
}
518518

519519
public function testCanRunRequest(): void
@@ -552,9 +552,9 @@ public function testWildcardRoute(): void
552552
$_SERVER['REQUEST_URI'] = '/unknown_path';
553553

554554
Http::init()
555-
->inject('route')
556-
->action(function ($route) {
557-
$this->container->set('myRoute', fn() => $route);
555+
->inject('match')
556+
->action(function (RouteMatch $match) {
557+
$this->container->set('myRoute', fn() => $match->route);
558558
});
559559

560560

0 commit comments

Comments
 (0)