Skip to content

Commit 170dc10

Browse files
committed
Integrate named routes into route processing
This enables the retrieval of named routes and URI generation from the main library configuration. Signed-off-by: Luís Cobucci <[email protected]>
1 parent 641b265 commit 170dc10

File tree

7 files changed

+186
-6
lines changed

7 files changed

+186
-6
lines changed

src/BadRouteException.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use LogicException;
77

88
use function sprintf;
9+
use function var_export;
910

1011
/** @final */
1112
class BadRouteException extends LogicException implements Exception
@@ -15,6 +16,16 @@ public static function alreadyRegistered(string $route, string $method): self
1516
return new self(sprintf('Cannot register two routes matching "%s" for method "%s"', $route, $method));
1617
}
1718

19+
public static function namedRouteAlreadyDefined(string $name): self
20+
{
21+
return new self(sprintf('Cannot register two routes under the name "%s"', $name));
22+
}
23+
24+
public static function invalidRouteName(mixed $name): self
25+
{
26+
return new self(sprintf('Route name must be a non-empty string, "%s" given', var_export($name, true)));
27+
}
28+
1829
public static function shadowedByVariableRoute(string $route, string $shadowedRegex, string $method): self
1930
{
2031
return new self(

src/ConfigureRoutes.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* @phpstan-import-type StaticRoutes from DataGenerator
88
* @phpstan-import-type DynamicRoutes from DataGenerator
99
* @phpstan-import-type ExtraParameters from DataGenerator
10-
* @phpstan-type ProcessedData array{StaticRoutes, DynamicRoutes}
10+
* @phpstan-import-type RoutesForUriGeneration from GenerateUri
11+
* @phpstan-type ProcessedData array{StaticRoutes, DynamicRoutes, RoutesForUriGeneration}
1112
*/
1213
interface ConfigureRoutes
1314
{

src/FastRoute.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ final class FastRoute
2121
* @param class-string<DataGenerator> $dataGenerator
2222
* @param class-string<Dispatcher> $dispatcher
2323
* @param class-string<ConfigureRoutes> $routesConfiguration
24+
* @param class-string<GenerateUri> $uriGenerator
2425
* @param Cache|class-string<Cache>|null $cacheDriver
2526
* @param non-empty-string|null $cacheKey
2627
*/
@@ -30,6 +31,7 @@ private function __construct(
3031
private readonly string $dataGenerator,
3132
private readonly string $dispatcher,
3233
private readonly string $routesConfiguration,
34+
private readonly string $uriGenerator,
3335
private readonly Cache|string|null $cacheDriver,
3436
private readonly ?string $cacheKey,
3537
) {
@@ -47,6 +49,7 @@ public static function recommendedSettings(Closure $routeDefinitionCallback, str
4749
DataGenerator\MarkBased::class,
4850
Dispatcher\MarkBased::class,
4951
RouteCollector::class,
52+
GenerateUri\FromProcessedConfiguration::class,
5053
FileCache::class,
5154
$cacheKey,
5255
);
@@ -60,6 +63,7 @@ public function disableCache(): self
6063
$this->dataGenerator,
6164
$this->dispatcher,
6265
$this->routesConfiguration,
66+
$this->uriGenerator,
6367
null,
6468
null,
6569
);
@@ -77,6 +81,7 @@ public function withCache(Cache|string $driver, string $cacheKey): self
7781
$this->dataGenerator,
7882
$this->dispatcher,
7983
$this->routesConfiguration,
84+
$this->uriGenerator,
8085
$driver,
8186
$cacheKey,
8287
);
@@ -114,6 +119,22 @@ public function useCustomDispatcher(string $dataGenerator, string $dispatcher):
114119
$dataGenerator,
115120
$dispatcher,
116121
$this->routesConfiguration,
122+
$this->uriGenerator,
123+
$this->cacheDriver,
124+
$this->cacheKey,
125+
);
126+
}
127+
128+
/** @param class-string<GenerateUri> $uriGenerator */
129+
public function withUriGenerator(string $uriGenerator): self
130+
{
131+
return new self(
132+
$this->routeDefinitionCallback,
133+
$this->routeParser,
134+
$this->dataGenerator,
135+
$this->dispatcher,
136+
$this->routesConfiguration,
137+
$uriGenerator,
117138
$this->cacheDriver,
118139
$this->cacheKey,
119140
);
@@ -122,6 +143,10 @@ public function useCustomDispatcher(string $dataGenerator, string $dispatcher):
122143
/** @return ProcessedData */
123144
private function buildConfiguration(): array
124145
{
146+
if ($this->processedConfiguration !== null) {
147+
return $this->processedConfiguration;
148+
}
149+
125150
$loader = function (): array {
126151
$configuredRoutes = new $this->routesConfiguration(
127152
new $this->routeParser(),
@@ -134,7 +159,7 @@ private function buildConfiguration(): array
134159
};
135160

136161
if ($this->cacheDriver === null) {
137-
return $loader();
162+
return $this->processedConfiguration = $loader();
138163
}
139164

140165
assert(is_string($this->cacheKey));
@@ -143,11 +168,16 @@ private function buildConfiguration(): array
143168
? new $this->cacheDriver()
144169
: $this->cacheDriver;
145170

146-
return $cache->get($this->cacheKey, $loader);
171+
return $this->processedConfiguration = $cache->get($this->cacheKey, $loader);
147172
}
148173

149174
public function dispatcher(): Dispatcher
150175
{
151176
return new $this->dispatcher($this->buildConfiguration());
152177
}
178+
179+
public function uriGenerator(): GenerateUri
180+
{
181+
return new $this->uriGenerator($this->buildConfiguration()[2]);
182+
}
153183
}

src/RouteCollector.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,24 @@
33

44
namespace FastRoute;
55

6+
use function array_key_exists;
7+
use function array_reverse;
8+
use function is_string;
9+
610
/**
711
* @phpstan-import-type ProcessedData from ConfigureRoutes
812
* @phpstan-import-type ExtraParameters from DataGenerator
13+
* @phpstan-import-type RoutesForUriGeneration from GenerateUri
14+
* @phpstan-import-type ParsedRoutes from RouteParser
915
* @final
1016
*/
1117
class RouteCollector implements ConfigureRoutes
1218
{
1319
protected string $currentGroupPrefix = '';
1420

21+
/** @var RoutesForUriGeneration */
22+
private array $namedRoutes = [];
23+
1524
public function __construct(
1625
protected readonly RouteParser $routeParser,
1726
protected readonly DataGenerator $dataGenerator,
@@ -31,6 +40,24 @@ public function addRoute(string|array $httpMethod, string $route, mixed $handler
3140
$this->dataGenerator->addRoute($method, $parsedRoute, $handler, $extraParameters);
3241
}
3342
}
43+
44+
if (array_key_exists(self::ROUTE_NAME, $extraParameters)) {
45+
$this->registerNamedRoute($extraParameters[self::ROUTE_NAME], $parsedRoutes);
46+
}
47+
}
48+
49+
/** @param ParsedRoutes $parsedRoutes */
50+
private function registerNamedRoute(mixed $name, array $parsedRoutes): void
51+
{
52+
if (! is_string($name) || $name === '') {
53+
throw BadRouteException::invalidRouteName($name);
54+
}
55+
56+
if (array_key_exists($name, $this->namedRoutes)) {
57+
throw BadRouteException::namedRouteAlreadyDefined($name);
58+
}
59+
60+
$this->namedRoutes[$name] = array_reverse($parsedRoutes);
3461
}
3562

3663
public function addGroup(string $prefix, callable $callback): void
@@ -92,7 +119,10 @@ public function options(string $route, mixed $handler, array $extraParameters =
92119
/** @inheritDoc */
93120
public function processedRoutes(): array
94121
{
95-
return $this->dataGenerator->getData();
122+
$data = $this->dataGenerator->getData();
123+
$data[] = $this->namedRoutes;
124+
125+
return $data;
96126
}
97127

98128
/**

test/Cache/Psr16CacheTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public function cacheShouldOnlyBeSetOnMiss(): void
1515
{
1616
$data = [];
1717

18-
$generatedData = [['GET' => ['/' => ['test', []]]], []];
18+
$generatedData = [['GET' => ['/' => ['test', []]]], [], []];
1919

2020
$adapter = new Psr16Cache($this->createDummyCache($data));
2121
$result = $adapter->get('test', static fn () => $generatedData);
@@ -24,7 +24,7 @@ public function cacheShouldOnlyBeSetOnMiss(): void
2424
self::assertSame($generatedData, $data['test']);
2525

2626
// Try again, now with a different callback
27-
$result = $adapter->get('test', static fn () => [['POST' => ['/' => ['test', []]]], []]);
27+
$result = $adapter->get('test', static fn () => [['POST' => ['/' => ['test', []]]], [], []]);
2828

2929
self::assertSame($generatedData, $result);
3030
}

test/FastRouteTest.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use FastRoute\ConfigureRoutes;
88
use FastRoute\Dispatcher;
99
use FastRoute\FastRoute;
10+
use FastRoute\GenerateUri;
1011
use PHPUnit\Framework\Attributes as PHPUnit;
1112
use PHPUnit\Framework\TestCase;
1213
use RuntimeException;
@@ -98,4 +99,70 @@ private static function routes(ConfigureRoutes $collector): void
9899
{
99100
$collector->get('/', 'test');
100101
}
102+
103+
#[PHPUnit\Test]
104+
public function defaultUriGeneratorMustBeProvided(): void
105+
{
106+
$uriGenerator = FastRoute::recommendedSettings(self::routes(...), 'test')
107+
->disableCache()
108+
->uriGenerator();
109+
110+
self::assertInstanceOf(GenerateUri\FromProcessedConfiguration::class, $uriGenerator);
111+
}
112+
113+
#[PHPUnit\Test]
114+
public function uriGeneratorCanBeOverridden(): void
115+
{
116+
$generator = new class () implements GenerateUri {
117+
/** @inheritDoc */
118+
public function forRoute(string $name, array $substitutions = []): string
119+
{
120+
return '';
121+
}
122+
};
123+
124+
$uriGenerator = FastRoute::recommendedSettings(self::routes(...), 'test')
125+
->disableCache()
126+
->withUriGenerator($generator::class)
127+
->uriGenerator();
128+
129+
self::assertInstanceOf($generator::class, $uriGenerator);
130+
}
131+
132+
#[PHPUnit\Test]
133+
public function processedDataShouldOnlyBeBuiltOnce(): void
134+
{
135+
$loader = static function (ConfigureRoutes $routes): void {
136+
$routes->addRoute(
137+
['GET', 'POST'],
138+
'/users/{name}',
139+
'do-stuff',
140+
[ConfigureRoutes::ROUTE_NAME => 'users'],
141+
);
142+
143+
$routes->get('/posts/{id}', 'fetchPosts', [ConfigureRoutes::ROUTE_NAME => 'posts.fetch']);
144+
145+
$routes->get(
146+
'/articles/{year}[/{month}[/{day}]]',
147+
'fetchArticle',
148+
[ConfigureRoutes::ROUTE_NAME => 'articles.fetch'],
149+
);
150+
};
151+
152+
$fastRoute = FastRoute::recommendedSettings($loader, 'test')
153+
->disableCache();
154+
155+
$dispatcher = $fastRoute->dispatcher();
156+
$uriGenerator = $fastRoute->uriGenerator();
157+
158+
self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('GET', '/users/lcobucci'));
159+
self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('POST', '/users/lcobucci'));
160+
self::assertInstanceOf(Dispatcher\Result\Matched::class, $dispatcher->dispatch('GET', '/posts/1234'));
161+
162+
self::assertSame('/users/lcobucci', $uriGenerator->forRoute('users', ['name' => 'lcobucci']));
163+
self::assertSame('/posts/1234', $uriGenerator->forRoute('posts.fetch', ['id' => '1234']));
164+
self::assertSame('/articles/2024', $uriGenerator->forRoute('articles.fetch', ['year' => '2024']));
165+
self::assertSame('/articles/2024/02', $uriGenerator->forRoute('articles.fetch', ['year' => '2024', 'month' => '02']));
166+
self::assertSame('/articles/2024/02/15', $uriGenerator->forRoute('articles.fetch', ['year' => '2024', 'month' => '02', 'day' => '15']));
167+
}
101168
}

test/RouteCollectorTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
namespace FastRoute\Test;
55

6+
use FastRoute\BadRouteException;
67
use FastRoute\ConfigureRoutes;
78
use FastRoute\DataGenerator;
89
use FastRoute\RouteCollector;
@@ -117,6 +118,46 @@ public function routesCanBeGrouped(): void
117118
self::assertSame($expected, $dataGenerator->routes);
118119
}
119120

121+
#[PHPUnit\Test]
122+
public function namedRoutesShouldBeRegistered(): void
123+
{
124+
$dataGenerator = self::dummyDataGenerator();
125+
126+
$r = new RouteCollector(new Std(), $dataGenerator);
127+
$r->get('/', 'index-handler', ['_name' => 'index']);
128+
$r->get('/users/me', 'fetch-user-handler', ['_name' => 'users.fetch']);
129+
130+
self::assertSame(['index' => [['/']], 'users.fetch' => [['/users/me']]], $r->processedRoutes()[2]);
131+
}
132+
133+
#[PHPUnit\Test]
134+
public function cannotDefineRouteWithEmptyName(): void
135+
{
136+
$r = new RouteCollector(new Std(), self::dummyDataGenerator());
137+
138+
$this->expectException(BadRouteException::class);
139+
$r->get('/', 'index-handler', ['_name' => '']);
140+
}
141+
142+
#[PHPUnit\Test]
143+
public function cannotDefineRouteWithInvalidTypeAsName(): void
144+
{
145+
$r = new RouteCollector(new Std(), self::dummyDataGenerator());
146+
147+
$this->expectException(BadRouteException::class);
148+
$r->get('/', 'index-handler', ['_name' => false]);
149+
}
150+
151+
#[PHPUnit\Test]
152+
public function cannotDefineDuplicatedRouteName(): void
153+
{
154+
$r = new RouteCollector(new Std(), self::dummyDataGenerator());
155+
156+
$this->expectException(BadRouteException::class);
157+
$r->get('/', 'index-handler', ['_name' => 'index']);
158+
$r->get('/users/me', 'fetch-user-handler', ['_name' => 'index']);
159+
}
160+
120161
private static function dummyDataGenerator(): DataGenerator
121162
{
122163
return new class implements DataGenerator

0 commit comments

Comments
 (0)