diff --git a/src/Http/Dispatcher.php b/src/Http/Dispatcher.php new file mode 100644 index 0000000..07cb893 --- /dev/null +++ b/src/Http/Dispatcher.php @@ -0,0 +1,205 @@ +match?->route; + } + + public function matchedRouteMatch(): ?RouteMatch + { + return $this->match; + } + + public function handle(): void + { + if ($this->http->isCompressionEnabled()) { + $this->response->setAcceptEncoding($this->request->getHeader('accept-encoding', '')); + $this->response->setCompressionMinSize($this->http->getCompressionMinSize()); + $this->response->setCompressionSupported($this->http->getCompressionSupported()); + } + + $this->http->setRequestResource('request', fn() => $this->request); + $this->http->setRequestResource('response', fn() => $this->response); + + try { + foreach (Hooks::$request as $hook) { + $arguments = $this->http->getArguments($hook, [], []); + \call_user_func_array($hook->getAction(), $arguments); + } + } catch (\Exception $e) { + $this->http->setRequestResource('error', fn() => $e); + + foreach (Hooks::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + try { + $arguments = $this->http->getArguments($error, [], []); + \call_user_func_array($error->getAction(), $arguments); + } catch (\Throwable $e) { + throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); + } + } + } + } + + if ($this->http->isFileLoaded($this->request->getURI())) { + $time = (60 * 60 * 24 * 365 * 2); + + $this->response + ->setContentType($this->http->getFileMimeType($this->request->getURI())) + ->addHeader('Cache-Control', 'public, max-age=' . $time) + ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') + ->send($this->http->getFileContents($this->request->getURI())); + + return; + } + + $method = $this->request->getMethod(); + $this->match = Router::matchRequest($this->request); + + $this->http->setRequestResource('route', fn() => $this->match?->route); + $this->http->setRequestResource('routeMatch', fn() => $this->match); + + $groups = $this->match?->route->getGroups() ?? []; + + if (Http::REQUEST_METHOD_HEAD === $method) { + $method = Http::REQUEST_METHOD_GET; + $this->response->disablePayload(); + } + + if (Http::REQUEST_METHOD_OPTIONS === $method) { + try { + foreach ($groups as $group) { + foreach (Hooks::$options as $option) { + if (\in_array($group, $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams())); + } + } + } + + foreach (Hooks::$options as $option) { + if (\in_array('*', $option->getGroups())) { + \call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams())); + } + } + } catch (\Throwable $e) { + foreach (Hooks::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + $this->http->setRequestResource('error', fn() => $e); + \call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams())); + } + } + } + + return; + } + + if ($this->match !== null) { + $this->execute($this->match); + + return; + } + + foreach (Hooks::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + $this->http->setRequestResource('error', fn() => new Exception('Not Found', 404)); + \call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams())); + } + } + } + + public function execute(RouteMatch $match): void + { + $route = $match->route; + $groups = $route->getGroups(); + $pathValues = $route->getPathValues($this->request, $match->preparedPath); + + // Request params are re-read at each call site: init/request hooks + // may mutate the request (e.g. applying filters), and later hooks + + // the route action must see the updated view. Hoisting this into a + // local would cache stale params across the lifecycle. + + try { + if ($route->getHook()) { + foreach (Hooks::$init as $hook) { + if (\in_array('*', $hook->getGroups())) { + \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); + } + } + } + + foreach ($groups as $group) { + foreach (Hooks::$init as $hook) { + if (\in_array($group, $hook->getGroups())) { + \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); + } + } + } + + if (!$this->response->isSent()) { + \call_user_func_array($route->getAction(), $this->http->getArguments($route, $pathValues, $this->request->getParams())); + } + + foreach ($groups as $group) { + foreach (Hooks::$shutdown as $hook) { + if (\in_array($group, $hook->getGroups())) { + \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); + } + } + } + + if ($route->getHook()) { + foreach (Hooks::$shutdown as $hook) { + if (\in_array('*', $hook->getGroups())) { + \call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams())); + } + } + } + } catch (\Throwable $e) { + $this->http->setRequestResource('error', fn() => $e); + + foreach ($groups as $group) { + foreach (Hooks::$errors as $error) { + if (\in_array($group, $error->getGroups())) { + try { + \call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams())); + } catch (\Throwable $e) { + throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); + } + } + } + } + + foreach (Hooks::$errors as $error) { + if (\in_array('*', $error->getGroups())) { + try { + \call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams())); + } catch (\Throwable $e) { + throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); + } + } + } + } + } +} diff --git a/src/Http/Hooks.php b/src/Http/Hooks.php new file mode 100644 index 0000000..7a57328 --- /dev/null +++ b/src/Http/Hooks.php @@ -0,0 +1,125 @@ +groups(['*']); + self::$init[] = $hook; + + return $hook; + } + + /** + * Register a callback that runs after the matched route action. + */ + public static function shutdown(): Hook + { + $hook = new Hook(); + $hook->groups(['*']); + self::$shutdown[] = $hook; + + return $hook; + } + + /** + * Register a callback for OPTIONS method requests. + */ + public static function options(): Hook + { + $hook = new Hook(); + $hook->groups(['*']); + self::$options[] = $hook; + + return $hook; + } + + /** + * Register an error callback. + */ + public static function error(): Hook + { + $hook = new Hook(); + $hook->groups(['*']); + self::$errors[] = $hook; + + return $hook; + } + + /** + * Register a callback that runs once when the server starts. + */ + public static function onStart(): Hook + { + $hook = new Hook(); + self::$start[] = $hook; + + return $hook; + } + + /** + * Register a callback that runs at the top of every request, before + * route matching. + */ + public static function onRequest(): Hook + { + $hook = new Hook(); + self::$request[] = $hook; + + return $hook; + } + + /** + * Clear every registered hook. Intended for test isolation. + */ + public static function reset(): void + { + self::$init = []; + self::$shutdown = []; + self::$options = []; + self::$errors = []; + self::$start = []; + self::$request = []; + } +} diff --git a/src/Http/Http.php b/src/Http/Http.php index da93d66..da94cff 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -48,76 +48,11 @@ class Http protected Container $container; - protected ?Container $requestContainer = null; - /** * Current running mode */ protected static string $mode = ''; - /** - * Errors - * - * Errors callbacks - * - * @var Hook[] - */ - protected static array $errors = []; - - /** - * Init - * - * A callback function that is initialized on application start - * - * @var Hook[] - */ - protected static array $init = []; - - /** - * Shutdown - * - * A callback function that is initialized on application end - * - * @var Hook[] - */ - protected static array $shutdown = []; - - /** - * Options - * - * A callback function for options method requests - * - * @var Hook[] - */ - protected static array $options = []; - - /** - * Server Start hooks - * - * @var Hook[] - */ - protected static array $startHooks = []; - - /** - * Request hooks - * - * @var Hook[] - */ - 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 */ @@ -178,6 +113,11 @@ public function setCompression(bool $compression): void $this->compression = $compression; } + public function isCompressionEnabled(): bool + { + return $this->compression; + } + /** * Set minimum compression size */ @@ -186,6 +126,11 @@ public function setCompressionMinSize(int $compressionMinSize): void $this->compressionMinSize = $compressionMinSize; } + public function getCompressionMinSize(): int + { + return $this->compressionMinSize; + } + /** * Set supported compression algorithms */ @@ -194,6 +139,11 @@ public function setCompressionSupported(mixed $compressionSupported): void $this->compressionSupported = $compressionSupported; } + public function getCompressionSupported(): mixed + { + return $this->compressionSupported; + } + /** * GET * @@ -245,75 +195,47 @@ public static function delete(string $url): Route } /** - * Wildcard - * - * Add Wildcard route + * Register a method-agnostic wildcard route. Invoked when no + * method-specific route matches the incoming request. */ public static function wildcard(): Route { - self::$wildcardRoute = new Route('', ''); + $route = new Route('', ''); + Router::setWildcard($route); - return self::$wildcardRoute; + return $route; } /** - * Init - * - * Set a callback function that will be initialized on application start + * Register a callback that runs before the matched route action. */ public static function init(): Hook { - $hook = new Hook(); - $hook->groups(['*']); - - self::$init[] = $hook; - - return $hook; + return Hooks::init(); } /** - * Shutdown - * - * Set a callback function that will be initialized on application end + * Register a callback that runs after the matched route action. */ public static function shutdown(): Hook { - $hook = new Hook(); - $hook->groups(['*']); - - self::$shutdown[] = $hook; - - return $hook; + return Hooks::shutdown(); } /** - * Options - * - * Set a callback function for all request with options method + * Register a callback for OPTIONS method requests. */ public static function options(): Hook { - $hook = new Hook(); - $hook->groups(['*']); - - self::$options[] = $hook; - - return $hook; + return Hooks::options(); } /** - * Error - * - * An error callback for failed or no matched requests + * Register an error callback for failed or unmatched requests. */ public static function error(): Hook { - $hook = new Hook(); - $hook->groups(['*']); - - self::$errors[] = $hook; - - return $hook; + return Hooks::error(); } /** @@ -417,9 +339,14 @@ public function setResource(string $name, callable $callback, array $injections /** * Set a request-scoped resource on the current request's container. * + * Relies on {@see Adapter::getContainer()} returning a container scoped + * to the current request/coroutine. Swoole adapters back this with + * `Coroutine::getContext()`; the FPM adapter has a single request per + * process so the shared container is safe there. + * * @param list $injections */ - protected function setRequestResource(string $name, callable $callback, array $injections = []): void + public function setRequestResource(string $name, callable $callback, array $injections = []): void { $this->server->getContainer()->set($name, $callback, $injections); } @@ -461,19 +388,32 @@ public static function getRoutes(): array } /** - * Get the current route + * @deprecated Read the `routeMatch` or `route` request resource instead + * (e.g. `$http->getResource('routeMatch')?->route`). The per-request + * route lives in the per-request DI container; returning it from the + * shared Http singleton is not safe under concurrent request handling. */ public function getRoute(): ?Route { - return $this->route ?? null; + try { + $match = $this->server->getContainer()->get('routeMatch'); + } catch (ContainerExceptionInterface|NotFoundExceptionInterface) { + return null; + } + + return $match instanceof RouteMatch ? $match->route : null; } /** - * Set the current route + * @deprecated Construct a {@see RouteMatch} and register it via + * `setRequestResource('routeMatch', ...)` instead. Provided as a shim + * for tests and legacy callers only. */ public function setRoute(Route $route): self { - $this->route = $route; + $match = new RouteMatch($route, '', '', ''); + $this->setRequestResource('route', fn() => $route); + $this->setRequestResource('routeMatch', fn() => $match); return $this; } @@ -506,7 +446,7 @@ public function loadFiles(string $directory, ?string $root = null): void /** * Is file loaded. */ - protected function isFileLoaded(string $uri): bool + public function isFileLoaded(string $uri): bool { return $this->files->isFileLoaded($uri); } @@ -514,10 +454,9 @@ protected function isFileLoaded(string $uri): bool /** * Get file contents. * - * @return string * @throws \Exception */ - protected function getFileContents(string $uri): mixed + public function getFileContents(string $uri): mixed { return $this->files->getFileContents($uri); } @@ -525,31 +464,25 @@ protected function getFileContents(string $uri): mixed /** * Get file MIME type. * - * @return string * @throws \Exception */ - protected function getFileMimeType(string $uri): mixed + public function getFileMimeType(string $uri): mixed { return $this->files->getFileMimeType($uri); } public static function onStart(): Hook { - $hook = new Hook(); - self::$startHooks[] = $hook; - return $hook; + return Hooks::onStart(); } public static function onRequest(): Hook { - $hook = new Hook(); - self::$requestHooks[] = $hook; - return $hook; + return Hooks::onRequest(); } public function start(): void { - $this->server->onRequest( fn(Request $request, Response $response) => $this->run($request, $response), ); @@ -557,15 +490,14 @@ public function start(): void $this->server->onStart(function ($server) { $this->setResource('server', fn() => $server); try { - - foreach (self::$startHooks as $hook) { + foreach (Hooks::$start as $hook) { $arguments = $this->getArguments($hook, [], []); \call_user_func_array($hook->getAction(), $arguments); } } catch (\Exception $e) { $this->setResource('error', fn() => $e); - foreach (self::$errors as $error) { // Global error hooks + foreach (Hooks::$errors as $error) { if (in_array('*', $error->getGroups())) { try { $arguments = $this->getArguments($error, [], []); @@ -582,107 +514,45 @@ public function start(): void } /** - * Match - * - * Find matching route given current user request - * - * @param bool $fresh If true, will not match any cached route + * @deprecated Use {@see Router::matchRequest()} which returns a + * per-request {@see RouteMatch}. This shim discards the `$fresh` + * argument: the previous implementation cached the match on the Http + * singleton, which is not safe under concurrent request handling. */ public function match(Request $request, bool $fresh = true): ?Route { - if (null !== $this->route && !$fresh) { - return $this->route; + $match = Router::matchRequest($request); + if ($match === null) { + return null; } - $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->setRequestResource('route', fn() => $match->route); + $this->setRequestResource('routeMatch', fn() => $match); - $this->route = Router::match($method, $url); - - return $this->route; + return $match->route; } /** - * Execute a given route with middlewares and error handling + * Execute a given route with middlewares and error handling. + * + * @deprecated Internal dispatch moved to {@see Dispatcher}. This shim + * remains for tests and callers that invoke `execute()` directly with a + * Route built outside the router; it synthesises a {@see RouteMatch} + * from the route's registered path. */ public function execute(Route $route, Request $request, Response $response): static { - $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()); - \call_user_func_array($hook->getAction(), $arguments); - } - } - } - - 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()); - \call_user_func_array($hook->getAction(), $arguments); - } - } - } - - if (!$response->isSent()) { - $arguments = $this->getArguments($route, $pathValues, $request->getParams()); - \call_user_func_array($route->getAction(), $arguments); - } + [$preparedPath] = Router::preparePath($route->getPath()); + $urlPath = \parse_url($request->getURI(), PHP_URL_PATH); + $urlPath = \is_string($urlPath) ? ($urlPath === '' ? '/' : $urlPath) : '/'; + $match = new RouteMatch($route, $urlPath, $preparedPath, $preparedPath); - 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()); - \call_user_func_array($hook->getAction(), $arguments); - } - } - } + $this->setRequestResource('request', fn() => $request); + $this->setRequestResource('response', fn() => $response); + $this->setRequestResource('route', fn() => $route); + $this->setRequestResource('routeMatch', fn() => $match); - if ($route->getHook()) { - foreach (self::$shutdown as $hook) { // Group shutdown hooks - if (\in_array('*', $hook->getGroups())) { - $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); - \call_user_func_array($hook->getAction(), $arguments); - } - } - } - } catch (\Throwable $e) { - $this->setRequestResource('error', fn() => $e, []); - - foreach ($groups as $group) { - foreach (self::$errors as $error) { // Group error hooks - if (\in_array($group, $error->getGroups())) { - try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); - \call_user_func_array($error->getAction(), $arguments); - } catch (\Throwable $e) { - throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); - } - } - } - } - - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - try { - $arguments = $this->getArguments($error, $pathValues, $request->getParams()); - \call_user_func_array($error->getAction(), $arguments); - } catch (\Throwable $e) { - throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); - } - } - } - } + (new Dispatcher($this, $request, $response))->execute($match); return $this; } @@ -695,10 +565,10 @@ public function execute(Route $route, Request $request, Response $response): sta * @return array * @throws Exception */ - protected function getArguments(Hook $hook, array $values, array $requestParams): array + public function getArguments(Hook $hook, array $values, array $requestParams): 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; @@ -731,7 +601,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } /** - * Run: wrapper function to record telemetry. All domain logic should happen in `runInternal`. + * Run: wrapper function to record telemetry. Dispatch lives in {@see Dispatcher}. */ public function run(Request $request, Response $response): static { @@ -741,13 +611,15 @@ public function run(Request $request, Response $response): static ]); $start = microtime(true); - $result = $this->runInternal($request, $response); + + $dispatcher = new Dispatcher($this, $request, $response); + $dispatcher->handle(); $requestDuration = microtime(true) - $start; $attributes = [ 'url.scheme' => $request->getProtocol(), 'http.request.method' => $request->getMethod(), - 'http.route' => $this->route?->getPath(), + 'http.route' => $dispatcher->matchedRoute()?->getPath(), 'http.response.status_code' => $response->getStatusCode(), ]; $this->requestDuration->record($requestDuration, $attributes); @@ -758,150 +630,9 @@ public function run(Request $request, Response $response): static 'url.scheme' => $request->getProtocol(), ]); - return $result; - } - - /** - * Run internal - * - * This is the place to initialize any pre routing logic. - * This is where you might want to parse the application current URL by any desired logic - * - * @param Response $response; - */ - private function runInternal(Request $request, Response $response): static - { - if ($this->compression) { - $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); - $response->setCompressionMinSize($this->compressionMinSize); - $response->setCompressionSupported($this->compressionSupported); - } - - $this->setRequestResource('request', fn() => $request); - $this->setRequestResource('response', fn() => $response); - - try { - foreach (self::$requestHooks as $hook) { - $arguments = $this->getArguments($hook, [], []); - \call_user_func_array($hook->getAction(), $arguments); - } - } catch (\Exception $e) { - $this->setRequestResource('error', fn() => $e, []); - - foreach (self::$errors as $error) { // Global error hooks - if (\in_array('*', $error->getGroups())) { - try { - $arguments = $this->getArguments($error, [], []); - \call_user_func_array($error->getAction(), $arguments); - } catch (\Throwable $e) { - throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e); - } - } - } - } - - if ($this->isFileLoaded($request->getURI())) { - $time = (60 * 60 * 24 * 365 * 2); // 45 days cache - - $response - ->setContentType($this->getFileMimeType($request->getURI())) - ->addHeader('Cache-Control', 'public, max-age=' . $time) - ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT') // 45 days cache - ->send($this->getFileContents($request->getURI())); - - return $this; - } - - $method = $request->getMethod(); - $route = $this->match($request); - $groups = ($route instanceof Route) ? $route->getGroups() : []; - - $this->setRequestResource('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->setRequestResource('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->setRequestResource('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->setRequestResource('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->setRequestResource('error', fn() => new Exception('Not Found', 404), []); - \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); - } - } - } - return $this; } - /** * Validate Param * @@ -917,13 +648,13 @@ protected function validate(string $key, array $param, mixed $value): void return; } - $validator = $param['validator']; // checking whether the class exists + $validator = $param['validator']; if (\is_callable($validator)) { $validator = \call_user_func_array($validator, \array_values($this->getResources($param['injections']))); } - if (!$validator instanceof Validator) { // is the validator object an instance of the Validator class + if (!$validator instanceof Validator) { throw new Exception('Validator object is not an instance of the Validator class', 500); } @@ -938,13 +669,7 @@ protected function validate(string $key, array $param, mixed $value): void public static function reset(): void { Router::reset(); + Hooks::reset(); self::$mode = ''; - self::$errors = []; - self::$init = []; - self::$shutdown = []; - self::$options = []; - self::$startHooks = []; - self::$requestHooks = []; - self::$wildcardRoute = null; } } diff --git a/src/Http/Route.php b/src/Http/Route.php index 46bfb29..0c72a19 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -38,8 +38,6 @@ class Route extends Hook */ protected int $order; - protected string $matchedPath = ''; - public function __construct(string $method, string $path) { parent::__construct(); @@ -48,17 +46,6 @@ public function __construct(string $method, string $path) $this->order = ++self::$counter; } - public function setMatchedPath(string $path): self - { - $this->matchedPath = $path; - return $this; - } - - public function getMatchedPath(): string - { - return $this->matchedPath; - } - /** * Get Route Order ID */ diff --git a/src/Http/RouteMatch.php b/src/Http/RouteMatch.php new file mode 100644 index 0000000..2aa97c5 --- /dev/null +++ b/src/Http/RouteMatch.php @@ -0,0 +1,21 @@ +getURI(), PHP_URL_PATH); + $url = \is_string($url) ? ($url === '' ? '/' : $url) : '/'; + + $method = $request->getMethod(); + $method = ($method === Http::REQUEST_METHOD_HEAD) ? Http::REQUEST_METHOD_GET : $method; - $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); - $length = count($parts) - 1; - $filteredParams = array_filter(self::$params, fn($i) => $i <= $length); + return self::matchRoute($method, $url); + } - foreach (self::combinations($filteredParams) as $sample) { - $sample = array_filter($sample, fn(int $i) => $i <= $length); - $match = implode( - '/', - array_replace( - $parts, - array_fill_keys($sample, self::PLACEHOLDER_TOKEN), - ), - ); + /** + * Match against a (method, path) pair. Internal — application code should + * call {@see self::matchRequest()} so URL parsing and HEAD normalisation + * are handled consistently. + */ + private static function matchRoute(string $method, string $path): ?RouteMatch + { + if (array_key_exists($method, self::$routes)) { + $parts = array_values(array_filter(explode('/', $path), fn($segment) => $segment !== '')); + $length = count($parts) - 1; + $filteredParams = array_filter(self::$params, fn($i) => $i <= $length); + + foreach (self::combinations($filteredParams) as $sample) { + $sample = array_filter($sample, fn(int $i) => $i <= $length); + $match = implode( + '/', + array_replace( + $parts, + array_fill_keys($sample, self::PLACEHOLDER_TOKEN), + ), + ); + + if (array_key_exists($match, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$match], $path, $match, $match); + } + } + /** + * Match root wildcard for this method (e.g. GET /*). + */ + $match = self::WILDCARD_TOKEN; if (array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; + return new RouteMatch(self::$routes[$method][$match], $path, $match, $match); } - } - /** - * 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; + /** + * Match wildcard for path segments (e.g. GET /foo/*). + */ + foreach ($parts as $part) { + $current = ($current ?? '') . "{$part}/"; + $match = $current . self::WILDCARD_TOKEN; + if (array_key_exists($match, self::$routes[$method])) { + return new RouteMatch(self::$routes[$method][$match], $path, $match, $match); + } + } } /** - * Match wildcard for path segments. + * Fall through to the method-agnostic wildcard registered via + * {@see self::setWildcard()}. */ - 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; - } + if (self::$wildcard !== null) { + return new RouteMatch(self::$wildcard, $path, self::WILDCARD_TOKEN, self::WILDCARD_TOKEN); } return null; @@ -219,6 +257,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/DispatcherTest.php b/tests/DispatcherTest.php new file mode 100644 index 0000000..c7320b4 --- /dev/null +++ b/tests/DispatcherTest.php @@ -0,0 +1,291 @@ +http = new Http(new Server($container), 'UTC'); + $this->savedMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $this->savedUri = $_SERVER['REQUEST_URI'] ?? null; + } + + protected function tearDown(): void + { + $_SERVER['REQUEST_METHOD'] = $this->savedMethod; + $_SERVER['REQUEST_URI'] = $this->savedUri; + } + + /** + * @param callable(): void $block + */ + private function capture(callable $block): string + { + \ob_start(); + $block(); + $output = \ob_get_contents() ?: ''; + \ob_end_clean(); + + return $output; + } + + private function runRequest(string $method, string $uri): string + { + $_SERVER['REQUEST_METHOD'] = $method; + $_SERVER['REQUEST_URI'] = $uri; + + return $this->capture(function () { + $this->http->run(new Request(), new Response()); + }); + } + + public function testRegistersRouteAndRouteMatchRequestResources(): void + { + $route = Http::get('/resources-check') + ->inject('route') + ->inject('routeMatch') + ->inject('response') + ->action(function (?Route $route, ?RouteMatch $match, Response $response) { + $payload = \json_encode([ + 'routeClass' => $route === null ? null : $route::class, + 'matchClass' => $match === null ? null : $match::class, + 'matchUrl' => $match?->urlPath, + 'matchKey' => $match?->routeKey, + ]); + $response->send($payload === false ? '' : $payload); + }); + + $output = $this->runRequest('GET', '/resources-check'); + + $decoded = \json_decode($output, true); + $this->assertIsArray($decoded); + $this->assertSame(Route::class, $decoded['routeClass']); + $this->assertSame(RouteMatch::class, $decoded['matchClass']); + $this->assertSame('/resources-check', $decoded['matchUrl']); + $this->assertSame('resources-check', $decoded['matchKey']); + $this->assertInstanceOf(Route::class, $route); + } + + public function testWildcardRouteIsNeverMutated(): void + { + $wildcard = Http::wildcard() + ->inject('routeMatch') + ->inject('response') + ->action(function (RouteMatch $match, Response $response) { + $response->send($match->urlPath); + }); + + $pathBefore = $wildcard->getPath(); + $groupsBefore = $wildcard->getGroups(); + + $first = $this->runRequest('GET', '/alpha/beta'); + $second = $this->runRequest('GET', '/something/else/entirely'); + + // The dispatcher must not write the request path back onto the + // shared wildcard Route definition — that was the concurrency bug. + $this->assertSame($pathBefore, $wildcard->getPath(), 'Wildcard Route::getPath() must not be mutated by dispatch.'); + $this->assertSame($groupsBefore, $wildcard->getGroups()); + + // But the match exposed to the handler must still reflect the + // current request URL. + $this->assertSame('/alpha/beta', $first); + $this->assertSame('/something/else/entirely', $second); + } + + public function testSequentialRequestsOnSameParameterizedRouteDoNotBleed(): void + { + Http::get('/users/:id') + ->inject('routeMatch') + ->inject('request') + ->inject('response') + ->action(function (RouteMatch $match, Request $request, Response $response) { + $response->send($match->urlPath . '|' . $match->route->getPathValues($request, $match->preparedPath)['id']); + }); + + $a = $this->runRequest('GET', '/users/42'); + $b = $this->runRequest('GET', '/users/99'); + + $this->assertSame('/users/42|42', $a); + $this->assertSame('/users/99|99', $b); + } + + public function testInitAndShutdownHooksFire(): void + { + Http::init()->action(function () { + echo 'init|'; + }); + Http::shutdown()->action(function () { + echo '|shutdown'; + }); + + Http::get('/lifecycle') + ->inject('response') + ->action(function (Response $response) { + echo 'handler'; + $response->send(''); + }); + + $output = $this->runRequest('GET', '/lifecycle'); + + $this->assertSame('init|handler|shutdown', $output); + } + + public function testErrorHookFiresForNotFound(): void + { + Http::error() + ->inject('error') + ->inject('response') + ->action(function (\Throwable $error, Response $response) { + $response->send('err:' . $error->getCode() . ':' . $error->getMessage()); + }); + + $output = $this->runRequest('GET', '/definitely-not-registered'); + + $this->assertSame('err:404:Not Found', $output); + } + + public function testErrorHookReceivesExceptionFromHandler(): void + { + Http::get('/boom') + ->action(function () { + throw new Exception('kaboom', 418); + }); + + Http::error() + ->inject('error') + ->inject('response') + ->action(function (\Throwable $error, Response $response) { + $response->send($error->getCode() . ':' . $error->getMessage()); + }); + + $output = $this->runRequest('GET', '/boom'); + + $this->assertSame('418:kaboom', $output); + } + + public function testHeadRequestResolvesToGetRouteWithPayloadDisabled(): void + { + Http::get('/head-check') + ->inject('response') + ->action(function (Response $response) { + $response->send('body-should-not-appear'); + }); + + $output = $this->runRequest('HEAD', '/head-check'); + + $this->assertStringNotContainsString('body-should-not-appear', $output); + } + + public function testOptionsHookFiresForOptionsMethod(): void + { + Http::get('/opts')->action(function () { + // never called + echo 'GET-HANDLER'; + }); + + Http::options() + ->inject('response') + ->action(function (Response $response) { + $response->send('OPTIONS-HANDLER'); + }); + + $output = $this->runRequest('OPTIONS', '/opts'); + + $this->assertSame('OPTIONS-HANDLER', $output); + $this->assertStringNotContainsString('GET-HANDLER', $output); + } + + public function testInitHookMutationsToRequestParamsAreVisibleToRouteAction(): void + { + // Regression: Dispatcher::execute must re-read $request->getParams() + // at each hook/action call site. Hoisting the array into a local + // before init hooks fire would cache a pre-hook snapshot, so the + // route action would see stale params despite an init hook having + // mutated them (e.g. to apply auth/filter rewrites). + Http::init() + ->inject('request') + ->action(function (Request $request) { + $request->setQueryString(['x' => 'from-init-hook']); + }); + + Http::get('/filter-me') + ->param('x', 'original', new Text(64), 'x param', true) + ->inject('response') + ->action(function (string $x, Response $response) { + $response->send($x); + }); + + $output = $this->runRequest('GET', '/filter-me'); + + $this->assertSame('from-init-hook', $output); + } + + public function testShutdownHookSeesMutationsFromInitHook(): void + { + // The same guarantee for the init → shutdown path: shutdown hooks + // read getArguments() fresh, so an init-time mutation is visible. + Http::init() + ->inject('request') + ->action(function (Request $request) { + $request->setQueryString(['token' => 'init-token']); + }); + + Http::shutdown() + ->param('token', '', new Text(64), 'token param', true) + ->inject('response') + ->action(function (string $token, Response $response) { + echo '|shutdown:' . $token; + }); + + Http::get('/lifecycle-params') + ->inject('response') + ->action(function (Response $response) { + $response->send('ok'); + }); + + $output = $this->runRequest('GET', '/lifecycle-params'); + + $this->assertStringContainsString('ok', $output); + $this->assertStringContainsString('|shutdown:init-token', $output); + } + + public function testWildcardRouteMatchCarriesWildcardToken(): void + { + Http::wildcard() + ->inject('routeMatch') + ->inject('response') + ->action(function (RouteMatch $match, Response $response) { + $response->send($match->routeKey); + }); + + $output = $this->runRequest('GET', '/whatever/this/is'); + + $this->assertSame(Router::WILDCARD_TOKEN, $output); + } +} diff --git a/tests/HttpTest.php b/tests/HttpTest.php index dbb703c..54a15ba 100755 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -455,28 +455,31 @@ public function testNoMismatchRoute(): void } } - public function testCanMatchFreshRoute(): void + public function testMatchAlwaysReturnsFreshRoute(): void { + // The previous implementation cached the last matched route on the + // Http singleton and returned it on subsequent match() calls with + // `fresh: false`. That cache was unsafe under concurrent request + // handling and has been removed; match() now always returns the + // fresh result, regardless of the `$fresh` argument. $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()); - // 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()); + $this->assertSame($route2, $matched); + $this->assertSame($route2, $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()); diff --git a/tests/RouteMatchTest.php b/tests/RouteMatchTest.php new file mode 100644 index 0000000..2f93714 --- /dev/null +++ b/tests/RouteMatchTest.php @@ -0,0 +1,54 @@ +assertSame($route, $match->route); + $this->assertSame('/users/42', $match->urlPath); + $this->assertSame('/users/:::', $match->routeKey); + $this->assertSame('/users/:::', $match->preparedPath); + } + + public function testIsReadonlyClass(): void + { + $reflection = new \ReflectionClass(RouteMatch::class); + $this->assertTrue( + $reflection->isReadOnly(), + 'RouteMatch must be a readonly class so per-request match facts cannot be mutated by handler code.', + ); + } + + public function testCannotReassignRouteField(): void + { + $route = new Route('GET', '/x'); + $match = new RouteMatch($route, '/x', '/x', '/x'); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line intentional runtime assertion */ + $match->urlPath = '/mutated'; + } + + public function testWildcardTokenRoundTrips(): void + { + $route = new Route('', ''); + $match = new RouteMatch($route, '/anything/at/all', Router::WILDCARD_TOKEN, Router::WILDCARD_TOKEN); + + $this->assertSame(Router::WILDCARD_TOKEN, $match->routeKey); + $this->assertSame('/anything/at/all', $match->urlPath); + } +} diff --git a/tests/RouterMatchRouteTest.php b/tests/RouterMatchRouteTest.php new file mode 100644 index 0000000..6c74810 --- /dev/null +++ b/tests/RouterMatchRouteTest.php @@ -0,0 +1,210 @@ +assertNull($this->match('FROBNICATE', '/anything')); + } + + public function testReturnsNullForUnmatchedPath(): void + { + Router::addRoute(new Route('GET', '/known')); + + $this->assertNull($this->match('GET', '/unknown')); + } + + public function testReturnsRouteMatchForExactPath(): void + { + $route = new Route('GET', '/users'); + Router::addRoute($route); + + $match = $this->match('GET', '/users'); + + $this->assertInstanceOf(RouteMatch::class, $match); + $this->assertSame($route, $match->route); + $this->assertSame('/users', $match->urlPath); + $this->assertSame('users', $match->routeKey); + $this->assertSame('users', $match->preparedPath); + } + + public function testReturnsRouteMatchForParameterizedPath(): void + { + $route = new Route('GET', '/users/:id'); + Router::addRoute($route); + + $match = $this->match('GET', '/users/42'); + + $this->assertNotNull($match); + $this->assertSame($route, $match->route); + $this->assertSame('/users/42', $match->urlPath); + $this->assertSame('users/:::', $match->routeKey); + } + + public function testReturnsRouteMatchForPrefixWildcardRoute(): void + { + $route = new Route('GET', '/files/*'); + Router::addRoute($route); + + $match = $this->match('GET', '/files/a/b/c'); + + $this->assertNotNull($match); + $this->assertSame($route, $match->route); + $this->assertSame('files/*', $match->routeKey); + $this->assertSame('/files/a/b/c', $match->urlPath); + } + + public function testReturnsRouteMatchForMethodSpecificRootWildcard(): void + { + $route = new Route('GET', '/*'); + Router::addRoute($route); + + $match = $this->match('GET', '/anything'); + + $this->assertNotNull($match); + $this->assertSame(Router::WILDCARD_TOKEN, $match->routeKey); + } + + public function testMethodAgnosticWildcardCatchesUnknownPath(): void + { + $wildcard = new Route('', ''); + Router::setWildcard($wildcard); + Router::addRoute(new Route('GET', '/known')); + + $match = $this->match('GET', '/definitely-unknown'); + + $this->assertNotNull($match); + $this->assertSame($wildcard, $match->route); + $this->assertSame(Router::WILDCARD_TOKEN, $match->routeKey); + $this->assertSame('/definitely-unknown', $match->urlPath); + } + + public function testMethodAgnosticWildcardCatchesUnknownMethod(): void + { + // Method-agnostic wildcard fires even when the HTTP method isn't + // one of the registered buckets (GET/POST/PUT/PATCH/DELETE). + $wildcard = new Route('', ''); + Router::setWildcard($wildcard); + + $match = $this->match('FROBNICATE', '/anything'); + + $this->assertNotNull($match); + $this->assertSame($wildcard, $match->route); + } + + public function testMethodSpecificMatchTakesPrecedenceOverWildcard(): void + { + $specific = new Route('GET', '/users'); + Router::addRoute($specific); + Router::setWildcard(new Route('', '')); + + $match = $this->match('GET', '/users'); + + $this->assertNotNull($match); + $this->assertSame($specific, $match->route, 'A method-specific route must win over the wildcard fallback.'); + } + + public function testMatchRequestExtractsPathFromUri(): void + { + $route = new Route('GET', '/users/:id'); + Router::addRoute($route); + + $match = $this->match('GET', '/users/42?extra=ignored'); + + $this->assertNotNull($match); + $this->assertSame($route, $match->route); + $this->assertSame('/users/42', $match->urlPath); + } + + public function testMatchRequestDefaultsEmptyPathToSlash(): void + { + $route = new Route('GET', '/'); + Router::addRoute($route); + + $match = $this->match('GET', 'https://example.com?x=1'); + + $this->assertNotNull($match); + $this->assertSame($route, $match->route); + $this->assertSame('/', $match->urlPath); + } + + public function testMatchRequestNormalisesHeadToGet(): void + { + $route = new Route('GET', '/head-target'); + Router::addRoute($route); + + $match = $this->match('HEAD', '/head-target'); + + $this->assertNotNull($match); + $this->assertSame($route, $match->route); + } + + public function testDoesNotMutateMatchedRoute(): void + { + // Regression guard: the router previously wrote matched facts back + // onto the Route via setMatchedPath(), creating a race between + // coroutines. Repeated matches must leave the Route byte-identical. + $route = new Route('GET', '/users/:id'); + $snapshot = [ + 'method' => $route->getMethod(), + 'path' => $route->getPath(), + 'groups' => $route->getGroups(), + 'hook' => $route->getHook(), + ]; + + Router::addRoute($route); + + $this->match('GET', '/users/1'); + $this->match('GET', '/users/99'); + $this->match('GET', '/users/hello'); + + $this->assertSame($snapshot['method'], $route->getMethod()); + $this->assertSame($snapshot['path'], $route->getPath()); + $this->assertSame($snapshot['groups'], $route->getGroups()); + $this->assertSame($snapshot['hook'], $route->getHook()); + } + + public function testTwoMatchesReturnDistinctRouteMatchInstances(): void + { + $route = new Route('GET', '/users/:id'); + Router::addRoute($route); + + $a = $this->match('GET', '/users/1'); + $b = $this->match('GET', '/users/2'); + + $this->assertNotNull($a); + $this->assertNotNull($b); + $this->assertNotSame($a, $b, 'Each call should produce a fresh RouteMatch value so concurrent handlers cannot observe each other.'); + $this->assertSame($route, $a->route); + $this->assertSame($route, $b->route); + $this->assertSame('/users/1', $a->urlPath); + $this->assertSame('/users/2', $b->urlPath); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 4ca0134..0c33636 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -5,6 +5,7 @@ namespace Utopia\Http; use PHPUnit\Framework\TestCase; +use Utopia\Http\Adapter\FPM\Request; final class RouterTest extends TestCase { @@ -13,6 +14,19 @@ public function tearDown(): void Router::reset(); } + /** + * Test helper: drive Router::matchRequest with a method + path, returning + * the matched Route (or null). Keeps the existing test cases readable + * now that `Router::match(string, string)` is no longer public. + */ + private function match(string $method, string $path): ?Route + { + $_SERVER['REQUEST_METHOD'] = $method; + $_SERVER['REQUEST_URI'] = $path; + + return Router::matchRequest(new Request())?->route; + } + public function testCanMatchUrl(): void { $routeIndex = new Route(Http::REQUEST_METHOD_GET, '/'); @@ -23,9 +37,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, $this->match(Http::REQUEST_METHOD_GET, '/')); + $this->assertEquals($routeAbout, $this->match(Http::REQUEST_METHOD_GET, '/about')); + $this->assertEquals($routeAboutMe, $this->match(Http::REQUEST_METHOD_GET, '/about/me')); } public function testCanMatchUrlWithPlaceholder(): void @@ -44,12 +58,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, $this->match(Http::REQUEST_METHOD_GET, '/blog')); + $this->assertEquals($routeBlogAuthors, $this->match(Http::REQUEST_METHOD_GET, '/blog/authors')); + $this->assertEquals($routeBlogAuthorsComments, $this->match(Http::REQUEST_METHOD_GET, '/blog/authors/comments')); + $this->assertEquals($routeBlogPost, $this->match(Http::REQUEST_METHOD_GET, '/blog/test')); + $this->assertEquals($routeBlogPostComments, $this->match(Http::REQUEST_METHOD_GET, '/blog/test/comments')); + $this->assertEquals($routeBlogPostCommentsSingle, $this->match(Http::REQUEST_METHOD_GET, '/blog/test/comments/:comment')); } public function testCanMatchUrlWithWildcard(): void @@ -62,11 +76,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, $this->match('GET', '/')); + $this->assertEquals($routeAbout, $this->match('GET', '/about')); + $this->assertEquals($routeAboutWildcard, $this->match('GET', '/about/me')); + $this->assertEquals($routeAboutWildcard, $this->match('GET', '/about/you')); + $this->assertEquals($routeAboutWildcard, $this->match('GET', '/about/me/myself/i')); } public function testCanMatchHttpMethod(): void @@ -77,11 +91,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, $this->match(Http::REQUEST_METHOD_GET, '/')); + $this->assertEquals($routePOST, $this->match(Http::REQUEST_METHOD_POST, '/')); - $this->assertNotEquals($routeGET, Router::match(Http::REQUEST_METHOD_POST, '/')); - $this->assertNotEquals($routePOST, Router::match(Http::REQUEST_METHOD_GET, '/')); + $this->assertNotEquals($routeGET, $this->match(Http::REQUEST_METHOD_POST, '/')); + $this->assertNotEquals($routePOST, $this->match(Http::REQUEST_METHOD_GET, '/')); } public function testCanMatchAlias(): void @@ -93,9 +107,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, $this->match(Http::REQUEST_METHOD_GET, '/target')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/alias')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/alias2')); } public function testCanMatchMultipleAliases(): void @@ -108,10 +122,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, $this->match(Http::REQUEST_METHOD_GET, '/target')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/alias1')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/alias2')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/alias3')); } public function testCanMatchMix(): void @@ -127,14 +141,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, $this->match(Http::REQUEST_METHOD_GET, '/')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/console')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/invite')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/login')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/recover')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/console/lorem/ipsum/dolor')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/auth/lorem/ipsum')); + $this->assertEquals($routeGET, $this->match(Http::REQUEST_METHOD_GET, '/register/lorem/ipsum')); } public function testCanMatchFilename(): void @@ -142,12 +156,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, $this->match(Http::REQUEST_METHOD_GET, '/robots.txt')); } public function testCannotFindUnknownRouteByPath(): void { - $this->assertNull(Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertNull($this->match(Http::REQUEST_METHOD_GET, '/404')); } public function testCannotFindUnknownRouteByMethod(): void @@ -156,8 +170,8 @@ public function testCannotFindUnknownRouteByMethod(): void Router::addRoute($route); - $this->assertEquals($route, Router::match(Http::REQUEST_METHOD_GET, '/404')); + $this->assertEquals($route, $this->match(Http::REQUEST_METHOD_GET, '/404')); - $this->assertNull(Router::match(Http::REQUEST_METHOD_POST, '/404')); + $this->assertNull($this->match(Http::REQUEST_METHOD_POST, '/404')); } }