diff --git a/src/ConfigureRoutes.php b/src/ConfigureRoutes.php index bcd97fc..740a59b 100644 --- a/src/ConfigureRoutes.php +++ b/src/ConfigureRoutes.php @@ -23,14 +23,14 @@ interface ConfigureRoutes * @param string|string[] $httpMethod * @param ExtraParameters $extraParameters */ - public function addRoute(string|array $httpMethod, string $route, mixed $handler, array $extraParameters = []): void; + public function addRoute(string|array $httpMethod, string $route, mixed $handler, array $extraParameters = []): static; /** * Create a route group with a common prefix. * * All routes created by the passed callback will have the given group prefix prepended. */ - public function addGroup(string $prefix, callable $callback): void; + public function addGroup(string $prefix, callable $callback): static; /** * Adds a fallback route to the collection @@ -39,7 +39,7 @@ public function addGroup(string $prefix, callable $callback): void; * * @param ExtraParameters $extraParameters */ - public function any(string $route, mixed $handler, array $extraParameters = []): void; + public function any(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a GET route to the collection @@ -48,7 +48,7 @@ public function any(string $route, mixed $handler, array $extraParameters = []): * * @param ExtraParameters $extraParameters */ - public function get(string $route, mixed $handler, array $extraParameters = []): void; + public function get(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a POST route to the collection @@ -57,7 +57,7 @@ public function get(string $route, mixed $handler, array $extraParameters = []): * * @param ExtraParameters $extraParameters */ - public function post(string $route, mixed $handler, array $extraParameters = []): void; + public function post(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a PUT route to the collection @@ -66,7 +66,7 @@ public function post(string $route, mixed $handler, array $extraParameters = []) * * @param ExtraParameters $extraParameters */ - public function put(string $route, mixed $handler, array $extraParameters = []): void; + public function put(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a DELETE route to the collection @@ -75,7 +75,7 @@ public function put(string $route, mixed $handler, array $extraParameters = []): * * @param ExtraParameters $extraParameters */ - public function delete(string $route, mixed $handler, array $extraParameters = []): void; + public function delete(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a PATCH route to the collection @@ -84,7 +84,7 @@ public function delete(string $route, mixed $handler, array $extraParameters = [ * * @param ExtraParameters $extraParameters */ - public function patch(string $route, mixed $handler, array $extraParameters = []): void; + public function patch(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds a HEAD route to the collection @@ -93,7 +93,7 @@ public function patch(string $route, mixed $handler, array $extraParameters = [] * * @param ExtraParameters $extraParameters */ - public function head(string $route, mixed $handler, array $extraParameters = []): void; + public function head(string $route, mixed $handler, array $extraParameters = []): static; /** * Adds an OPTIONS route to the collection @@ -102,7 +102,7 @@ public function head(string $route, mixed $handler, array $extraParameters = []) * * @param ExtraParameters $extraParameters */ - public function options(string $route, mixed $handler, array $extraParameters = []): void; + public function options(string $route, mixed $handler, array $extraParameters = []): static; /** * Returns the processed aggregated route data. diff --git a/src/RouteCollector.php b/src/RouteCollector.php index 9de94b6..845f1e9 100644 --- a/src/RouteCollector.php +++ b/src/RouteCollector.php @@ -4,31 +4,11 @@ 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 +final class RouteCollector extends RouteCollectorAbstract { - protected string $currentGroupPrefix = ''; - - /** @var RoutesForUriGeneration */ - private array $namedRoutes = []; - - public function __construct( - protected readonly RouteParser $routeParser, - protected readonly DataGenerator $dataGenerator, - ) { - } - /** @inheritDoc */ - public function addRoute(string|array $httpMethod, string $route, mixed $handler, array $extraParameters = []): void + public function addRoute(string|array $httpMethod, string $route, mixed $handler, array $extraParameters = []): static { $route = $this->currentGroupPrefix . $route; $parsedRoutes = $this->routeParser->parse($route); @@ -44,96 +24,18 @@ public function addRoute(string|array $httpMethod, string $route, mixed $handler 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); + return $this; } - public function addGroup(string $prefix, callable $callback): void + /** @inheritDoc */ + public function addGroup(string $prefix, callable $callback): static { $previousGroupPrefix = $this->currentGroupPrefix; $this->currentGroupPrefix = $previousGroupPrefix . $prefix; $callback($this); $this->currentGroupPrefix = $previousGroupPrefix; - } - - /** @inheritDoc */ - public function any(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('*', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function get(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('GET', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function post(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('POST', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function put(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('PUT', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function delete(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('DELETE', $route, $handler, $extraParameters); - } - /** @inheritDoc */ - public function patch(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('PATCH', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function head(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('HEAD', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function options(string $route, mixed $handler, array $extraParameters = []): void - { - $this->addRoute('OPTIONS', $route, $handler, $extraParameters); - } - - /** @inheritDoc */ - public function processedRoutes(): array - { - $data = $this->dataGenerator->getData(); - $data[] = $this->namedRoutes; - - return $data; - } - - /** - * @deprecated - * - * @see ConfigureRoutes::processedRoutes() - * - * @return ProcessedData - */ - public function getData(): array - { - return $this->processedRoutes(); + return $this; } } diff --git a/src/RouteCollectorAbstract.php b/src/RouteCollectorAbstract.php new file mode 100644 index 0000000..66fc981 --- /dev/null +++ b/src/RouteCollectorAbstract.php @@ -0,0 +1,111 @@ +namedRoutes)) { + throw BadRouteException::namedRouteAlreadyDefined($name); + } + + $this->namedRoutes[$name] = array_reverse($parsedRoutes); + } + + /** @inheritDoc */ + public function processedRoutes(): array + { + $data = $this->dataGenerator->getData(); + $data[] = $this->namedRoutes; + + return $data; + } + + /** + * @deprecated + * + * @see ConfigureRoutes::processedRoutes() + * + * @return ProcessedData + */ + public function getData(): array + { + return $this->processedRoutes(); + } + + /** @inheritDoc */ + public function any(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('*', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function get(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('GET', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function post(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('POST', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function put(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('PUT', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function delete(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('DELETE', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function patch(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('PATCH', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function head(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('HEAD', $route, $handler, $extraParameters); + } + + /** @inheritDoc */ + public function options(string $route, mixed $handler, array $extraParameters = []): static + { + return $this->addRoute('OPTIONS', $route, $handler, $extraParameters); + } +} diff --git a/src/RouteCollectorImmutable.php b/src/RouteCollectorImmutable.php new file mode 100644 index 0000000..55ff1f9 --- /dev/null +++ b/src/RouteCollectorImmutable.php @@ -0,0 +1,46 @@ +dataGenerator = clone $clone->dataGenerator; + + $route = $clone->currentGroupPrefix . $route; + $parsedRoutes = $clone->routeParser->parse($route); + + $extraParameters = [self::ROUTE_REGEX => $route] + $extraParameters; + + foreach ((array) $httpMethod as $method) { + foreach ($parsedRoutes as $parsedRoute) { + $clone->dataGenerator->addRoute($method, $parsedRoute, $handler, $extraParameters); + } + } + + if (array_key_exists(self::ROUTE_NAME, $extraParameters)) { + $clone->registerNamedRoute($extraParameters[self::ROUTE_NAME], $parsedRoutes); + } + + return $clone; + } + + /** @inheritDoc */ + public function addGroup(string $prefix, callable $callback): static + { + $clone = clone $this; + + $previousGroupPrefix = $clone->currentGroupPrefix; + $clone->currentGroupPrefix = $previousGroupPrefix . $prefix; + $clone = $callback($clone); + $clone->currentGroupPrefix = $previousGroupPrefix; + + return $clone; + } +} diff --git a/test/RouteCollectorImmutableTest.php b/test/RouteCollectorImmutableTest.php new file mode 100644 index 0000000..f45efcf --- /dev/null +++ b/test/RouteCollectorImmutableTest.php @@ -0,0 +1,190 @@ +any('/any', 'any') + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + + $expected = [ + ['*', '/any', 'any', ['_route' => '/any']], + ['DELETE', '/delete', 'delete', ['_route' => '/delete']], + ['GET', '/get', 'get', ['_route' => '/get']], + ['HEAD', '/head', 'head', ['_route' => '/head']], + ['PATCH', '/patch', 'patch', ['_route' => '/patch']], + ['POST', '/post', 'post', ['_route' => '/post']], + ['PUT', '/put', 'put', ['_route' => '/put']], + ['OPTIONS', '/options', 'options', ['_route' => '/options']], + ]; + + self::assertSame($expected, $immutable->processedRoutes()[0]); + } + + #[PHPUnit\Test] + public function routesCanBeGrouped(): void + { + $r = self::routeCollector(); + + $immutable = $r + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + + $immutable = $immutable->addGroup('/group-one', static function (ConfigureRoutes $r1): ConfigureRoutes { + $immutable1 = $r1 + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + + return $immutable1->addGroup('/group-two', static function (ConfigureRoutes $r2): ConfigureRoutes { + return $r2 + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + }); + }); + + $immutable = $immutable->addGroup('/admin', static function (ConfigureRoutes $r): ConfigureRoutes { + return $r->get('-some-info', 'admin-some-info'); + }); + + $immutable = $immutable->addGroup('/admin-', static function (ConfigureRoutes $r): ConfigureRoutes { + return $r->get('more-info', 'admin-more-info'); + }); + + $expected = [ + ['DELETE', '/delete', 'delete', ['_route' => '/delete']], + ['GET', '/get', 'get', ['_route' => '/get']], + ['HEAD', '/head', 'head', ['_route' => '/head']], + ['PATCH', '/patch', 'patch', ['_route' => '/patch']], + ['POST', '/post', 'post', ['_route' => '/post']], + ['PUT', '/put', 'put', ['_route' => '/put']], + ['OPTIONS', '/options', 'options', ['_route' => '/options']], + ['DELETE', '/group-one/delete', 'delete', ['_route' => '/group-one/delete']], + ['GET', '/group-one/get', 'get', ['_route' => '/group-one/get']], + ['HEAD', '/group-one/head', 'head', ['_route' => '/group-one/head']], + ['PATCH', '/group-one/patch', 'patch', ['_route' => '/group-one/patch']], + ['POST', '/group-one/post', 'post', ['_route' => '/group-one/post']], + ['PUT', '/group-one/put', 'put', ['_route' => '/group-one/put']], + ['OPTIONS', '/group-one/options', 'options', ['_route' => '/group-one/options']], + ['DELETE', '/group-one/group-two/delete', 'delete', ['_route' => '/group-one/group-two/delete']], + ['GET', '/group-one/group-two/get', 'get', ['_route' => '/group-one/group-two/get']], + ['HEAD', '/group-one/group-two/head', 'head', ['_route' => '/group-one/group-two/head']], + ['PATCH', '/group-one/group-two/patch', 'patch', ['_route' => '/group-one/group-two/patch']], + ['POST', '/group-one/group-two/post', 'post', ['_route' => '/group-one/group-two/post']], + ['PUT', '/group-one/group-two/put', 'put', ['_route' => '/group-one/group-two/put']], + ['OPTIONS', '/group-one/group-two/options', 'options', ['_route' => '/group-one/group-two/options']], + ['GET', '/admin-some-info', 'admin-some-info', ['_route' => '/admin-some-info']], + ['GET', '/admin-more-info', 'admin-more-info', ['_route' => '/admin-more-info']], + ]; + + self::assertSame($expected, $immutable->processedRoutes()[0]); + } + + #[PHPUnit\Test] + public function namedRoutesShouldBeRegistered(): void + { + $r = self::routeCollector(); + + $immutable = $r->get('/', 'index-handler', ['_name' => 'index']); + $immutable = $immutable->get('/users/me', 'fetch-user-handler', ['_name' => 'users.fetch']); + + self::assertSame(['index' => [['/']], 'users.fetch' => [['/users/me']]], $immutable->processedRoutes()[2]); + } + + #[PHPUnit\Test] + public function cannotDefineRouteWithEmptyName(): void + { + $r = self::routeCollector(); + + $this->expectException(BadRouteException::class); + + $r->get('/', 'index-handler', ['_name' => '']); + } + + #[PHPUnit\Test] + public function cannotDefineRouteWithInvalidTypeAsName(): void + { + $r = self::routeCollector(); + + $this->expectException(BadRouteException::class); + + $r->get('/', 'index-handler', ['_name' => false]); + } + + #[PHPUnit\Test] + public function cannotDefineDuplicatedRouteName(): void + { + $r = self::routeCollector(); + + $this->expectException(BadRouteException::class); + + $immutable = $r->get('/', 'index-handler', ['_name' => 'index']); + $immutable->get('/users/me', 'fetch-user-handler', ['_name' => 'index']); + } + + private static function routeCollector(): ConfigureRoutes + { + return new RouteCollectorImmutable(new Std(), self::dummyDataGenerator()); + } + + private static function dummyDataGenerator(): DataGenerator + { + return new class implements DataGenerator + { + /** @var list}> */ + private array $routes = []; + + /** @inheritDoc */ + public function getData(): array + { + return [$this->routes, []]; + } + + /** @inheritDoc */ + public function addRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters = []): void + { + TestCase::assertTrue(count($routeData) === 1 && is_string($routeData[0])); + + $this->routes[] = [$httpMethod, $routeData[0], $handler, $extraParameters]; + } + }; + } +} diff --git a/test/RouteCollectorTest.php b/test/RouteCollectorTest.php index 4dd52ce..0e4f411 100644 --- a/test/RouteCollectorTest.php +++ b/test/RouteCollectorTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\Attributes as PHPUnit; use PHPUnit\Framework\TestCase; -use function assert; use function count; use function is_string; @@ -20,17 +19,17 @@ final class RouteCollectorTest extends TestCase #[PHPUnit\Test] public function shortcutsCanBeUsedToRegisterRoutes(): void { - $dataGenerator = self::dummyDataGenerator(); - $r = new RouteCollector(new Std(), $dataGenerator); - - $r->any('/any', 'any'); - $r->delete('/delete', 'delete'); - $r->get('/get', 'get'); - $r->head('/head', 'head'); - $r->patch('/patch', 'patch'); - $r->post('/post', 'post'); - $r->put('/put', 'put'); - $r->options('/options', 'options'); + $r = self::routeCollector(); + + $r + ->any('/any', 'any') + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); $expected = [ ['*', '/any', 'any', ['_route' => '/any']], @@ -43,47 +42,49 @@ public function shortcutsCanBeUsedToRegisterRoutes(): void ['OPTIONS', '/options', 'options', ['_route' => '/options']], ]; - self::assertObjectHasProperty('routes', $dataGenerator); - self::assertSame($expected, $dataGenerator->routes); + self::assertSame($expected, $r->processedRoutes()[0]); } #[PHPUnit\Test] public function routesCanBeGrouped(): void { - $dataGenerator = self::dummyDataGenerator(); - $r = new RouteCollector(new Std(), $dataGenerator); - - $r->delete('/delete', 'delete'); - $r->get('/get', 'get'); - $r->head('/head', 'head'); - $r->patch('/patch', 'patch'); - $r->post('/post', 'post'); - $r->put('/put', 'put'); - $r->options('/options', 'options'); - - $r->addGroup('/group-one', static function (ConfigureRoutes $r): void { - $r->delete('/delete', 'delete'); - $r->get('/get', 'get'); - $r->head('/head', 'head'); - $r->patch('/patch', 'patch'); - $r->post('/post', 'post'); - $r->put('/put', 'put'); - $r->options('/options', 'options'); - - $r->addGroup('/group-two', static function (ConfigureRoutes $r): void { - $r->delete('/delete', 'delete'); - $r->get('/get', 'get'); - $r->head('/head', 'head'); - $r->patch('/patch', 'patch'); - $r->post('/post', 'post'); - $r->put('/put', 'put'); - $r->options('/options', 'options'); + $r = self::routeCollector(); + + $r + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + + $r->addGroup('/group-one', static function (ConfigureRoutes $r1): void { + $r1 + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); + + $r1->addGroup('/group-two', static function (ConfigureRoutes $r2): void { + $r2 + ->delete('/delete', 'delete') + ->get('/get', 'get') + ->head('/head', 'head') + ->patch('/patch', 'patch') + ->post('/post', 'post') + ->put('/put', 'put') + ->options('/options', 'options'); }); }); $r->addGroup('/admin', static function (ConfigureRoutes $r): void { $r->get('-some-info', 'admin-some-info'); }); + $r->addGroup('/admin-', static function (ConfigureRoutes $r): void { $r->get('more-info', 'admin-more-info'); }); @@ -114,16 +115,14 @@ public function routesCanBeGrouped(): void ['GET', '/admin-more-info', 'admin-more-info', ['_route' => '/admin-more-info']], ]; - self::assertObjectHasProperty('routes', $dataGenerator); - self::assertSame($expected, $dataGenerator->routes); + self::assertSame($expected, $r->processedRoutes()[0]); } #[PHPUnit\Test] public function namedRoutesShouldBeRegistered(): void { - $dataGenerator = self::dummyDataGenerator(); + $r = self::routeCollector(); - $r = new RouteCollector(new Std(), $dataGenerator); $r->get('/', 'index-handler', ['_name' => 'index']); $r->get('/users/me', 'fetch-user-handler', ['_name' => 'users.fetch']); @@ -133,48 +132,56 @@ public function namedRoutesShouldBeRegistered(): void #[PHPUnit\Test] public function cannotDefineRouteWithEmptyName(): void { - $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + $r = self::routeCollector(); $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => '']); } #[PHPUnit\Test] public function cannotDefineRouteWithInvalidTypeAsName(): void { - $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + $r = self::routeCollector(); $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => false]); } #[PHPUnit\Test] public function cannotDefineDuplicatedRouteName(): void { - $r = new RouteCollector(new Std(), self::dummyDataGenerator()); + $r = self::routeCollector(); $this->expectException(BadRouteException::class); + $r->get('/', 'index-handler', ['_name' => 'index']); $r->get('/users/me', 'fetch-user-handler', ['_name' => 'index']); } + private static function routeCollector(): ConfigureRoutes + { + return new RouteCollector(new Std(), self::dummyDataGenerator()); + } + private static function dummyDataGenerator(): DataGenerator { return new class implements DataGenerator { /** @var list}> */ - public array $routes = []; + private array $routes = []; /** @inheritDoc */ public function getData(): array { - return [[], []]; + return [$this->routes, []]; } /** @inheritDoc */ public function addRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters = []): void { - assert(count($routeData) === 1 && is_string($routeData[0])); + TestCase::assertTrue(count($routeData) === 1 && is_string($routeData[0])); $this->routes[] = [$httpMethod, $routeData[0], $handler, $extraParameters]; }