aboutsummaryrefslogtreecommitdiff
path: root/src/Routing/Router.php
diff options
context:
space:
mode:
Diffstat (limited to 'src/Routing/Router.php')
-rw-r--r--src/Routing/Router.php220
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, '/');
+ }
+}