Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/Routing/HandlerReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $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
{
Expand Down
52 changes: 52 additions & 0 deletions src/Routing/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $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<mixed> $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<class-string> $middleware
*
* @return list<self>
*/
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<class-string> $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
{
Expand Down
33 changes: 33 additions & 0 deletions tests/Routing/HandlerReferenceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, array{array<mixed>}>
*/
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<mixed> $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');
Expand Down
57 changes: 57 additions & 0 deletions tests/Routing/RouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading