From d039a5d62a9ac6ababae70d9d96422ca59f2b2d4 Mon Sep 17 00:00:00 2001 From: Brett Parson Date: Fri, 21 Aug 2026 13:55:37 -0500 Subject: Scaffold miniroute routing/middleware kernel --- src/Routing/Router.php | 220 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/Routing/Router.php (limited to 'src/Routing/Router.php') diff --git a/src/Routing/Router.php b/src/Routing/Router.php new file mode 100644 index 0000000..4c2d615 --- /dev/null +++ b/src/Routing/Router.php @@ -0,0 +1,220 @@ + */ + 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, '/'); + } +} -- cgit v1.2.3