*/ private array $routes = []; /** @var list, methods: list|null}> */ private array $groups = []; /** @var callable(RequestInterface): ResponseInterface|null */ private $notFoundHandler = null; public function __construct( private readonly ControllerResolverInterface $resolver, ?RouteLoader $loader = null, ?RouteMatcher $matcher = null, ) { $this->loader = $loader ?? new RouteLoader(); $this->matcher = $matcher ?? new RouteMatcher(); } /** * Register every route declared on a controller class. * * @param class-string $controllerClass */ public function registerController(string $controllerClass): void { foreach ($this->loader->load($controllerClass) as $route) { $this->add($route); } } /** * @param list $controllerClasses */ public function registerControllers(array $controllerClasses): void { foreach ($controllerClasses as $controllerClass) { $this->registerController($controllerClass); } } /** * Apply middleware to every route under a path prefix. * * @param list $middleware Middleware class names, outermost first. * @param list|null $methods Restrict to these HTTP methods, or null for all. */ public function group(string $prefix, array $middleware, ?array $methods = null): void { $this->groups[] = [ 'prefix' => $this->normalizePrefix($prefix), 'middleware' => $middleware, 'methods' => $methods === null ? null : array_map('strtoupper', $methods), ]; } /** * Set the handler used when no route matches. Defaults to throwing * RouteNotFoundException when unset. * * @param callable(RequestInterface): ResponseInterface $handler */ public function setNotFoundHandler(callable $handler): void { $this->notFoundHandler = $handler; } public function dispatch(RequestInterface $request): ResponseInterface { $method = strtoupper($request->method()); $path = $this->normalizePath($request->path()); $route = $this->find($method, $path); if ($route === null) { if ($this->notFoundHandler !== null) { return ($this->notFoundHandler)($request); } throw new RouteNotFoundException("No route matches {$method} {$path}"); } $params = $this->matcher->extractParams($route, $path); if ($params !== []) { $request = $request->withParams($params); } $controller = $this->resolver->resolve($route->controllerClass); $core = static fn (RequestInterface $r): ResponseInterface => $controller->{$route->controllerMethod}($r); $middleware = array_merge( $this->groupMiddleware($route, $method), $route->middleware, ); return MiddlewarePipeline::compose($this->resolveMiddleware($middleware), $core)($request); } private function add(RouteDef $route): void { foreach ($this->routes as $existing) { if ($existing->method === $route->method && $existing->path === $route->path) { throw new RouteRegistrationException( "Duplicate route {$route->method} {$route->path} declared on " . "{$existing->controllerClass}::{$existing->controllerMethod} and " . "{$route->controllerClass}::{$route->controllerMethod}", ); } } $this->routes[] = $route; usort($this->routes, fn (RouteDef $a, RouteDef $b): int => $this->matcher->compare($a, $b)); } private function find(string $method, string $path): ?RouteDef { foreach ($this->routes as $route) { if ($this->matcher->matches($route, $method, $path)) { return $route; } } return null; } /** * @return list */ private function groupMiddleware(RouteDef $route, string $method): array { $middleware = []; foreach ($this->groups as $group) { if (!$this->matchesPrefix($group['prefix'], $route->path)) { continue; } if ($group['methods'] !== null && !in_array($method, $group['methods'], true)) { continue; } $middleware = array_merge($middleware, $group['middleware']); } return $middleware; } /** * @param list $classes * @return list */ private function resolveMiddleware(array $classes): array { $instances = []; foreach ($classes as $class) { $instance = $this->resolver->resolve($class); if (!$instance instanceof MiddlewareInterface) { throw new LogicException("Middleware {$class} must implement " . MiddlewareInterface::class); } $instances[] = $instance; } return $instances; } private function matchesPrefix(string $prefix, string $path): bool { if ($prefix === '') { return true; } if ($path === $prefix) { return true; } return str_starts_with($path, $prefix . '/'); } private function normalizePrefix(string $prefix): string { $trimmed = rtrim($prefix, '/'); return $trimmed === '' ? '' : '/' . ltrim($trimmed, '/'); } private function normalizePath(string $path): string { $parsed = parse_url($path, PHP_URL_PATH); if (!is_string($parsed) || $parsed === '') { return '/'; } return '/' . trim($parsed, '/'); } }