diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a87333..4f76205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), No unreleased changes. +## [2.0.0] - 2022-11-23 + +### Changed + +- Changed the structure of the route array: `id` must now be included in the array. + ## [1.0.1] - 2022-10-22 ### Added @@ -22,6 +28,7 @@ No unreleased changes. First stable release. -[unreleased]: https://github.com/danbettles/marigold/compare/v1.0.1...HEAD +[unreleased]: https://github.com/danbettles/marigold/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/danbettles/marigold/compare/v1.0.1...v2.0.0 [1.0.1]: https://github.com/danbettles/marigold/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/danbettles/marigold/releases/tag/v1.0.0 diff --git a/src/Router.php b/src/Router.php index 10e0655..6bac05a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -30,14 +30,15 @@ * An array of routes looks like: * * [ - * 'posts' => [ + * [ + * 'id' => 'showBlogPost', * 'path' => '/posts/{postId}', - * 'action' => ['foo', 'bar'], + * 'action' => ['FooBar', 'baz'], * ], + * // ... * ] * - * The key of a route is its 'ID'. `action` can be anything: the name of a method; a callable; whatever's appropriate - * for the app. + * `action` can be anything: the name of a method; a callable; whatever's appropriate for the app. * * A matched route, the return value of `match()`, will have an additional element, `parameters`, containing the values * of any parameters found in the path. @@ -45,25 +46,26 @@ class Router { /** - * @var array{path: null, action: null}> + * @var array{id: null, path: null, action: null} */ private const EMPTY_ROUTE = [ + 'id' => null, 'path' => null, 'action' => null, ]; /** - * @var array + * @var array */ private array $routes; /** - * @var array> + * @var array> */ private array $placeholdersByRouteId = []; /** - * @param array $routes + * @param array $routes */ public function __construct(array $routes) { @@ -76,8 +78,8 @@ private function countPathParts(string $path): int } /** - * @param array $routes - * @return array + * @param array $routes + * @return array */ private function eliminateUnmatchableRoutes(string $path, array $routes): array { @@ -91,7 +93,21 @@ private function eliminateUnmatchableRoutes(string $path, array $routes): array } /** - * @return array{path: string, action: mixed, parameters: string[]}|null + * @param array{id: string, path: string, action: mixed} $baseRoute + * @param array $parameters + * @return array{id: string, path: string, action: mixed, parameters: array} + */ + private function createMatchedRoute( + array $baseRoute, + array $parameters = [] + ): array { + $baseRoute['parameters'] = $parameters; + + return $baseRoute; + } + + /** + * @return array{id: string, path: string, action: mixed, parameters: array}|null * @throws OutOfBoundsException If there is no request URI in the server vars. * @throws InvalidArgumentException If the request URI is invalid. */ @@ -126,9 +142,7 @@ public function match(HttpRequest $request): ?array } if ($path === $route['path']) { - $route['parameters'] = []; - - return $route; + return $this->createMatchedRoute($route); } } @@ -154,21 +168,21 @@ public function match(HttpRequest $request): ?array continue; } - $route['parameters'] = array_filter($pathParameterMatches, '\is_string', ARRAY_FILTER_USE_KEY); - - return $route; + return $this->createMatchedRoute( + $route, + array_filter($pathParameterMatches, '\is_string', ARRAY_FILTER_USE_KEY) + ); } return null; } /** - * @param string|int $routeId * @param array $parameters * @throws OutOfBoundsException If the route does not exist. * @throws InvalidArgumentException If parameter values were missing. */ - public function generatePath($routeId, array $parameters = []): string + public function generatePath(string $routeId, array $parameters = []): string { if (!array_key_exists($routeId, $this->getRoutes())) { throw new OutOfBoundsException("The route, `{$routeId}`, does not exist."); @@ -199,7 +213,7 @@ public function generatePath($routeId, array $parameters = []): string } /** - * @param array $routes + * @param array $routes * @throws InvalidArgumentException If there are no routes. * @throws InvalidArgumentException If a route is missing elements. */ @@ -211,7 +225,7 @@ private function setRoutes(array $routes): self $numExpectedRouteEls = count(self::EMPTY_ROUTE); - foreach ($routes as $id => $route) { + foreach ($routes as $i => $route) { $filteredRoute = array_intersect_key($route, self::EMPTY_ROUTE); // In reality, some routes may not be what we were hoping for, hence why we need to adjust PHPStan's @@ -219,19 +233,21 @@ private function setRoutes(array $routes): self /** @phpstan-var mixed[] $filteredRoute */ if ($numExpectedRouteEls !== count($filteredRoute)) { throw new InvalidArgumentException( - "Route `{$id}` is missing elements. Required: " . implode(', ', array_keys(self::EMPTY_ROUTE)) . '.' + "The route at index `{$i}` is missing elements. " . + 'Required: ' . implode(', ', array_keys(self::EMPTY_ROUTE)) . '.' ); } - /** @var array{path: string, action: mixed} $filteredRoute */ - $this->routes[$id] = $filteredRoute; + $routeId = $route['id']; + /** @var array{id: string, path: string, action: mixed} $filteredRoute */ + $this->routes[$routeId] = $filteredRoute; } return $this; } /** - * @return array + * @return array */ public function getRoutes(): array { @@ -239,10 +255,9 @@ public function getRoutes(): array } /** - * @param string|int $routeId * @return array */ - private function getRoutePlaceholders($routeId): array + private function getRoutePlaceholders(string $routeId): array { if (!array_key_exists($routeId, $this->placeholdersByRouteId)) { $route = $this->getRoutes()[$routeId]; diff --git a/tests/src/RouterTest.php b/tests/src/RouterTest.php index dbfcd85..6f9af9d 100755 --- a/tests/src/RouterTest.php +++ b/tests/src/RouterTest.php @@ -22,9 +22,10 @@ private function createHttpRequest(array $serverVars): HttpRequest private function createRouterWithPostsRoute(): Router { return new Router([ - 'posts' => [ + [ + 'id' => 'blogPostsIndex', 'path' => '/posts', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ]); } @@ -32,16 +33,23 @@ private function createRouterWithPostsRoute(): Router public function testIsInstantiable(): void { - $routes = [ - 'posts' => [ - 'path' => '/posts', - 'action' => ['FooBar', 'baz'], - ], + $route = [ + 'id' => 'blogPostsIndex', + 'path' => '/posts', + 'action' => 'anything', ]; - $router = new Router($routes); + $actualRoutes = [ + $route, + ]; + + $expectedRoutes = [ + ($route['id']) => $route, + ]; - $this->assertSame($routes, $router->getRoutes()); + $router = new Router($actualRoutes); + + $this->assertSame($expectedRoutes, $router->getRoutes()); } public function testThrowsAnExceptionIfThereAreNoRoutes(): void @@ -57,33 +65,37 @@ public function providesRoutesContainingInvalid(): array { return [ [ - 'invalid', + 0, [ - 'invalid' => [ + [ + 'id' => 'invalid', // `path` missing. - 'action' => ['Foo', 'bar'], + 'action' => 'anything', ], ], ], [ - 'invalid', + 0, [ - 'invalid' => [ + [ + 'id' => 'invalid', 'path' => '/something', // `action` missing. ], ], ], [ - 'invalid', + 1, [ - 'valid' => [ + [ + 'id' => 'valid', 'path' => '/something', - 'action' => ['Foo', 'bar'], + 'action' => 'anything', ], - 'invalid' => [ + [ + 'id' => 'invalid', // `path` missing. - 'action' => ['Foo', 'bar'], + 'action' => 'anything', ], ], ], @@ -92,14 +104,14 @@ public function providesRoutesContainingInvalid(): array /** * @dataProvider providesRoutesContainingInvalid - * @param array $routesContainingInvalid (Using the valid type to silence PHPStan.) + * @param array $routesContainingInvalid (Using the valid type to silence PHPStan.) */ public function testThrowsAnExceptionIfARouteIsInvalid( - string $invalidRouteId, + int $routeIndex, array $routesContainingInvalid ): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Route `{$invalidRouteId}` is missing elements. Required: path, action."); + $this->expectExceptionMessage("The route at index `{$routeIndex}` is missing elements. Required: id, path, action."); new Router($routesContainingInvalid); } @@ -110,18 +122,21 @@ public function providesMatchableRoutes(): array return [ [ [ + 'id' => 'homepage', 'path' => '/', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', 'parameters' => [], ], [ [ + 'id' => 'homepage', 'path' => '/', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], [ - 'path' => '/path', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'articlesIndex', + 'path' => '/articles', + 'action' => 'anything', ], ], $this->createHttpRequest(['REQUEST_URI' => '/']), @@ -129,116 +144,134 @@ public function providesMatchableRoutes(): array // #1: Trailing slashes are significant: [ [ - 'path' => '/path', - 'action' => ['FooBar', 'baz'], + 'id' => 'pageCalledArticles', + 'path' => '/articles', + 'action' => 'anything', 'parameters' => [], ], [ [ - 'path' => '/path', - 'action' => ['FooBar', 'baz'], + 'id' => 'pageCalledArticles', + 'path' => '/articles', + 'action' => 'anything', ], [ - 'path' => '/path/', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'articlesIndex', + 'path' => '/articles/', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/path?arg=value']), + $this->createHttpRequest(['REQUEST_URI' => '/articles?name=value']), ], // #2: Trailing slashes are significant: [ [ - 'path' => '/path', - 'action' => ['FooBar', 'baz'], + 'id' => 'pageCalledArticles', + 'path' => '/articles', + 'action' => 'anything', 'parameters' => [], ], [ - [ - 'path' => '/path/', // Still isn't a match. - 'action' => ['QuxQuux', 'corge'], + [ // Still isn't a match. + 'id' => 'articlesIndex', + 'path' => '/articles/', + 'action' => 'anything', ], [ - 'path' => '/path', - 'action' => ['FooBar', 'baz'], + 'id' => 'pageCalledArticles', + 'path' => '/articles', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/path?arg=value']), + $this->createHttpRequest(['REQUEST_URI' => '/articles?name=value']), ], // Placeholders: [ [ - 'path' => '/posts/{postId}', - 'action' => ['QuxQuux', 'corge'], - 'parameters' => ['postId' => 'the-quick-brown-fox'], + 'id' => 'showArticle', + 'path' => '/articles/{id}', + 'action' => 'anything', + 'parameters' => ['id' => 'the-quick-brown-fox'], ], [ [ - 'path' => '/posts', - 'action' => ['FooBar', 'baz'], + 'id' => 'articlesIndex', + 'path' => '/articles', + 'action' => 'anything', ], [ - 'path' => '/posts/{postId}', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'showArticle', + 'path' => '/articles/{id}', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/posts/the-quick-brown-fox?foo=bar']), + $this->createHttpRequest(['REQUEST_URI' => '/articles/the-quick-brown-fox?foo=bar']), ], // Trailing slashes are significant: [ [ - 'path' => '/posts/{postId}/', - 'action' => ['QuxQuux', 'corge'], - 'parameters' => ['postId' => 'the-quick-brown-fox'], + 'id' => 'showArticleWithTrailingSlash', + 'path' => '/articles/{id}/', + 'action' => 'anything', + 'parameters' => ['id' => 'the-quick-brown-fox'], ], [ [ - 'path' => '/posts/{postId}', - 'action' => ['FooBar', 'baz'], + 'id' => 'showArticle', + 'path' => '/articles/{id}', + 'action' => 'anything', ], [ - 'path' => '/posts/{postId}/', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'showArticleWithTrailingSlash', + 'path' => '/articles/{id}/', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/posts/the-quick-brown-fox/']), + $this->createHttpRequest(['REQUEST_URI' => '/articles/the-quick-brown-fox/']), ], // The order of routes is important: [ [ - 'path' => '/posts/{id}', - 'action' => ['FooBar', 'baz'], + 'id' => 'showArticleById', + 'path' => '/articles/{id}', + 'action' => 'anything', 'parameters' => ['id' => 'the-quick-brown-fox'], ], [ [ - 'path' => '/posts/{id}', - 'action' => ['FooBar', 'baz'], + 'id' => 'showArticleById', + 'path' => '/articles/{id}', + 'action' => 'anything', ], [ - 'path' => '/posts/{slug}', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'showArticleBySlug', + 'path' => '/articles/{slug}', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/posts/the-quick-brown-fox']), + $this->createHttpRequest(['REQUEST_URI' => '/articles/the-quick-brown-fox']), ], - // However, exact matches are prioritised: + // *However*, exact matches are prioritised: [ [ - 'path' => '/posts/the-quick-brown-fox', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'theQuickBrownFox', + 'path' => '/articles/the-quick-brown-fox', + 'action' => 'anything', 'parameters' => [], ], [ [ - 'path' => '/posts/{postId}', - 'action' => ['FooBar', 'baz'], + 'id' => 'showArticle', + 'path' => '/articles/{id}', + 'action' => 'anything', ], [ - 'path' => '/posts/the-quick-brown-fox', - 'action' => ['QuxQuux', 'corge'], + 'id' => 'theQuickBrownFox', + 'path' => '/articles/the-quick-brown-fox', + 'action' => 'anything', ], ], - $this->createHttpRequest(['REQUEST_URI' => '/posts/the-quick-brown-fox']), + $this->createHttpRequest(['REQUEST_URI' => '/articles/the-quick-brown-fox']), ], ]; } @@ -246,7 +279,7 @@ public function providesMatchableRoutes(): array /** * @dataProvider providesMatchableRoutes * @param array{path: string, action: mixed, parameters: string[]} $expectedRoute - * @param array $routes + * @param array $routes */ public function testMatchAttemptsToFindAMatchingRoute( array $expectedRoute, @@ -267,12 +300,14 @@ public function providesUnmatchableRoutes(): array [ [ [ + 'id' => 'blogPostsIndex', 'path' => '/posts', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], [ + 'id' => 'showBlogPost', 'path' => '/posts/{postId}', - 'action' => ['QuxQuux', 'corge'], + 'action' => 'anything', ], ], $this->createHttpRequest(['REQUEST_URI' => '/posts/']), // (Trailing slash.) @@ -280,12 +315,14 @@ public function providesUnmatchableRoutes(): array [ [ [ + 'id' => 'blogPostsIndex', 'path' => '/posts', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], [ + 'id' => 'showBlogPost', 'path' => '/posts/{postId}', - 'action' => ['QuxQuux', 'corge'], + 'action' => 'anything', ], ], $this->createHttpRequest(['REQUEST_URI' => '/posts/the-quick-brown-fox/']), // (Trailing slash.) @@ -295,7 +332,7 @@ public function providesUnmatchableRoutes(): array /** * @dataProvider providesUnmatchableRoutes - * @param array $unmatchableRoutes + * @param array $unmatchableRoutes */ public function testMatchReturnsNullIfThereIsNoMatchingRoute( array $unmatchableRoutes, @@ -347,9 +384,10 @@ public function testMatchThrowsAnExceptionIfTheRequestUriIsInvalid(HttpRequest $ public function testGeneratepathGeneratesAUrlPath(): void { $routes = [ - 'fooBar' => [ + [ + 'id' => 'fooBar', 'path' => '/foo/{fooId}/bar/{barId}', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ]; @@ -364,13 +402,14 @@ public function testGeneratepathGeneratesAUrlPath(): void public function testParameterValuesNeedNotBePassedToGeneratepath(): void { $routes = [ - 'posts' => [ + [ + 'id' => 'blogPostsIndex', 'path' => '/posts', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ]; - $path = (new Router($routes))->generatePath('posts'); + $path = (new Router($routes))->generatePath('blogPostsIndex'); $this->assertSame('/posts', $path); } @@ -392,9 +431,10 @@ public function providesIncompleteArgsForGeneratepath(): array return [ [ [ - 'fooBar' => [ + [ + 'id' => 'fooBar', 'path' => '/foo/{fooId}/bar/{barId}', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ], 'fooBar', @@ -402,9 +442,10 @@ public function providesIncompleteArgsForGeneratepath(): array ], [ [ - 'fooBar' => [ + [ + 'id' => 'fooBar', 'path' => '/foo/{fooId}/bar/{barId}', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ], 'fooBar', @@ -412,9 +453,10 @@ public function providesIncompleteArgsForGeneratepath(): array ], [ [ - 'fooBar' => [ + [ + 'id' => 'fooBar', 'path' => '/foo/{fooId}/bar/{barId}', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ], 'fooBar', @@ -422,9 +464,10 @@ public function providesIncompleteArgsForGeneratepath(): array ], [ [ - 'fooBar' => [ + [ + 'id' => 'fooBar', 'path' => '/foo/{fooId}/bar/{barId}', - 'action' => ['FooBar', 'baz'], + 'action' => 'anything', ], ], 'fooBar', @@ -435,7 +478,7 @@ public function providesIncompleteArgsForGeneratepath(): array /** * @dataProvider providesIncompleteArgsForGeneratepath - * @param array $routes + * @param array $routes * @param array $parameters */ public function testGeneratepathThrowsAnExceptionIfInsufficientParametersWerePassed(