aboutsummaryrefslogtreecommitdiff
path: root/src/Routing
diff options
context:
space:
mode:
authorBrett Parson <brett@brett-parson.com>2026-08-21 13:55:37 -0500
committerBrett Parson <brett@brett-parson.com>2026-08-21 14:00:11 -0500
commitd039a5d62a9ac6ababae70d9d96422ca59f2b2d4 (patch)
treef0db4750a571e8040a5da02cc7190079c0d62914 /src/Routing
parent0de77a6334cee1f6e15f7e0fd35602514560be33 (diff)
downloadminiroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.tar.gz
miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.zip
Scaffold miniroute routing/middleware kernel
Diffstat (limited to 'src/Routing')
-rw-r--r--src/Routing/RouteDef.php24
-rw-r--r--src/Routing/RouteLoader.php98
-rw-r--r--src/Routing/RouteMatcher.php109
-rw-r--r--src/Routing/RouteNotFoundException.php11
-rw-r--r--src/Routing/RouteRegistrationException.php11
-rw-r--r--src/Routing/Router.php220
6 files changed, 473 insertions, 0 deletions
diff --git a/src/Routing/RouteDef.php b/src/Routing/RouteDef.php
new file mode 100644
index 0000000..9d54ff0
--- /dev/null
+++ b/src/Routing/RouteDef.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+/**
+ * A compiled route: one controller method plus its method, path, and
+ * middleware.
+ */
+final class RouteDef
+{
+ /**
+ * @param list<string> $middleware Middleware class names, outermost first.
+ */
+ public function __construct(
+ public readonly string $method,
+ public readonly string $path,
+ public readonly string $controllerClass,
+ public readonly string $controllerMethod,
+ public readonly array $middleware = [],
+ ) {
+ }
+}
diff --git a/src/Routing/RouteLoader.php b/src/Routing/RouteLoader.php
new file mode 100644
index 0000000..596340f
--- /dev/null
+++ b/src/Routing/RouteLoader.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use BrettParson\MiniRoute\Attribute\Middleware;
+use BrettParson\MiniRoute\Attribute\RouteAttribute;
+use ReflectionAttribute;
+use ReflectionClass;
+use ReflectionException;
+use ReflectionMethod;
+
+final class RouteLoader
+{
+ /**
+ * Scan a controller class and compile the routes declared on its public
+ * methods.
+ *
+ * @param class-string $controllerClass
+ * @return list<RouteDef>
+ */
+ public function load(string $controllerClass): array
+ {
+ if (!class_exists($controllerClass)) {
+ throw new RouteRegistrationException("Controller class not found: {$controllerClass}");
+ }
+
+ try {
+ $reflector = new ReflectionClass($controllerClass);
+ } catch (ReflectionException $e) {
+ throw new RouteRegistrationException("Unable to reflect controller: {$controllerClass}", 0, $e);
+ }
+
+ $classMiddleware = $this->middlewareClasses($reflector->getAttributes(Middleware::class));
+
+ $routes = [];
+
+ foreach ($reflector->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ foreach ($this->loadMethod($controllerClass, $method, $classMiddleware) as $route) {
+ $routes[] = $route;
+ }
+ }
+
+ return $routes;
+ }
+
+ /**
+ * @param list<string> $classMiddleware
+ * @return list<RouteDef>
+ */
+ private function loadMethod(string $controllerClass, ReflectionMethod $method, array $classMiddleware): array
+ {
+ $routeAttributes = $method->getAttributes(RouteAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
+
+ if ($routeAttributes === []) {
+ return [];
+ }
+
+ $methodMiddleware = $this->middlewareClasses($method->getAttributes(Middleware::class));
+
+ $middleware = array_merge($classMiddleware, $methodMiddleware);
+
+ $routes = [];
+
+ foreach ($routeAttributes as $attribute) {
+ /** @var RouteAttribute $route */
+ $route = $attribute->newInstance();
+
+ $routes[] = new RouteDef(
+ strtoupper($route->method()),
+ $route->path(),
+ $controllerClass,
+ $method->getName(),
+ $middleware,
+ );
+ }
+
+ return $routes;
+ }
+
+ /**
+ * @param list<ReflectionAttribute> $attributes
+ * @return list<string>
+ */
+ private function middlewareClasses(array $attributes): array
+ {
+ $classes = [];
+
+ foreach ($attributes as $attribute) {
+ /** @var Middleware $middleware */
+ $middleware = $attribute->newInstance();
+ $classes[] = $middleware->middlewareClass;
+ }
+
+ return $classes;
+ }
+}
diff --git a/src/Routing/RouteMatcher.php b/src/Routing/RouteMatcher.php
new file mode 100644
index 0000000..f5b725f
--- /dev/null
+++ b/src/Routing/RouteMatcher.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+final class RouteMatcher
+{
+ public function matches(RouteDef $route, string $method, string $path): bool
+ {
+ if ($route->method !== strtoupper($method)) {
+ return false;
+ }
+
+ return preg_match($this->compile($route->path), $path) === 1;
+ }
+
+ /**
+ * Extract named route parameters from a matched path.
+ *
+ * @return array<string, string>
+ */
+ public function extractParams(RouteDef $route, string $path): array
+ {
+ if (preg_match($this->compile($route->path), $path, $matches) !== 1) {
+ return [];
+ }
+
+ $params = [];
+
+ foreach ($matches as $key => $value) {
+ if (is_string($key)) {
+ $params[$key] = $value;
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Order two routes so the more specific one sorts first.
+ *
+ * Segments are compared left to right; a literal segment beats a
+ * {parameter} segment at the same position. So /entries/archive sorts
+ * before /entries/{slug} and /admin/entries/preview before
+ * /admin/entries/{id}, independent of registration order.
+ */
+ public function compare(RouteDef $a, RouteDef $b): int
+ {
+ $segmentsA = $this->segments($a->path);
+ $segmentsB = $this->segments($b->path);
+
+ $length = max(count($segmentsA), count($segmentsB));
+
+ for ($i = 0; $i < $length; $i++) {
+ $rankA = $this->rank($segmentsA[$i] ?? null);
+ $rankB = $this->rank($segmentsB[$i] ?? null);
+
+ if ($rankA !== $rankB) {
+ return $rankA <=> $rankB;
+ }
+ }
+
+ return 0;
+ }
+
+ private function compile(string $path): string
+ {
+ $escaped = preg_quote($path, '#');
+
+ $pattern = preg_replace(
+ '#\\\\\{([a-zA-Z_]+)\\\\\}#',
+ '(?P<$1>[^/]+)',
+ $escaped,
+ );
+
+ return '#^' . $pattern . '$#';
+ }
+
+ /**
+ * @return list<string>
+ */
+ private function segments(string $path): array
+ {
+ $trimmed = trim($path, '/');
+
+ if ($trimmed === '') {
+ return [];
+ }
+
+ return explode('/', $trimmed);
+ }
+
+ /**
+ * Lower rank = more specific: literal (0) < parameter (1) < missing (2).
+ */
+ private function rank(?string $segment): int
+ {
+ if ($segment === null) {
+ return 2;
+ }
+
+ if (str_starts_with($segment, '{') && str_ends_with($segment, '}')) {
+ return 1;
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Routing/RouteNotFoundException.php b/src/Routing/RouteNotFoundException.php
new file mode 100644
index 0000000..ae0289d
--- /dev/null
+++ b/src/Routing/RouteNotFoundException.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use RuntimeException;
+
+final class RouteNotFoundException extends RuntimeException
+{
+}
diff --git a/src/Routing/RouteRegistrationException.php b/src/Routing/RouteRegistrationException.php
new file mode 100644
index 0000000..7d01fec
--- /dev/null
+++ b/src/Routing/RouteRegistrationException.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BrettParson\MiniRoute\Routing;
+
+use RuntimeException;
+
+final class RouteRegistrationException extends RuntimeException
+{
+}
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, '/');
+ }
+}