From 20a944f1c9a43143b066123e7717c6ff6f0d43c5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:00:34 +0100 Subject: [PATCH 01/21] Fix: Eliminate shared-state races on the Http/Route/Router singletons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the Swoole and SwooleCoroutine adapters multiple requests run concurrently in the same process and shared a single Http instance plus the static Router/Route/Hook registries. Per-request data was being written onto those shared objects, so a coroutine yielding on I/O could return to find another request had clobbered its routing state. Four shared mutations are removed: 1. Http::$route — moved to the per-request container (coroutine-local under both Swoole adapters via Coroutine::getContext()), so getRoute() and the match() cache no longer collide between requests. Telemetry in run() now reads through getRoute(). 2. Route::$matchedPath — Router::match() now returns the matched route together with the prepared-path string instead of mutating the singleton Route. Http::match() stashes the path on the request container; execute() reads it via getMatchedPath(). 3. Http::$wildcardRoute — the wildcard branch now clones the singleton before stamping the request URI onto its $path, so two concurrent wildcard requests can't race on the shared route's path. 4. Hook::setParamValue() writeback in getArguments() — removed. The resolved $arguments array is still fed to the action; writing values back onto the shared Hook/Route was the only mutation that made shared hooks unsafe under concurrency. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 67 ++++++++++++++++++++++++++++++++---------- src/Http/Router.php | 20 ++++++------- tests/RouterTest.php | 70 ++++++++++++++++++++++---------------------- 3 files changed, 96 insertions(+), 61 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 74965fc..60add2c 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -106,11 +106,14 @@ class Http protected static array $requestHooks = []; /** - * Route - * - * Memory cached result for chosen route + * Per-request container keys for the currently matched route and + * the prepared-path string it was matched under. Storing these on + * the per-request container (which is coroutine-local in the Swoole + * adapters) keeps concurrent requests from clobbering each other's + * routing state on the shared Http singleton. */ - protected ?Route $route = null; + private const string ROUTE_CONTEXT_KEY = '__utopia_http_route'; + private const string MATCHED_PATH_CONTEXT_KEY = '__utopia_http_matched_path'; /** * Wildcard route @@ -465,19 +468,38 @@ public static function getRoutes(): array */ public function getRoute(): ?Route { - return $this->route ?? null; + $container = $this->server->getContainer(); + + return $container->has(self::ROUTE_CONTEXT_KEY) ? $container->get(self::ROUTE_CONTEXT_KEY) : null; } /** * Set the current route */ - public function setRoute(Route $route): self + public function setRoute(?Route $route): self { - $this->route = $route; + $this->server->getContainer()->set(self::ROUTE_CONTEXT_KEY, fn() => $route); return $this; } + /** + * Read the prepared-path string the current request matched under. + * Falls back to '' so callers (e.g. tests invoking execute() directly) + * keep getting the legacy "first registered path-params" behavior. + */ + private function getMatchedPath(): string + { + $container = $this->server->getContainer(); + + return $container->has(self::MATCHED_PATH_CONTEXT_KEY) ? $container->get(self::MATCHED_PATH_CONTEXT_KEY) : ''; + } + + private function setMatchedPath(string $path): void + { + $this->server->getContainer()->set(self::MATCHED_PATH_CONTEXT_KEY, fn() => $path); + } + /** * Add Route * @@ -590,8 +612,11 @@ public function start(): void */ public function match(Request $request, bool $fresh = true): ?Route { - if (null !== $this->route && !$fresh) { - return $this->route; + if (!$fresh) { + $cached = $this->getRoute(); + if (null !== $cached) { + return $cached; + } } $url = parse_url($request->getURI(), PHP_URL_PATH); @@ -599,9 +624,18 @@ 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); + $matched = Router::match($method, $url); + if (null === $matched) { + $this->setRoute(null); + $this->setMatchedPath(''); + return null; + } + + [$route, $matchedPath] = $matched; + $this->setRoute($route); + $this->setMatchedPath($matchedPath); - return $this->route; + return $route; } /** @@ -612,7 +646,7 @@ public function execute(Route $route, Request $request, Response $response): sta $arguments = []; $groups = $route->getGroups(); - $preparedPath = Router::preparePath($route->getMatchedPath()); + $preparedPath = Router::preparePath($this->getMatchedPath()); $pathValues = $route->getPathValues($request, $preparedPath[0]); try { @@ -719,7 +753,6 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } } - $hook->setParamValue($key, $value); $arguments[$param['order']] = $value; } @@ -747,7 +780,7 @@ public function run(Request $request, Response $response): static $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $this->route?->getPath(), + 'http.route' => $this->getRoute()?->getPath(), 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -854,12 +887,14 @@ private function runInternal(Request $request, Response $response): static } if (null === $route && null !== self::$wildcardRoute) { - $route = self::$wildcardRoute; - $this->route = $route; + // Clone so we can stamp the request URI onto $path without + // mutating the singleton shared across concurrent coroutines. + $route = clone self::$wildcardRoute; $path = parse_url($request->getURI(), PHP_URL_PATH); $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); + $this->setRoute($route); $this->setRequestResource('route', fn() => $route, []); } if (null !== $route) { diff --git a/src/Http/Router.php b/src/Http/Router.php index 8118ffe..23a8398 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -107,8 +107,14 @@ public static function addRouteAlias(string $path, Route $route): void /** * Match route against the method and path. + * + * Returns the matched Route together with the prepared-path key it was + * found under, so callers can resolve path params without mutating the + * shared Route singleton. + * + * @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; @@ -129,9 +135,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 +144,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,9 +154,7 @@ 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]; } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 4ca0134..01d270b 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->assertSame($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); + $this->assertSame($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')[0] ?? null); + $this->assertSame($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')[0] ?? null); } 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->assertSame($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')[0] ?? null); + $this->assertSame($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')[0] ?? null); + $this->assertSame($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')[0] ?? null); + $this->assertSame($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')[0] ?? null); + $this->assertSame($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')[0] ?? null); + $this->assertSame($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')[0] ?? null); } 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->assertSame($routeIndex, Router::match('GET', '/')[0] ?? null); + $this->assertSame($routeAbout, Router::match('GET', '/about')[0] ?? null); + $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me')[0] ?? null); + $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/you')[0] ?? null); + $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')[0] ?? null); } 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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); + $this->assertSame($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')[0] ?? null); - $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')); - $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')); + $this->assertNotSame($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')[0]); + $this->assertNotSame($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')[0]); } 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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null); } 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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')[0] ?? null); } 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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')[0] ?? null); } public function testCanMatchFilename(): void @@ -142,7 +142,7 @@ 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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')[0] ?? null); } public function testCannotFindUnknownRouteByPath(): void @@ -156,7 +156,7 @@ public function testCannotFindUnknownRouteByMethod(): void Router::addRoute($route); - $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertSame($route, Router::match(Http::REQUEST_METHOD_GET, '/404')[0] ?? null); $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')); } From 61bbb78ee4515f0336c48f77857d86d74c0c2792 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:02:40 +0100 Subject: [PATCH 02/21] Chore: Mark dead Route::matchedPath methods and unused Http::$requestContainer as @deprecated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route::setMatchedPath() / Route::getMatchedPath() are no longer called by the framework after Router::match() switched to returning the matched path in its return tuple. Http::$requestContainer was never assigned — per-request state lives on the adapter's container. Left in place for backwards compatibility; flagged for removal in a future major release. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 5 +++++ src/Http/Route.php | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Http/Http.php b/src/Http/Http.php index 60add2c..73c3e36 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -48,6 +48,11 @@ class Http protected Container $container; + /** + * @deprecated Never assigned by the framework. Per-request state lives + * on the adapter's container (coroutine-local under Swoole). Will be + * removed in a future major release. + */ protected ?Container $requestContainer = null; /** diff --git a/src/Http/Route.php b/src/Http/Route.php index b72886d..e3743d5 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -48,12 +48,23 @@ public function __construct(string $method, string $path) $this->order = ++self::$counter; } + /** + * @deprecated No longer used by the framework. The matched-path string + * is returned alongside the Route from Router::match() instead, so the + * shared Route singleton is not mutated per request. Will be removed + * in a future major release. + */ public function setMatchedPath(string $path): self { $this->matchedPath = $path; return $this; } + /** + * @deprecated No longer populated by Router::match(). Read the matched + * path from the second element of Router::match()'s return tuple + * instead. Will be removed in a future major release. + */ public function getMatchedPath(): string { return $this->matchedPath; From b226bf55003e6f2e502bd2db09a2861eca1c4f65 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:42:09 +0100 Subject: [PATCH 03/21] Remove dead Route::matchedPath methods and unused Http::$requestContainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed instead of left as @deprecated — neither has been part of any working code path since the previous commit, and keeping them around just preserves footguns (the matched-path getter would silently return '' on a route returned from Router::match). Removed: - Route::$matchedPath property - Route::setMatchedPath() / Route::getMatchedPath() - Http::$requestContainer property (never assigned) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 7 ------- src/Http/Route.php | 24 ------------------------ 2 files changed, 31 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 73c3e36..b06547a 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -48,13 +48,6 @@ class Http protected Container $container; - /** - * @deprecated Never assigned by the framework. Per-request state lives - * on the adapter's container (coroutine-local under Swoole). Will be - * removed in a future major release. - */ - protected ?Container $requestContainer = null; - /** * Current running mode */ diff --git a/src/Http/Route.php b/src/Http/Route.php index e3743d5..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,28 +46,6 @@ public function __construct(string $method, string $path) $this->order = ++self::$counter; } - /** - * @deprecated No longer used by the framework. The matched-path string - * is returned alongside the Route from Router::match() instead, so the - * shared Route singleton is not mutated per request. Will be removed - * in a future major release. - */ - public function setMatchedPath(string $path): self - { - $this->matchedPath = $path; - return $this; - } - - /** - * @deprecated No longer populated by Router::match(). Read the matched - * path from the second element of Router::match()'s return tuple - * instead. Will be removed in a future major release. - */ - public function getMatchedPath(): string - { - return $this->matchedPath; - } - /** * Get Route Order ID */ From 6a86d7e29ef6e9daa7619dd2775ea72804c4be67 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:23:21 +0100 Subject: [PATCH 04/21] Use plain 'route'/'matchedPath' keys in the request container The framework already exposes per-request resources under plain names ('request', 'response', 'error', 'server', 'route'), so the namespaced sentinel constants were inconsistent. Drop them and use the same convention. As a side effect, 'matchedPath' is now injectable into hooks and actions like any other request resource. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index b06547a..8e6ab7e 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -103,16 +103,6 @@ class Http */ protected static array $requestHooks = []; - /** - * Per-request container keys for the currently matched route and - * the prepared-path string it was matched under. Storing these on - * the per-request container (which is coroutine-local in the Swoole - * adapters) keeps concurrent requests from clobbering each other's - * routing state on the shared Http singleton. - */ - private const string ROUTE_CONTEXT_KEY = '__utopia_http_route'; - private const string MATCHED_PATH_CONTEXT_KEY = '__utopia_http_matched_path'; - /** * Wildcard route * If set, this get's executed if no other route is matched @@ -468,7 +458,7 @@ public function getRoute(): ?Route { $container = $this->server->getContainer(); - return $container->has(self::ROUTE_CONTEXT_KEY) ? $container->get(self::ROUTE_CONTEXT_KEY) : null; + return $container->has('route') ? $container->get('route') : null; } /** @@ -476,7 +466,7 @@ public function getRoute(): ?Route */ public function setRoute(?Route $route): self { - $this->server->getContainer()->set(self::ROUTE_CONTEXT_KEY, fn() => $route); + $this->server->getContainer()->set('route', fn() => $route); return $this; } @@ -490,12 +480,12 @@ private function getMatchedPath(): string { $container = $this->server->getContainer(); - return $container->has(self::MATCHED_PATH_CONTEXT_KEY) ? $container->get(self::MATCHED_PATH_CONTEXT_KEY) : ''; + return $container->has('matchedPath') ? $container->get('matchedPath') : ''; } private function setMatchedPath(string $path): void { - $this->server->getContainer()->set(self::MATCHED_PATH_CONTEXT_KEY, fn() => $path); + $this->server->getContainer()->set('matchedPath', fn() => $path); } /** From 042ed2efb9ba529f8a895e49ba9b74cc6d95f5a6 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:28:45 +0100 Subject: [PATCH 05/21] Make Http::setRoute private Only the framework itself called setRoute (internal cache plumbing in match() and the wildcard branch). Drop it from the public API and the self-referential test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 7 +------ tests/HttpTest.php | 9 --------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 8e6ab7e..89b812f 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -461,14 +461,9 @@ public function getRoute(): ?Route return $container->has('route') ? $container->get('route') : null; } - /** - * Set the current route - */ - public function setRoute(?Route $route): self + private function setRoute(?Route $route): void { $this->server->getContainer()->set('route', fn() => $route); - - return $this; } /** diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 6535df0..13fb987 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -366,15 +366,6 @@ public function testCanHookThrowExceptions(): void $this->assertSame('(init)-y-def-x-def-(shutdown)', $result); } - public function testCanSetRoute(): void - { - $route = new Route('GET', '/path'); - - $this->assertNull($this->http->getRoute()); - $this->http->setRoute($route); - $this->assertSame($route, $this->http->getRoute()); - } - /** * @return \Iterator> */ From 23c23c402945a1bf4efb3c50fb7ce1129aedc0f9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:30:59 +0100 Subject: [PATCH 06/21] Rename request-scoped helpers to use 'context' terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Singletons stay under setResource/getResource (the global container); per-request values are now setContext (the request container). The adapters' coroutine-context key and local variable rename to match: - Http::setRequestResource() -> Http::setContext() - Adapter\Swoole\Server::REQUEST_CONTAINER_CONTEXT_KEY -> Adapter\Swoole\Server::CONTEXT_KEY - same in Adapter\SwooleCoroutine\Server - $requestContainer locals -> $context - internal storage key '__utopia_http_request_container' -> '__utopia_http_context' setContext is protected, so this is internal-only — no application API break. Subclasses overriding the old name need to rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Adapter/Swoole/Server.php | 12 ++++----- src/Http/Adapter/SwooleCoroutine/Server.php | 14 +++++------ src/Http/Http.php | 28 +++++++++++++-------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index b8cf4f1..015a278 100755 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -12,7 +12,7 @@ class Server extends Adapter { protected SwooleServer $server; - protected const string REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container'; + protected const string CONTEXT_KEY = '__utopia_http_context'; protected Container $container; /** @@ -28,11 +28,11 @@ public function __construct(string $host, ?string $port = null, array $settings public function onRequest(callable $callback): void { $this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { - $requestContainer = new Container($this->container); - $requestContainer->set('swooleRequest', fn() => $request); - $requestContainer->set('swooleResponse', fn() => $response); + $context = new Container($this->container); + $context->set('swooleRequest', fn() => $request); + $context->set('swooleResponse', fn() => $response); - Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; + Coroutine::getContext()[self::CONTEXT_KEY] = $context; \call_user_func($callback, new Request($request), new Response($response)); }); @@ -41,7 +41,7 @@ public function onRequest(callable $callback): void public function getContainer(): Container { if (Coroutine::getCid() !== -1) { - return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; + return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container; } return $this->container; diff --git a/src/Http/Adapter/SwooleCoroutine/Server.php b/src/Http/Adapter/SwooleCoroutine/Server.php index 2b21ab1..9f27298 100644 --- a/src/Http/Adapter/SwooleCoroutine/Server.php +++ b/src/Http/Adapter/SwooleCoroutine/Server.php @@ -11,7 +11,7 @@ class Server extends Adapter { - protected const string REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container'; + protected const string CONTEXT_KEY = '__utopia_http_context'; protected SwooleServer $server; protected Container $container; @@ -36,23 +36,23 @@ public function __construct( public function onRequest(callable $callback): void { $this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { - $requestContainer = new Container($this->container); - $requestContainer->set('swooleRequest', fn() => $request); - $requestContainer->set('swooleResponse', fn() => $response); + $context = new Container($this->container); + $context->set('swooleRequest', fn() => $request); + $context->set('swooleResponse', fn() => $response); - Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; + Coroutine::getContext()[self::CONTEXT_KEY] = $context; try { \call_user_func($callback, new Request($request), new Response($response)); } finally { - unset(Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY]); + unset(Coroutine::getContext()[self::CONTEXT_KEY]); } }); } public function getContainer(): Container { - return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; + return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container; } public function getServer(): SwooleServer diff --git a/src/Http/Http.php b/src/Http/Http.php index 89b812f..5ac4b53 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -406,11 +406,17 @@ public function setResource(string $name, callable $callback, array $injections } /** - * Set a request-scoped resource on the current request's container. + * Set a request-scoped value on the current request's context container. + * + * The framework convention is: `setResource()` registers singletons on + * the global container; `setContext()` registers per-request values on + * the adapter's request container, which is coroutine-local under the + * Swoole adapters. `getResource()` reads from the request container + * with parent-fallback to the global one, so either kind resolves. * * @param list $injections */ - protected function setRequestResource(string $name, callable $callback, array $injections = []): void + protected function setContext(string $name, callable $callback, array $injections = []): void { $this->server->getContainer()->set($name, $callback, $injections); } @@ -674,7 +680,7 @@ public function execute(Route $route, Request $request, Response $response): sta } } } catch (\Throwable $e) { - $this->setRequestResource('error', fn() => $e, []); + $this->setContext('error', fn() => $e, []); foreach ($groups as $group) { foreach (self::$errors as $error) { // Group error hooks @@ -793,8 +799,8 @@ private function runInternal(Request $request, Response $response): static $response->setCompressionSupported($this->compressionSupported); } - $this->setRequestResource('request', fn() => $request); - $this->setRequestResource('response', fn() => $response); + $this->setContext('request', fn() => $request); + $this->setContext('response', fn() => $response); try { foreach (self::$requestHooks as $hook) { @@ -802,7 +808,7 @@ private function runInternal(Request $request, Response $response): static \call_user_func_array($hook->getAction(), $arguments); } } catch (\Exception $e) { - $this->setRequestResource('error', fn() => $e, []); + $this->setContext('error', fn() => $e, []); foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { @@ -832,7 +838,7 @@ private function runInternal(Request $request, Response $response): static $route = $this->match($request); $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->setRequestResource('route', fn() => $route, []); + $this->setContext('route', fn() => $route, []); if (self::REQUEST_METHOD_HEAD === $method) { $method = self::REQUEST_METHOD_GET; @@ -860,7 +866,7 @@ private function runInternal(Request $request, Response $response): static foreach (self::$errors as $error) { // Global error hooks /** @var Hook $error */ if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => $e, []); + $this->setContext('error', fn() => $e, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -878,7 +884,7 @@ private function runInternal(Request $request, Response $response): static $route->path($path); $this->setRoute($route); - $this->setRequestResource('route', fn() => $route, []); + $this->setContext('route', fn() => $route, []); } if (null !== $route) { return $this->execute($route, $request, $response); @@ -902,7 +908,7 @@ private function runInternal(Request $request, Response $response): static } catch (\Throwable $e) { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => $e, []); + $this->setContext('error', fn() => $e, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -910,7 +916,7 @@ private function runInternal(Request $request, Response $response): static } else { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setRequestResource('error', fn() => new Exception('Not Found', 404), []); + $this->setContext('error', fn() => new Exception('Not Found', 404), []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } From 694d8070ac1ae870fd1dfa7fee48fdd4de508a2f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:34:25 +0100 Subject: [PATCH 07/21] Remove Http::getRoute / setRoute / get-set MatchedPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route and matched path are now pure context values — read them via \$http->getResource('route') / 'matchedPath', or inject them into a hook/action. Internal call sites that wrote them go through the context container directly (or via setContext()). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 57 ++++++++++++---------------------------------- tests/HttpTest.php | 16 ++++++------- 2 files changed, 23 insertions(+), 50 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 5ac4b53..795eff4 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -457,38 +457,6 @@ public static function getRoutes(): array return Router::getRoutes(); } - /** - * Get the current route - */ - public function getRoute(): ?Route - { - $container = $this->server->getContainer(); - - return $container->has('route') ? $container->get('route') : null; - } - - private function setRoute(?Route $route): void - { - $this->server->getContainer()->set('route', fn() => $route); - } - - /** - * Read the prepared-path string the current request matched under. - * Falls back to '' so callers (e.g. tests invoking execute() directly) - * keep getting the legacy "first registered path-params" behavior. - */ - private function getMatchedPath(): string - { - $container = $this->server->getContainer(); - - return $container->has('matchedPath') ? $container->get('matchedPath') : ''; - } - - private function setMatchedPath(string $path): void - { - $this->server->getContainer()->set('matchedPath', fn() => $path); - } - /** * Add Route * @@ -601,8 +569,10 @@ public function start(): void */ public function match(Request $request, bool $fresh = true): ?Route { - if (!$fresh) { - $cached = $this->getRoute(); + $context = $this->server->getContainer(); + + if (!$fresh && $context->has('route')) { + $cached = $context->get('route'); if (null !== $cached) { return $cached; } @@ -615,14 +585,14 @@ public function match(Request $request, bool $fresh = true): ?Route $matched = Router::match($method, $url); if (null === $matched) { - $this->setRoute(null); - $this->setMatchedPath(''); + $context->set('route', fn() => null); + $context->set('matchedPath', fn() => ''); return null; } [$route, $matchedPath] = $matched; - $this->setRoute($route); - $this->setMatchedPath($matchedPath); + $context->set('route', fn() => $route); + $context->set('matchedPath', fn() => $matchedPath); return $route; } @@ -635,7 +605,9 @@ public function execute(Route $route, Request $request, Response $response): sta $arguments = []; $groups = $route->getGroups(); - $preparedPath = Router::preparePath($this->getMatchedPath()); + $context = $this->server->getContainer(); + $matchedPath = $context->has('matchedPath') ? $context->get('matchedPath') : ''; + $preparedPath = Router::preparePath($matchedPath); $pathValues = $route->getPathValues($request, $preparedPath[0]); try { @@ -766,10 +738,12 @@ public function run(Request $request, Response $response): static $result = $this->runInternal($request, $response); $requestDuration = microtime(true) - $start; + $context = $this->server->getContainer(); + $route = $context->has('route') ? $context->get('route') : null; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $this->getRoute()?->getPath(), + 'http.route' => $route?->getPath(), 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -883,8 +857,7 @@ private function runInternal(Request $request, Response $response): static $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); - $this->setRoute($route); - $this->setContext('route', fn() => $route, []); + $this->setContext('route', fn() => $route); } if (null !== $route) { return $this->execute($route, $request, $response); diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 13fb987..f01bfa4 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -413,7 +413,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_URI'] = $url; $this->assertSame($expected, $this->http->match(new Request())); - $this->assertSame($expected, $this->http->getRoute()); + $this->assertSame($expected, $this->http->getResource("route")); } public function testNoMismatchRoute(): void @@ -442,7 +442,7 @@ public function testNoMismatchRoute(): void $route = $this->http->match(new Request(), fresh: true); $this->assertNull($route); - $this->assertNull($this->http->getRoute()); + $this->assertNull($this->http->getResource("route")); } } @@ -457,7 +457,7 @@ public function testCanMatchFreshRoute(): void $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getRoute()); + $this->assertSame($route1, $this->http->getResource("route")); // Second request match returns cached route $_SERVER['REQUEST_METHOD'] = 'HEAD'; @@ -465,12 +465,12 @@ public function testCanMatchFreshRoute(): void $request2 = new Request(); $matched = $this->http->match($request2, fresh: false); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getRoute()); + $this->assertSame($route1, $this->http->getResource("route")); // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); $this->assertSame($route2, $matched); - $this->assertSame($route2, $this->http->getRoute()); + $this->assertSame($route2, $this->http->getResource("route")); } catch (\Exception $e) { $this->fail($e->getMessage()); } @@ -484,7 +484,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; $this->assertSame($route, $this->http->match(new Request())); - $this->assertSame($route, $this->http->getRoute()); + $this->assertSame($route, $this->http->getResource("route")); } public function testCanRunRequest(): void @@ -523,8 +523,8 @@ public function testWildcardRoute(): void $_SERVER['REQUEST_URI'] = '/unknown_path'; Http::init() - ->action(function () { - $route = $this->http->getRoute(); + ->inject('route') + ->action(function ($route) { $this->container->set('myRoute', fn() => $route); }); From 2da69dfc09220a22e9c50f568be43b22f8f8369d Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:35:53 +0100 Subject: [PATCH 08/21] Format: single-quote 'route' in HttpTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pint catch from CI — sed introduced double quotes when replacing \$http->getRoute() with \$http->getResource('route'). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/HttpTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/HttpTest.php b/tests/HttpTest.php index f01bfa4..9d94139 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -413,7 +413,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_URI'] = $url; $this->assertSame($expected, $this->http->match(new Request())); - $this->assertSame($expected, $this->http->getResource("route")); + $this->assertSame($expected, $this->http->getResource('route')); } public function testNoMismatchRoute(): void @@ -442,7 +442,7 @@ public function testNoMismatchRoute(): void $route = $this->http->match(new Request(), fresh: true); $this->assertNull($route); - $this->assertNull($this->http->getResource("route")); + $this->assertNull($this->http->getResource('route')); } } @@ -457,7 +457,7 @@ public function testCanMatchFreshRoute(): void $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getResource("route")); + $this->assertSame($route1, $this->http->getResource('route')); // Second request match returns cached route $_SERVER['REQUEST_METHOD'] = 'HEAD'; @@ -465,12 +465,12 @@ public function testCanMatchFreshRoute(): void $request2 = new Request(); $matched = $this->http->match($request2, fresh: false); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getResource("route")); + $this->assertSame($route1, $this->http->getResource('route')); // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); $this->assertSame($route2, $matched); - $this->assertSame($route2, $this->http->getResource("route")); + $this->assertSame($route2, $this->http->getResource('route')); } catch (\Exception $e) { $this->fail($e->getMessage()); } @@ -484,7 +484,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; $this->assertSame($route, $this->http->match(new Request())); - $this->assertSame($route, $this->http->getResource("route")); + $this->assertSame($route, $this->http->getResource('route')); } public function testCanRunRequest(): void From 0707b7caff4ec0561027eba12ed045940e7b1b0d Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:30:40 +0100 Subject: [PATCH 09/21] Split Adapter::getContainer() into getContainer + getContext Before, getContainer() did double duty: returning the request-scoped container when inside a coroutine, the global container otherwise. That made request-side reads/writes look like global ones (\$server->getContainer() inside match()), which obscured the scope split. Now: - getContainer() always returns the global singleton container. - getContext() returns the per-request context container (coroutine-local under Swoole, identical to the global one under FPM). Lookups still fall through to the global container's parent chain, so getContext() resolves singletons too. Http internals updated to call getContext() for all per-request reads and writes; the constructor's setup of \$this->container keeps using getContainer() since it's grabbing the global. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Adapter.php | 16 ++++++++++++++++ src/Http/Adapter/FPM/Server.php | 5 +++++ src/Http/Adapter/Swoole/Server.php | 5 +++++ src/Http/Adapter/SwooleCoroutine/Server.php | 5 +++++ src/Http/Http.php | 10 +++++----- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Http/Adapter.php b/src/Http/Adapter.php index 0b0e478..7490bb5 100755 --- a/src/Http/Adapter.php +++ b/src/Http/Adapter.php @@ -11,5 +11,21 @@ abstract class Adapter abstract public function onStart(callable $callback): void; abstract public function onRequest(callable $callback): void; abstract public function start(): void; + + /** + * Return the global container — the singleton-scoped registry shared + * across all requests. Use this for resources that should outlive a + * single request (clients, configs, etc.). + */ abstract public function getContainer(): Container; + + /** + * Return the per-request context container. Coroutine-local under the + * Swoole adapters; identical to getContainer() under FPM. Use this for + * values scoped to a single request — request, response, route, + * matchedPath, error, etc. Lookups fall through to the global + * container's parent chain, so getContext()->get('someSingleton') + * still resolves. + */ + abstract public function getContext(): Container; } diff --git a/src/Http/Adapter/FPM/Server.php b/src/Http/Adapter/FPM/Server.php index 2b56568..62f98df 100755 --- a/src/Http/Adapter/FPM/Server.php +++ b/src/Http/Adapter/FPM/Server.php @@ -30,5 +30,10 @@ public function getContainer(): Container return $this->container; } + public function getContext(): Container + { + return $this->container; + } + public function start(): void {} } diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index 015a278..b9facf6 100755 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -39,6 +39,11 @@ public function onRequest(callable $callback): void } public function getContainer(): Container + { + return $this->container; + } + + public function getContext(): Container { if (Coroutine::getCid() !== -1) { return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container; diff --git a/src/Http/Adapter/SwooleCoroutine/Server.php b/src/Http/Adapter/SwooleCoroutine/Server.php index 9f27298..6cceab9 100644 --- a/src/Http/Adapter/SwooleCoroutine/Server.php +++ b/src/Http/Adapter/SwooleCoroutine/Server.php @@ -51,6 +51,11 @@ public function onRequest(callable $callback): void } public function getContainer(): Container + { + return $this->container; + } + + public function getContext(): Container { return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container; } diff --git a/src/Http/Http.php b/src/Http/Http.php index 795eff4..82ea9cf 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -363,7 +363,7 @@ public static function setAllowOverride(bool $value): void public function getResource(string $name): mixed { try { - return $this->server->getContainer()->get($name); + return $this->server->getContext()->get($name); } catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) { // Normalize DI container errors to the Http layer's "resource" terminology. $message = str_replace('dependency', 'resource', $e->getMessage()); @@ -418,7 +418,7 @@ public function setResource(string $name, callable $callback, array $injections */ protected function setContext(string $name, callable $callback, array $injections = []): void { - $this->server->getContainer()->set($name, $callback, $injections); + $this->server->getContext()->set($name, $callback, $injections); } /** @@ -569,7 +569,7 @@ public function start(): void */ public function match(Request $request, bool $fresh = true): ?Route { - $context = $this->server->getContainer(); + $context = $this->server->getContext(); if (!$fresh && $context->has('route')) { $cached = $context->get('route'); @@ -605,7 +605,7 @@ public function execute(Route $route, Request $request, Response $response): sta $arguments = []; $groups = $route->getGroups(); - $context = $this->server->getContainer(); + $context = $this->server->getContext(); $matchedPath = $context->has('matchedPath') ? $context->get('matchedPath') : ''; $preparedPath = Router::preparePath($matchedPath); $pathValues = $route->getPathValues($request, $preparedPath[0]); @@ -738,7 +738,7 @@ public function run(Request $request, Response $response): static $result = $this->runInternal($request, $response); $requestDuration = microtime(true) - $start; - $context = $this->server->getContainer(); + $context = $this->server->getContext(); $route = $context->has('route') ? $context->get('route') : null; $attributes = [ 'url.scheme' => $request->getProtocol(), From 5f86d408d7cbc2c5957fcd5f669945ca8343bbce Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:58:01 +0100 Subject: [PATCH 10/21] Inject route's resolved arguments into shutdown/error hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier removal of Hook::setParamValue writeback closed the concurrency hole but stranded a real use case: shutdown hooks that need the request's full resolved param map (post-validation, post- coercion) to do label substitution like {request.fileId}. Populate a name-keyed snapshot of the route's resolved arguments on the per-request context as 'arguments', set right before the action runs. Race-free (per-request context container, coroutine-local under Swoole), no per-Hook mutation, and reuses the values getArguments() already resolved — no validator double-invocation. Available via ->inject('arguments') from shutdown / error hooks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 12 ++++++++++++ tests/HttpTest.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Http/Http.php b/src/Http/Http.php index 82ea9cf..98a05c3 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -631,6 +631,18 @@ public function execute(Route $route, Request $request, Response $response): sta if (!$response->isSent()) { $arguments = $this->getArguments($route, $pathValues, $request->getParams()); + + // Stash a name-keyed map of the route's resolved+validated + // params on the context so shutdown / error hooks can read + // the same values the action saw — e.g. for label + // substitution like {request.fileId}. Race-free because + // the context container is per-request. + $resolved = []; + foreach ($route->getParams() as $name => $param) { + $resolved[$name] = $arguments[$param['order']] ?? null; + } + $this->setContext('arguments', fn() => $resolved); + \call_user_func_array($route->getAction(), $arguments); } diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 9d94139..98316eb 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -366,6 +366,35 @@ public function testCanHookThrowExceptions(): void $this->assertSame('(init)-y-def-x-def-(shutdown)', $result); } + public function testShutdownHookCanInjectResolvedArguments(): void + { + $route = new Route('GET', '/files/:fileId'); + $route + ->param('fileId', '', new Text(64), 'file id', false) + ->param('width', 0, new Text(8), 'width', true) + ->action(function ($fileId, $width) { + echo 'action:' . $fileId . ',' . $width; + }); + + $this->http + ->shutdown() + ->inject('arguments') + ->action(function (array $arguments) { + echo '|shutdown:fileId=' . $arguments['fileId'] . ',width=' . $arguments['width']; + }); + + $request = new UtopiaFPMRequestTest(); + $request::_setParams(['fileId' => 'abc123', 'width' => '200']); + $_SERVER['REQUEST_URI'] = '/files/abc123'; + + ob_start(); + $this->http->execute($route, $request, new Response()); + $result = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('action:abc123,200|shutdown:fileId=abc123,width=200', $result); + } + /** * @return \Iterator> */ From 1ca856c053940377e2b8dde62ba4a1750c97d2f4 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:04:01 +0100 Subject: [PATCH 11/21] Bundle route + matchedPath + arguments into immutable RouteMatch The framework was juggling three separate context keys ('route', 'matchedPath', 'arguments') for state that's logically one thing: "how this request was routed and what its action received." Replace them with a single immutable RouteMatch value class set on the context as 'match'. Shape: final class RouteMatch { public function __construct( public readonly Route $route, public readonly string $matchedPath, public readonly array $arguments = [], ) {} public function withArguments(array $arguments): self; } Lifecycle: - Router::match() now returns ?RouteMatch (was ?array{Route, string}) - Http::match() stores it on the context as 'match' (or null on miss) - Http::execute() reads it, calls withArguments() once the route's params are resolved, and re-stores. Synthesizes a RouteMatch if execute() is called directly without prior match() (test path). - Wildcard branch in runInternal() builds a RouteMatch around the cloned wildcard route. - Telemetry attribution reads it once at the end of run(). Inject via ->inject('match') from any hook/action; access ->route, ->matchedPath, ->arguments. The previous separate 'route' / 'matchedPath' / 'arguments' keys are gone. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 51 +++++++++++++++--------------- src/Http/RouteMatch.php | 41 ++++++++++++++++++++++++ src/Http/Router.php | 19 ++++++----- tests/HttpTest.php | 24 +++++++------- tests/RouterTest.php | 70 ++++++++++++++++++++--------------------- 5 files changed, 122 insertions(+), 83 deletions(-) create mode 100644 src/Http/RouteMatch.php diff --git a/src/Http/Http.php b/src/Http/Http.php index 98a05c3..c928f73 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -571,10 +571,10 @@ public function match(Request $request, bool $fresh = true): ?Route { $context = $this->server->getContext(); - if (!$fresh && $context->has('route')) { - $cached = $context->get('route'); - if (null !== $cached) { - return $cached; + if (!$fresh && $context->has('match')) { + $cached = $context->get('match'); + if ($cached instanceof RouteMatch) { + return $cached->route; } } @@ -583,18 +583,10 @@ public function match(Request $request, bool $fresh = true): ?Route $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $matched = Router::match($method, $url); - if (null === $matched) { - $context->set('route', fn() => null); - $context->set('matchedPath', fn() => ''); - return null; - } - - [$route, $matchedPath] = $matched; - $context->set('route', fn() => $route); - $context->set('matchedPath', fn() => $matchedPath); + $match = Router::match($method, $url); + $context->set('match', fn() => $match); - return $route; + return $match?->route; } /** @@ -606,8 +598,15 @@ public function execute(Route $route, Request $request, Response $response): sta $groups = $route->getGroups(); $context = $this->server->getContext(); - $matchedPath = $context->has('matchedPath') ? $context->get('matchedPath') : ''; - $preparedPath = Router::preparePath($matchedPath); + $match = $context->has('match') ? $context->get('match') : null; + if (!$match instanceof RouteMatch || $match->route !== $route) { + // execute() called directly (e.g. from a test) without a prior + // match() — synthesize a RouteMatch so shutdown / error hooks + // injecting 'match' still see the route they're running under. + $match = new RouteMatch($route, ''); + $context->set('match', fn() => $match); + } + $preparedPath = Router::preparePath($match->matchedPath); $pathValues = $route->getPathValues($request, $preparedPath[0]); try { @@ -632,16 +631,17 @@ public function execute(Route $route, Request $request, Response $response): sta if (!$response->isSent()) { $arguments = $this->getArguments($route, $pathValues, $request->getParams()); - // Stash a name-keyed map of the route's resolved+validated - // params on the context so shutdown / error hooks can read - // the same values the action saw — e.g. for label + // Update the per-request RouteMatch with the resolved+ + // validated argument map so shutdown / error hooks can + // read the same values the action saw — e.g. for label // substitution like {request.fileId}. Race-free because // the context container is per-request. $resolved = []; foreach ($route->getParams() as $name => $param) { $resolved[$name] = $arguments[$param['order']] ?? null; } - $this->setContext('arguments', fn() => $resolved); + $match = $match->withArguments($resolved); + $this->setContext('match', fn() => $match); \call_user_func_array($route->getAction(), $arguments); } @@ -751,11 +751,11 @@ public function run(Request $request, Response $response): static $requestDuration = microtime(true) - $start; $context = $this->server->getContext(); - $route = $context->has('route') ? $context->get('route') : null; + $match = $context->has('match') ? $context->get('match') : null; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $route?->getPath(), + 'http.route' => $match instanceof RouteMatch ? $match->route->getPath() : null, 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -824,8 +824,6 @@ private function runInternal(Request $request, Response $response): static $route = $this->match($request); $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->setContext('route', fn() => $route, []); - if (self::REQUEST_METHOD_HEAD === $method) { $method = self::REQUEST_METHOD_GET; $response->disablePayload(); @@ -869,7 +867,8 @@ private function runInternal(Request $request, Response $response): static $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; $route->path($path); - $this->setContext('route', fn() => $route); + $match = new RouteMatch($route, ''); + $this->setContext('match', fn() => $match); } if (null !== $route) { return $this->execute($route, $request, $response); diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php new file mode 100644 index 0000000..abfe94c --- /dev/null +++ b/src/Http/RouteMatch.php @@ -0,0 +1,41 @@ +inject('match')`. + */ +final class RouteMatch +{ + /** + * @param array $arguments + */ + public function __construct( + public readonly Route $route, + public readonly string $matchedPath, + public readonly array $arguments = [], + ) {} + + /** + * Return a copy of this match with the resolved-argument map replaced. + * Used by the framework once the action's parameters have been + * validated, so subsequent shutdown / error hooks can read the same + * values the action received. + * + * @param array $arguments + */ + public function withArguments(array $arguments): self + { + return new self($this->route, $this->matchedPath, $arguments); + } +} diff --git a/src/Http/Router.php b/src/Http/Router.php index 23a8398..f37189a 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -106,15 +106,14 @@ public static function addRouteAlias(string $path, Route $route): void } /** - * Match route against the method and path. + * Match a request against the registered routes. * - * Returns the matched Route together with the prepared-path key it was - * found under, so callers can resolve path params without mutating the - * shared Route singleton. - * - * @return array{0: Route, 1: string}|null + * Returns a RouteMatch holding the matched Route and the prepared-path + * key it was found under (placeholders replaced with `:::`), so callers + * can resolve path params without mutating the shared Route singleton. + * Returns null when no route matches. */ - public static function match(string $method, string $path): ?array + public static function match(string $method, string $path): ?RouteMatch { if (!\array_key_exists($method, self::$routes)) { return null; @@ -135,7 +134,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 RouteMatch(self::$routes[$method][$match], $match); } } @@ -144,7 +143,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 RouteMatch(self::$routes[$method][$match], $match); } /** @@ -154,7 +153,7 @@ 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 RouteMatch(self::$routes[$method][$match], $match); } } diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 98316eb..7b0875c 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -378,9 +378,9 @@ public function testShutdownHookCanInjectResolvedArguments(): void $this->http ->shutdown() - ->inject('arguments') - ->action(function (array $arguments) { - echo '|shutdown:fileId=' . $arguments['fileId'] . ',width=' . $arguments['width']; + ->inject('match') + ->action(function (RouteMatch $match) { + echo '|shutdown:fileId=' . $match->arguments['fileId'] . ',width=' . $match->arguments['width']; }); $request = new UtopiaFPMRequestTest(); @@ -442,7 +442,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_URI'] = $url; $this->assertSame($expected, $this->http->match(new Request())); - $this->assertSame($expected, $this->http->getResource('route')); + $this->assertSame($expected, $this->http->getResource('match')?->route); } public function testNoMismatchRoute(): void @@ -471,7 +471,7 @@ public function testNoMismatchRoute(): void $route = $this->http->match(new Request(), fresh: true); $this->assertNull($route); - $this->assertNull($this->http->getResource('route')); + $this->assertNull($this->http->getResource('match')?->route); } } @@ -486,7 +486,7 @@ public function testCanMatchFreshRoute(): void $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getResource('route')); + $this->assertSame($route1, $this->http->getResource('match')?->route); // Second request match returns cached route $_SERVER['REQUEST_METHOD'] = 'HEAD'; @@ -494,12 +494,12 @@ public function testCanMatchFreshRoute(): void $request2 = new Request(); $matched = $this->http->match($request2, fresh: false); $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getResource('route')); + $this->assertSame($route1, $this->http->getResource('match')?->route); // Fresh match returns new route $matched = $this->http->match($request2, fresh: true); $this->assertSame($route2, $matched); - $this->assertSame($route2, $this->http->getResource('route')); + $this->assertSame($route2, $this->http->getResource('match')?->route); } catch (\Exception $e) { $this->fail($e->getMessage()); } @@ -513,7 +513,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; $this->assertSame($route, $this->http->match(new Request())); - $this->assertSame($route, $this->http->getResource('route')); + $this->assertSame($route, $this->http->getResource('match')?->route); } public function testCanRunRequest(): void @@ -552,9 +552,9 @@ public function testWildcardRoute(): void $_SERVER['REQUEST_URI'] = '/unknown_path'; Http::init() - ->inject('route') - ->action(function ($route) { - $this->container->set('myRoute', fn() => $route); + ->inject('match') + ->action(function (RouteMatch $match) { + $this->container->set('myRoute', fn() => $match->route); }); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 01d270b..d6ec216 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -23,9 +23,9 @@ public function testCanMatchUrl(): void Router::addRoute($routeAbout); Router::addRoute($routeAboutMe); - $this->assertSame($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); - $this->assertSame($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')[0] ?? null); - $this->assertSame($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')[0] ?? null); + $this->assertSame($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')->route); + $this->assertSame($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')->route); + $this->assertSame($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->assertSame($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')[0] ?? null); - $this->assertSame($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')[0] ?? null); - $this->assertSame($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')[0] ?? null); - $this->assertSame($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')[0] ?? null); - $this->assertSame($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')[0] ?? null); - $this->assertSame($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')[0] ?? null); + $this->assertSame($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')->route); + $this->assertSame($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')->route); + $this->assertSame($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')->route); + $this->assertSame($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')->route); + $this->assertSame($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')->route); + $this->assertSame($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->assertSame($routeIndex, Router::match('GET', '/')[0] ?? null); - $this->assertSame($routeAbout, Router::match('GET', '/about')[0] ?? null); - $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me')[0] ?? null); - $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/you')[0] ?? null); - $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')[0] ?? null); + $this->assertSame($routeIndex, Router::match('GET', '/')->route); + $this->assertSame($routeAbout, Router::match('GET', '/about')->route); + $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/me')->route); + $this->assertSame($routeAboutWildcard, Router::match('GET', '/about/you')->route); + $this->assertSame($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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); - $this->assertSame($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')->route); + $this->assertSame($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')->route); - $this->assertNotSame($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')[0]); - $this->assertNotSame($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')[0]); + $this->assertNotSame($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')?->route); + $this->assertNotSame($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); } public function testCanMatchAlias(): void @@ -93,9 +93,9 @@ public function testCanMatchAlias(): void Router::addRoute($routeGET); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')->route); + $this->assertSame($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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')->route); + $this->assertSame($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->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')[0] ?? null); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')->route); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')->route); } public function testCanMatchFilename(): void @@ -142,7 +142,7 @@ public function testCanMatchFilename(): void $routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt'); Router::addRoute($routeGET); - $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')[0] ?? null); + $this->assertSame($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')->route); } public function testCannotFindUnknownRouteByPath(): void @@ -156,7 +156,7 @@ public function testCannotFindUnknownRouteByMethod(): void Router::addRoute($route); - $this->assertSame($route, Router::match(Http::REQUEST_METHOD_GET, '/404')[0] ?? null); + $this->assertSame($route, Router::match(Http::REQUEST_METHOD_GET, '/404')->route); $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')); } From 41179392e58553f03467ba1d53710e4ecb542e03 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:08:05 +0100 Subject: [PATCH 12/21] Rename RouteMatch::\$matchedPath to RouteMatch::\$path Since the property already lives on a class called RouteMatch, the "matched" prefix is redundant. \$match->path reads cleaner than \$match->matchedPath. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 2 +- src/Http/RouteMatch.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index c928f73..6f52853 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -606,7 +606,7 @@ public function execute(Route $route, Request $request, Response $response): sta $match = new RouteMatch($route, ''); $context->set('match', fn() => $match); } - $preparedPath = Router::preparePath($match->matchedPath); + $preparedPath = Router::preparePath($match->path); $pathValues = $route->getPathValues($request, $preparedPath[0]); try { diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index abfe94c..991c297 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -7,9 +7,9 @@ /** * Immutable bundle of everything the framework knows about how the current * request was routed: the matched Route, the prepared-path key it was - * matched under (with placeholders like ":::") and — once the action has - * resolved its parameters — the name-keyed map of resolved + validated - * argument values. + * matched under (`$path`, with placeholders like ":::") and — once the + * action has resolved its parameters — the name-keyed map of resolved + + * validated argument values. * * Lives on the per-request context container (coroutine-local under the * Swoole adapters) under the key `'match'`. Inject it into a hook or @@ -22,7 +22,7 @@ final class RouteMatch */ public function __construct( public readonly Route $route, - public readonly string $matchedPath, + public readonly string $path, public readonly array $arguments = [], ) {} @@ -36,6 +36,6 @@ public function __construct( */ public function withArguments(array $arguments): self { - return new self($this->route, $this->matchedPath, $arguments); + return new self($this->route, $this->path, $arguments); } } From e6ffc4738810d08887da042f3c71b0e96cf6734b Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:10:58 +0100 Subject: [PATCH 13/21] Drop RouteMatch::withArguments(), use the constructor directly The helper had a single caller (Http::execute() updating the arguments map after the action's params resolve) and the constructor already accepts \$arguments. Inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 2 +- src/Http/RouteMatch.php | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 6f52853..430af0f 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -640,7 +640,7 @@ public function execute(Route $route, Request $request, Response $response): sta foreach ($route->getParams() as $name => $param) { $resolved[$name] = $arguments[$param['order']] ?? null; } - $match = $match->withArguments($resolved); + $match = new RouteMatch($match->route, $match->path, $resolved); $this->setContext('match', fn() => $match); \call_user_func_array($route->getAction(), $arguments); diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 991c297..cc9ce6a 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -25,17 +25,4 @@ public function __construct( public readonly string $path, public readonly array $arguments = [], ) {} - - /** - * Return a copy of this match with the resolved-argument map replaced. - * Used by the framework once the action's parameters have been - * validated, so subsequent shutdown / error hooks can read the same - * values the action received. - * - * @param array $arguments - */ - public function withArguments(array $arguments): self - { - return new self($this->route, $this->path, $arguments); - } } From 1200c1aa07101a05bb3145545c101c37665e6b94 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:54:55 +0100 Subject: [PATCH 14/21] Seed context 'match' to null at the top of runInternal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously 'match' was only set after Http::match() ran (or by execute() when called directly). Code that read it earlier — request hooks, the global error path triggered by an exception in a request hook, top-level introspection in app/http.php — got "Failed to find resource" from getResource('match'). Set it to null up front, alongside 'request' and 'response', so inject('match') and getResource('match') always return at least null during a request. The real RouteMatch overwrites it once routing resolves. Added testMatchIsNullDuringEarlyErrorBeforeRouting covering the "error in onRequest hook -> global error handler reads 'match'" path that motivated this fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 5 +++++ tests/HttpTest.php | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/Http/Http.php b/src/Http/Http.php index 430af0f..f78348a 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -787,6 +787,11 @@ private function runInternal(Request $request, Response $response): static $this->setContext('request', fn() => $request); $this->setContext('response', fn() => $response); + // Seed 'match' to null up front so requestHooks, the global error + // path, and any pre-routing code can read it without tripping the + // "Failed to find resource" error from getResource('match'). It + // gets overwritten with the real RouteMatch once match() runs. + $this->setContext('match', fn() => null); try { foreach (self::$requestHooks as $hook) { diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 7b0875c..e08d954 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -395,6 +395,35 @@ public function testShutdownHookCanInjectResolvedArguments(): void $this->assertSame('action:abc123,200|shutdown:fileId=abc123,width=200', $result); } + public function testMatchIsNullDuringEarlyErrorBeforeRouting(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/whatever'; + + Http::onRequest() + ->action(function (): void { + throw new \RuntimeException('boom'); + }); + + $seen = new \stdClass(); + $seen->matchWasSet = false; + $seen->matchValue = 'sentinel'; + + Http::error() + ->inject('match') + ->action(function (?RouteMatch $match) use ($seen): void { + $seen->matchWasSet = true; + $seen->matchValue = $match; + }); + + ob_start(); + @$this->http->run(new Request(), new Response()); + ob_end_clean(); + + $this->assertTrue($seen->matchWasSet, 'global error hook should have run'); + $this->assertNull($seen->matchValue, "'match' should be null on the context before routing"); + } + /** * @return \Iterator> */ From a2c5636965f1248329334836f021f7330300ff2d Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:04:59 +0100 Subject: [PATCH 15/21] Seed RouteMatch::\$arguments with path values at match-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-phase population so init hooks can read URI segments via \$match->arguments without falling back to Route::getPathValues(): - match() : seeds \$arguments with the path values from Route::getPathValues() — init hooks see them - execute() (action): replaces \$arguments with the full resolved+validated set (path + query + body) right before the action runs — shutdown / error hooks see the same map the action received Also seeds path values when execute() is called directly without a prior match() (test path). Closes the gap that forced downstream init-hook code (e.g. Appwrite's api/web request filters substituting databaseId/collectionId from the URL) to keep calling Route::getPathValues() directly. Added testInitHookSeesPathValuesInMatchArguments covering the init -> match.arguments[pathParam] path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 12 +++++++++++- tests/HttpTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index f78348a..5a4de0a 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -584,6 +584,14 @@ public function match(Request $request, bool $fresh = true): ?Route $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; $match = Router::match($method, $url); + if ($match !== null) { + // Seed arguments with path values right away so init hooks + // can read URI segments (e.g. {databaseId}, {fileId}) via + // $match->arguments. execute() replaces this with the full + // resolved+validated set right before the action runs. + $pathValues = $match->route->getPathValues($request, $match->path); + $match = new RouteMatch($match->route, $match->path, $pathValues); + } $context->set('match', fn() => $match); return $match?->route; @@ -603,7 +611,9 @@ public function execute(Route $route, Request $request, Response $response): sta // execute() called directly (e.g. from a test) without a prior // match() — synthesize a RouteMatch so shutdown / error hooks // injecting 'match' still see the route they're running under. - $match = new RouteMatch($route, ''); + // Seed arguments with path values for the same reason match() + // does: init hooks need URI segments before the action runs. + $match = new RouteMatch($route, '', $route->getPathValues($request, '')); $context->set('match', fn() => $match); } $preparedPath = Router::preparePath($match->path); diff --git a/tests/HttpTest.php b/tests/HttpTest.php index e08d954..0bc45f8 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -395,6 +395,37 @@ public function testShutdownHookCanInjectResolvedArguments(): void $this->assertSame('action:abc123,200|shutdown:fileId=abc123,width=200', $result); } + public function testInitHookSeesPathValuesInMatchArguments(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/databases/db_1/collections/col_2'; + + Http::get('/databases/:databaseId/collections/:collectionId') + ->param('databaseId', '', new Text(64), 'database id', false) + ->param('collectionId', '', new Text(64), 'collection id', false) + ->inject('response') + ->action(function ($databaseId, $collectionId, $response) { + $response->send('action:' . $databaseId . '/' . $collectionId); + }); + + $seen = new \stdClass(); + + Http::init() + ->inject('match') + ->action(function (?RouteMatch $match) use ($seen) { + $seen->arguments = $match?->arguments; + }); + + ob_start(); + $this->http->run(new Request(), new Response()); + $result = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('action:db_1/col_2', $result); + $this->assertSame('db_1', $seen->arguments['databaseId'] ?? null); + $this->assertSame('col_2', $seen->arguments['collectionId'] ?? null); + } + public function testMatchIsNullDuringEarlyErrorBeforeRouting(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; From 6340582b14e8ddba475c5014b7bac04b106b31d8 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:08:47 +0100 Subject: [PATCH 16/21] Make RouteMatch::\$arguments mutable, drop the replace-on-update dance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \$route and \$path stay readonly — they're genuinely immutable across a request — but \$arguments is filled in two phases (path values at match-time, full resolved set before the action) so making it mutable saves us reconstructing the RouteMatch and re-pushing it onto the context. Safe because the RouteMatch lives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 3 +-- src/Http/RouteMatch.php | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index 5a4de0a..d67eb25 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -650,8 +650,7 @@ public function execute(Route $route, Request $request, Response $response): sta foreach ($route->getParams() as $name => $param) { $resolved[$name] = $arguments[$param['order']] ?? null; } - $match = new RouteMatch($match->route, $match->path, $resolved); - $this->setContext('match', fn() => $match); + $match->arguments = $resolved; \call_user_func_array($route->getAction(), $arguments); } diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index cc9ce6a..169c627 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -5,15 +5,19 @@ namespace Utopia\Http; /** - * Immutable bundle of everything the framework knows about how the current - * request was routed: the matched Route, the prepared-path key it was - * matched under (`$path`, with placeholders like ":::") and — once the - * action has resolved its parameters — the name-keyed map of resolved + - * validated argument values. + * Bundle of everything the framework knows about how the current request + * was routed: the matched Route, the prepared-path key it was matched + * under (`$path`, with placeholders like ":::") and the name-keyed map + * of argument values for the current phase. * - * Lives on the per-request context container (coroutine-local under the - * Swoole adapters) under the key `'match'`. Inject it into a hook or - * action with `->inject('match')`. + * `$route` and `$path` are immutable. `$arguments` is filled in two + * passes during a request — path values at match-time so init hooks can + * read them, then the full resolved+validated set right before the + * action runs — and is therefore mutable. Safe because the RouteMatch + * lives on the per-request context container (coroutine-local under + * the Swoole adapters), so only the request's own coroutine touches it. + * + * Inject it into a hook or action with `->inject('match')`. */ final class RouteMatch { @@ -23,6 +27,6 @@ final class RouteMatch public function __construct( public readonly Route $route, public readonly string $path, - public readonly array $arguments = [], + public array $arguments = [], ) {} } From 2e2c19aef535b1f9241bb5c9e1bf9811a11ec3d2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:13:55 +0100 Subject: [PATCH 17/21] Revert: path-value seeding into RouteMatch::\$arguments at match-time Splitting that change out into its own PR. RouteMatch::\$arguments is now empty during init hooks again; the validated set still lands in \$arguments right before the action runs. Reverts the relevant parts of a2c5636 ("Seed RouteMatch::\$arguments with path values at match-time"). Keeps the mutability change from 6340582 since it's orthogonal. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 12 +----------- tests/HttpTest.php | 31 ------------------------------- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index d67eb25..9652ead 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -584,14 +584,6 @@ public function match(Request $request, bool $fresh = true): ?Route $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; $match = Router::match($method, $url); - if ($match !== null) { - // Seed arguments with path values right away so init hooks - // can read URI segments (e.g. {databaseId}, {fileId}) via - // $match->arguments. execute() replaces this with the full - // resolved+validated set right before the action runs. - $pathValues = $match->route->getPathValues($request, $match->path); - $match = new RouteMatch($match->route, $match->path, $pathValues); - } $context->set('match', fn() => $match); return $match?->route; @@ -611,9 +603,7 @@ public function execute(Route $route, Request $request, Response $response): sta // execute() called directly (e.g. from a test) without a prior // match() — synthesize a RouteMatch so shutdown / error hooks // injecting 'match' still see the route they're running under. - // Seed arguments with path values for the same reason match() - // does: init hooks need URI segments before the action runs. - $match = new RouteMatch($route, '', $route->getPathValues($request, '')); + $match = new RouteMatch($route, ''); $context->set('match', fn() => $match); } $preparedPath = Router::preparePath($match->path); diff --git a/tests/HttpTest.php b/tests/HttpTest.php index 0bc45f8..e08d954 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -395,37 +395,6 @@ public function testShutdownHookCanInjectResolvedArguments(): void $this->assertSame('action:abc123,200|shutdown:fileId=abc123,width=200', $result); } - public function testInitHookSeesPathValuesInMatchArguments(): void - { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/databases/db_1/collections/col_2'; - - Http::get('/databases/:databaseId/collections/:collectionId') - ->param('databaseId', '', new Text(64), 'database id', false) - ->param('collectionId', '', new Text(64), 'collection id', false) - ->inject('response') - ->action(function ($databaseId, $collectionId, $response) { - $response->send('action:' . $databaseId . '/' . $collectionId); - }); - - $seen = new \stdClass(); - - Http::init() - ->inject('match') - ->action(function (?RouteMatch $match) use ($seen) { - $seen->arguments = $match?->arguments; - }); - - ob_start(); - $this->http->run(new Request(), new Response()); - $result = ob_get_contents(); - ob_end_clean(); - - $this->assertSame('action:db_1/col_2', $result); - $this->assertSame('db_1', $seen->arguments['databaseId'] ?? null); - $this->assertSame('col_2', $seen->arguments['collectionId'] ?? null); - } - public function testMatchIsNullDuringEarlyErrorBeforeRouting(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; From e26b1a995f01b136ec88f6f2fd34811188b627cc Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:24:50 +0100 Subject: [PATCH 18/21] Collapse Adapter API to a single getContext() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Adapter::getContainer() / getContext() split was a foot-gun: two methods with the same return type, distinguishable only by docs. Downstream code that called getContainer() expecting per-request behavior silently got the global one after the rename, leading to 500-on-every-request bugs. Reduce the public API surface: - Adapter exposes only getContext(): smart-routed (request-scoped during a request, global otherwise), parent-chain falls through to the global for singleton lookups. - The global container is constructor-injected and not retrievable post-construction. Adapters keep their own private reference. - Http captures the global at construction via getContext() — the invariant being that Http is constructed at boot, never inside a request, so getContext() returns the global directly there. Make Http::setContext() public so application code (not just the framework) can register per-request values. Symmetric with the already-public Http::setResource(). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Adapter.php | 23 +++++++++------------ src/Http/Adapter/FPM/Server.php | 5 ----- src/Http/Adapter/Swoole/Server.php | 5 ----- src/Http/Adapter/SwooleCoroutine/Server.php | 5 ----- src/Http/Http.php | 9 ++++++-- 5 files changed, 17 insertions(+), 30 deletions(-) diff --git a/src/Http/Adapter.php b/src/Http/Adapter.php index 7490bb5..2853580 100755 --- a/src/Http/Adapter.php +++ b/src/Http/Adapter.php @@ -13,19 +13,16 @@ abstract public function onRequest(callable $callback): void; abstract public function start(): void; /** - * Return the global container — the singleton-scoped registry shared - * across all requests. Use this for resources that should outlive a - * single request (clients, configs, etc.). - */ - abstract public function getContainer(): Container; - - /** - * Return the per-request context container. Coroutine-local under the - * Swoole adapters; identical to getContainer() under FPM. Use this for - * values scoped to a single request — request, response, route, - * matchedPath, error, etc. Lookups fall through to the global - * container's parent chain, so getContext()->get('someSingleton') - * still resolves. + * Return the container for the current execution context: + * + * - Inside a request, the per-request container (coroutine-local + * under the Swoole adapters), with parent-chain fallback to the + * global container — so singleton lookups still resolve. + * - Outside a request (boot, onStart hooks), the global container + * directly. + * + * Callers don't need to know which "scope" they're in; they get + * the right container for where they are. */ abstract public function getContext(): Container; } diff --git a/src/Http/Adapter/FPM/Server.php b/src/Http/Adapter/FPM/Server.php index 62f98df..c8fa593 100755 --- a/src/Http/Adapter/FPM/Server.php +++ b/src/Http/Adapter/FPM/Server.php @@ -25,11 +25,6 @@ public function onStart(callable $callback): void \call_user_func($callback, $this); } - public function getContainer(): Container - { - return $this->container; - } - public function getContext(): Container { return $this->container; diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index b9facf6..232302c 100755 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -38,11 +38,6 @@ public function onRequest(callable $callback): void }); } - public function getContainer(): Container - { - return $this->container; - } - public function getContext(): Container { if (Coroutine::getCid() !== -1) { diff --git a/src/Http/Adapter/SwooleCoroutine/Server.php b/src/Http/Adapter/SwooleCoroutine/Server.php index 6cceab9..d29c964 100644 --- a/src/Http/Adapter/SwooleCoroutine/Server.php +++ b/src/Http/Adapter/SwooleCoroutine/Server.php @@ -50,11 +50,6 @@ public function onRequest(callable $callback): void }); } - public function getContainer(): Container - { - return $this->container; - } - public function getContext(): Container { return Coroutine::getContext()[self::CONTEXT_KEY] ?? $this->container; diff --git a/src/Http/Http.php b/src/Http/Http.php index 9652ead..1ff8189 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -136,7 +136,12 @@ public function __construct(Adapter $server, string $timezone) date_default_timezone_set($timezone); $this->files = new Files(); $this->server = $server; - $this->container = $server->getContainer(); + // Capture the global container at construction. INVARIANT: `Http` + // is constructed at boot, never inside a request. With no + // coroutine active, getContext() returns the global container + // directly — so this capture is stable for the lifetime of the + // Http instance. + $this->container = $server->getContext(); $this->setTelemetry(new NoTelemetry()); } @@ -416,7 +421,7 @@ public function setResource(string $name, callable $callback, array $injections * * @param list $injections */ - protected function setContext(string $name, callable $callback, array $injections = []): void + public function setContext(string $name, callable $callback, array $injections = []): void { $this->server->getContext()->set($name, $callback, $injections); } From 536f945e0261acf70a7b123d5766ac70d62bc48c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:25:57 +0100 Subject: [PATCH 19/21] Fix RouteMatch docblock: \$arguments is single-write, not two-pass Earlier draft claimed \$arguments gets pre-populated with path values at match-time so init hooks can read them. That seeding pass was never merged (split out into a follow-up PR), so the docblock was lying about behavior. Rewrite to describe what actually happens: \$arguments is empty until execute() writes it once, just before the route action runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/RouteMatch.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 169c627..84cbdef 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -8,14 +8,18 @@ * Bundle of everything the framework knows about how the current request * was routed: the matched Route, the prepared-path key it was matched * under (`$path`, with placeholders like ":::") and the name-keyed map - * of argument values for the current phase. + * of resolved argument values. * - * `$route` and `$path` are immutable. `$arguments` is filled in two - * passes during a request — path values at match-time so init hooks can - * read them, then the full resolved+validated set right before the - * action runs — and is therefore mutable. Safe because the RouteMatch - * lives on the per-request context container (coroutine-local under - * the Swoole adapters), so only the request's own coroutine touches it. + * `$route` and `$path` are immutable. `$arguments` starts empty and is + * written by the framework once — right before the route action runs, + * after `getArguments()` has resolved and validated the action's + * parameters. From that point on (action body, shutdown hooks, error + * hooks) it holds the same values the action received. + * + * Init hooks run *before* this write, so they see `$arguments === []`. + * The mutable property is safe because the RouteMatch lives on the + * per-request context container (coroutine-local under the Swoole + * adapters), so only the request's own coroutine touches it. * * Inject it into a hook or action with `->inject('match')`. */ From a3ece56dafb581be768c33a382585267d0e575c8 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:27:20 +0100 Subject: [PATCH 20/21] Trim verbose comments and docblocks Most of the explanatory blocks I added are restating obvious behavior or duplicating what the PR description covers. Reduce to one-liners where intent isn't already clear from the code. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Adapter.php | 13 +++---------- src/Http/Http.php | 31 +++++-------------------------- src/Http/RouteMatch.php | 22 ++++++---------------- 3 files changed, 14 insertions(+), 52 deletions(-) diff --git a/src/Http/Adapter.php b/src/Http/Adapter.php index 2853580..1dd94bb 100755 --- a/src/Http/Adapter.php +++ b/src/Http/Adapter.php @@ -13,16 +13,9 @@ abstract public function onRequest(callable $callback): void; abstract public function start(): void; /** - * Return the container for the current execution context: - * - * - Inside a request, the per-request container (coroutine-local - * under the Swoole adapters), with parent-chain fallback to the - * global container — so singleton lookups still resolve. - * - Outside a request (boot, onStart hooks), the global container - * directly. - * - * Callers don't need to know which "scope" they're in; they get - * the right container for where they are. + * Container for the current execution context: the per-request + * container inside a request (coroutine-local under Swoole, with + * parent-chain fallback to global), the global container otherwise. */ abstract public function getContext(): Container; } diff --git a/src/Http/Http.php b/src/Http/Http.php index 1ff8189..be9c3ff 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -136,11 +136,7 @@ public function __construct(Adapter $server, string $timezone) date_default_timezone_set($timezone); $this->files = new Files(); $this->server = $server; - // Capture the global container at construction. INVARIANT: `Http` - // is constructed at boot, never inside a request. With no - // coroutine active, getContext() returns the global container - // directly — so this capture is stable for the lifetime of the - // Http instance. + // Captures the global container; assumes Http is constructed at boot, not inside a request. $this->container = $server->getContext(); $this->setTelemetry(new NoTelemetry()); } @@ -411,13 +407,8 @@ public function setResource(string $name, callable $callback, array $injections } /** - * Set a request-scoped value on the current request's context container. - * - * The framework convention is: `setResource()` registers singletons on - * the global container; `setContext()` registers per-request values on - * the adapter's request container, which is coroutine-local under the - * Swoole adapters. `getResource()` reads from the request container - * with parent-fallback to the global one, so either kind resolves. + * Register a per-request value on the context container. + * Counterpart to setResource() for global singletons. * * @param list $injections */ @@ -605,9 +596,7 @@ public function execute(Route $route, Request $request, Response $response): sta $context = $this->server->getContext(); $match = $context->has('match') ? $context->get('match') : null; if (!$match instanceof RouteMatch || $match->route !== $route) { - // execute() called directly (e.g. from a test) without a prior - // match() — synthesize a RouteMatch so shutdown / error hooks - // injecting 'match' still see the route they're running under. + // execute() called directly without a prior match(). $match = new RouteMatch($route, ''); $context->set('match', fn() => $match); } @@ -636,11 +625,6 @@ public function execute(Route $route, Request $request, Response $response): sta if (!$response->isSent()) { $arguments = $this->getArguments($route, $pathValues, $request->getParams()); - // Update the per-request RouteMatch with the resolved+ - // validated argument map so shutdown / error hooks can - // read the same values the action saw — e.g. for label - // substitution like {request.fileId}. Race-free because - // the context container is per-request. $resolved = []; foreach ($route->getParams() as $name => $param) { $resolved[$name] = $arguments[$param['order']] ?? null; @@ -791,10 +775,6 @@ private function runInternal(Request $request, Response $response): static $this->setContext('request', fn() => $request); $this->setContext('response', fn() => $response); - // Seed 'match' to null up front so requestHooks, the global error - // path, and any pre-routing code can read it without tripping the - // "Failed to find resource" error from getResource('match'). It - // gets overwritten with the real RouteMatch once match() runs. $this->setContext('match', fn() => null); try { @@ -869,8 +849,7 @@ private function runInternal(Request $request, Response $response): static } if (null === $route && null !== self::$wildcardRoute) { - // Clone so we can stamp the request URI onto $path without - // mutating the singleton shared across concurrent coroutines. + // Clone before stamping $path so concurrent coroutines don't fight over the singleton. $route = clone self::$wildcardRoute; $path = parse_url($request->getURI(), PHP_URL_PATH); $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 84cbdef..7244424 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -5,23 +5,13 @@ namespace Utopia\Http; /** - * Bundle of everything the framework knows about how the current request - * was routed: the matched Route, the prepared-path key it was matched - * under (`$path`, with placeholders like ":::") and the name-keyed map - * of resolved argument values. + * Routing state for the current request: the matched Route, the + * prepared-path key it matched under (placeholders → ":::"), and the + * resolved+validated argument map. `$arguments` is empty until + * `Http::execute()` writes it just before the route action runs; + * available to the action and downstream shutdown / error hooks. * - * `$route` and `$path` are immutable. `$arguments` starts empty and is - * written by the framework once — right before the route action runs, - * after `getArguments()` has resolved and validated the action's - * parameters. From that point on (action body, shutdown hooks, error - * hooks) it holds the same values the action received. - * - * Init hooks run *before* this write, so they see `$arguments === []`. - * The mutable property is safe because the RouteMatch lives on the - * per-request context container (coroutine-local under the Swoole - * adapters), so only the request's own coroutine touches it. - * - * Inject it into a hook or action with `->inject('match')`. + * Inject with `->inject('match')`. */ final class RouteMatch { From df3b31c8d2ad5a919a9100e64b7a9f7cfa1f4ad3 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:38:27 +0100 Subject: [PATCH 21/21] Populate \$match->arguments incrementally so error hooks see partials If a validator throws partway through param resolution (the normal 400-validation path), \$match->arguments would have been left empty under the previous all-or-nothing assignment. Error hooks reading it saw nothing. Add a by-ref \$resolved out-param to getArguments() that gets written *before* validate() runs for each param. The route call site binds it to \$match->arguments, so a mid-loop throw leaves everything resolved up to that point visible to downstream error hooks. Other call sites omit the param. Restores the partial-read behavior the old Hook::setParamValue writeback gave us, race-free this time. Added testErrorHookSeesPartialMatchArgumentsWhenValidationFails. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Http/Http.php | 19 +++++++------------ src/Http/RouteMatch.php | 9 +++++++-- tests/HttpTest.php | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/Http/Http.php b/src/Http/Http.php index be9c3ff..9853ba3 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -623,14 +623,7 @@ public function execute(Route $route, Request $request, Response $response): sta } if (!$response->isSent()) { - $arguments = $this->getArguments($route, $pathValues, $request->getParams()); - - $resolved = []; - foreach ($route->getParams() as $name => $param) { - $resolved[$name] = $arguments[$param['order']] ?? null; - } - $match->arguments = $resolved; - + $arguments = $this->getArguments($route, $pathValues, $request->getParams(), $match->arguments); \call_user_func_array($route->getAction(), $arguments); } @@ -683,17 +676,17 @@ public function execute(Route $route, Request $request, Response $response): sta } /** - * Get Arguments - * * @param array $values * @param array $requestParams + * @param array $resolved + * @param-out array $resolved * @return array * @throws Exception */ - protected function getArguments(Hook $hook, array $values, array $requestParams): array + protected function getArguments(Hook $hook, array $values, array $requestParams, array &$resolved = []): array { $arguments = []; - foreach ($hook->getParams() as $key => $param) { // Get value from route or request object + foreach ($hook->getParams() as $key => $param) { $existsInRequest = \array_key_exists($key, $requestParams); $existsInValues = \array_key_exists($key, $values); $paramExists = $existsInRequest || $existsInValues; @@ -704,6 +697,8 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } $value = $existsInValues ? $values[$key] : $arg; + $resolved[(string) $key] = $value; + if (!$param['skipValidation']) { if (!$paramExists && !$param['optional']) { throw new Exception('Param "' . $key . '" is not optional.', 400); diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php index 7244424..53a8011 100644 --- a/src/Http/RouteMatch.php +++ b/src/Http/RouteMatch.php @@ -15,12 +15,17 @@ */ final class RouteMatch { + /** @var array */ + public array $arguments; + /** * @param array $arguments */ public function __construct( public readonly Route $route, public readonly string $path, - public array $arguments = [], - ) {} + array $arguments = [], + ) { + $this->arguments = $arguments; + } } diff --git a/tests/HttpTest.php b/tests/HttpTest.php index e08d954..498bf04 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -395,6 +395,38 @@ public function testShutdownHookCanInjectResolvedArguments(): void $this->assertSame('action:abc123,200|shutdown:fileId=abc123,width=200', $result); } + public function testErrorHookSeesPartialMatchArgumentsWhenValidationFails(): void + { + $route = new Route('GET', '/files/:fileId/things/:thingId'); + $route + ->param('fileId', '', new Text(64), 'file id', false) + ->param('thingId', '', new Text(1, min: 0), 'thing id', false) + ->action(function ($fileId, $thingId) { + echo 'never'; + }); + + $seen = new \stdClass(); + + $this->http + ->error() + ->inject('match') + ->action(function (?RouteMatch $match) use ($seen) { + $seen->arguments = $match?->arguments; + }); + + $request = new UtopiaFPMRequestTest(); + $request::_setParams(['fileId' => 'abc123', 'thingId' => 'too-long-for-the-validator']); + + ob_start(); + $this->http->execute($route, $request, new Response()); + ob_end_clean(); + + // fileId resolved fine; thingId failed validation. + // Error hook should see fileId (and the candidate thingId value). + $this->assertSame('abc123', $seen->arguments['fileId'] ?? null); + $this->assertSame('too-long-for-the-validator', $seen->arguments['thingId'] ?? null); + } + public function testMatchIsNullDuringEarlyErrorBeforeRouting(): void { $_SERVER['REQUEST_METHOD'] = 'GET';