diff options
| author | Brett Parson <brett@brett-parson.com> | 2026-08-21 13:55:37 -0500 |
|---|---|---|
| committer | Brett Parson <brett@brett-parson.com> | 2026-08-21 14:00:11 -0500 |
| commit | d039a5d62a9ac6ababae70d9d96422ca59f2b2d4 (patch) | |
| tree | f0db4750a571e8040a5da02cc7190079c0d62914 /src | |
| parent | 0de77a6334cee1f6e15f7e0fd35602514560be33 (diff) | |
| download | miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.tar.gz miniroute-d039a5d62a9ac6ababae70d9d96422ca59f2b2d4.zip | |
Scaffold miniroute routing/middleware kernel
Diffstat (limited to 'src')
| -rw-r--r-- | src/Attribute/Delete.php | 25 | ||||
| -rw-r--r-- | src/Attribute/Get.php | 25 | ||||
| -rw-r--r-- | src/Attribute/Middleware.php | 21 | ||||
| -rw-r--r-- | src/Attribute/Patch.php | 25 | ||||
| -rw-r--r-- | src/Attribute/Post.php | 25 | ||||
| -rw-r--r-- | src/Attribute/Put.php | 25 | ||||
| -rw-r--r-- | src/Attribute/RouteAttribute.php | 19 | ||||
| -rw-r--r-- | src/Controller/ControllerResolverInterface.php | 17 | ||||
| -rw-r--r-- | src/Http/RequestInterface.php | 31 | ||||
| -rw-r--r-- | src/Http/ResponseInterface.php | 17 | ||||
| -rw-r--r-- | src/Middleware/MiddlewareInterface.php | 21 | ||||
| -rw-r--r-- | src/Middleware/MiddlewarePipeline.php | 35 | ||||
| -rw-r--r-- | src/Routing/RouteDef.php | 24 | ||||
| -rw-r--r-- | src/Routing/RouteLoader.php | 98 | ||||
| -rw-r--r-- | src/Routing/RouteMatcher.php | 109 | ||||
| -rw-r--r-- | src/Routing/RouteNotFoundException.php | 11 | ||||
| -rw-r--r-- | src/Routing/RouteRegistrationException.php | 11 | ||||
| -rw-r--r-- | src/Routing/Router.php | 220 |
18 files changed, 759 insertions, 0 deletions
diff --git a/src/Attribute/Delete.php b/src/Attribute/Delete.php new file mode 100644 index 0000000..4588f76 --- /dev/null +++ b/src/Attribute/Delete.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD)] +final class Delete implements RouteAttribute +{ + public function __construct(public readonly string $path) + { + } + + public function method(): string + { + return 'DELETE'; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Attribute/Get.php b/src/Attribute/Get.php new file mode 100644 index 0000000..779031a --- /dev/null +++ b/src/Attribute/Get.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD)] +final class Get implements RouteAttribute +{ + public function __construct(public readonly string $path) + { + } + + public function method(): string + { + return 'GET'; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Attribute/Middleware.php b/src/Attribute/Middleware.php new file mode 100644 index 0000000..e98c580 --- /dev/null +++ b/src/Attribute/Middleware.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +/** + * Declares middleware to apply to a controller method or class. + * + * Repeatable, and valid on both classes (applies to every method) and + * individual methods (applies only to that method). + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class Middleware +{ + public function __construct(public readonly string $middlewareClass) + { + } +} diff --git a/src/Attribute/Patch.php b/src/Attribute/Patch.php new file mode 100644 index 0000000..8402398 --- /dev/null +++ b/src/Attribute/Patch.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD)] +final class Patch implements RouteAttribute +{ + public function __construct(public readonly string $path) + { + } + + public function method(): string + { + return 'PATCH'; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Attribute/Post.php b/src/Attribute/Post.php new file mode 100644 index 0000000..a395acf --- /dev/null +++ b/src/Attribute/Post.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD)] +final class Post implements RouteAttribute +{ + public function __construct(public readonly string $path) + { + } + + public function method(): string + { + return 'POST'; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Attribute/Put.php b/src/Attribute/Put.php new file mode 100644 index 0000000..fc08399 --- /dev/null +++ b/src/Attribute/Put.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_METHOD)] +final class Put implements RouteAttribute +{ + public function __construct(public readonly string $path) + { + } + + public function method(): string + { + return 'PUT'; + } + + public function path(): string + { + return $this->path; + } +} diff --git a/src/Attribute/RouteAttribute.php b/src/Attribute/RouteAttribute.php new file mode 100644 index 0000000..a7b0458 --- /dev/null +++ b/src/Attribute/RouteAttribute.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Attribute; + +/** + * Shared contract implemented by every HTTP-method route attribute + * (Get, Post, Put, Patch, Delete). + * + * The loader discovers route attributes by matching against this interface, + * so new HTTP methods can be added as attributes without touching the loader. + */ +interface RouteAttribute +{ + public function method(): string; + + public function path(): string; +} diff --git a/src/Controller/ControllerResolverInterface.php b/src/Controller/ControllerResolverInterface.php new file mode 100644 index 0000000..3b4ea48 --- /dev/null +++ b/src/Controller/ControllerResolverInterface.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Controller; + +/** + * Resolves a class name to an object instance. + * + * Used for both controllers and middleware. Applications provide an + * implementation backed by their own dependency container or plain + * construction, keeping the kernel independent of any specific wiring. + */ +interface ControllerResolverInterface +{ + public function resolve(string $class): object; +} diff --git a/src/Http/RequestInterface.php b/src/Http/RequestInterface.php new file mode 100644 index 0000000..fe54667 --- /dev/null +++ b/src/Http/RequestInterface.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Http; + +/** + * Minimal HTTP request contract the router needs. + * + * Applications keep their own concrete Request class and implement this + * interface. The router only reads method/path and injects route parameters + * via withParams(); everything else stays application-owned. + */ +interface RequestInterface +{ + public function method(): string; + + public function path(): string; + + /** + * Get a route parameter extracted by the router (e.g. {slug}). + */ + public function param(string $name, string $default = ''): string; + + /** + * Return a copy of the request with route parameters attached. + * + * @param array<string, string> $params + */ + public function withParams(array $params): RequestInterface; +} diff --git a/src/Http/ResponseInterface.php b/src/Http/ResponseInterface.php new file mode 100644 index 0000000..cb74ea0 --- /dev/null +++ b/src/Http/ResponseInterface.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Http; + +interface ResponseInterface +{ + public function status(): int; + + public function body(): string; + + /** + * Return a copy of the response with an additional (or overridden) header. + */ + public function withHeader(string $name, string $value): ResponseInterface; +} diff --git a/src/Middleware/MiddlewareInterface.php b/src/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..3d21a3f --- /dev/null +++ b/src/Middleware/MiddlewareInterface.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Middleware; + +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +interface MiddlewareInterface +{ + /** + * Process the request and either short-circuit with a Response or pass + * control to $next. + * + * Middleware may wrap $next to post-process the response. + * + * @param callable(RequestInterface): ResponseInterface $next + */ + public function handle(RequestInterface $request, callable $next): ResponseInterface; +} diff --git a/src/Middleware/MiddlewarePipeline.php b/src/Middleware/MiddlewarePipeline.php new file mode 100644 index 0000000..65a300e --- /dev/null +++ b/src/Middleware/MiddlewarePipeline.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Middleware; + +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +final class MiddlewarePipeline +{ + /** + * Wrap a core handler with an ordered list of middleware instances. + * + * The first middleware in $middleware becomes the outermost layer, so a + * request flows through it first and its response post-processing runs + * last. + * + * @param list<MiddlewareInterface> $middleware + * @param callable(RequestInterface): ResponseInterface $core + * @return callable(RequestInterface): ResponseInterface + */ + public static function compose(array $middleware, callable $core): callable + { + foreach (array_reverse($middleware) as $middlewareInstance) { + $next = $core; + + $core = static function (RequestInterface $request) use ($middlewareInstance, $next): ResponseInterface { + return $middlewareInstance->handle($request, $next); + }; + } + + return $core; + } +} 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, '/'); + } +} |
