From bfbf6802467d435668795eae6c0fd9ccdf4d0af7 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 14 Sep 2026 17:32:14 -0600 Subject: [PATCH] feat(routing): declare a route by its verb, and its door once for a group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route::get() and Route::post() build the very Route the constructor builds, with the verb said once — as the method name — and the handler named the way a controller is named, [Controller::class, 'method'], through HandlerReference::of(), which refuses any other shape by naming what it received. Route::behind($middleware, ...$routes) prepends one door to a group of routes; Route::withMiddleware() is the wither behind it. Only the verbs and the handler shape with a consumer in the family exist here: put/patch/delete and a bare Controller::class arrive with their first caller. Measured in the greenhouse: agent-workspace's five routes rewritten with this produce a byte-identical routes:list on fresh cattle; only the declaration order moves, and a test declares it (greenhouse decisions/0386, evidence/0704). --- README.md | 20 +++++++++ src/Routing/HandlerReference.php | 27 ++++++++++++ src/Routing/Route.php | 52 +++++++++++++++++++++++ tests/Routing/HandlerReferenceTest.php | 33 +++++++++++++++ tests/Routing/RouteTest.php | 57 ++++++++++++++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/README.md b/README.md index 7e7fe1e..8502372 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,26 @@ final class ShowUser } ``` +A provider that declares a table by hand says each verb once, names the handler the way a +controller is named, and declares its door once for the group — the constructor stays the canon and +these produce the very same `Route` values: + +```php +use Milpa\Http\Routing\Route; + +return [ + ...Route::behind($gate, + Route::get('/workspace/hub', [HubController::class, 'connect'], 'desktop.hub'), + Route::post('/workspace/settings', [MutationController::class, 'saveSettings'], 'desktop.settings.save'), + ), + Route::get('/assets/{file}', [AssetsController::class, 'component'], 'desktop.assets.component'), +]; +``` + +`HandlerReference::of()` accepts `[Controller::class, 'method']` and refuses anything else by shape, naming +what it received. Only `get` and `post` exist, and only the pair is accepted: the other verbs and a bare +`Controller::class` arrive with their first consumer. + Match a PSR-7 request and branch on the typed status — no nulls, no exceptions for a miss: ```php diff --git a/src/Routing/HandlerReference.php b/src/Routing/HandlerReference.php index a0e1174..023eece 100644 --- a/src/Routing/HandlerReference.php +++ b/src/Routing/HandlerReference.php @@ -52,6 +52,33 @@ public static function method(string $controller, string $method): self return new self($controller, $method); } + /** + * The reference behind `[Controller::class, 'method']` — the way a route names its handler. + * + * Anything else is refused here, by shape and naming what was received: a route whose handler cannot + * be resolved would otherwise surface at dispatch, as a 500 to whoever asked, instead of at + * declaration, to whoever wrote it. Only the pair is accepted: a bare `Controller::class` for a + * single-action handler arrives with its first consumer in the family (greenhouse decisions/0386). + * + * @param array $handler + */ + public static function of(array $handler): self + { + if ( + \count($handler) === 2 + && array_is_list($handler) + && \is_string($handler[0]) && $handler[0] !== '' + && \is_string($handler[1]) && $handler[1] !== '' + ) { + return new self($handler[0], $handler[1]); + } + + throw new \InvalidArgumentException( + 'A handler is named as [Controller::class, \'method\']; received ' + . json_encode($handler, \JSON_INVALID_UTF8_SUBSTITUTE | \JSON_PARTIAL_OUTPUT_ON_ERROR) . '.', + ); + } + /** Render the reference as `Controller::method`. */ public function __toString(): string { diff --git a/src/Routing/Route.php b/src/Routing/Route.php index c0e7f50..19247aa 100644 --- a/src/Routing/Route.php +++ b/src/Routing/Route.php @@ -52,6 +52,58 @@ public function __construct( $this->methods = \is_array($methods) ? $methods : [$methods]; } + /** + * A `GET` route, its handler named the way a controller is named: `[Controller::class, 'method']`. + * + * The constructor stays the canon; this produces the same value with the verb said once, as the + * method name, and the reference derived from the pair ({@see HandlerReference::of()}, greenhouse + * decisions/0386). Only the verbs with a consumer in the family exist here — `put`/`patch`/`delete` + * arrive with their first caller. + * + * @param array $handler + */ + public static function get(string $path, array $handler, ?string $name = null): self + { + return new self($path, HttpMethod::GET, $name, [], HandlerReference::of($handler)); + } + + /** + * A `POST` route — the handler is named as in {@see self::get()}. + * + * @param array $handler + */ + public static function post(string $path, array $handler, ?string $name = null): self + { + return new self($path, HttpMethod::POST, $name, [], HandlerReference::of($handler)); + } + + /** + * The same routes, each behind one door: the given middleware is prepended — outermost, the position + * the constructor's list gives it — to whatever each route already carries, so a provider declares + * its gate once for the group instead of once per route. Keys are not preserved: the argument is a list. + * + * @param list $middleware + * + * @return list + */ + public static function behind(array $middleware, self ...$routes): array + { + return array_map( + static fn (self $route): self => $route->withMiddleware([...$middleware, ...$route->middleware]), + $routes, + ); + } + + /** + * Return a copy carrying the given per-route middleware, outermost first. + * + * @param list $middleware + */ + public function withMiddleware(array $middleware): self + { + return new self($this->path, $this->methods, $this->name, $middleware, $this->handler); + } + /** Bind the handler discovered by attribute reflection, returning a new instance. */ public function withHandler(HandlerReference $handler): self { diff --git a/tests/Routing/HandlerReferenceTest.php b/tests/Routing/HandlerReferenceTest.php index 2e287e5..aabd856 100644 --- a/tests/Routing/HandlerReferenceTest.php +++ b/tests/Routing/HandlerReferenceTest.php @@ -15,6 +15,7 @@ namespace Milpa\Http\Tests\Routing; use Milpa\Http\Routing\HandlerReference; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class HandlerReferenceTest extends TestCase @@ -28,6 +29,38 @@ public function testActionDefaultsToInvoke(): void $this->assertSame('App\\ShowUser::__invoke', (string) $ref); } + public function testOfNamesAMethodByThePair(): void + { + $this->assertSame('App\\UserController::show', (string) HandlerReference::of(['App\\UserController', 'show'])); + } + + /** + * @return iterable}> + */ + public static function malformedHandlers(): iterable + { + yield 'empty' => [[]]; + yield 'one element' => [['App\\Only']]; + yield 'three elements' => [['App\\C', 'a', 'b']]; + yield 'keyed pair' => [['controller' => 'App\\C', 'method' => 'a']]; + yield 'non-string method' => [['App\\C', 7]]; + yield 'empty class' => [['', 'a']]; + yield 'empty method' => [['App\\C', '']]; + yield 'bytes that are not UTF-8 still get the documented refusal' => [["\xff\xfe", 'a', 'b']]; + } + + /** + * @param array $handler + */ + #[DataProvider('malformedHandlers')] + public function testOfRefusesAMalformedHandlerByShape(array $handler): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('A handler is named as [Controller::class, \'method\']'); + + HandlerReference::of($handler); + } + public function testMethodReference(): void { $ref = HandlerReference::method('App\\UserController', 'show'); diff --git a/tests/Routing/RouteTest.php b/tests/Routing/RouteTest.php index 5c3a91e..7d12e8b 100644 --- a/tests/Routing/RouteTest.php +++ b/tests/Routing/RouteTest.php @@ -67,6 +67,63 @@ public function testWithNameIsImmutable(): void $this->assertSame('x.route', $named->name); } + public function testTheVerbFactoriesProduceTheValueTheConstructorProduces(): void + { + $handler = new HandlerReference('App\\Settings', 'save'); + + $this->assertEquals( + new Route('/settings', HttpMethod::GET, 'settings.show', [], $handler), + Route::get('/settings', ['App\\Settings', 'save'], 'settings.show'), + ); + $this->assertEquals( + new Route('/settings', HttpMethod::POST, 'settings.save', [], $handler), + Route::post('/settings', ['App\\Settings', 'save'], 'settings.save'), + ); + } + + public function testAVerbFactoryRouteIsUnnamedAndUngatedUnlessSaidSo(): void + { + $route = Route::post('/x', ['App\\Act', 'run']); + + $this->assertNull($route->name); + $this->assertSame([], $route->middleware); + $this->assertSame([HttpMethod::POST], $route->methods); + $this->assertSame('App\\Act::run', (string) $route->handler); + } + + public function testWithMiddlewareIsImmutable(): void + { + $route = Route::get('/x', ['App\\Act', 'run']); + $gated = $route->withMiddleware(['App\\Gate']); + + $this->assertSame([], $route->middleware); + $this->assertSame(['App\\Gate'], $gated->middleware); + $this->assertSame($route->handler, $gated->handler); + $this->assertSame($route->methods, $gated->methods); + } + + public function testBehindPrependsTheGateToEveryRouteAndKeepsWhatEachCarried(): void + { + $routes = Route::behind( + ['App\\Door'], + Route::get('/a', ['App\\A', 'index'], 'a'), + Route::post('/b', ['App\\B', 'save'], 'b')->withMiddleware(['App\\Own']), + ); + + $this->assertCount(2, $routes); + $this->assertSame(['App\\Door'], $routes[0]->middleware); + $this->assertSame(['App\\Door', 'App\\Own'], $routes[1]->middleware); + $this->assertSame('a', $routes[0]->name); + $this->assertSame('/b', $routes[1]->path); + $this->assertSame([HttpMethod::POST], $routes[1]->methods); + $this->assertSame('App\\B::save', (string) $routes[1]->handler); + } + + public function testBehindWithNoRoutesIsAnEmptyList(): void + { + $this->assertSame([], Route::behind(['App\\Door'])); + } + public function testIsARepeatableMethodAttribute(): void { $attributes = (new \ReflectionClass(Route::class))->getAttributes(\Attribute::class);