diff options
Diffstat (limited to 'src/Routing/Router.php')
| -rw-r--r-- | src/Routing/Router.php | 220 |
1 files changed, 220 insertions, 0 deletions
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 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Routing; + +use BrettParson\MiniRoute\Controller\ControllerResolverInterface; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; +use BrettParson\MiniRoute\Middleware\MiddlewarePipeline; +use LogicException; + +final class Router +{ + private RouteLoader $loader; + + private RouteMatcher $matcher; + + /** @var list<RouteDef> */ + private array $routes = []; + + /** @var list<array{prefix: string, middleware: list<string>, methods: list<string>|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<class-string> $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<string> $middleware Middleware class names, outermost first. + * @param list<string>|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<string> + */ + 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<string> $classes + * @return list<MiddlewareInterface> + */ + 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, '/'); + } +} |
