From 339f1ce1b7ee262a361d4c976b9d9ab248915841 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:19:54 +0100 Subject: [PATCH 01/18] Make routing coroutine-safe by removing Route mutations Router::match and the wildcard branch in Http::runInternal both wrote to the shared Route singleton (setMatchedPath, path) on every request. Under Swoole coroutines the Route is shared across in-flight requests, so concurrent requests could observe each other's matched path. - Router::match now returns [Route, matchedPath] instead of mutating the Route. A new Router::setFallback slot replaces Http::$wildcardRoute, so the method-agnostic catch-all flows through the same matching path as any other route. - Route::matchedPath / setMatchedPath / getMatchedPath are removed. - Http::execute takes the matched path as a parameter; runInternal threads it through. Public Http::match keeps its ?Route shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 64 +++++++++++++++++++++++++++++---------------- src/Http/Route.php | 13 --------- src/Http/Router.php | 41 +++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 47 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 06351fb..01f0555 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -106,10 +106,11 @@ class Http protected ?Route $route = null; /** - * Wildcard route - * If set, this get's executed if no other route is matched + * Matched route key (template after placeholder substitution). + * + * Cached alongside $route for $fresh=false re-matches. */ - protected static ?Route $wildcardRoute = null; + protected string $matchedPath = ''; /** * Compression @@ -242,9 +243,10 @@ public static function delete(string $url): Route */ public static function wildcard(): Route { - self::$wildcardRoute = new Route('', ''); + $route = new Route('', ''); + Router::setFallback($route); - return self::$wildcardRoute; + return $route; } /** @@ -545,9 +547,21 @@ public function start(): void * @param bool $fresh If true, will not match any cached route */ public function match(Request $request, bool $fresh = true): ?Route + { + return $this->matchInternal($request, $fresh)[0] ?? null; + } + + /** + * Match a request and return both the matched Route and the route key it + * matched against. Returning the matched key separately avoids mutating + * the shared Route instance, which would race under coroutines. + * + * @return array{0: Route, 1: string}|null + */ + private function matchInternal(Request $request, bool $fresh = true): ?array { if (null !== $this->route && !$fresh) { - return $this->route; + return [$this->route, $this->matchedPath]; } $url = parse_url($request->getURI(), PHP_URL_PATH); @@ -555,20 +569,32 @@ public function match(Request $request, bool $fresh = true): ?Route $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $this->route = Router::match($method, $url); + $match = Router::match($method, $url); + + if ($match === null) { + $this->route = null; + $this->matchedPath = ''; + return null; + } + + [$this->route, $this->matchedPath] = $match; - return $this->route; + return $match; } /** - * Execute a given route with middlewares and error handling + * Execute a given route with middlewares and error handling. + * + * $matchedPath is the route key this request matched against (the + * registered template after placeholder substitution). Pass '' for the + * fallback route or when path params aren't relevant. */ - public function execute(Route $route, Request $request, Response $response): static + public function execute(Route $route, Request $request, Response $response, string $matchedPath = ''): static { $arguments = []; $groups = $route->getGroups(); - $preparedPath = Router::preparePath($route->getMatchedPath()); + $preparedPath = Router::preparePath($matchedPath); $pathValues = $route->getPathValues($request, $preparedPath[0]); try { @@ -790,7 +816,9 @@ private function runInternal(Request $request, Response $response): static } $method = $request->getMethod(); - $route = $this->match($request); + $match = $this->matchInternal($request); + $route = $match[0] ?? null; + $matchedPath = $match[1] ?? ''; $groups = ($route instanceof Route) ? $route->getGroups() : []; $this->context()->set('route', fn() => $route, []); @@ -830,17 +858,8 @@ private function runInternal(Request $request, Response $response): static return $this; } - if (null === $route && null !== self::$wildcardRoute) { - $route = self::$wildcardRoute; - $this->route = $route; - $path = parse_url($request->getURI(), PHP_URL_PATH); - $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; - $route->path($path); - - $this->context()->set('route', fn() => $route, []); - } if (null !== $route) { - return $this->execute($route, $request, $response); + return $this->execute($route, $request, $response, $matchedPath); } if (self::REQUEST_METHOD_OPTIONS === $method) { @@ -923,6 +942,5 @@ public static function reset(): void self::$options = []; self::$startHooks = []; self::$requestHooks = []; - self::$wildcardRoute = null; } } diff --git a/src/Http/Route.php b/src/Http/Route.php index b72886d..adb8ca3 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -38,8 +38,6 @@ class Route extends Hook */ protected int $order; - protected string $matchedPath = ''; - public function __construct(string $method, string $path) { parent::__construct(); @@ -48,17 +46,6 @@ public function __construct(string $method, string $path) $this->order = ++self::$counter; } - public function setMatchedPath(string $path): self - { - $this->matchedPath = $path; - return $this; - } - - public function getMatchedPath(): string - { - return $this->matchedPath; - } - /** * Get Route Order ID */ diff --git a/src/Http/Router.php b/src/Http/Router.php index 8118ffe..dbfee27 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -14,6 +14,11 @@ class Router protected static bool $allowOverride = false; + /** + * Fallback route used when no method-specific route matches. Method-agnostic. + */ + protected static ?Route $fallback = null; + /** * @var array */ @@ -105,13 +110,28 @@ public static function addRouteAlias(string $path, Route $route): void self::$routes[$route->getMethod()][$alias] = $route; } + /** + * Set the method-agnostic fallback route used when nothing else matches. + */ + public static function setFallback(?Route $route): void + { + self::$fallback = $route; + } + /** * Match route against the method and path. + * + * Returns the matched Route together with the route key it matched against + * (the registered template after placeholder substitution, or '*' for a + * wildcard, or '' for the fallback). Returning the matched key avoids + * mutating the shared Route instance, which would race under coroutines. + * + * @return array{0: Route, 1: string}|null */ - public static function match(string $method, string $path): ?Route + public static function match(string $method, string $path): ?array { if (!\array_key_exists($method, self::$routes)) { - return null; + return self::$fallback !== null ? [self::$fallback, ''] : null; } $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); @@ -129,9 +149,7 @@ public static function match(string $method, string $path): ?Route ); if (\array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; + return [self::$routes[$method][$match], $match]; } } @@ -140,9 +158,7 @@ public static function match(string $method, string $path): ?Route */ $match = self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; + return [self::$routes[$method][$match], $match]; } /** @@ -152,12 +168,14 @@ public static function match(string $method, string $path): ?Route $current = ($current ?? '') . "{$part}/"; $match = $current . self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; + return [self::$routes[$method][$match], $match]; } } + if (self::$fallback !== null) { + return [self::$fallback, '']; + } + return null; } @@ -219,6 +237,7 @@ public static function preparePath(string $path): array public static function reset(): void { self::$params = []; + self::$fallback = null; self::$routes = [ Http::REQUEST_METHOD_GET => [], Http::REQUEST_METHOD_POST => [], From a6e13724235b2b4cb4d5a0db9163c48638bac2e6 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:22:54 +0100 Subject: [PATCH 02/18] Move matched route + matched path into request context The Http instance is shared across coroutines, so $this->route and $this->matchedPath would race the same way Route's mutable fields did. Store them in the per-request context() container instead, which is already request-scoped post-#254. getRoute()/setRoute() now read/write through the context too. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 54 +++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 01f0555..e7d417a 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -99,18 +99,14 @@ class Http protected static array $requestHooks = []; /** - * Route + * Per-request context keys for the matched route + matched template key. * - * Memory cached result for chosen route + * The match result lives in the request-scoped context() container rather + * than on $this so it does not race when the Http instance is shared + * across coroutines. */ - protected ?Route $route = null; - - /** - * Matched route key (template after placeholder substitution). - * - * Cached alongside $route for $fresh=false re-matches. - */ - protected string $matchedPath = ''; + private const string CONTEXT_ROUTE = 'route'; + private const string CONTEXT_MATCHED_PATH = 'matchedPath'; /** * Compression @@ -423,7 +419,13 @@ public static function getRoutes(): array */ public function getRoute(): ?Route { - return $this->route ?? null; + $context = $this->context(); + if (!$context->has(self::CONTEXT_ROUTE)) { + return null; + } + + $route = $context->get(self::CONTEXT_ROUTE); + return $route instanceof Route ? $route : null; } /** @@ -431,7 +433,7 @@ public function getRoute(): ?Route */ public function setRoute(Route $route): self { - $this->route = $route; + $this->context()->set(self::CONTEXT_ROUTE, fn() => $route, []); return $this; } @@ -556,12 +558,23 @@ public function match(Request $request, bool $fresh = true): ?Route * matched against. Returning the matched key separately avoids mutating * the shared Route instance, which would race under coroutines. * + * Caches the result in the per-request context so re-matches with + * $fresh=false hit memory without re-running Router::match. + * * @return array{0: Route, 1: string}|null */ private function matchInternal(Request $request, bool $fresh = true): ?array { - if (null !== $this->route && !$fresh) { - return [$this->route, $this->matchedPath]; + $context = $this->context(); + + if (!$fresh && $context->has(self::CONTEXT_ROUTE)) { + $route = $context->get(self::CONTEXT_ROUTE); + if ($route instanceof Route) { + $matchedPath = $context->has(self::CONTEXT_MATCHED_PATH) + ? (string) $context->get(self::CONTEXT_MATCHED_PATH) + : ''; + return [$route, $matchedPath]; + } } $url = parse_url($request->getURI(), PHP_URL_PATH); @@ -572,12 +585,12 @@ private function matchInternal(Request $request, bool $fresh = true): ?array $match = Router::match($method, $url); if ($match === null) { - $this->route = null; - $this->matchedPath = ''; return null; } - [$this->route, $this->matchedPath] = $match; + [$route, $matchedPath] = $match; + $context->set(self::CONTEXT_ROUTE, fn() => $route, []); + $context->set(self::CONTEXT_MATCHED_PATH, fn() => $matchedPath, []); return $match; } @@ -746,11 +759,14 @@ public function run(Request $request, Response $response): static $start = microtime(true); $result = $this->runInternal($request, $response); + $context = $this->context(); + $matchedRoute = $context->has(self::CONTEXT_ROUTE) ? $context->get(self::CONTEXT_ROUTE) : null; + $requestDuration = microtime(true) - $start; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $this->route?->getPath(), + 'http.route' => $matchedRoute instanceof Route ? $matchedRoute->getPath() : null, 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -821,8 +837,6 @@ private function runInternal(Request $request, Response $response): static $matchedPath = $match[1] ?? ''; $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->context()->set('route', fn() => $route, []); - if (self::REQUEST_METHOD_HEAD === $method) { $method = self::REQUEST_METHOD_GET; $response->disablePayload(); From 79963186c34e4674f524e625e3806973c235c8ef Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:23:42 +0100 Subject: [PATCH 03/18] Inline context key strings Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index e7d417a..40e84b0 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -98,16 +98,6 @@ class Http */ protected static array $requestHooks = []; - /** - * Per-request context keys for the matched route + matched template key. - * - * The match result lives in the request-scoped context() container rather - * than on $this so it does not race when the Http instance is shared - * across coroutines. - */ - private const string CONTEXT_ROUTE = 'route'; - private const string CONTEXT_MATCHED_PATH = 'matchedPath'; - /** * Compression */ @@ -420,11 +410,11 @@ public static function getRoutes(): array public function getRoute(): ?Route { $context = $this->context(); - if (!$context->has(self::CONTEXT_ROUTE)) { + if (!$context->has('route')) { return null; } - $route = $context->get(self::CONTEXT_ROUTE); + $route = $context->get('route'); return $route instanceof Route ? $route : null; } @@ -433,7 +423,7 @@ public function getRoute(): ?Route */ public function setRoute(Route $route): self { - $this->context()->set(self::CONTEXT_ROUTE, fn() => $route, []); + $this->context()->set('route', fn() => $route, []); return $this; } @@ -567,11 +557,11 @@ private function matchInternal(Request $request, bool $fresh = true): ?array { $context = $this->context(); - if (!$fresh && $context->has(self::CONTEXT_ROUTE)) { - $route = $context->get(self::CONTEXT_ROUTE); + if (!$fresh && $context->has('route')) { + $route = $context->get('route'); if ($route instanceof Route) { - $matchedPath = $context->has(self::CONTEXT_MATCHED_PATH) - ? (string) $context->get(self::CONTEXT_MATCHED_PATH) + $matchedPath = $context->has('matchedPath') + ? (string) $context->get('matchedPath') : ''; return [$route, $matchedPath]; } @@ -589,8 +579,8 @@ private function matchInternal(Request $request, bool $fresh = true): ?array } [$route, $matchedPath] = $match; - $context->set(self::CONTEXT_ROUTE, fn() => $route, []); - $context->set(self::CONTEXT_MATCHED_PATH, fn() => $matchedPath, []); + $context->set('route', fn() => $route, []); + $context->set('matchedPath', fn() => $matchedPath, []); return $match; } @@ -760,7 +750,7 @@ public function run(Request $request, Response $response): static $result = $this->runInternal($request, $response); $context = $this->context(); - $matchedRoute = $context->has(self::CONTEXT_ROUTE) ? $context->get(self::CONTEXT_ROUTE) : null; + $matchedRoute = $context->has('route') ? $context->get('route') : null; $requestDuration = microtime(true) - $start; $attributes = [ From 9d2e861eca2ca8f7b5a27db18a48a6d87ef4ecb9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:25:16 +0100 Subject: [PATCH 04/18] Rename Router fallback slot to wildcard Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 4 ++-- src/Http/Router.php | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 40e84b0..af594df 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -230,7 +230,7 @@ public static function delete(string $url): Route public static function wildcard(): Route { $route = new Route('', ''); - Router::setFallback($route); + Router::setWildcard($route); return $route; } @@ -590,7 +590,7 @@ private function matchInternal(Request $request, bool $fresh = true): ?array * * $matchedPath is the route key this request matched against (the * registered template after placeholder substitution). Pass '' for the - * fallback route or when path params aren't relevant. + * wildcard route or when path params aren't relevant. */ public function execute(Route $route, Request $request, Response $response, string $matchedPath = ''): static { diff --git a/src/Http/Router.php b/src/Http/Router.php index dbfee27..9be16b8 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -15,9 +15,9 @@ class Router protected static bool $allowOverride = false; /** - * Fallback route used when no method-specific route matches. Method-agnostic. + * Method-agnostic wildcard route used when no method-specific route matches. */ - protected static ?Route $fallback = null; + protected static ?Route $wildcard = null; /** * @var array @@ -111,11 +111,11 @@ public static function addRouteAlias(string $path, Route $route): void } /** - * Set the method-agnostic fallback route used when nothing else matches. + * Set the method-agnostic wildcard route used when nothing else matches. */ - public static function setFallback(?Route $route): void + public static function setWildcard(?Route $route): void { - self::$fallback = $route; + self::$wildcard = $route; } /** @@ -123,7 +123,7 @@ public static function setFallback(?Route $route): void * * Returns the matched Route together with the route key it matched against * (the registered template after placeholder substitution, or '*' for a - * wildcard, or '' for the fallback). Returning the matched key avoids + * wildcard, or '' for the method-agnostic wildcard). Returning the matched key avoids * mutating the shared Route instance, which would race under coroutines. * * @return array{0: Route, 1: string}|null @@ -131,7 +131,7 @@ public static function setFallback(?Route $route): void public static function match(string $method, string $path): ?array { if (!\array_key_exists($method, self::$routes)) { - return self::$fallback !== null ? [self::$fallback, ''] : null; + return self::$wildcard !== null ? [self::$wildcard, ''] : null; } $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); @@ -172,8 +172,8 @@ public static function match(string $method, string $path): ?array } } - if (self::$fallback !== null) { - return [self::$fallback, '']; + if (self::$wildcard !== null) { + return [self::$wildcard, '']; } return null; @@ -237,7 +237,7 @@ public static function preparePath(string $path): array public static function reset(): void { self::$params = []; - self::$fallback = null; + self::$wildcard = null; self::$routes = [ Http::REQUEST_METHOD_GET => [], Http::REQUEST_METHOD_POST => [], From 03c51c22a64b7e08019eb84dec1bec57444547f2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:29:46 +0100 Subject: [PATCH 05/18] Introduce Router\Result DTO for match results Replace the [Route, matchedPath] tuple with a readonly Router\Result value object so callers get named, typed access instead of positional unpacking. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 29 +++++++++++++++-------------- src/Http/Router.php | 24 ++++++++++++------------ src/Http/Router/Result.php | 22 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 src/Http/Router/Result.php diff --git a/src/Http/Http.php b/src/Http/Http.php index af594df..72a8fe2 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -3,6 +3,7 @@ namespace Utopia\Http; use Utopia\DI\Container; +use Utopia\Http\Router\Result as RouterResult; use Utopia\Servers\Hook; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -540,20 +541,19 @@ public function start(): void */ public function match(Request $request, bool $fresh = true): ?Route { - return $this->matchInternal($request, $fresh)[0] ?? null; + return $this->matchInternal($request, $fresh)?->route; } /** - * Match a request and return both the matched Route and the route key it - * matched against. Returning the matched key separately avoids mutating - * the shared Route instance, which would race under coroutines. + * Match a request and return the {@see RouterResult} carrying both the + * Route and the route key it matched against. Returning the matched key + * via the Result avoids mutating the shared Route instance, which would + * race under coroutines. * * Caches the result in the per-request context so re-matches with * $fresh=false hit memory without re-running Router::match. - * - * @return array{0: Route, 1: string}|null */ - private function matchInternal(Request $request, bool $fresh = true): ?array + private function matchInternal(Request $request, bool $fresh = true): ?RouterResult { $context = $this->context(); @@ -563,7 +563,7 @@ private function matchInternal(Request $request, bool $fresh = true): ?array $matchedPath = $context->has('matchedPath') ? (string) $context->get('matchedPath') : ''; - return [$route, $matchedPath]; + return new RouterResult($route, $matchedPath); } } @@ -572,17 +572,18 @@ private function matchInternal(Request $request, bool $fresh = true): ?array $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $match = Router::match($method, $url); + $result = Router::match($method, $url); - if ($match === null) { + if ($result === null) { return null; } - [$route, $matchedPath] = $match; + $route = $result->route; + $matchedPath = $result->matchedPath; $context->set('route', fn() => $route, []); $context->set('matchedPath', fn() => $matchedPath, []); - return $match; + return $result; } /** @@ -823,8 +824,8 @@ private function runInternal(Request $request, Response $response): static $method = $request->getMethod(); $match = $this->matchInternal($request); - $route = $match[0] ?? null; - $matchedPath = $match[1] ?? ''; + $route = $match?->route; + $matchedPath = $match->matchedPath ?? ''; $groups = ($route instanceof Route) ? $route->getGroups() : []; if (self::REQUEST_METHOD_HEAD === $method) { diff --git a/src/Http/Router.php b/src/Http/Router.php index 9be16b8..59531eb 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -3,6 +3,7 @@ namespace Utopia\Http; use Exception; +use Utopia\Http\Router\Result; class Router { @@ -121,17 +122,16 @@ public static function setWildcard(?Route $route): void /** * Match route against the method and path. * - * Returns the matched Route together with the route key it matched against - * (the registered template after placeholder substitution, or '*' for a - * wildcard, or '' for the method-agnostic wildcard). Returning the matched key avoids - * mutating the shared Route instance, which would race under coroutines. - * - * @return array{0: Route, 1: string}|null + * Returns a {@see Result} carrying the matched Route together with the + * route key it matched against (the registered template after placeholder + * substitution, '*' for a wildcard, or '' for the method-agnostic + * wildcard). Returning the key separately avoids mutating the shared + * Route instance, which would race under coroutines. */ - public static function match(string $method, string $path): ?array + public static function match(string $method, string $path): ?Result { if (!\array_key_exists($method, self::$routes)) { - return self::$wildcard !== null ? [self::$wildcard, ''] : null; + return self::$wildcard !== null ? new Result(self::$wildcard, '') : null; } $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); @@ -149,7 +149,7 @@ public static function match(string $method, string $path): ?array ); if (\array_key_exists($match, self::$routes[$method])) { - return [self::$routes[$method][$match], $match]; + return new Result(self::$routes[$method][$match], $match); } } @@ -158,7 +158,7 @@ public static function match(string $method, string $path): ?array */ $match = self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - return [self::$routes[$method][$match], $match]; + return new Result(self::$routes[$method][$match], $match); } /** @@ -168,12 +168,12 @@ public static function match(string $method, string $path): ?array $current = ($current ?? '') . "{$part}/"; $match = $current . self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - return [self::$routes[$method][$match], $match]; + return new Result(self::$routes[$method][$match], $match); } } if (self::$wildcard !== null) { - return [self::$wildcard, '']; + return new Result(self::$wildcard, ''); } return null; diff --git a/src/Http/Router/Result.php b/src/Http/Router/Result.php new file mode 100644 index 0000000..f1016dd --- /dev/null +++ b/src/Http/Router/Result.php @@ -0,0 +1,22 @@ + Date: Tue, 5 May 2026 16:31:28 +0100 Subject: [PATCH 06/18] Drop redundant preparePath in Http::execute $matchedPath is already the prepared form (the key from Router::$routes), so re-preparing it just returned the same string. Pass it straight to Route::getPathValues. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 72a8fe2..cc5e6f1 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -598,8 +598,7 @@ public function execute(Route $route, Request $request, Response $response, stri $arguments = []; $groups = $route->getGroups(); - $preparedPath = Router::preparePath($matchedPath); - $pathValues = $route->getPathValues($request, $preparedPath[0]); + $pathValues = $route->getPathValues($request, $matchedPath); try { if ($route->getHook()) { From 291326e87dfe5c669ff812875a252decae224019 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:36:53 +0100 Subject: [PATCH 07/18] Rename Router\Result to RouteMatch, drop matchInternal indirection - Move the value object to Utopia\Http\RouteMatch (top-level), since 'Match' is reserved by PHP 8.0+. RouteMatch is short, clear, and doesn't shadow the keyword. - Rename matchedPath -> path on the DTO; the field name is qualified by the surrounding RouteMatch context. - Inline matchInternal: public Http::match now returns ?RouteMatch directly instead of indirecting through a private helper. - Http::execute now takes a RouteMatch (route + matched path together) instead of separate args, so callers can't pass mismatched pairs. - Cache the whole RouteMatch under 'match' in the per-request context; keep 'route' set too for downstream injection compat. - Add per-property docblocks on RouteMatch. - Update tests to wrap raw Routes in RouteMatch when calling execute(). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 60 ++++++++++++-------------------------- src/Http/RouteMatch.php | 33 +++++++++++++++++++++ src/Http/Router.php | 15 +++++----- src/Http/Router/Result.php | 22 -------------- tests/HttpTest.php | 54 +++++++++++++++++----------------- 5 files changed, 85 insertions(+), 99 deletions(-) create mode 100644 src/Http/RouteMatch.php delete mode 100644 src/Http/Router/Result.php diff --git a/src/Http/Http.php b/src/Http/Http.php index cc5e6f1..ff38659 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -3,7 +3,6 @@ namespace Utopia\Http; use Utopia\DI\Container; -use Utopia\Http\Router\Result as RouterResult; use Utopia\Servers\Hook; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -539,31 +538,14 @@ public function start(): void * * @param bool $fresh If true, will not match any cached route */ - public function match(Request $request, bool $fresh = true): ?Route - { - return $this->matchInternal($request, $fresh)?->route; - } - - /** - * Match a request and return the {@see RouterResult} carrying both the - * Route and the route key it matched against. Returning the matched key - * via the Result avoids mutating the shared Route instance, which would - * race under coroutines. - * - * Caches the result in the per-request context so re-matches with - * $fresh=false hit memory without re-running Router::match. - */ - private function matchInternal(Request $request, bool $fresh = true): ?RouterResult + public function match(Request $request, bool $fresh = true): ?RouteMatch { $context = $this->context(); - if (!$fresh && $context->has('route')) { - $route = $context->get('route'); - if ($route instanceof Route) { - $matchedPath = $context->has('matchedPath') - ? (string) $context->get('matchedPath') - : ''; - return new RouterResult($route, $matchedPath); + if (!$fresh && $context->has('match')) { + $cached = $context->get('match'); + if ($cached instanceof RouteMatch) { + return $cached; } } @@ -572,33 +554,29 @@ private function matchInternal(Request $request, bool $fresh = true): ?RouterRes $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $result = Router::match($method, $url); + $match = Router::match($method, $url); - if ($result === null) { + if ($match === null) { return null; } - $route = $result->route; - $matchedPath = $result->matchedPath; + $context->set('match', fn() => $match, []); + $route = $match->route; $context->set('route', fn() => $route, []); - $context->set('matchedPath', fn() => $matchedPath, []); - return $result; + return $match; } /** - * Execute a given route with middlewares and error handling. - * - * $matchedPath is the route key this request matched against (the - * registered template after placeholder substitution). Pass '' for the - * wildcard route or when path params aren't relevant. + * Execute a matched route with middlewares and error handling. */ - public function execute(Route $route, Request $request, Response $response, string $matchedPath = ''): static + public function execute(RouteMatch $match, Request $request, Response $response): static { + $route = $match->route; $arguments = []; $groups = $route->getGroups(); - $pathValues = $route->getPathValues($request, $matchedPath); + $pathValues = $route->getPathValues($request, $match->path); try { if ($route->getHook()) { @@ -822,10 +800,8 @@ private function runInternal(Request $request, Response $response): static } $method = $request->getMethod(); - $match = $this->matchInternal($request); - $route = $match?->route; - $matchedPath = $match->matchedPath ?? ''; - $groups = ($route instanceof Route) ? $route->getGroups() : []; + $match = $this->match($request); + $groups = $match instanceof RouteMatch ? $match->route->getGroups() : []; if (self::REQUEST_METHOD_HEAD === $method) { $method = self::REQUEST_METHOD_GET; @@ -862,8 +838,8 @@ private function runInternal(Request $request, Response $response): static return $this; } - if (null !== $route) { - return $this->execute($route, $request, $response, $matchedPath); + if ($match instanceof RouteMatch) { + return $this->execute($match, $request, $response); } if (self::REQUEST_METHOD_OPTIONS === $method) { diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php new file mode 100644 index 0000000..6b46eaa --- /dev/null +++ b/src/Http/RouteMatch.php @@ -0,0 +1,33 @@ + $segment !== '')); @@ -149,7 +148,7 @@ public static function match(string $method, string $path): ?Result ); if (\array_key_exists($match, self::$routes[$method])) { - return new Result(self::$routes[$method][$match], $match); + return new RouteMatch(self::$routes[$method][$match], $match); } } @@ -158,7 +157,7 @@ public static function match(string $method, string $path): ?Result */ $match = self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - return new Result(self::$routes[$method][$match], $match); + return new RouteMatch(self::$routes[$method][$match], $match); } /** @@ -168,12 +167,12 @@ public static function match(string $method, string $path): ?Result $current = ($current ?? '') . "{$part}/"; $match = $current . self::WILDCARD_TOKEN; if (\array_key_exists($match, self::$routes[$method])) { - return new Result(self::$routes[$method][$match], $match); + return new RouteMatch(self::$routes[$method][$match], $match); } } if (self::$wildcard !== null) { - return new Result(self::$wildcard, ''); + return new RouteMatch(self::$wildcard, ''); } return null; diff --git a/src/Http/Router/Result.php b/src/Http/Router/Result.php deleted file mode 100644 index f1016dd..0000000 --- a/src/Http/Router/Result.php +++ /dev/null @@ -1,22 +0,0 @@ -http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -135,7 +135,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y', 'z' => 'param-z']); - $this->http->execute($route, $request, new Response()); + $this->http->execute(new RouteMatch($route, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -155,7 +155,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($route, $request, new Response()); + $this->http->execute(new RouteMatch($route, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -227,7 +227,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($route, $request, new Response()); + $this->http->execute(new RouteMatch($route, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -237,7 +237,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($homepage, $request, new Response()); + $this->http->execute(new RouteMatch($homepage, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -267,7 +267,7 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -283,7 +283,7 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -316,7 +316,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -333,7 +333,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -350,7 +350,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -367,7 +367,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -384,7 +384,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -401,7 +401,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -420,7 +420,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -439,7 +439,7 @@ public function testCanResolveParamAliases(): void }); $matched = $this->http->match(new Request()); - $this->assertSame($route, $matched); + $this->assertSame($route, $matched?->route); ob_start(); $this->http->execute($matched, new Request(), new Response()); @@ -459,7 +459,7 @@ public function testCanResolveParamAliases(): void }); $matched = $this->http->match(new Request()); - $this->assertSame($route, $matched); + $this->assertSame($route, $matched?->route); ob_start(); $this->http->execute($matched, new Request(), new Response()); @@ -539,7 +539,7 @@ public function testCanHookThrowExceptions(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -547,7 +547,7 @@ public function testCanHookThrowExceptions(): void ob_start(); $_GET['y'] = 'y-def'; - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -609,7 +609,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_METHOD'] = $method; $_SERVER['REQUEST_URI'] = $url; - $this->assertSame($expected, $this->http->match(new Request())); + $this->assertSame($expected, $this->http->match(new Request())?->route); $this->assertSame($expected, $this->http->getRoute()); } @@ -653,7 +653,7 @@ public function testCanMatchFreshRoute(): void $_SERVER['REQUEST_METHOD'] = 'HEAD'; $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); - $this->assertSame($route1, $matched); + $this->assertSame($route1, $matched?->route); $this->assertSame($route1, $this->http->getRoute()); // Second request match returns cached route @@ -661,12 +661,12 @@ public function testCanMatchFreshRoute(): void $_SERVER['REQUEST_URI'] = '/path2'; $request2 = new Request(); $matched = $this->http->match($request2, fresh: false); - $this->assertSame($route1, $matched); + $this->assertSame($route1, $matched?->route); $this->assertSame($route1, $this->http->getRoute()); // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); - $this->assertSame($route2, $matched); + $this->assertSame($route2, $matched?->route); $this->assertSame($route2, $this->http->getRoute()); } catch (\Exception $e) { $this->fail($e->getMessage()); @@ -680,7 +680,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_METHOD'] = Http::REQUEST_METHOD_GET; $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; - $this->assertSame($route, $this->http->match(new Request())); + $this->assertSame($route, $this->http->match(new Request())?->route); $this->assertSame($route, $this->http->getRoute()); } @@ -796,7 +796,7 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -814,7 +814,7 @@ public function testCallableStringParametersNotExecuted(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['func' => 'system']); - $this->http->execute($route2, $request, new Response()); + $this->http->execute(new RouteMatch($route2, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -830,7 +830,7 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute($route3, new Request(), new Response()); + $this->http->execute(new RouteMatch($route3, ""), new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -858,7 +858,7 @@ public function testCanInjectResourceAndParamWithSameName(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['locale' => 'es']); - $this->http->execute($route, $request, new Response()); + $this->http->execute(new RouteMatch($route, ""), $request, new Response()); $result = ob_get_contents(); ob_end_clean(); From 2ba2fa486b00a64a0f4393642cc49422792467b0 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:54:28 +0100 Subject: [PATCH 08/18] Make execute(Request, Response) the public dispatch entry point Aligns the API with how callers think about it: Route is a definition, RouteMatch is the immutable result of matching, execute() is the verb that ties them together (match -> resolve -> run). - Http::execute now takes (Request, Response) and does match + dispatch internally, including OPTIONS/HEAD handling and 404 fallback. Replaces the prior shape that required callers to pre-build a RouteMatch. - Http::match becomes stateless: drop the $fresh / context-cache that silently returned the previous request's match when a caller invoked execute() multiple times with different requests. - runInternal collapses to: pre-checks (compression, request hooks, static files) + delegate to execute(). - Update tests: hand-built routes now register via Http::get/post/etc., set $_SERVER['REQUEST_URI'] before execute(), and use Http::setAllowOverride(true) for tests that re-register the same path. - Update Router::match callers to unwrap ->route from RouteMatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 158 ++++++++++++++++------------------------ src/Http/RouteMatch.php | 12 +++ tests/HttpTest.php | 129 ++++++++++++++++++-------------- tests/RouterTest.php | 74 +++++++++---------- 4 files changed, 188 insertions(+), 185 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index ff38659..b46be9f 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -532,23 +532,13 @@ public function start(): void } /** - * Match + * Find the matching route for a request, or null if none match. * - * Find matching route given current user request - * - * @param bool $fresh If true, will not match any cached route + * Stateless: re-runs the lookup every call, so callers always see a + * result reflecting the request they passed in. */ - public function match(Request $request, bool $fresh = true): ?RouteMatch + public function match(Request $request): ?RouteMatch { - $context = $this->context(); - - if (!$fresh && $context->has('match')) { - $cached = $context->get('match'); - if ($cached instanceof RouteMatch) { - return $cached; - } - } - $url = parse_url($request->getURI(), PHP_URL_PATH); $url = \is_string($url) ? ($url === '' ? '/' : $url) : '/'; $method = $request->getMethod(); @@ -560,18 +550,73 @@ public function match(Request $request, bool $fresh = true): ?RouteMatch return null; } - $context->set('match', fn() => $match, []); $route = $match->route; - $context->set('route', fn() => $route, []); + $this->context()->set('route', fn() => $route, []); return $match; } /** - * Execute a matched route with middlewares and error handling. + * Match the request to a registered route, then run its handler and hooks. + * + * Handles OPTIONS preflight (fires options hooks, returns) and HEAD + * (matches as GET, suppresses the response body). If no route matches and + * the method isn't OPTIONS, fires global error hooks with a 404 Exception. */ - public function execute(RouteMatch $match, Request $request, Response $response): static + public function execute(Request $request, Response $response): static { + $method = $request->getMethod(); + + if (self::REQUEST_METHOD_HEAD === $method) { + $method = self::REQUEST_METHOD_GET; + $response->disablePayload(); + } + + $match = $this->match($request); + + if (self::REQUEST_METHOD_OPTIONS === $method) { + $groups = $match?->route->getGroups() ?? []; + + try { + foreach ($groups as $group) { + foreach (self::$options as $option) { // Group options hooks + /** @var Hook $option */ + if (\in_array($group, $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); + } + } + } + + foreach (self::$options as $option) { // Global options hooks + /** @var Hook $option */ + if (\in_array('*', $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); + } + } + } catch (\Throwable $e) { + foreach (self::$errors as $error) { // Global error hooks + /** @var Hook $error */ + if (\in_array('*', $error->getGroups())) { + $this->context()->set('error', fn() => $e, []); + \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); + } + } + } + + return $this; + } + + if ($match === null) { + foreach (self::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + $this->context()->set('error', fn() => new Exception('Not Found', 404), []); + \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); + } + } + + return $this; + } + $route = $match->route; $arguments = []; $groups = $route->getGroups(); @@ -799,82 +844,7 @@ private function runInternal(Request $request, Response $response): static return $this; } - $method = $request->getMethod(); - $match = $this->match($request); - $groups = $match instanceof RouteMatch ? $match->route->getGroups() : []; - - if (self::REQUEST_METHOD_HEAD === $method) { - $method = self::REQUEST_METHOD_GET; - $response->disablePayload(); - } - - if (self::REQUEST_METHOD_OPTIONS === $method) { - try { - foreach ($groups as $group) { - foreach (self::$options as $option) { // Group options hooks - /** @var Hook $option */ - if (\in_array($group, $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } - - foreach (self::$options as $option) { // Global options hooks - /** @var Hook $option */ - if (\in_array('*', $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } catch (\Throwable $e) { - foreach (self::$errors as $error) { // Global error hooks - /** @var Hook $error */ - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => $e, []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - - return $this; - } - - if ($match instanceof RouteMatch) { - return $this->execute($match, $request, $response); - } - - if (self::REQUEST_METHOD_OPTIONS === $method) { - try { - foreach ($groups as $group) { - foreach (self::$options as $option) { // Group options hooks - if (\in_array($group, $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } - - foreach (self::$options as $option) { // Global options hooks - if (\in_array('*', $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } catch (\Throwable $e) { - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => $e, []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - } else { - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => new Exception('Not Found', 404), []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - - return $this; + return $this->execute($request, $response); } diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 6b46eaa..8a682a9 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -30,4 +30,16 @@ public function __construct( public string $path, ) { } + + /** + * Wrap a Route with no matched template — for invoking a Route's handler + * outside the routing pipeline (e.g. in tests). Path-param resolution + * falls back to the Route's first registered template, which is correct + * iff the Route has no aliases. Routed callers should construct a full + * RouteMatch via {@see Router::match()} to pick up the right template. + */ + public static function for(Route $route): self + { + return new self($route, ''); + } } diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 8d8bbce..6182c11 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -90,6 +90,10 @@ public function testCanGetEnvironmentVariable(): void public function testCanExecuteRoute(): void { + Http::setAllowOverride(true); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->resources->set('rand', fn() => rand()); $resource = $this->resources->get('rand'); @@ -101,7 +105,7 @@ public function testCanExecuteRoute(): void }); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) @@ -111,13 +115,13 @@ public function testCanExecuteRoute(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); // With Params $resource = $this->resources->get('rand'); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) @@ -135,7 +139,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y', 'z' => 'param-z']); - $this->http->execute(new RouteMatch($route, ""), $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -143,7 +147,7 @@ public function testCanExecuteRoute(): void // With Error $resource = $this->resources->get('rand'); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(1, min: 0), 'x param', false) @@ -155,7 +159,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute(new RouteMatch($route, ""), $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -204,7 +208,7 @@ public function testCanExecuteRoute(): void echo '-(shutdown-homepage)'; }); - $route = new Route('GET', '/path'); + $route = Http::get('/api'); $route ->groups(['api']) @@ -214,7 +218,7 @@ public function testCanExecuteRoute(): void echo $x . '-', $y; }); - $homepage = new Route('GET', '/path'); + $homepage = Http::get('/homepage'); $homepage ->groups(['homepage']) @@ -227,7 +231,8 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute(new RouteMatch($route, ""), $request, new Response()); + $_SERVER['REQUEST_URI'] = '/api'; + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -237,7 +242,8 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute(new RouteMatch($homepage, ""), $request, new Response()); + $_SERVER['REQUEST_URI'] = '/homepage'; + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -246,6 +252,10 @@ public function testCanExecuteRoute(): void public function testCanAddAndExecuteHooks(): void { + Http::setAllowOverride(true); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->http ->init() ->action(function () { @@ -259,7 +269,7 @@ public function testCanAddAndExecuteHooks(): void }); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->action(function ($x) { @@ -267,14 +277,14 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('(init)-x-def-(shutdown)', $result); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->hook(false) @@ -283,7 +293,7 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -292,6 +302,8 @@ public function testCanAddAndExecuteHooks(): void public function testCanResolveParamAliases(): void { + Http::setAllowOverride(true); + $this->http ->error() ->inject('error') @@ -302,13 +314,15 @@ public function testCanResolveParamAliases(): void $savedGet = $_GET; $savedPost = $_POST; $savedMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $savedUri = $_SERVER['REQUEST_URI'] ?? null; try { // GET request: alias resolves from $_GET when canonical key is absent $_GET = ['xAlias' => 'from-alias']; $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias', 'xLegacy']) ->action(function ($x) { @@ -316,7 +330,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -325,7 +339,7 @@ public function testCanResolveParamAliases(): void // GET request: canonical key wins when both are present in $_GET $_GET = ['x' => 'canonical', 'xAlias' => 'aliased']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -333,7 +347,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -342,7 +356,7 @@ public function testCanResolveParamAliases(): void // GET request: first matching alias wins when multiple are present in $_GET $_GET = ['xAlias2' => 'second', 'xAlias1' => 'first']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias1', 'xAlias2']) ->action(function ($x) { @@ -350,7 +364,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -359,7 +373,7 @@ public function testCanResolveParamAliases(): void // GET request: falls back to default when neither canonical nor any alias is in $_GET $_GET = ['unrelated' => 'value']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -367,7 +381,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -376,7 +390,7 @@ public function testCanResolveParamAliases(): void // GET request: required param throws when neither canonical nor any alias is in $_GET $_GET = ['unrelated' => 'value']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', '', new Text(200), 'x param', false, aliases: ['xAlias']) ->action(function ($x) { @@ -384,7 +398,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -393,7 +407,7 @@ public function testCanResolveParamAliases(): void // GET request: validation runs against the aliased value and reports the canonical key $_GET = ['xAlias' => 'too-long']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', '', new Text(1, min: 0), 'x param', false, aliases: ['xAlias']) ->action(function ($x) { @@ -401,7 +415,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -412,7 +426,7 @@ public function testCanResolveParamAliases(): void $_POST = ['xAlias' => 'posted-alias']; $_SERVER['REQUEST_METHOD'] = 'POST'; - $route = new Route('POST', '/path'); + $route = Http::post('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -420,7 +434,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -442,7 +456,7 @@ public function testCanResolveParamAliases(): void $this->assertSame($route, $matched?->route); ob_start(); - $this->http->execute($matched, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -462,7 +476,7 @@ public function testCanResolveParamAliases(): void $this->assertSame($route, $matched?->route); ob_start(); - $this->http->execute($matched, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -475,6 +489,11 @@ public function testCanResolveParamAliases(): void } else { $_SERVER['REQUEST_METHOD'] = $savedMethod; } + if ($savedUri === null) { + unset($_SERVER['REQUEST_URI']); + } else { + $_SERVER['REQUEST_URI'] = $savedUri; + } } } @@ -510,6 +529,9 @@ public function testAllowRouteOverrides(): void public function testCanHookThrowExceptions(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->http ->init() ->param('y', '', new Text(5), 'y param', false) @@ -531,7 +553,7 @@ public function testCanHookThrowExceptions(): void }); // param not provided for init - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->action(function ($x) { @@ -539,7 +561,7 @@ public function testCanHookThrowExceptions(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -547,7 +569,7 @@ public function testCanHookThrowExceptions(): void ob_start(); $_GET['y'] = 'y-def'; - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -556,7 +578,7 @@ public function testCanHookThrowExceptions(): void public function testCanSetRoute(): void { - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $this->assertNull($this->http->getRoute()); $this->http->setRoute($route); @@ -636,36 +658,27 @@ public function testNoMismatchRoute(): void $_SERVER['REQUEST_METHOD'] = Http::REQUEST_METHOD_GET; $_SERVER['REQUEST_URI'] = $request['url']; - $route = $this->http->match(new Request(), fresh: true); + $route = $this->http->match(new Request()); $this->assertNull($route); $this->assertNull($this->http->getRoute()); } } - public function testCanMatchFreshRoute(): void + public function testMatchReflectsCurrentRequest(): void { $route1 = Http::get('/path1'); $route2 = Http::get('/path2'); try { - // Match first request $_SERVER['REQUEST_METHOD'] = 'HEAD'; $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); $this->assertSame($route1, $matched?->route); $this->assertSame($route1, $this->http->getRoute()); - // Second request match returns cached route - $_SERVER['REQUEST_METHOD'] = 'HEAD'; $_SERVER['REQUEST_URI'] = '/path2'; - $request2 = new Request(); - $matched = $this->http->match($request2, fresh: false); - $this->assertSame($route1, $matched?->route); - $this->assertSame($route1, $this->http->getRoute()); - - // Fresh match returns new route - $matched = $this->http->match($request2, fresh: true); + $matched = $this->http->match(new Request()); $this->assertSame($route2, $matched?->route); $this->assertSame($route2, $this->http->getRoute()); } catch (\Exception $e) { @@ -784,8 +797,11 @@ public function testWildcardRouteWhenUriHasNoPath(): void public function testCallableStringParametersNotExecuted(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + // Test that callable strings (like function names) are not executed - $route = new Route('GET', '/test-callable-string'); + $_SERVER['REQUEST_URI'] = '/test-callable-string'; + $route = Http::get('/test-callable-string'); $route ->param('callback', 'phpinfo', new Text(200), 'callback param', true) @@ -796,14 +812,15 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute(new RouteMatch($route, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('callback-value: phpinfo', $result); // Test with request parameter that is a callable string - $route2 = new Route('GET', '/test-callable-string-param'); + $_SERVER['REQUEST_URI'] = '/test-callable-string-param'; + $route2 = Http::get('/test-callable-string-param'); $route2 ->param('func', 'default', new Text(200), 'func param', false) @@ -814,14 +831,15 @@ public function testCallableStringParametersNotExecuted(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['func' => 'system']); - $this->http->execute(new RouteMatch($route2, ""), $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('func-value: system', $result); // Test callable closure still works - $route3 = new Route('GET', '/test-callable-closure'); + $_SERVER['REQUEST_URI'] = '/test-callable-closure'; + $route3 = Http::get('/test-callable-closure'); $route3 ->param('generated', fn() => 'generated-value', new Text(200), 'generated param', true) @@ -830,7 +848,7 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute(new RouteMatch($route3, ""), new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -839,11 +857,14 @@ public function testCallableStringParametersNotExecuted(): void public function testCanInjectResourceAndParamWithSameName(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + // Register a 'locale' resource returning a Locale instance whose // `name` statically resolves to "en". $this->resources->set('locale', fn() => new Locale()); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('locale', 'en-default', new Text(10), 'locale param', false) @@ -858,7 +879,7 @@ public function testCanInjectResourceAndParamWithSameName(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['locale' => 'es']); - $this->http->execute(new RouteMatch($route, ""), $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 4ca0134..5eaa0c7 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -23,9 +23,9 @@ public function testCanMatchUrl(): void Router::addRoute($routeAbout); Router::addRoute($routeAboutMe); - $this->assertEquals($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')); - $this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')); + $this->assertEquals($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')?->route); + $this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')?->route); } public function testCanMatchUrlWithPlaceholder(): void @@ -44,12 +44,12 @@ public function testCanMatchUrlWithPlaceholder(): void Router::addRoute($routeBlogPostComments); Router::addRoute($routeBlogPostCommentsSingle); - $this->assertEquals($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')); - $this->assertEquals($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')); - $this->assertEquals($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')); - $this->assertEquals($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')); - $this->assertEquals($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')); - $this->assertEquals($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')); + $this->assertEquals($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')?->route); + $this->assertEquals($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')?->route); + $this->assertEquals($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')?->route); + $this->assertEquals($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')?->route); + $this->assertEquals($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')?->route); + $this->assertEquals($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')?->route); } public function testCanMatchUrlWithWildcard(): void @@ -62,11 +62,11 @@ public function testCanMatchUrlWithWildcard(): void Router::addRoute($routeAbout); Router::addRoute($routeAboutWildcard); - $this->assertEquals($routeIndex, Router::match('GET', '/')); - $this->assertEquals($routeAbout, Router::match('GET', '/about')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/you')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')); + $this->assertEquals($routeIndex, Router::match('GET', '/')?->route); + $this->assertEquals($routeAbout, Router::match('GET', '/about')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/you')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')?->route); } public function testCanMatchHttpMethod(): void @@ -77,11 +77,11 @@ public function testCanMatchHttpMethod(): void Router::addRoute($routeGET); Router::addRoute($routePOST); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')?->route); - $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')); - $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')); + $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')?->route); + $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); } public function testCanMatchAlias(): void @@ -93,9 +93,9 @@ public function testCanMatchAlias(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')?->route); } public function testCanMatchMultipleAliases(): void @@ -108,10 +108,10 @@ public function testCanMatchMultipleAliases(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')?->route); } public function testCanMatchMix(): void @@ -127,14 +127,14 @@ public function testCanMatchMix(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')?->route); } public function testCanMatchFilename(): void @@ -142,12 +142,12 @@ public function testCanMatchFilename(): void $routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt'); Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')?->route); } public function testCannotFindUnknownRouteByPath(): void { - $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/404')?->route); } public function testCannotFindUnknownRouteByMethod(): void @@ -156,8 +156,8 @@ public function testCannotFindUnknownRouteByMethod(): void Router::addRoute($route); - $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')?->route); - $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')); + $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')?->route); } } From 0fe0807a115e18da0ca9018ca8fcf9a5c8889130 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 16:58:39 +0100 Subject: [PATCH 09/18] RouteMatch carries resolved params, not the matched template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matched-template string was purely instrumental — its only job was to look up Route::pathParams[$template] so callers could resolve URL segments into a name->value map. Now Router::match resolves the params itself and stores them on RouteMatch directly, so dispatch is just `$match->params` with no second-stage resolution. - RouteMatch.path: string -> RouteMatch.params: array. - Route::getPathValues renamed to Route::resolveParams; takes a URL string instead of a Request (the resolution doesn't need anything else from the request). - Router::match calls resolveParams at match time. Static and wildcard matches pass [] for params. - Http::execute drops getPathValues call; reads $match->params directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 2 +- src/Http/Route.php | 17 ++++++++++------- src/Http/RouteMatch.php | 25 +++++-------------------- src/Http/Router.php | 32 ++++++++++++++++---------------- 4 files changed, 32 insertions(+), 44 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index b46be9f..63bd342 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -621,7 +621,7 @@ public function execute(Request $request, Response $response): static $arguments = []; $groups = $route->getGroups(); - $pathValues = $route->getPathValues($request, $match->path); + $pathValues = $match->params; try { if ($route->getHook()) { diff --git a/src/Http/Route.php b/src/Http/Route.php index adb8ca3..1ea3606 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -117,19 +117,22 @@ public function setPathParam(string $key, int $index, string $path = ''): void } /** - * Get path params. + * Resolve path params for the given request URL against a registered + * template. Pass `''` to fall back to the route's first registered + * template (correct only when there are no aliases with differing + * placeholder positions). * - * @return array + * @return array */ - public function getPathValues(Request $request, string $path = ''): array + public function resolveParams(string $url, string $matchedTemplate = ''): array { $pathValues = []; - $parts = explode('/', ltrim($request->getURI(), '/')); + $parts = explode('/', ltrim($url, '/')); - if (empty($path)) { - $pathParams = $this->pathParams[$path] ?? array_values($this->pathParams)[0] ?? []; + if (empty($matchedTemplate)) { + $pathParams = $this->pathParams[$matchedTemplate] ?? array_values($this->pathParams)[0] ?? []; } else { - $pathParams = $this->pathParams[$path] ?? []; + $pathParams = $this->pathParams[$matchedTemplate] ?? []; } foreach ($pathParams as $key => $index) { diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 8a682a9..aa644dc 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -18,28 +18,13 @@ public function __construct( */ public Route $route, /** - * The route key this request matched against: the registered template - * after placeholder substitution (e.g. `users/:::` for `/users/:id`), - * `*` for a method-specific wildcard, or `''` for the method-agnostic - * wildcard set via {@see Router::setWildcard()}. + * Path params resolved from the request URL against the matched + * template (e.g. `['id' => 'abc-123']` for `/users/:id` matching + * `/users/abc-123`). Empty for static routes and wildcards. * - * Used as the key into {@see Route::getPathValues()} to resolve path - * params for the matched template (a single Route can be registered - * under multiple templates via aliases). + * @var array */ - public string $path, + public array $params, ) { } - - /** - * Wrap a Route with no matched template — for invoking a Route's handler - * outside the routing pipeline (e.g. in tests). Path-param resolution - * falls back to the Route's first registered template, which is correct - * iff the Route has no aliases. Routed callers should construct a full - * RouteMatch via {@see Router::match()} to pick up the right template. - */ - public static function for(Route $route): self - { - return new self($route, ''); - } } diff --git a/src/Http/Router.php b/src/Http/Router.php index a05346b..889e091 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -121,16 +121,15 @@ public static function setWildcard(?Route $route): void /** * Match route against the method and path. * - * Returns a {@see RouteMatch} carrying the matched Route together with the - * route key it matched against (the registered template after placeholder - * substitution, '*' for a wildcard, or '' for the method-agnostic - * wildcard). Returning the key separately avoids mutating the shared - * Route instance, which would race under coroutines. + * Returns a {@see RouteMatch} carrying the matched Route and the path + * params resolved from the request URL against the matched template. + * Resolving params at match time (rather than mutating the shared Route) + * keeps the Route immutable across coroutines. */ public static function match(string $method, string $path): ?RouteMatch { if (!\array_key_exists($method, self::$routes)) { - return self::$wildcard !== null ? new RouteMatch(self::$wildcard, '') : null; + return self::$wildcard !== null ? new RouteMatch(self::$wildcard, []) : null; } $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); @@ -139,7 +138,7 @@ public static function match(string $method, string $path): ?RouteMatch foreach (self::combinations($filteredParams) as $sample) { $sample = array_filter($sample, fn(int $i) => $i <= $length); - $match = implode( + $template = implode( '/', array_replace( $parts, @@ -147,17 +146,18 @@ public static function match(string $method, string $path): ?RouteMatch ), ); - if (\array_key_exists($match, self::$routes[$method])) { - return new RouteMatch(self::$routes[$method][$match], $match); + if (\array_key_exists($template, self::$routes[$method])) { + $route = self::$routes[$method][$template]; + return new RouteMatch($route, $route->resolveParams($path, $template)); } } /** * Match root wildcard. */ - $match = self::WILDCARD_TOKEN; - if (\array_key_exists($match, self::$routes[$method])) { - return new RouteMatch(self::$routes[$method][$match], $match); + $template = self::WILDCARD_TOKEN; + if (\array_key_exists($template, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$template], []); } /** @@ -165,14 +165,14 @@ public static function match(string $method, string $path): ?RouteMatch */ foreach ($parts as $part) { $current = ($current ?? '') . "{$part}/"; - $match = $current . self::WILDCARD_TOKEN; - if (\array_key_exists($match, self::$routes[$method])) { - return new RouteMatch(self::$routes[$method][$match], $match); + $template = $current . self::WILDCARD_TOKEN; + if (\array_key_exists($template, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$template], []); } } if (self::$wildcard !== null) { - return new RouteMatch(self::$wildcard, ''); + return new RouteMatch(self::$wildcard, []); } return null; From 08545daf9778eb494ad8ff68d9c59d702f547014 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:00:57 +0100 Subject: [PATCH 10/18] Drop Http::setRoute() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matched route is owned by the routing pipeline and lives in the per-request context. setRoute let arbitrary code overwrite it post-match without invalidating any other state — a footgun under coroutines and not used in production. Drop it; getRoute() remains as a read-only view. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 13 ++----------- tests/HttpTest.php | 9 --------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 63bd342..3a76520 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -405,7 +405,8 @@ public static function getRoutes(): array } /** - * Get the current route + * Get the route matched for the current request, or null if none matched + * yet (or no match was found). Populated by {@see Http::match()}. */ public function getRoute(): ?Route { @@ -418,16 +419,6 @@ public function getRoute(): ?Route return $route instanceof Route ? $route : null; } - /** - * Set the current route - */ - public function setRoute(Route $route): self - { - $this->context()->set('route', fn() => $route, []); - - return $this; - } - /** * Add Route * diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 6182c11..b5e8c03 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -576,15 +576,6 @@ public function testCanHookThrowExceptions(): void $this->assertSame('(init)-y-def-x-def-(shutdown)', $result); } - public function testCanSetRoute(): void - { - $route = Http::get('/path'); - - $this->assertNull($this->http->getRoute()); - $this->http->setRoute($route); - $this->assertSame($route, $this->http->getRoute()); - } - /** * @return \Iterator> */ From 94371ded04a0abf16bfe875f884dd878a101fe52 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:02:39 +0100 Subject: [PATCH 11/18] Drop Http::getRoute() The supported way to consume the matched route is via the 'route' injection inside hooks/actions: Http::init() ->inject('route') ->action(function (?Route $route) { ... }); getRoute() was a convenience accessor on the shared Http instance. Reading mutable per-request state through a method on a shared object encourages racy patterns under coroutines (e.g. caching a Route reference, calling getRoute() outside a request scope). Drop it; tests that needed the matched route now consume it via the injection or via the RouteMatch returned from match() directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 15 --------------- tests/HttpTest.php | 9 ++------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 3a76520..7b6dbc6 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -404,21 +404,6 @@ public static function getRoutes(): array return Router::getRoutes(); } - /** - * Get the route matched for the current request, or null if none matched - * yet (or no match was found). Populated by {@see Http::match()}. - */ - public function getRoute(): ?Route - { - $context = $this->context(); - if (!$context->has('route')) { - return null; - } - - $route = $context->get('route'); - return $route instanceof Route ? $route : null; - } - /** * Add Route * diff --git a/tests/HttpTest.php b/tests/HttpTest.php index b5e8c03..f7b142d 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -623,7 +623,6 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_URI'] = $url; $this->assertSame($expected, $this->http->match(new Request())?->route); - $this->assertSame($expected, $this->http->getRoute()); } public function testNoMismatchRoute(): void @@ -652,7 +651,6 @@ public function testNoMismatchRoute(): void $route = $this->http->match(new Request()); $this->assertNull($route); - $this->assertNull($this->http->getRoute()); } } @@ -666,12 +664,10 @@ public function testMatchReflectsCurrentRequest(): void $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); $this->assertSame($route1, $matched?->route); - $this->assertSame($route1, $this->http->getRoute()); $_SERVER['REQUEST_URI'] = '/path2'; $matched = $this->http->match(new Request()); $this->assertSame($route2, $matched?->route); - $this->assertSame($route2, $this->http->getRoute()); } catch (\Exception $e) { $this->fail($e->getMessage()); } @@ -685,7 +681,6 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; $this->assertSame($route, $this->http->match(new Request())?->route); - $this->assertSame($route, $this->http->getRoute()); } public function testCanRunRequest(): void @@ -724,8 +719,8 @@ public function testWildcardRoute(): void $_SERVER['REQUEST_URI'] = '/unknown_path'; Http::init() - ->action(function () { - $route = $this->http->getRoute(); + ->inject('route') + ->action(function (?Route $route) { $this->resources->set('myRoute', fn() => $route); }); From 9e697cb8b1722235db1d9e97ffc065bb91f5280b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:05:19 +0100 Subject: [PATCH 12/18] Tighten doc comments to user-facing intent Drop internal narrative about coroutine safety, mutation-vs-immutability, and "we used to do X." Comments now describe what each public surface does for someone calling it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 13 +++++-------- src/Http/Route.php | 5 +---- src/Http/RouteMatch.php | 17 +++++------------ src/Http/Router.php | 9 ++------- 4 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 7b6dbc6..aa656c5 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -508,10 +508,7 @@ public function start(): void } /** - * Find the matching route for a request, or null if none match. - * - * Stateless: re-runs the lookup every call, so callers always see a - * result reflecting the request they passed in. + * Find the route registered for the given request, or null if none match. */ public function match(Request $request): ?RouteMatch { @@ -533,11 +530,11 @@ public function match(Request $request): ?RouteMatch } /** - * Match the request to a registered route, then run its handler and hooks. + * Match the request, then run the route's handler and hooks. * - * Handles OPTIONS preflight (fires options hooks, returns) and HEAD - * (matches as GET, suppresses the response body). If no route matches and - * the method isn't OPTIONS, fires global error hooks with a 404 Exception. + * HEAD requests run as GET with the response body suppressed. + * OPTIONS requests fire options hooks and return without dispatching. + * Unmatched requests fire global error hooks with a 404. */ public function execute(Request $request, Response $response): static { diff --git a/src/Http/Route.php b/src/Http/Route.php index 1ea3606..ab1d769 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -117,10 +117,7 @@ public function setPathParam(string $key, int $index, string $path = ''): void } /** - * Resolve path params for the given request URL against a registered - * template. Pass `''` to fall back to the route's first registered - * template (correct only when there are no aliases with differing - * placeholder positions). + * Extract this route's path params from a request URL. * * @return array */ diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index aa644dc..590fb7a 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -3,24 +3,17 @@ namespace Utopia\Http; /** - * Immutable result of {@see Router::match()}. - * - * Carries the matched Route together with the route key it matched against - * (the registered template after placeholder substitution, '*' for a wildcard, - * or '' for the method-agnostic wildcard). Returning a value object instead of - * mutating the Route avoids racing the shared Route under coroutines. + * The result of matching a request against the registered routes. */ final readonly class RouteMatch { public function __construct( - /** - * The matched Route — the registered handler for this request. - */ public Route $route, /** - * Path params resolved from the request URL against the matched - * template (e.g. `['id' => 'abc-123']` for `/users/:id` matching - * `/users/abc-123`). Empty for static routes and wildcards. + * Path params parsed from the request URL. + * + * For example `['id' => 'abc-123']` when `/users/:id` matches + * `/users/abc-123`. Empty for static routes and wildcards. * * @var array */ diff --git a/src/Http/Router.php b/src/Http/Router.php index 889e091..81ef21d 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -111,7 +111,7 @@ public static function addRouteAlias(string $path, Route $route): void } /** - * Set the method-agnostic wildcard route used when nothing else matches. + * Register a method-agnostic catch-all route, used when nothing else matches. */ public static function setWildcard(?Route $route): void { @@ -119,12 +119,7 @@ public static function setWildcard(?Route $route): void } /** - * Match route against the method and path. - * - * Returns a {@see RouteMatch} carrying the matched Route and the path - * params resolved from the request URL against the matched template. - * Resolving params at match time (rather than mutating the shared Route) - * keeps the Route immutable across coroutines. + * Find the route registered for a request's method and path. */ public static function match(string $method, string $path): ?RouteMatch { From 14d9e79f90005b58393b21de73c9c1d5ebb782c2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:10:48 +0100 Subject: [PATCH 13/18] Document run vs execute as distinct entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run() is the top-level request lifecycle (compression, request hooks, static files, match, dispatch, telemetry) — wired into the server adapter. execute() is the re-entrant dispatch primitive — match + handler + hooks only — for sub-requests from inside a handler (e.g. GraphQL resolvers synthesizing Request/Response pairs). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index aa656c5..1689ebc 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -530,11 +530,17 @@ public function match(Request $request): ?RouteMatch } /** - * Match the request, then run the route's handler and hooks. + * Match a request and run its route's handler and hooks. * - * HEAD requests run as GET with the response body suppressed. - * OPTIONS requests fire options hooks and return without dispatching. - * Unmatched requests fire global error hooks with a 404. + * HEAD runs as GET with the response body suppressed. OPTIONS fires + * options hooks and returns without dispatching. An unmatched request + * fires global error hooks with a 404. + * + * This is a re-entrant dispatch primitive — safe to call from inside + * another handler with a synthesized Request/Response (e.g. a GraphQL + * resolver invoking an API route). It does not run request-level setup + * (compression, request hooks, telemetry); those belong to {@see run()}, + * which is the entry point for top-level requests from the server. */ public function execute(Request $request, Response $response): static { @@ -733,7 +739,17 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } /** - * Run: wrapper function to record telemetry. All domain logic should happen in `runInternal`. + * Handle a top-level HTTP request. + * + * This is the entry point wired into the server adapter for each + * incoming request. It runs the full request lifecycle: compression + * setup, request hooks, static-file serving, route match, dispatch, + * and telemetry. + * + * For dispatching a sub-request from inside a handler (e.g. a + * GraphQL resolver invoking another API route with a synthesized + * Request/Response), use {@see execute()} instead — it skips the + * outer-request setup that has already run. */ public function run(Request $request, Response $response): static { From 1befdefcf00827bff253734cdb6851dc67e2e59f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:16:01 +0100 Subject: [PATCH 14/18] Inline \$match->params in execute, drop \$pathValues alias Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 1689ebc..ecb8c68 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -600,13 +600,11 @@ public function execute(Request $request, Response $response): static $arguments = []; $groups = $route->getGroups(); - $pathValues = $match->params; - try { if ($route->getHook()) { foreach (self::$init as $hook) { // Global init hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } } @@ -615,21 +613,21 @@ public function execute(Request $request, Response $response): static foreach ($groups as $group) { foreach (self::$init as $hook) { // Group init hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } } } if (!$response->isSent()) { - $arguments = $this->getArguments($route, $pathValues, $request->getParams()); + $arguments = $this->getArguments($route, $match->params, $request->getParams()); \call_user_func_array($route->getAction(), $arguments); } foreach ($groups as $group) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } } @@ -638,7 +636,7 @@ public function execute(Request $request, Response $response): static if ($route->getHook()) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } } @@ -650,7 +648,7 @@ public function execute(Request $request, Response $response): static foreach (self::$errors as $error) { // Group error hooks if (\in_array($group, $error->getGroups())) { try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams()); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); @@ -662,7 +660,7 @@ public function execute(Request $request, Response $response): static foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams()); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); From 88d77138db8fc01caaafc18344ea5fcd5e05a18e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:12 +0100 Subject: [PATCH 15/18] Drop intermediate variables in run() telemetry Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index ecb8c68..08242aa 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -759,14 +759,13 @@ public function run(Request $request, Response $response): static $start = microtime(true); $result = $this->runInternal($request, $response); - $context = $this->context(); - $matchedRoute = $context->has('route') ? $context->get('route') : null; + $route = $this->context()->has('route') ? $this->context()->get('route') : null; $requestDuration = microtime(true) - $start; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $matchedRoute instanceof Route ? $matchedRoute->getPath() : null, + 'http.route' => $route instanceof Route ? $route->getPath() : null, 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); From df820799525d5ac0ed175d2a91806756b91154ee Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 17:20:27 +0100 Subject: [PATCH 16/18] Apply rector and pint to RouteMatch.php Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/RouteMatch.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 590fb7a..4bcd811 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -1,5 +1,7 @@ */ public array $params, - ) { - } + ) {} } From 30e1405edacc8a312c3648ca7c53cd558b900e12 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 18:41:29 +0100 Subject: [PATCH 17/18] Save/restore context['route'] across execute() dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit match() no longer writes to context — that was leaking the inner match into the outer's context whenever execute() was called for a sub-request, breaking outer-request shutdown hooks doing ->inject('route') and the http.route telemetry attribute. execute() now sets context['route'] right before dispatching and restores the prior value (or null) in a finally clause, so nested execute() calls don't trample each other's frame. Adds testSubrequestRestoresOuterRoute as a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 17 +++++++---------- tests/HttpTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 08242aa..be6c69b 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -517,16 +517,7 @@ public function match(Request $request): ?RouteMatch $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $match = Router::match($method, $url); - - if ($match === null) { - return null; - } - - $route = $match->route; - $this->context()->set('route', fn() => $route, []); - - return $match; + return Router::match($method, $url); } /** @@ -600,6 +591,10 @@ public function execute(Request $request, Response $response): static $arguments = []; $groups = $route->getGroups(); + $context = $this->context(); + $priorRoute = $context->has('route') ? $context->get('route') : null; + $context->set('route', fn() => $route, []); + try { if ($route->getHook()) { foreach (self::$init as $hook) { // Global init hooks @@ -667,6 +662,8 @@ public function execute(Request $request, Response $response): static } } } + } finally { + $context->set('route', fn() => $priorRoute, []); } return $this; diff --git a/tests/HttpTest.php b/tests/HttpTest.php index f7b142d..e13dba1 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -710,6 +710,37 @@ public function testCanRunRequest(): void $this->assertStringNotContainsString('HELLO', $result); } + public function testSubrequestRestoresOuterRoute(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $captured = []; + + Http::shutdown() + ->inject('route') + ->action(function (Route $route) use (&$captured) { + $captured[] = $route->getPath(); + }); + + Http::get('/inner')->action(function () { + // no-op handler — only here so the inner dispatch matches + }); + + Http::get('/outer')->action(function () { + $inner = new Request(); + $inner->setMethod('GET'); + $inner->setURI('/inner'); + $this->http->execute($inner, new Response()); + }); + + $_SERVER['REQUEST_URI'] = '/outer'; + $this->http->execute(new Request(), new Response()); + + // Inner's shutdown fires first (with inner route), then outer's + // shutdown — which must see the outer route, not the inner one. + $this->assertEquals(['/inner', '/outer'], $captured); + } + public function testWildcardRoute(): void { $method = $_SERVER['REQUEST_METHOD'] ?? null; From b58bfab57000031fa6e8eaa5063db55f731c175b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 20:08:13 +0100 Subject: [PATCH 18/18] Make 'route' injection frame-local instead of stateful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save/restore pattern was just bookkeeping around a shared mutable slot — anything else writing to context['route'] during dispatch would break the restore, and a missed restore in any branch leaks the inner match into the outer frame. Drop context['route'] entirely. Pass the dispatch frame's Route through to getArguments and special-case the 'route' injection there. Each dispatch frame (including sub-requests via execute()) carries its own matched Route as a parameter; nested calls can't trample each other because there's no shared state to trample. Telemetry in run() now reads the outer match by calling match() once locally — match() is pure and cheap. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index be6c69b..47c1891 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -552,7 +552,7 @@ public function execute(Request $request, Response $response): static foreach (self::$options as $option) { // Group options hooks /** @var Hook $option */ if (\in_array($group, $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match->route)); } } } @@ -560,7 +560,7 @@ public function execute(Request $request, Response $response): static foreach (self::$options as $option) { // Global options hooks /** @var Hook $option */ if (\in_array('*', $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match?->route)); } } } catch (\Throwable $e) { @@ -568,7 +568,7 @@ public function execute(Request $request, Response $response): static /** @var Hook $error */ if (\in_array('*', $error->getGroups())) { $this->context()->set('error', fn() => $e, []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); + \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams(), $match?->route)); } } } @@ -591,15 +591,11 @@ public function execute(Request $request, Response $response): static $arguments = []; $groups = $route->getGroups(); - $context = $this->context(); - $priorRoute = $context->has('route') ? $context->get('route') : null; - $context->set('route', fn() => $route, []); - try { if ($route->getHook()) { foreach (self::$init as $hook) { // Global init hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $match->params, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -608,21 +604,21 @@ public function execute(Request $request, Response $response): static foreach ($groups as $group) { foreach (self::$init as $hook) { // Group init hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $match->params, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } } if (!$response->isSent()) { - $arguments = $this->getArguments($route, $match->params, $request->getParams()); + $arguments = $this->getArguments($route, $match->params, $request->getParams(), $route); \call_user_func_array($route->getAction(), $arguments); } foreach ($groups as $group) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $match->params, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -631,7 +627,7 @@ public function execute(Request $request, Response $response): static if ($route->getHook()) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $match->params, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -643,7 +639,7 @@ public function execute(Request $request, Response $response): static foreach (self::$errors as $error) { // Group error hooks if (\in_array($group, $error->getGroups())) { try { - $arguments = $this->getArguments($error, $match->params, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams(), $route); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); @@ -655,15 +651,13 @@ public function execute(Request $request, Response $response): static foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { try { - $arguments = $this->getArguments($error, $match->params, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams(), $route); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); } } } - } finally { - $context->set('route', fn() => $priorRoute, []); } return $this; @@ -677,7 +671,7 @@ public function execute(Request $request, Response $response): static * @return array * @throws Exception */ - protected function getArguments(Hook $hook, array $values, array $requestParams): array + protected function getArguments(Hook $hook, array $values, array $requestParams, ?Route $route = null): array { $arguments = []; foreach ($hook->getParams() as $key => $param) { // Get value from route or request object @@ -727,6 +721,13 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } foreach ($hook->getInjections() as $injection) { + // 'route' is frame-local: pass the dispatch frame's matched Route + // through directly instead of routing through shared context. + if ($injection['name'] === 'route') { + $arguments[$injection['order']] = $route; + continue; + } + $arguments[$injection['order']] = $this->adapter->context()->get($injection['name']); } @@ -756,13 +757,11 @@ public function run(Request $request, Response $response): static $start = microtime(true); $result = $this->runInternal($request, $response); - $route = $this->context()->has('route') ? $this->context()->get('route') : null; - $requestDuration = microtime(true) - $start; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $route instanceof Route ? $route->getPath() : null, + 'http.route' => $this->match($request)?->route->getPath(), 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes);