diff options
38 files changed, 1515 insertions, 1 deletions
@@ -11,7 +11,7 @@ bd ready # Find available work bd show <id> # View issue details bd update <id> --claim # Claim work atomically bd close <id> # Complete work -bd create -f docs/roadmap.md # Batch-create beads from the roadmap spec +bd create # Create v0.1.0 beads individually (see docs/roadmap.md) ``` ## Conventions @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brett Parson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..97be075 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# miniroute + +A small attribute-based routing and middleware kernel for PHP applications. + +miniroute is the tiny piece of routing machinery extracted from +[brett-parson.com](https://brett-parson.com). It provides attribute-declared +routes, deterministic route matching, and an onion-style middleware pipeline — +without being a framework. + +## Requirements + +- PHP >= 8.2 + +## Install + +### Via Composer (VCS repository) + +```bash +composer config repositories.miniroute vcs https://src.brett-parson.com/git/miniroute.git +composer require brettparson/miniroute:^0.1 +``` + +### Via a local path repository (development) + +```json +{ + "repositories": [ + { "type": "path", "url": "../miniroute", "options": { "symlink": true } } + ], + "require": { "brettparson/miniroute": "@dev" } +} +``` + +## Usage + +```php +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; +use BrettParson\MiniRoute\Attribute\Post; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Routing\Router; + +final class NoteController +{ + #[Get('/notes')] + public function index(RequestInterface $request): ResponseInterface + { + // ... + } + + #[Post('/notes')] + #[Middleware(SomeMiddleware::class)] + public function store(RequestInterface $request): ResponseInterface + { + // ... + } +} +``` + +The router needs a controller/middleware resolver — an app-owned seam for +building objects: + +```php +$router = new Router($resolver); + +$router->group('/admin', [RequireAdmin::class, RequireCsrf::class]); + +$router->registerControllers([ + HomeController::class, + NoteController::class, +]); + +$response = $router->dispatch($request); +$response->send(); +``` + +## What it does + +- **Route attributes** — `#[Get]`, `#[Post]`, `#[Put]`, `#[Patch]`, `#[Delete]` + declare routes directly on controller methods. +- **Deterministic matching** — literal segments beat `{parameter}` segments, + so registration order never matters. +- **Middleware** — `#[Middleware(...)]` on methods or classes, plus + router-level `group()` middleware by path prefix. +- **Thin HTTP contracts** — `RequestInterface` and `ResponseInterface` keep + the kernel decoupled from any one application's HTTP objects. + +## What it is not + +No container, no template engine, no ORM, no auth, no session handling, no +configuration loader. Those stay application concerns. + +## Tests + +```bash +composer install +vendor/bin/phpunit +``` + +Or, without Composer, drop a `phpunit.phar` into the repo root and run +`php phpunit.phar`. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e9f55d1 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "brettparson/miniroute", + "description": "A small attribute-based routing and middleware kernel for PHP applications.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2" + }, + "autoload": { + "psr-4": { + "BrettParson\\MiniRoute\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "BrettParson\\MiniRoute\\Tests\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0" + }, + "config": { + "sort-packages": true + } +} diff --git a/docs/adr/0001-kernel-not-framework.md b/docs/adr/0001-kernel-not-framework.md new file mode 100644 index 0000000..e77cc2d --- /dev/null +++ b/docs/adr/0001-kernel-not-framework.md @@ -0,0 +1,22 @@ +# ADR 0001: A Routing Kernel, Not a Framework + +- Status: Accepted +- Date: 2026-08-17 + +## Context + +The routing/middleware logic from brett-parson.com is being extracted into a +reusable package. The temptation is to grow it into a general-purpose web +framework. + +## Decision + +miniroute ships routing, matching, middleware composition, and controller +resolution only. It deliberately owns no HTTP object implementations, no +container, no templating, no database, no session, and no configuration. + +## Consequences + +- The package stays small enough to explain end-to-end. +- Consumers own their concrete Request/Response and dependency wiring. +- Reuse is broad because the kernel makes no assumptions about an app's stack. diff --git a/docs/adr/0002-attribute-based-route-declaration.md b/docs/adr/0002-attribute-based-route-declaration.md new file mode 100644 index 0000000..d1d4c79 --- /dev/null +++ b/docs/adr/0002-attribute-based-route-declaration.md @@ -0,0 +1,24 @@ +# ADR 0002: Attribute-Based Route Declaration + +- Status: Accepted +- Date: 2026-08-17 + +## Context + +Routes need a home that does not grow linearly in a central composition root. + +## Decision + +Routes are declared with method-specific PHP attributes (`Get`, `Post`, `Put`, +`Patch`, `Delete`) on controller methods. A reflection-based `RouteLoader` +compiles them into `RouteDef` objects. `Middleware` is a repeatable attribute +valid on classes and methods. + +Matching precedence is deterministic: literal segments sort before +`{parameter}` segments, so registration order is irrelevant. + +## Consequences + +- Adding a route touches only the controller. +- The loader must be covered by tests (reflection is where mistakes hide). +- Precedence is explicit rather than dependent on registration order. diff --git a/docs/adr/0003-request-response-boundary.md b/docs/adr/0003-request-response-boundary.md new file mode 100644 index 0000000..9129c0d --- /dev/null +++ b/docs/adr/0003-request-response-boundary.md @@ -0,0 +1,27 @@ +# ADR 0003: Thin HTTP Contracts + +- Status: Accepted +- Date: 2026-08-17 + +## Context + +The kernel needs request/response types, but applications already have their +own concrete HTTP objects (and their own security headers, session handling, +and parsing). + +## Decision + +The kernel defines `RequestInterface` and `ResponseInterface` with only the +surface routing needs (method, path, params) and middleware needs (headers). +Applications implement these interfaces on their own classes. + +Middleware and controllers operate on the interfaces, so application code that +needs app-specific features narrows to its concrete types. + +## Consequences + +- The kernel stays decoupled from any one app's HTTP layer. +- Applications must implement the two interfaces (usually a small change to + existing Request/Response classes). +- Middleware parameters use `RequestInterface`; concrete middleware may need + an `instanceof` narrowing to access app-specific request methods. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..59eb6c9 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,50 @@ +# v0.1.0 Roadmap + +Initial release of the routing/middleware kernel. + +## Proposed beads + +### [EPIC] miniroute v0.1.0 — attribute routing + middleware kernel + +- Type: epic +- Priority: P2 + +Children (create with `--parent`): + +1. **Core: attribute route declaration and reflection loader** + - Type: task, Priority: P2 + - Description: Implement `Get`/`Post`/`Put`/`Patch`/`Delete`/`Middleware` + attributes and `RouteLoader` reflection scanning. + - Acceptance: `RouteLoaderTest` passes. + +2. **Core: deterministic route matching** + - Type: task, Priority: P2 + - Description: `RouteMatcher` with `{param}` support and + literal-before-parameter precedence. + - Acceptance: `RouteMatcherTest` passes. + +3. **Core: middleware pipeline and groups** + - Type: task, Priority: P2 + - Description: `MiddlewarePipeline` onion composition and router-level + `group()` prefix middleware. + - Acceptance: `MiddlewarePipelineTest` and router group coverage pass. + +4. **Core: controller resolution and dispatch** + - Type: task, Priority: P2 + - Description: `ControllerResolverInterface` seam and `Router::dispatch`. + - Acceptance: `RouterTest` passes. + +5. **Tests: unit coverage for loader/matcher/pipeline/router** + - Type: task, Priority: P2 + - Acceptance: `vendor/bin/phpunit` green. + +6. **Docs: ADRs, README, usage** + - Type: task, Priority: P2 + +7. **Release: Composer metadata and git tags** + - Type: task, Priority: P2 + - Description: `composer.json` PSR-4 autoload, MIT license, tag `v0.1.0`. + +8. **Repo: publish to src.brett-parson.com** + - Type: task, Priority: P2 + - Description: Add cgit remote, push, set `git-daemon-export-ok` marker. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..658da50 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit bootstrap="tests/bootstrap.php" colors="true"> + <testsuites> + <testsuite name="miniroute"> + <directory>tests</directory> + </testsuite> + </testsuites> +</phpunit> 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, '/'); + } +} diff --git a/tests/Fixtures/ClassLevelController.php b/tests/Fixtures/ClassLevelController.php new file mode 100644 index 0000000..88ef6e6 --- /dev/null +++ b/tests/Fixtures/ClassLevelController.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; + +#[Middleware(FakeMiddleware::class)] +final class ClassLevelController +{ + #[Get('/admin')] + public function dashboard(): string + { + return 'dashboard'; + } +} diff --git a/tests/Fixtures/FakeController.php b/tests/Fixtures/FakeController.php new file mode 100644 index 0000000..3b476ae --- /dev/null +++ b/tests/Fixtures/FakeController.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Attribute\Get; +use BrettParson\MiniRoute\Attribute\Middleware; +use BrettParson\MiniRoute\Attribute\Post; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; + +final class FakeController +{ + #[Get('/entries')] + public function index(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'index'); + } + + #[Get('/entries/{slug}')] + public function show(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'show:' . $request->param('slug')); + } + + #[Post('/entries')] + #[Middleware(FakeMiddleware::class)] + public function store(RequestInterface $request): ResponseInterface + { + return new TestResponse(200, 'store'); + } +} diff --git a/tests/Fixtures/FakeMiddleware.php b/tests/Fixtures/FakeMiddleware.php new file mode 100644 index 0000000..d684e7c --- /dev/null +++ b/tests/Fixtures/FakeMiddleware.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewareInterface; + +final class FakeMiddleware implements MiddlewareInterface +{ + /** @var ArrayObject<int, string>|null */ + private ?ArrayObject $log; + + public function __construct( + private readonly string $name = 'fake', + ?ArrayObject $log = null, + ) { + $this->log = $log; + } + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $this->log?->append($this->name); + + return $next($request); + } +} diff --git a/tests/Fixtures/FakeResolver.php b/tests/Fixtures/FakeResolver.php new file mode 100644 index 0000000..af6ec1f --- /dev/null +++ b/tests/Fixtures/FakeResolver.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Controller\ControllerResolverInterface; +use RuntimeException; + +final class FakeResolver implements ControllerResolverInterface +{ + /** @var array<string, object> */ + private array $bindings = []; + + public function bind(string $class, object $instance): void + { + $this->bindings[$class] = $instance; + } + + public function resolve(string $class): object + { + if (!array_key_exists($class, $this->bindings)) { + throw new RuntimeException("No binding for {$class}"); + } + + return $this->bindings[$class]; + } +} diff --git a/tests/Fixtures/TestRequest.php b/tests/Fixtures/TestRequest.php new file mode 100644 index 0000000..c449610 --- /dev/null +++ b/tests/Fixtures/TestRequest.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Http\RequestInterface; + +final class TestRequest implements RequestInterface +{ + /** + * @param array<string, string> $params + */ + public function __construct( + private readonly string $method, + private readonly string $path, + private readonly array $params = [], + ) { + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function param(string $name, string $default = ''): string + { + return $this->params[$name] ?? $default; + } + + public function withParams(array $params): RequestInterface + { + return new self($this->method, $this->path, $params); + } +} diff --git a/tests/Fixtures/TestResponse.php b/tests/Fixtures/TestResponse.php new file mode 100644 index 0000000..25a0307 --- /dev/null +++ b/tests/Fixtures/TestResponse.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests\Fixtures; + +use BrettParson\MiniRoute\Http\ResponseInterface; + +final class TestResponse implements ResponseInterface +{ + /** + * @param array<string, string> $headers + */ + public function __construct( + private readonly int $status, + private readonly string $body, + private readonly array $headers = [], + ) { + } + + public function status(): int + { + return $this->status; + } + + public function body(): string + { + return $this->body; + } + + public function withHeader(string $name, string $value): ResponseInterface + { + return new self($this->status, $this->body, array_merge($this->headers, [$name => $value])); + } + + /** + * @return array<string, string> + */ + public function headers(): array + { + return $this->headers; + } +} diff --git a/tests/MiddlewarePipelineTest.php b/tests/MiddlewarePipelineTest.php new file mode 100644 index 0000000..107dce3 --- /dev/null +++ b/tests/MiddlewarePipelineTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use ArrayObject; +use BrettParson\MiniRoute\Http\RequestInterface; +use BrettParson\MiniRoute\Http\ResponseInterface; +use BrettParson\MiniRoute\Middleware\MiddlewarePipeline; +use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use BrettParson\MiniRoute\Tests\Fixtures\TestResponse; +use PHPUnit\Framework\TestCase; + +final class MiddlewarePipelineTest extends TestCase +{ + public function testMiddlewareRunsOutermostFirstAndCoreLast(): void + { + $log = new ArrayObject(); + + $outer = new FakeMiddleware('outer', $log); + $inner = new FakeMiddleware('inner', $log); + + $core = static function (RequestInterface $request) use ($log): ResponseInterface { + $log->append('core'); + + return new TestResponse(200, 'ok'); + }; + + $pipeline = MiddlewarePipeline::compose([$outer, $inner], $core); + + $response = $pipeline(new TestRequest('GET', '/')); + + $this->assertSame(['outer', 'inner', 'core'], $log->getArrayCopy()); + $this->assertSame(200, $response->status()); + } +} diff --git a/tests/RouteLoaderTest.php b/tests/RouteLoaderTest.php new file mode 100644 index 0000000..c20f58b --- /dev/null +++ b/tests/RouteLoaderTest.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use BrettParson\MiniRoute\Routing\RouteLoader; +use BrettParson\MiniRoute\Tests\Fixtures\ClassLevelController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; +use PHPUnit\Framework\TestCase; + +final class RouteLoaderTest extends TestCase +{ + public function testLoadsRouteAttributesFromControllerMethods(): void + { + $routes = (new RouteLoader())->load(FakeController::class); + + $indexed = []; + + foreach ($routes as $route) { + $indexed[$route->method . ' ' . $route->path] = $route; + } + + $this->assertCount(3, $routes); + + $this->assertSame('GET', $indexed['GET /entries']->method); + $this->assertSame('index', $indexed['GET /entries']->controllerMethod); + $this->assertSame([], $indexed['GET /entries']->middleware); + + $this->assertSame('show', $indexed['GET /entries/{slug}']->controllerMethod); + } + + public function testMethodMiddlewareIsAttached(): void + { + $routes = (new RouteLoader())->load(FakeController::class); + + $post = null; + + foreach ($routes as $route) { + if ($route->method === 'POST') { + $post = $route; + } + } + + $this->assertNotNull($post); + $this->assertSame([FakeMiddleware::class], $post->middleware); + } + + public function testClassLevelMiddlewareIsInheritedByMethods(): void + { + $routes = (new RouteLoader())->load(ClassLevelController::class); + + $this->assertCount(1, $routes); + $this->assertSame([FakeMiddleware::class], $routes[0]->middleware); + } +} diff --git a/tests/RouteMatcherTest.php b/tests/RouteMatcherTest.php new file mode 100644 index 0000000..5181c50 --- /dev/null +++ b/tests/RouteMatcherTest.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use BrettParson\MiniRoute\Routing\RouteDef; +use BrettParson\MiniRoute\Routing\RouteMatcher; +use PHPUnit\Framework\TestCase; + +final class RouteMatcherTest extends TestCase +{ + private RouteMatcher $matcher; + + protected function setUp(): void + { + $this->matcher = new RouteMatcher(); + } + + private function route(string $method, string $path): RouteDef + { + return new RouteDef($method, $path, 'C', 'm'); + } + + public function testMatchesExactPath(): void + { + $route = $this->route('GET', '/entries'); + + $this->assertTrue($this->matcher->matches($route, 'get', '/entries')); + $this->assertFalse($this->matcher->matches($route, 'POST', '/entries')); + $this->assertFalse($this->matcher->matches($route, 'GET', '/entries/1')); + } + + public function testMatchesParameterizedPath(): void + { + $route = $this->route('GET', '/entries/{slug}'); + + $this->assertTrue($this->matcher->matches($route, 'GET', '/entries/hello-world')); + $this->assertFalse($this->matcher->matches($route, 'GET', '/entries/hello/world')); + } + + public function testExtractsNamedParameters(): void + { + $route = $this->route('GET', '/entries/{slug}'); + + $this->assertSame( + ['slug' => 'hello-world'], + $this->matcher->extractParams($route, '/entries/hello-world'), + ); + } + + public function testLiteralBeatsParameter(): void + { + $literal = $this->route('GET', '/entries/archive'); + $param = $this->route('GET', '/entries/{slug}'); + + $this->assertLessThan(0, $this->matcher->compare($literal, $param)); + $this->assertGreaterThan(0, $this->matcher->compare($param, $literal)); + } + + public function testExactBeatsParameterAtSamePosition(): void + { + $exact = $this->route('POST', '/admin/entries/preview'); + $param = $this->route('POST', '/admin/entries/{id}'); + + $this->assertLessThan(0, $this->matcher->compare($exact, $param)); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..b8d4d49 --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +namespace BrettParson\MiniRoute\Tests; + +use ArrayObject; +use BrettParson\MiniRoute\Routing\RouteNotFoundException; +use BrettParson\MiniRoute\Routing\RouteRegistrationException; +use BrettParson\MiniRoute\Routing\Router; +use BrettParson\MiniRoute\Tests\Fixtures\FakeController; +use BrettParson\MiniRoute\Tests\Fixtures\FakeMiddleware; +use BrettParson\MiniRoute\Tests\Fixtures\FakeResolver; +use BrettParson\MiniRoute\Tests\Fixtures\TestRequest; +use PHPUnit\Framework\TestCase; + +final class RouterTest extends TestCase +{ + /** + * @return array{Router, FakeResolver, ArrayObject} + */ + private function makeRouter(): array + { + $resolver = new FakeResolver(); + $resolver->bind(FakeController::class, new FakeController()); + + $log = new ArrayObject(); + $resolver->bind(FakeMiddleware::class, new FakeMiddleware('m', $log)); + + return [new Router($resolver), $resolver, $log]; + } + + public function testDispatchesToController(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries')); + + $this->assertSame(200, $response->status()); + $this->assertSame('index', $response->body()); + } + + public function testInjectsRouteParameters(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $response = $router->dispatch(new TestRequest('GET', '/entries/hello-world')); + + $this->assertSame('show:hello-world', $response->body()); + } + + public function testAppliesRouteMiddleware(): void + { + [$router, , $log] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $router->dispatch(new TestRequest('POST', '/entries')); + + $this->assertSame(['m'], $log->getArrayCopy()); + } + + public function testDuplicateRouteThrows(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteRegistrationException::class); + $router->registerController(FakeController::class); + } + + public function testNotFoundThrowsWithoutHandler(): void + { + [$router] = $this->makeRouter(); + $router->registerController(FakeController::class); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(new TestRequest('GET', '/missing')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f49f712 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * Test bootstrap — registers a small PSR-4 autoloader so the library and its + * test fixtures can be loaded without requiring a Composer install. If + * vendor/autoload.php exists (Composer installed), prefer it. + */ +$vendorAutoload = dirname(__DIR__) . '/vendor/autoload.php'; + +if (is_file($vendorAutoload)) { + require $vendorAutoload; +} else { + spl_autoload_register(static function (string $class): void { + $prefixes = [ + 'BrettParson\\MiniRoute\\Tests\\' => __DIR__ . '/', + 'BrettParson\\MiniRoute\\' => dirname(__DIR__) . '/src/', + ]; + + foreach ($prefixes as $prefix => $baseDir) { + if (!str_starts_with($class, $prefix)) { + continue; + } + + $relative = substr($class, strlen($prefix)); + $file = $baseDir . str_replace('\\', '/', $relative) . '.php'; + + if (is_file($file)) { + require $file; + + return; + } + } + }); +} |
