From ca220dbe840544a527625fad610d837336f3225f Mon Sep 17 00:00:00 2001 From: Daniel Bettles Date: Sun, 18 Sep 2022 10:48:31 +0100 Subject: [PATCH] Added 'Router::generatePath()'. --- src/Router.php | 111 +++++++++++++++++++++++++++++---------- tests/src/RouterTest.php | 83 ++++++++++++++++++++--------- 2 files changed, 141 insertions(+), 53 deletions(-) diff --git a/src/Router.php b/src/Router.php index 0b39aa6..67b9843 100644 --- a/src/Router.php +++ b/src/Router.php @@ -6,13 +6,16 @@ use InvalidArgumentException; +use function array_combine; use function array_filter; +use function array_intersect_key; use function array_key_exists; use function count; use function explode; use function preg_match; -use function preg_replace; +use function preg_match_all; use function strpos; +use function str_replace; use const ARRAY_FILTER_USE_KEY; use const false; @@ -21,29 +24,34 @@ /** * Maps paths to actions. * - * A route looks like: + * An array of routes looks like: * - * [ - * 'path' => '/posts/{postId}', - * 'action' => ['foo', 'bar'], - * ] + * [ + * 'posts' => [ + * 'path' => '/posts/{postId}', + * 'action' => ['foo', 'bar'], + * ], + * ] * - * `action` can be anything: the name of a method; a callable; whatever. + * The key of a route is its 'ID'. `action` can be anything: the name of a method; a callable; whatever. * * A matched route, the return value of `match()`, will have an additional element, `parameters`, containing the values * of any parameters found in the path. - * - * @todo `generatePath()`. Each route will need a name. */ class Router { /** - * @var array + * @var array */ private array $routes; /** - * @param array $routes + * @var array> + */ + private array $placeholdersByRouteId = []; + + /** + * @param array $routes */ public function __construct(array $routes) { @@ -56,8 +64,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 { @@ -70,14 +78,6 @@ private function eliminateUnmatchableRoutes(string $path, array $routes): array return $filteredRoutes; } - private function createPathRegExpFromRoutePath(string $routePath): string - { - $placeholderNamePattern = '[a-zA-Z]+'; - $regExp = '~^' . preg_replace("~\{($placeholderNamePattern)\}~", '(?P<$1>.+?)', $routePath) . '$~'; - - return $regExp; - } - /** * @param array $serverVars * @return array{path: string, action: mixed, parameters: string[]}|null @@ -103,10 +103,10 @@ public function match(array $serverVars): ?array $routesContainingPlaceholders = []; // Look for exact matches. - foreach ($this->getRoutes() as $route) { + foreach ($this->getRoutes() as $routeId => $route) { // We're looking for *exact* matches, so skip this route if its path contains placeholders. if (false !== strpos($route['path'], '{')) { - $routesContainingPlaceholders[] = $route; + $routesContainingPlaceholders[$routeId] = $route; continue; } @@ -123,8 +123,15 @@ public function match(array $serverVars): ?array return null; } - foreach ($routesToInvestigate as $route) { - $pathRegExp = $this->createPathRegExpFromRoutePath($route['path']); + foreach ($routesToInvestigate as $routeId => $route) { + $pathRegExp = $route['path']; + + foreach ($this->getRoutePlaceholders($routeId) as $parameterName => $placeholder) { + $pathRegExp = str_replace($placeholder, "(?P<{$parameterName}>.+?)", $pathRegExp); + } + + $pathRegExp = '~^' . $pathRegExp . '$~'; + $pathParameterMatches = null; $routeIsAMatch = (bool) preg_match($pathRegExp, $path, $pathParameterMatches); @@ -141,7 +148,32 @@ public function match(array $serverVars): ?array } /** - * @param array $routes + * @param string|int $routeId + * @param array $parameters + * @todo Validation. + */ + public function generatePath($routeId, array $parameters = []): string + { + $route = $this->getRoutes()[$routeId]; + $path = $route['path']; + + if (!$parameters) { + return $path; + } + + $placeholders = $this->getRoutePlaceholders($routeId); + + $filteredParameters = array_intersect_key($parameters, $placeholders); + + foreach ($placeholders as $parameterName => $placeholder) { + $path = str_replace($placeholder, (string) $filteredParameters[$parameterName], $path); + } + + return $path; + } + + /** + * @param array $routes */ private function setRoutes(array $routes): self { @@ -150,10 +182,35 @@ private function setRoutes(array $routes): self } /** - * @return array + * @return array */ public function getRoutes(): array { return $this->routes; } + + /** + * @param string|int $routeId + * @return array + */ + private function getRoutePlaceholders($routeId): array + { + if (!array_key_exists($routeId, $this->placeholdersByRouteId)) { + $route = $this->getRoutes()[$routeId]; + + $placeholderNamePattern = '[a-zA-Z]+'; + $matches = null; + $matched = (bool) preg_match_all("~\{($placeholderNamePattern)\}~", $route['path'], $matches); + + /** @var array */ + $placeholders = $matched + ? array_combine($matches[1], $matches[0]) + : [] + ; + + $this->placeholdersByRouteId[$routeId] = $placeholders; + } + + return $this->placeholdersByRouteId[$routeId]; + } } diff --git a/tests/src/RouterTest.php b/tests/src/RouterTest.php index d611a99..6539c48 100755 --- a/tests/src/RouterTest.php +++ b/tests/src/RouterTest.php @@ -15,7 +15,7 @@ public function testIsInstantiable(): void $routes = [ [ 'path' => '/posts', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], ]; @@ -31,17 +31,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], 'parameters' => [], ], [ [ 'path' => '/', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/path', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/'], @@ -50,17 +50,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/path', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], 'parameters' => [], ], [ [ 'path' => '/path', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/path/', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/path?arg=value'], @@ -69,17 +69,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/path', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], 'parameters' => [], ], [ [ 'path' => '/path/', // Still isn't a match. - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], [ 'path' => '/path', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], ], ['REQUEST_URI' => '/path?arg=value'], @@ -88,17 +88,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/posts/{postId}', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], 'parameters' => ['postId' => 'the-quick-brown-fox'], ], [ [ 'path' => '/posts', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/{postId}', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/the-quick-brown-fox?foo=bar'], @@ -107,17 +107,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/posts/{postId}/', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], 'parameters' => ['postId' => 'the-quick-brown-fox'], ], [ [ 'path' => '/posts/{postId}', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/{postId}/', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/the-quick-brown-fox/'], @@ -126,17 +126,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/posts/{id}', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], 'parameters' => ['id' => 'the-quick-brown-fox'], ], [ [ 'path' => '/posts/{id}', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/{slug}', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/the-quick-brown-fox'], @@ -145,17 +145,17 @@ public function providesMatchedRoutes(): array [ [ 'path' => '/posts/the-quick-brown-fox', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], 'parameters' => [], ], [ [ 'path' => '/posts/{postId}', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/the-quick-brown-fox', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/the-quick-brown-fox'], @@ -189,11 +189,11 @@ public function providesUnmatchableRoutes(): array [ [ 'path' => '/posts', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/{postId}', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/'], // (Trailing slash.) @@ -202,11 +202,11 @@ public function providesUnmatchableRoutes(): array [ [ 'path' => '/posts', - 'action' => ['foo', 'bar'], + 'action' => ['FooBar', 'baz'], ], [ 'path' => '/posts/{postId}', - 'action' => ['baz', 'qux'], + 'action' => ['QuxQuux', 'corge'], ], ], ['REQUEST_URI' => '/posts/the-quick-brown-fox/'], // (Trailing slash.) @@ -266,4 +266,35 @@ public function testMatchThrowsAnExceptionIfTheRequestUriIsInvalid(array $server ->match($serverVars) ; } + + public function testGeneratepathGeneratesAUrlPath(): void + { + $routes = [ + 'fooBar' => [ + 'path' => '/foo/{fooId}/bar/{barId}', + 'action' => ['FooBar', 'baz'], + ], + ]; + + $path = (new Router($routes))->generatePath('fooBar', [ + 'fooId' => 123, + 'barId' => '456', + ]); + + $this->assertSame('/foo/123/bar/456', $path); + } + + public function testGeneratepathDoesNotRequireParameterValues(): void + { + $routes = [ + 'posts' => [ + 'path' => '/posts', + 'action' => ['FooBar', 'baz'], + ], + ]; + + $path = (new Router($routes))->generatePath('posts'); + + $this->assertSame('/posts', $path); + } }