diff --git a/src/Http/Http.php b/src/Http/Http.php index 06351fb..47c1891 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -98,19 +98,6 @@ class Http */ protected static array $requestHooks = []; - /** - * Route - * - * Memory cached result for chosen route - */ - protected ?Route $route = null; - - /** - * Wildcard route - * If set, this get's executed if no other route is matched - */ - protected static ?Route $wildcardRoute = null; - /** * Compression */ @@ -242,9 +229,10 @@ public static function delete(string $url): Route */ public static function wildcard(): Route { - self::$wildcardRoute = new Route('', ''); + $route = new Route('', ''); + Router::setWildcard($route); - return self::$wildcardRoute; + return $route; } /** @@ -416,24 +404,6 @@ public static function getRoutes(): array return Router::getRoutes(); } - /** - * Get the current route - */ - public function getRoute(): ?Route - { - return $this->route ?? null; - } - - /** - * Set the current route - */ - public function setRoute(Route $route): self - { - $this->route = $route; - - return $this; - } - /** * Add Route * @@ -538,44 +508,94 @@ public function start(): void } /** - * Match - * - * Find matching route given current user request - * - * @param bool $fresh If true, will not match any cached route + * Find the route registered for the given request, or null if none match. */ - public function match(Request $request, bool $fresh = true): ?Route + public function match(Request $request): ?RouteMatch { - if (null !== $this->route && !$fresh) { - return $this->route; - } - $url = parse_url($request->getURI(), PHP_URL_PATH); $url = \is_string($url) ? ($url === '' ? '/' : $url) : '/'; $method = $request->getMethod(); $method = (self::REQUEST_METHOD_HEAD === $method) ? self::REQUEST_METHOD_GET : $method; - $this->route = Router::match($method, $url); - - return $this->route; + return Router::match($method, $url); } /** - * Execute a given route with middlewares and error handling + * Match a request and run its route's handler and hooks. + * + * HEAD runs as GET with the response body suppressed. OPTIONS fires + * options hooks and returns without dispatching. An unmatched request + * fires global error hooks with a 404. + * + * This is a re-entrant dispatch primitive — safe to call from inside + * another handler with a synthesized Request/Response (e.g. a GraphQL + * resolver invoking an API route). It does not run request-level setup + * (compression, request hooks, telemetry); those belong to {@see run()}, + * which is the entry point for top-level requests from the server. */ - public function execute(Route $route, Request $request, Response $response): static + public function execute(Request $request, Response $response): static { + $method = $request->getMethod(); + + if (self::REQUEST_METHOD_HEAD === $method) { + $method = self::REQUEST_METHOD_GET; + $response->disablePayload(); + } + + $match = $this->match($request); + + if (self::REQUEST_METHOD_OPTIONS === $method) { + $groups = $match?->route->getGroups() ?? []; + + try { + foreach ($groups as $group) { + foreach (self::$options as $option) { // Group options hooks + /** @var Hook $option */ + if (\in_array($group, $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match->route)); + } + } + } + + foreach (self::$options as $option) { // Global options hooks + /** @var Hook $option */ + if (\in_array('*', $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams(), $match?->route)); + } + } + } catch (\Throwable $e) { + foreach (self::$errors as $error) { // Global error hooks + /** @var Hook $error */ + if (\in_array('*', $error->getGroups())) { + $this->context()->set('error', fn() => $e, []); + \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams(), $match?->route)); + } + } + } + + return $this; + } + + if ($match === null) { + foreach (self::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + $this->context()->set('error', fn() => new Exception('Not Found', 404), []); + \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); + } + } + + return $this; + } + + $route = $match->route; $arguments = []; $groups = $route->getGroups(); - $preparedPath = Router::preparePath($route->getMatchedPath()); - $pathValues = $route->getPathValues($request, $preparedPath[0]); - try { if ($route->getHook()) { foreach (self::$init as $hook) { // Global init hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -584,21 +604,21 @@ public function execute(Route $route, Request $request, Response $response): sta foreach ($groups as $group) { foreach (self::$init as $hook) { // Group init hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } } if (!$response->isSent()) { - $arguments = $this->getArguments($route, $pathValues, $request->getParams()); + $arguments = $this->getArguments($route, $match->params, $request->getParams(), $route); \call_user_func_array($route->getAction(), $arguments); } foreach ($groups as $group) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array($group, $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -607,7 +627,7 @@ public function execute(Route $route, Request $request, Response $response): sta if ($route->getHook()) { foreach (self::$shutdown as $hook) { // Group shutdown hooks if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); + $arguments = $this->getArguments($hook, $match->params, $request->getParams(), $route); \call_user_func_array($hook->getAction(), $arguments); } } @@ -619,7 +639,7 @@ public function execute(Route $route, Request $request, Response $response): sta foreach (self::$errors as $error) { // Group error hooks if (\in_array($group, $error->getGroups())) { try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams(), $route); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); @@ -631,7 +651,7 @@ public function execute(Route $route, Request $request, Response $response): sta foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); + $arguments = $this->getArguments($error, $match->params, $request->getParams(), $route); \call_user_func_array($error->getAction(), $arguments); } catch (\Throwable $e) { throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); @@ -651,7 +671,7 @@ public function execute(Route $route, Request $request, Response $response): sta * @return array * @throws Exception */ - protected function getArguments(Hook $hook, array $values, array $requestParams): array + protected function getArguments(Hook $hook, array $values, array $requestParams, ?Route $route = null): array { $arguments = []; foreach ($hook->getParams() as $key => $param) { // Get value from route or request object @@ -701,6 +721,13 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } foreach ($hook->getInjections() as $injection) { + // 'route' is frame-local: pass the dispatch frame's matched Route + // through directly instead of routing through shared context. + if ($injection['name'] === 'route') { + $arguments[$injection['order']] = $route; + continue; + } + $arguments[$injection['order']] = $this->adapter->context()->get($injection['name']); } @@ -708,7 +735,17 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } /** - * Run: wrapper function to record telemetry. All domain logic should happen in `runInternal`. + * Handle a top-level HTTP request. + * + * This is the entry point wired into the server adapter for each + * incoming request. It runs the full request lifecycle: compression + * setup, request hooks, static-file serving, route match, dispatch, + * and telemetry. + * + * For dispatching a sub-request from inside a handler (e.g. a + * GraphQL resolver invoking another API route with a synthesized + * Request/Response), use {@see execute()} instead — it skips the + * outer-request setup that has already run. */ public function run(Request $request, Response $response): static { @@ -724,7 +761,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->match($request)?->route->getPath(), 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -789,93 +826,7 @@ private function runInternal(Request $request, Response $response): static return $this; } - $method = $request->getMethod(); - $route = $this->match($request); - $groups = ($route instanceof Route) ? $route->getGroups() : []; - - $this->context()->set('route', fn() => $route, []); - - if (self::REQUEST_METHOD_HEAD === $method) { - $method = self::REQUEST_METHOD_GET; - $response->disablePayload(); - } - - if (self::REQUEST_METHOD_OPTIONS === $method) { - try { - foreach ($groups as $group) { - foreach (self::$options as $option) { // Group options hooks - /** @var Hook $option */ - if (\in_array($group, $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } - - foreach (self::$options as $option) { // Global options hooks - /** @var Hook $option */ - if (\in_array('*', $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } catch (\Throwable $e) { - foreach (self::$errors as $error) { // Global error hooks - /** @var Hook $error */ - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => $e, []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - - return $this; - } - - if (null === $route && null !== self::$wildcardRoute) { - $route = self::$wildcardRoute; - $this->route = $route; - $path = parse_url($request->getURI(), PHP_URL_PATH); - $path = \is_string($path) ? ($path === '' ? '/' : $path) : '/'; - $route->path($path); - - $this->context()->set('route', fn() => $route, []); - } - if (null !== $route) { - return $this->execute($route, $request, $response); - } - - if (self::REQUEST_METHOD_OPTIONS === $method) { - try { - foreach ($groups as $group) { - foreach (self::$options as $option) { // Group options hooks - if (\in_array($group, $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } - - foreach (self::$options as $option) { // Global options hooks - if (\in_array('*', $option->getGroups())) { - \call_user_func_array($option->getAction(), $this->getArguments($option, [], $request->getParams())); - } - } - } catch (\Throwable $e) { - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => $e, []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - } else { - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - $this->context()->set('error', fn() => new Exception('Not Found', 404), []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - - return $this; + return $this->execute($request, $response); } @@ -923,6 +874,5 @@ public static function reset(): void self::$options = []; self::$startHooks = []; self::$requestHooks = []; - self::$wildcardRoute = null; } } diff --git a/src/Http/Route.php b/src/Http/Route.php index b72886d..ab1d769 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -38,8 +38,6 @@ class Route extends Hook */ protected int $order; - protected string $matchedPath = ''; - public function __construct(string $method, string $path) { parent::__construct(); @@ -48,17 +46,6 @@ public function __construct(string $method, string $path) $this->order = ++self::$counter; } - public function setMatchedPath(string $path): self - { - $this->matchedPath = $path; - return $this; - } - - public function getMatchedPath(): string - { - return $this->matchedPath; - } - /** * Get Route Order ID */ @@ -130,19 +117,19 @@ public function setPathParam(string $key, int $index, string $path = ''): void } /** - * Get path params. + * Extract this route's path params from a request URL. * - * @return array + * @return array */ - public function getPathValues(Request $request, string $path = ''): array + public function resolveParams(string $url, string $matchedTemplate = ''): array { $pathValues = []; - $parts = explode('/', ltrim($request->getURI(), '/')); + $parts = explode('/', ltrim($url, '/')); - if (empty($path)) { - $pathParams = $this->pathParams[$path] ?? array_values($this->pathParams)[0] ?? []; + if (empty($matchedTemplate)) { + $pathParams = $this->pathParams[$matchedTemplate] ?? array_values($this->pathParams)[0] ?? []; } else { - $pathParams = $this->pathParams[$path] ?? []; + $pathParams = $this->pathParams[$matchedTemplate] ?? []; } foreach ($pathParams as $key => $index) { diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php new file mode 100644 index 0000000..4bcd811 --- /dev/null +++ b/src/Http/RouteMatch.php @@ -0,0 +1,24 @@ + 'abc-123']` when `/users/:id` matches + * `/users/abc-123`. Empty for static routes and wildcards. + * + * @var array + */ + public array $params, + ) {} +} diff --git a/src/Http/Router.php b/src/Http/Router.php index 8118ffe..81ef21d 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -14,6 +14,11 @@ class Router protected static bool $allowOverride = false; + /** + * Method-agnostic wildcard route used when no method-specific route matches. + */ + protected static ?Route $wildcard = null; + /** * @var array */ @@ -106,12 +111,20 @@ public static function addRouteAlias(string $path, Route $route): void } /** - * Match route against the method and path. + * Register a method-agnostic catch-all route, used when nothing else matches. + */ + public static function setWildcard(?Route $route): void + { + self::$wildcard = $route; + } + + /** + * Find the route registered for a request's method and path. */ - public static function match(string $method, string $path): ?Route + public static function match(string $method, string $path): ?RouteMatch { if (!\array_key_exists($method, self::$routes)) { - return null; + return self::$wildcard !== null ? new RouteMatch(self::$wildcard, []) : null; } $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); @@ -120,7 +133,7 @@ public static function match(string $method, string $path): ?Route foreach (self::combinations($filteredParams) as $sample) { $sample = array_filter($sample, fn(int $i) => $i <= $length); - $match = implode( + $template = implode( '/', array_replace( $parts, @@ -128,21 +141,18 @@ 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; + if (\array_key_exists($template, self::$routes[$method])) { + $route = self::$routes[$method][$template]; + return new RouteMatch($route, $route->resolveParams($path, $template)); } } /** * Match root wildcard. */ - $match = self::WILDCARD_TOKEN; - if (\array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; + $template = self::WILDCARD_TOKEN; + if (\array_key_exists($template, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$template], []); } /** @@ -150,14 +160,16 @@ public static function match(string $method, string $path): ?Route */ foreach ($parts as $part) { $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; + $template = $current . self::WILDCARD_TOKEN; + if (\array_key_exists($template, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$template], []); } } + if (self::$wildcard !== null) { + return new RouteMatch(self::$wildcard, []); + } + return null; } @@ -219,6 +231,7 @@ public static function preparePath(string $path): array public static function reset(): void { self::$params = []; + self::$wildcard = null; self::$routes = [ Http::REQUEST_METHOD_GET => [], Http::REQUEST_METHOD_POST => [], diff --git a/tests/HttpTest.php b/tests/HttpTest.php index e37291b..e13dba1 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -90,6 +90,10 @@ public function testCanGetEnvironmentVariable(): void public function testCanExecuteRoute(): void { + Http::setAllowOverride(true); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->resources->set('rand', fn() => rand()); $resource = $this->resources->get('rand'); @@ -101,7 +105,7 @@ public function testCanExecuteRoute(): void }); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) @@ -111,13 +115,13 @@ public function testCanExecuteRoute(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); // With Params $resource = $this->resources->get('rand'); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) @@ -135,7 +139,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y', 'z' => 'param-z']); - $this->http->execute($route, $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -143,7 +147,7 @@ public function testCanExecuteRoute(): void // With Error $resource = $this->resources->get('rand'); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(1, min: 0), 'x param', false) @@ -155,7 +159,7 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($route, $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -204,7 +208,7 @@ public function testCanExecuteRoute(): void echo '-(shutdown-homepage)'; }); - $route = new Route('GET', '/path'); + $route = Http::get('/api'); $route ->groups(['api']) @@ -214,7 +218,7 @@ public function testCanExecuteRoute(): void echo $x . '-', $y; }); - $homepage = new Route('GET', '/path'); + $homepage = Http::get('/homepage'); $homepage ->groups(['homepage']) @@ -227,7 +231,8 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($route, $request, new Response()); + $_SERVER['REQUEST_URI'] = '/api'; + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -237,7 +242,8 @@ public function testCanExecuteRoute(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['x' => 'param-x', 'y' => 'param-y']); - $this->http->execute($homepage, $request, new Response()); + $_SERVER['REQUEST_URI'] = '/homepage'; + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -246,6 +252,10 @@ public function testCanExecuteRoute(): void public function testCanAddAndExecuteHooks(): void { + Http::setAllowOverride(true); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->http ->init() ->action(function () { @@ -259,7 +269,7 @@ public function testCanAddAndExecuteHooks(): void }); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->action(function ($x) { @@ -267,14 +277,14 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('(init)-x-def-(shutdown)', $result); // Default Params - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->hook(false) @@ -283,7 +293,7 @@ public function testCanAddAndExecuteHooks(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -292,6 +302,8 @@ public function testCanAddAndExecuteHooks(): void public function testCanResolveParamAliases(): void { + Http::setAllowOverride(true); + $this->http ->error() ->inject('error') @@ -302,13 +314,15 @@ public function testCanResolveParamAliases(): void $savedGet = $_GET; $savedPost = $_POST; $savedMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $savedUri = $_SERVER['REQUEST_URI'] ?? null; try { // GET request: alias resolves from $_GET when canonical key is absent $_GET = ['xAlias' => 'from-alias']; $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias', 'xLegacy']) ->action(function ($x) { @@ -316,7 +330,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -325,7 +339,7 @@ public function testCanResolveParamAliases(): void // GET request: canonical key wins when both are present in $_GET $_GET = ['x' => 'canonical', 'xAlias' => 'aliased']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -333,7 +347,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -342,7 +356,7 @@ public function testCanResolveParamAliases(): void // GET request: first matching alias wins when multiple are present in $_GET $_GET = ['xAlias2' => 'second', 'xAlias1' => 'first']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias1', 'xAlias2']) ->action(function ($x) { @@ -350,7 +364,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -359,7 +373,7 @@ public function testCanResolveParamAliases(): void // GET request: falls back to default when neither canonical nor any alias is in $_GET $_GET = ['unrelated' => 'value']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -367,7 +381,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -376,7 +390,7 @@ public function testCanResolveParamAliases(): void // GET request: required param throws when neither canonical nor any alias is in $_GET $_GET = ['unrelated' => 'value']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', '', new Text(200), 'x param', false, aliases: ['xAlias']) ->action(function ($x) { @@ -384,7 +398,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -393,7 +407,7 @@ public function testCanResolveParamAliases(): void // GET request: validation runs against the aliased value and reports the canonical key $_GET = ['xAlias' => 'too-long']; - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', '', new Text(1, min: 0), 'x param', false, aliases: ['xAlias']) ->action(function ($x) { @@ -401,7 +415,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -412,7 +426,7 @@ public function testCanResolveParamAliases(): void $_POST = ['xAlias' => 'posted-alias']; $_SERVER['REQUEST_METHOD'] = 'POST'; - $route = new Route('POST', '/path'); + $route = Http::post('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true, aliases: ['xAlias']) ->action(function ($x) { @@ -420,7 +434,7 @@ public function testCanResolveParamAliases(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -439,10 +453,10 @@ public function testCanResolveParamAliases(): void }); $matched = $this->http->match(new Request()); - $this->assertSame($route, $matched); + $this->assertSame($route, $matched?->route); ob_start(); - $this->http->execute($matched, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -459,10 +473,10 @@ public function testCanResolveParamAliases(): void }); $matched = $this->http->match(new Request()); - $this->assertSame($route, $matched); + $this->assertSame($route, $matched?->route); ob_start(); - $this->http->execute($matched, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -475,6 +489,11 @@ public function testCanResolveParamAliases(): void } else { $_SERVER['REQUEST_METHOD'] = $savedMethod; } + if ($savedUri === null) { + unset($_SERVER['REQUEST_URI']); + } else { + $_SERVER['REQUEST_URI'] = $savedUri; + } } } @@ -510,6 +529,9 @@ public function testAllowRouteOverrides(): void public function testCanHookThrowExceptions(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + $this->http ->init() ->param('y', '', new Text(5), 'y param', false) @@ -531,7 +553,7 @@ public function testCanHookThrowExceptions(): void }); // param not provided for init - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('x', 'x-def', new Text(200), 'x param', true) ->action(function ($x) { @@ -539,7 +561,7 @@ public function testCanHookThrowExceptions(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -547,22 +569,13 @@ public function testCanHookThrowExceptions(): void ob_start(); $_GET['y'] = 'y-def'; - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); $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> */ @@ -609,8 +622,7 @@ public function testCanMatchRoute(string $method, string $path, ?string $url = n $_SERVER['REQUEST_METHOD'] = $method; $_SERVER['REQUEST_URI'] = $url; - $this->assertSame($expected, $this->http->match(new Request())); - $this->assertSame($expected, $this->http->getRoute()); + $this->assertSame($expected, $this->http->match(new Request())?->route); } public function testNoMismatchRoute(): void @@ -636,38 +648,26 @@ public function testNoMismatchRoute(): void $_SERVER['REQUEST_METHOD'] = Http::REQUEST_METHOD_GET; $_SERVER['REQUEST_URI'] = $request['url']; - $route = $this->http->match(new Request(), fresh: true); + $route = $this->http->match(new Request()); $this->assertNull($route); - $this->assertNull($this->http->getRoute()); } } - public function testCanMatchFreshRoute(): void + public function testMatchReflectsCurrentRequest(): void { $route1 = Http::get('/path1'); $route2 = Http::get('/path2'); try { - // Match first request $_SERVER['REQUEST_METHOD'] = 'HEAD'; $_SERVER['REQUEST_URI'] = '/path1'; $matched = $this->http->match(new Request()); - $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getRoute()); + $this->assertSame($route1, $matched?->route); - // Second request match returns cached route - $_SERVER['REQUEST_METHOD'] = 'HEAD'; $_SERVER['REQUEST_URI'] = '/path2'; - $request2 = new Request(); - $matched = $this->http->match($request2, fresh: false); - $this->assertSame($route1, $matched); - $this->assertSame($route1, $this->http->getRoute()); - - // Fresh match returns new route - $matched = $this->http->match($request2, fresh: true); - $this->assertSame($route2, $matched); - $this->assertSame($route2, $this->http->getRoute()); + $matched = $this->http->match(new Request()); + $this->assertSame($route2, $matched?->route); } catch (\Exception $e) { $this->fail($e->getMessage()); } @@ -680,8 +680,7 @@ public function testCanMatchRootRouteWhenUriHasNoPath(): void $_SERVER['REQUEST_METHOD'] = Http::REQUEST_METHOD_GET; $_SERVER['REQUEST_URI'] = 'https://example.com?x=1'; - $this->assertSame($route, $this->http->match(new Request())); - $this->assertSame($route, $this->http->getRoute()); + $this->assertSame($route, $this->http->match(new Request())?->route); } public function testCanRunRequest(): void @@ -711,6 +710,37 @@ public function testCanRunRequest(): void $this->assertStringNotContainsString('HELLO', $result); } + public function testSubrequestRestoresOuterRoute(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $captured = []; + + Http::shutdown() + ->inject('route') + ->action(function (Route $route) use (&$captured) { + $captured[] = $route->getPath(); + }); + + Http::get('/inner')->action(function () { + // no-op handler — only here so the inner dispatch matches + }); + + Http::get('/outer')->action(function () { + $inner = new Request(); + $inner->setMethod('GET'); + $inner->setURI('/inner'); + $this->http->execute($inner, new Response()); + }); + + $_SERVER['REQUEST_URI'] = '/outer'; + $this->http->execute(new Request(), new Response()); + + // Inner's shutdown fires first (with inner route), then outer's + // shutdown — which must see the outer route, not the inner one. + $this->assertEquals(['/inner', '/outer'], $captured); + } + public function testWildcardRoute(): void { $method = $_SERVER['REQUEST_METHOD'] ?? null; @@ -720,8 +750,8 @@ public function testWildcardRoute(): void $_SERVER['REQUEST_URI'] = '/unknown_path'; Http::init() - ->action(function () { - $route = $this->http->getRoute(); + ->inject('route') + ->action(function (?Route $route) { $this->resources->set('myRoute', fn() => $route); }); @@ -784,8 +814,11 @@ public function testWildcardRouteWhenUriHasNoPath(): void public function testCallableStringParametersNotExecuted(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + // Test that callable strings (like function names) are not executed - $route = new Route('GET', '/test-callable-string'); + $_SERVER['REQUEST_URI'] = '/test-callable-string'; + $route = Http::get('/test-callable-string'); $route ->param('callback', 'phpinfo', new Text(200), 'callback param', true) @@ -796,14 +829,15 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute($route, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('callback-value: phpinfo', $result); // Test with request parameter that is a callable string - $route2 = new Route('GET', '/test-callable-string-param'); + $_SERVER['REQUEST_URI'] = '/test-callable-string-param'; + $route2 = Http::get('/test-callable-string-param'); $route2 ->param('func', 'default', new Text(200), 'func param', false) @@ -814,14 +848,15 @@ public function testCallableStringParametersNotExecuted(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['func' => 'system']); - $this->http->execute($route2, $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); $this->assertSame('func-value: system', $result); // Test callable closure still works - $route3 = new Route('GET', '/test-callable-closure'); + $_SERVER['REQUEST_URI'] = '/test-callable-closure'; + $route3 = Http::get('/test-callable-closure'); $route3 ->param('generated', fn() => 'generated-value', new Text(200), 'generated param', true) @@ -830,7 +865,7 @@ public function testCallableStringParametersNotExecuted(): void }); ob_start(); - $this->http->execute($route3, new Request(), new Response()); + $this->http->execute(new Request(), new Response()); $result = ob_get_contents(); ob_end_clean(); @@ -839,11 +874,14 @@ public function testCallableStringParametersNotExecuted(): void public function testCanInjectResourceAndParamWithSameName(): void { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/path'; + // Register a 'locale' resource returning a Locale instance whose // `name` statically resolves to "en". $this->resources->set('locale', fn() => new Locale()); - $route = new Route('GET', '/path'); + $route = Http::get('/path'); $route ->param('locale', 'en-default', new Text(10), 'locale param', false) @@ -858,7 +896,7 @@ public function testCanInjectResourceAndParamWithSameName(): void ob_start(); $request = new UtopiaFPMRequestTest(); $request::_setParams(['locale' => 'es']); - $this->http->execute($route, $request, new Response()); + $this->http->execute($request, new Response()); $result = ob_get_contents(); ob_end_clean(); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 4ca0134..5eaa0c7 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -23,9 +23,9 @@ public function testCanMatchUrl(): void Router::addRoute($routeAbout); Router::addRoute($routeAboutMe); - $this->assertEquals($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')); - $this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')); + $this->assertEquals($routeIndex, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routeAbout, Router::match(Http::REQUEST_METHOD_GET, '/about')?->route); + $this->assertEquals($routeAboutMe, Router::match(Http::REQUEST_METHOD_GET, '/about/me')?->route); } public function testCanMatchUrlWithPlaceholder(): void @@ -44,12 +44,12 @@ public function testCanMatchUrlWithPlaceholder(): void Router::addRoute($routeBlogPostComments); Router::addRoute($routeBlogPostCommentsSingle); - $this->assertEquals($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')); - $this->assertEquals($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')); - $this->assertEquals($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')); - $this->assertEquals($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')); - $this->assertEquals($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')); - $this->assertEquals($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')); + $this->assertEquals($routeBlog, Router::match(Http::REQUEST_METHOD_GET, '/blog')?->route); + $this->assertEquals($routeBlogAuthors, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors')?->route); + $this->assertEquals($routeBlogAuthorsComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')?->route); + $this->assertEquals($routeBlogPost, Router::match(Http::REQUEST_METHOD_GET, '/blog/test')?->route); + $this->assertEquals($routeBlogPostComments, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments')?->route); + $this->assertEquals($routeBlogPostCommentsSingle, Router::match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')?->route); } public function testCanMatchUrlWithWildcard(): void @@ -62,11 +62,11 @@ public function testCanMatchUrlWithWildcard(): void Router::addRoute($routeAbout); Router::addRoute($routeAboutWildcard); - $this->assertEquals($routeIndex, Router::match('GET', '/')); - $this->assertEquals($routeAbout, Router::match('GET', '/about')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/you')); - $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')); + $this->assertEquals($routeIndex, Router::match('GET', '/')?->route); + $this->assertEquals($routeAbout, Router::match('GET', '/about')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/you')?->route); + $this->assertEquals($routeAboutWildcard, Router::match('GET', '/about/me/myself/i')?->route); } public function testCanMatchHttpMethod(): void @@ -77,11 +77,11 @@ public function testCanMatchHttpMethod(): void Router::addRoute($routeGET); Router::addRoute($routePOST); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routePOST, Router::match(Http::REQUEST_METHOD_POST, '/')?->route); - $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')); - $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')); + $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')?->route); + $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); } public function testCanMatchAlias(): void @@ -93,9 +93,9 @@ public function testCanMatchAlias(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')?->route); } public function testCanMatchMultipleAliases(): void @@ -108,10 +108,10 @@ public function testCanMatchMultipleAliases(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/target')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias1')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias2')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/alias3')?->route); } public function testCanMatchMix(): void @@ -127,14 +127,14 @@ public function testCanMatchMix(): void Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/invite')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/login')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/recover')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')?->route); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')?->route); } public function testCanMatchFilename(): void @@ -142,12 +142,12 @@ public function testCanMatchFilename(): void $routeGET = new Route(Http::REQUEST_METHOD_GET, '/robots.txt'); Router::addRoute($routeGET); - $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')); + $this->assertEquals($routeGET, Router::match(Http::REQUEST_METHOD_GET, '/robots.txt')?->route); } public function testCannotFindUnknownRouteByPath(): void { - $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/404')?->route); } public function testCannotFindUnknownRouteByMethod(): void @@ -156,8 +156,8 @@ public function testCannotFindUnknownRouteByMethod(): void Router::addRoute($route); - $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')?->route); - $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')); + $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')?->route); } }