aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--LICENSE21
-rw-r--r--README.md106
-rw-r--r--composer.json25
-rw-r--r--docs/adr/0001-kernel-not-framework.md22
-rw-r--r--docs/adr/0002-attribute-based-route-declaration.md24
-rw-r--r--docs/adr/0003-request-response-boundary.md27
-rw-r--r--docs/roadmap.md50
-rw-r--r--phpunit.xml8
-rw-r--r--src/Attribute/Delete.php25
-rw-r--r--src/Attribute/Get.php25
-rw-r--r--src/Attribute/Middleware.php21
-rw-r--r--src/Attribute/Patch.php25
-rw-r--r--src/Attribute/Post.php25
-rw-r--r--src/Attribute/Put.php25
-rw-r--r--src/Attribute/RouteAttribute.php19
-rw-r--r--src/Controller/ControllerResolverInterface.php17
-rw-r--r--src/Http/RequestInterface.php31
-rw-r--r--src/Http/ResponseInterface.php17
-rw-r--r--src/Middleware/MiddlewareInterface.php21
-rw-r--r--src/Middleware/MiddlewarePipeline.php35
-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
-rw-r--r--tests/Fixtures/ClassLevelController.php18
-rw-r--r--tests/Fixtures/FakeController.php33
-rw-r--r--tests/Fixtures/FakeMiddleware.php30
-rw-r--r--tests/Fixtures/FakeResolver.php28
-rw-r--r--tests/Fixtures/TestRequest.php40
-rw-r--r--tests/Fixtures/TestResponse.php43
-rw-r--r--tests/MiddlewarePipelineTest.php38
-rw-r--r--tests/RouteLoaderTest.php57
-rw-r--r--tests/RouteMatcherTest.php68
-rw-r--r--tests/RouterTest.php81
-rw-r--r--tests/bootstrap.php36
38 files changed, 1515 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index b537116..ee9b430 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1967876
--- /dev/null
+++ b/LICENSE
@@ -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;
+ }
+ }
+ });
+}