Skip to content

Commit

Permalink
Integrate named routes into route processing
Browse files Browse the repository at this point in the history
This enables the retrieval of named routes and URI generation from the
main library configuration.

Signed-off-by: Luís Cobucci <[email protected]>
  • Loading branch information
lcobucci committed Mar 4, 2024
1 parent 28698b2 commit e24b7c8
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 6 deletions.
11 changes: 11 additions & 0 deletions src/BadRouteException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use LogicException;

use function sprintf;
use function var_export;

/** @final */
class BadRouteException extends LogicException implements Exception
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/ConfigureRoutes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
34 changes: 32 additions & 2 deletions src/FastRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class FastRoute
* @param class-string<DataGenerator> $dataGenerator
* @param class-string<Dispatcher> $dispatcher
* @param class-string<ConfigureRoutes> $routesConfiguration
* @param class-string<GenerateUri> $uriGenerator
* @param Cache|class-string<Cache>|null $cacheDriver
* @param non-empty-string|null $cacheKey
*/
Expand All @@ -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,
) {
Expand All @@ -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,
);
Expand All @@ -60,6 +63,7 @@ public function disableCache(): self
$this->dataGenerator,
$this->dispatcher,
$this->routesConfiguration,
$this->uriGenerator,
null,
null,
);
Expand All @@ -77,6 +81,7 @@ public function withCache(Cache|string $driver, string $cacheKey): self
$this->dataGenerator,
$this->dispatcher,
$this->routesConfiguration,
$this->uriGenerator,
$driver,
$cacheKey,
);
Expand Down Expand Up @@ -114,6 +119,22 @@ public function useCustomDispatcher(string $dataGenerator, string $dispatcher):
$dataGenerator,
$dispatcher,
$this->routesConfiguration,
$this->uriGenerator,
$this->cacheDriver,
$this->cacheKey,
);
}

/** @param class-string<GenerateUri> $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,
);
Expand All @@ -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(),
Expand All @@ -134,7 +159,7 @@ private function buildConfiguration(): array
};

if ($this->cacheDriver === null) {
return $loader();
return $this->processedConfiguration = $loader();
}

assert(is_string($this->cacheKey));
Expand All @@ -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]);
}
}
32 changes: 31 additions & 1 deletion src/RouteCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions test/Cache/Psr16CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
67 changes: 67 additions & 0 deletions test/FastRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']));
}
}
41 changes: 41 additions & 0 deletions test/RouteCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace FastRoute\Test;

use FastRoute\BadRouteException;
use FastRoute\ConfigureRoutes;
use FastRoute\DataGenerator;
use FastRoute\RouteCollector;
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit e24b7c8

Please sign in to comment.