From e24b7c8cb649156e0e75ea2f427fc9e669488d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Cobucci?= Date: Tue, 5 Mar 2024 00:10:26 +0100 Subject: [PATCH] Integrate named routes into route processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enables the retrieval of named routes and URI generation from the main library configuration. Signed-off-by: Luís Cobucci --- src/BadRouteException.php | 11 ++++++ src/ConfigureRoutes.php | 3 +- src/FastRoute.php | 34 ++++++++++++++++-- src/RouteCollector.php | 32 ++++++++++++++++- test/Cache/Psr16CacheTest.php | 4 +-- test/FastRouteTest.php | 67 +++++++++++++++++++++++++++++++++++ test/RouteCollectorTest.php | 41 +++++++++++++++++++++ 7 files changed, 186 insertions(+), 6 deletions(-) diff --git a/src/BadRouteException.php b/src/BadRouteException.php index fe1a97c..5bc051c 100644 --- a/src/BadRouteException.php +++ b/src/BadRouteException.php @@ -6,6 +6,7 @@ use LogicException; use function sprintf; +use function var_export; /** @final */ class BadRouteException extends LogicException implements Exception @@ -15,6 +16,16 @@ public static function alreadyRegistered(string $route, string $method): self return new self(sprintf('Cannot register two routes matching "%s" for method "%s"', $route, $method)); } + public static function namedRouteAlreadyDefined(string $name): self + { + return new self(sprintf('Cannot register two routes under the name "%s"', $name)); + } + + public static function invalidRouteName(mixed $name): self + { + return new self(sprintf('Route name must be a non-empty string, "%s" given', var_export($name, true))); + } + public static function shadowedByVariableRoute(string $route, string $shadowedRegex, string $method): self { return new self( diff --git a/src/ConfigureRoutes.php b/src/ConfigureRoutes.php index c001e0c..bcd97fc 100644 --- a/src/ConfigureRoutes.php +++ b/src/ConfigureRoutes.php @@ -7,7 +7,8 @@ * @phpstan-import-type StaticRoutes from DataGenerator * @phpstan-import-type DynamicRoutes from DataGenerator * @phpstan-import-type ExtraParameters from DataGenerator - * @phpstan-type ProcessedData array{StaticRoutes, DynamicRoutes} + * @phpstan-import-type RoutesForUriGeneration from GenerateUri + * @phpstan-type ProcessedData array{StaticRoutes, DynamicRoutes, RoutesForUriGeneration} */ interface ConfigureRoutes { diff --git a/src/FastRoute.php b/src/FastRoute.php index 5195b3a..6187833 100644 --- a/src/FastRoute.php +++ b/src/FastRoute.php @@ -21,6 +21,7 @@ final class FastRoute * @param class-string $dataGenerator * @param class-string $dispatcher * @param class-string $routesConfiguration + * @param class-string $uriGenerator * @param Cache|class-string|null $cacheDriver * @param non-empty-string|null $cacheKey */ @@ -30,6 +31,7 @@ private function __construct( private readonly string $dataGenerator, private readonly string $dispatcher, private readonly string $routesConfiguration, + private readonly string $uriGenerator, private readonly Cache|string|null $cacheDriver, private readonly ?string $cacheKey, ) { @@ -47,6 +49,7 @@ public static function recommendedSettings(Closure $routeDefinitionCallback, str DataGenerator\MarkBased::class, Dispatcher\MarkBased::class, RouteCollector::class, + GenerateUri\FromProcessedConfiguration::class, FileCache::class, $cacheKey, ); @@ -60,6 +63,7 @@ public function disableCache(): self $this->dataGenerator, $this->dispatcher, $this->routesConfiguration, + $this->uriGenerator, null, null, ); @@ -77,6 +81,7 @@ public function withCache(Cache|string $driver, string $cacheKey): self $this->dataGenerator, $this->dispatcher, $this->routesConfiguration, + $this->uriGenerator, $driver, $cacheKey, ); @@ -114,6 +119,22 @@ public function useCustomDispatcher(string $dataGenerator, string $dispatcher): $dataGenerator, $dispatcher, $this->routesConfiguration, + $this->uriGenerator, + $this->cacheDriver, + $this->cacheKey, + ); + } + + /** @param class-string $uriGenerator */ + public function withUriGenerator(string $uriGenerator): self + { + return new self( + $this->routeDefinitionCallback, + $this->routeParser, + $this->dataGenerator, + $this->dispatcher, + $this->routesConfiguration, + $uriGenerator, $this->cacheDriver, $this->cacheKey, ); @@ -122,6 +143,10 @@ public function useCustomDispatcher(string $dataGenerator, string $dispatcher): /** @return ProcessedData */ private function buildConfiguration(): array { + if ($this->processedConfiguration !== null) { + return $this->processedConfiguration; + } + $loader = function (): array { $configuredRoutes = new $this->routesConfiguration( new $this->routeParser(), @@ -134,7 +159,7 @@ private function buildConfiguration(): array }; if ($this->cacheDriver === null) { - return $loader(); + return $this->processedConfiguration = $loader(); } assert(is_string($this->cacheKey)); @@ -143,11 +168,16 @@ private function buildConfiguration(): array ? new $this->cacheDriver() : $this->cacheDriver; - return $cache->get($this->cacheKey, $loader); + return $this->processedConfiguration = $cache->get($this->cacheKey, $loader); } public function dispatcher(): Dispatcher { return new $this->dispatcher($this->buildConfiguration()); } + + public function uriGenerator(): GenerateUri + { + return new $this->uriGenerator($this->buildConfiguration()[2]); + } } diff --git a/src/RouteCollector.php b/src/RouteCollector.php index 0cd577a..9de94b6 100644 --- a/src/RouteCollector.php +++ b/src/RouteCollector.php @@ -3,15 +3,24 @@ namespace FastRoute; +use function array_key_exists; +use function array_reverse; +use function is_string; + /** * @phpstan-import-type ProcessedData from ConfigureRoutes * @phpstan-import-type ExtraParameters from DataGenerator + * @phpstan-import-type RoutesForUriGeneration from GenerateUri + * @phpstan-import-type ParsedRoutes from RouteParser * @final */ class RouteCollector implements ConfigureRoutes { protected string $currentGroupPrefix = ''; + /** @var RoutesForUriGeneration */ + private array $namedRoutes = []; + public function __construct( protected readonly RouteParser $routeParser, protected readonly DataGenerator $dataGenerator, @@ -31,6 +40,24 @@ public function addRoute(string|array $httpMethod, string $route, mixed $handler $this->dataGenerator->addRoute($method, $parsedRoute, $handler, $extraParameters); } } + + if (array_key_exists(self::ROUTE_NAME, $extraParameters)) { + $this->registerNamedRoute($extraParameters[self::ROUTE_NAME], $parsedRoutes); + } + } + + /** @param ParsedRoutes $parsedRoutes */ + private function registerNamedRoute(mixed $name, array $parsedRoutes): void + { + if (! is_string($name) || $name === '') { + throw BadRouteException::invalidRouteName($name); + } + + if (array_key_exists($name, $this->namedRoutes)) { + throw BadRouteException::namedRouteAlreadyDefined($name); + } + + $this->namedRoutes[$name] = array_reverse($parsedRoutes); } public function addGroup(string $prefix, callable $callback): void @@ -92,7 +119,10 @@ public function options(string $route, mixed $handler, array $extraParameters = /** @inheritDoc */ public function processedRoutes(): array { - return $this->dataGenerator->getData(); + $data = $this->dataGenerator->getData(); + $data[] = $this->namedRoutes; + + return $data; } /** diff --git a/test/Cache/Psr16CacheTest.php b/test/Cache/Psr16CacheTest.php index 43e5b03..28faa0d 100644 --- a/test/Cache/Psr16CacheTest.php +++ b/test/Cache/Psr16CacheTest.php @@ -15,7 +15,7 @@ public function cacheShouldOnlyBeSetOnMiss(): void { $data = []; - $generatedData = [['GET' => ['/' => ['test', []]]], []]; + $generatedData = [['GET' => ['/' => ['test', []]]], [], []]; $adapter = new Psr16Cache($this->createDummyCache($data)); $result = $adapter->get('test', static fn () => $generatedData); @@ -24,7 +24,7 @@ public function cacheShouldOnlyBeSetOnMiss(): void self::assertSame($generatedData, $data['test']); // Try again, now with a different callback - $result = $adapter->get('test', static fn () => [['POST' => ['/' => ['test', []]]], []]); + $result = $adapter->get('test', static fn () => [['POST' => ['/' => ['test', []]]], [], []]); self::assertSame($generatedData, $result); } diff --git a/test/FastRouteTest.php b/test/FastRouteTest.php index 9d6351a..8afe5f5 100644 --- a/test/FastRouteTest.php +++ b/test/FastRouteTest.php @@ -7,6 +7,7 @@ use FastRoute\ConfigureRoutes; use FastRoute\Dispatcher; use FastRoute\FastRoute; +use FastRoute\GenerateUri; use PHPUnit\Framework\Attributes as PHPUnit; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -98,4 +99,70 @@ private static function routes(ConfigureRoutes $collector): void { $collector->get('/', 'test'); } + + #[PHPUnit\Test] + public function defaultUriGeneratorMustBeProvided(): void + { + $uriGenerator = FastRoute::recommendedSettings(self::routes(...), 'test') + ->disableCache() + ->uriGenerator(); + + self::assertInstanceOf(GenerateUri\FromProcessedConfiguration::class, $uriGenerator); + } + + #[PHPUnit\Test] + public function uriGeneratorCanBeOverridden(): void + { + $generator = new class () implements GenerateUri { + /** @inheritDoc */ + public function forRoute(string $name, array $substitutions = []): string + { + return ''; + } + }; + + $uriGenerator = FastRoute::recommendedSettings(self::routes(...), 'test') + ->disableCache() + ->withUriGenerator($generator::class) + ->uriGenerator(); + + self::assertInstanceOf($generator::class, $uriGenerator); + } + + #[PHPUnit\Test] + public function processedDataShouldOnlyBeBuiltOnce(): void + { + $loader = static function (ConfigureRoutes $routes): void { + $routes->addRoute( + ['GET', 'POST'], + '/users/{name}', + 'do-stuff', + [ConfigureRoutes::ROUTE_NAME => 'users'], + ); + + $routes->get('/posts/{id}', 'fetchPosts', [ConfigureRoutes::ROUTE_NAME => 'posts.fetch']); + + $routes->get( + '/articles/{year}[/{month}[/{day}]]', + 'fetchArticle', + [ConfigureRoutes::ROUTE_NAME => 'articles.fetch'], + ); + }; + + $fastRoute = FastRoute::recommendedSettings($loader, 'test') + ->disableCache(); + + $dispatcher = $fastRoute->dispatcher(); + $uriGenerator = $fastRoute->uriGenerator(); + + self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('GET', '/users/lcobucci')); + self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('POST', '/users/lcobucci')); + self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('GET', '/posts/1234')); + + self::assertSame('/users/lcobucci', $uriGenerator->forRoute('users', ['name' => 'lcobucci'])); + self::assertSame('/posts/1234', $uriGenerator->forRoute('posts.fetch', ['id' => '1234'])); + self::assertSame('/articles/2024', $uriGenerator->forRoute('articles.fetch', ['year' => '2024'])); + self::assertSame('/articles/2024/02', $uriGenerator->forRoute('articles.fetch', ['year' => '2024', 'month' => '02'])); + self::assertSame('/articles/2024/02/15', $uriGenerator->forRoute('articles.fetch', ['year' => '2024', 'month' => '02', 'day' => '15'])); + } } diff --git a/test/RouteCollectorTest.php b/test/RouteCollectorTest.php index 99ff33f..4dd52ce 100644 --- a/test/RouteCollectorTest.php +++ b/test/RouteCollectorTest.php @@ -3,6 +3,7 @@ namespace FastRoute\Test; +use FastRoute\BadRouteException; use FastRoute\ConfigureRoutes; use FastRoute\DataGenerator; use FastRoute\RouteCollector; @@ -117,6 +118,46 @@ public function routesCanBeGrouped(): void self::assertSame($expected, $dataGenerator->routes); } + #[PHPUnit\Test] + public function namedRoutesShouldBeRegistered(): void + { + $dataGenerator = self::dummyDataGenerator(); + + $r = new RouteCollector(new Std(), $dataGenerator); + $r->get('/', 'index-handler', ['_name' => 'index']); + $r->get('/users/me', 'fetch-user-handler', ['_name' => 'users.fetch']); + + self::assertSame(['index' => [['/']], 'users.fetch' => [['/users/me']]], $r->processedRoutes()[2]); + } + + #[PHPUnit\Test] + public function cannotDefineRouteWithEmptyName(): void + { + $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + + $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => '']); + } + + #[PHPUnit\Test] + public function cannotDefineRouteWithInvalidTypeAsName(): void + { + $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + + $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => false]); + } + + #[PHPUnit\Test] + public function cannotDefineDuplicatedRouteName(): void + { + $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + + $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => 'index']); + $r->get('/users/me', 'fetch-user-handler', ['_name' => 'index']); + } + private static function dummyDataGenerator(): DataGenerator { return new class implements DataGenerator