From 61fd0f335422cac39433a433c32b343d257aea8b Mon Sep 17 00:00:00 2001 From: PJ Dietz Date: Wed, 13 May 2015 18:49:49 -0400 Subject: [PATCH] TemplateRoute more throughly implements URI Templates as defined in RFC 6570 Template support: - Simple strings /{var} - Reserved string /{+var} - Multiple variables per expression /{hello,larry} - Dot-prefixes /{.filename,extension} - Slash-prefiex {/path,to,here} - Explosion {/paths*}, /cats/{ids*} explode to list arrays --- src/Routing/Route/TemplateRoute.php | 158 +++++++-- .../unit/Routing/Route/TemplateRouteTest.php | 321 +++++++++++++----- 2 files changed, 360 insertions(+), 119 deletions(-) diff --git a/src/Routing/Route/TemplateRoute.php b/src/Routing/Route/TemplateRoute.php index 4838239..8361532 100644 --- a/src/Routing/Route/TemplateRoute.php +++ b/src/Routing/Route/TemplateRoute.php @@ -2,54 +2,160 @@ namespace WellRESTed\Routing\Route; -class TemplateRoute extends RegexRoute +class TemplateRoute extends Route { + private $pathVariables; + private $explosions; + /** * Regular expression matching 1 or more unreserved characters. * ALPHA / DIGIT / "-" / "." / "_" / "~" */ - const RE_UNRESERVED = '[0-9a-zA-Z\-._\~]+'; + const RE_UNRESERVED = '[0-9a-zA-Z\-._\~%]*'; /** Regular expression matching a URI template variable (e.g., {id}) */ - const URI_TEMPLATE_EXPRESSION_RE = '/{([[a-zA-Z][a-zA-Z0-_]*)}/'; + const URI_TEMPLATE_EXPRESSION_RE = '/{([+.\/]?[a-zA-Z0-9_,]+\*?)}/'; - public function __construct($target, $methodMap) + public function getType() { - $pattern = $this->buildPattern($target); - parent::__construct($pattern, $methodMap); + return RouteInterface::TYPE_PATTERN; + } + + public function getPathVariables() + { + return $this->pathVariables ?: []; } /** - * Translate the URI template into a regular expression. + * Examines a request target to see if it is a match for the route. * - * @param string $template URI template the path must match - * @return string + * @param string $requestTarget + * @return boolean */ - private function buildPattern($template) + public function matchesRequestTarget($requestTarget) + { + $this->pathVariables = []; + $this->explosions = []; + + if (!$this->matchesStartOfRequestTarget($requestTarget)) { + return false; + } + + $matchingPattern = $this->getMatchingPattern(); + + if (preg_match($matchingPattern, $requestTarget, $captures)) { + $this->pathVariables = $this->processMatches($captures); + return true; + } + return false; + } + + private function matchesStartOfRequestTarget($requestTarget) + { + $firstVarPos = strpos($this->target, "{"); + return (substr($requestTarget, 0, $firstVarPos) === substr($this->target, 0, $firstVarPos)); + } + + private function processMatches($matches) + { + $variables = []; + + // Isolate the named captures. + $keys = array_filter(array_keys($matches), "is_string"); + + // Store named captures to the variables. + foreach ($keys as $key) { + + $value = $matches[$key]; + + if (isset($this->explosions[$key])) { + $values = explode($this->explosions[$key], $value); + $variables[$key] = array_map("urldecode", $values); + } else { + $value = urldecode($value); + $variables[$key] = $value; + } + + } + + return $variables; + } + + private function getMatchingPattern() { // Convert the template into the pattern - $pattern = $template; + $pattern = $this->target; // Escape allowable characters with regex meaning. - $pattern = str_replace( - array("-", "."), - array("\\-", "\\."), - $pattern); - - // Replace * with .* AFTER escaping to avoid escaping .* - $pattern = str_replace("*", ".*", $pattern); + $escape = [ + "." => "\\.", + "-" => "\\-", + "+" => "\\+", + "*" => "\\*" + ]; + $pattern = str_replace(array_keys($escape), array_values($escape), $pattern); + $unescape = [ + "{\\+" => "{+", + "{\\." => "{.", + "\\*}" => "*}" + ]; + $pattern = str_replace(array_keys($unescape), array_values($unescape), $pattern); // Surround the pattern with delimiters. $pattern = "~^{$pattern}$~"; - // Replace all template variables with matching subpatterns. - $callback = function ($matches) { - $key = $matches[1]; - // TODO Check for reserved characters, etc. - $pattern = self::RE_UNRESERVED; - return "(?<{$key}>{$pattern})"; - }; - $pattern = preg_replace_callback(self::URI_TEMPLATE_EXPRESSION_RE, $callback, $pattern); + $pattern = preg_replace_callback( + self::URI_TEMPLATE_EXPRESSION_RE, + [$this, "uriVariableReplacementCallback"], + $pattern + ); return $pattern; } + + private function uriVariableReplacementCallback($matches) + { + $name = $matches[1]; + $pattern = self::RE_UNRESERVED; + + $prefix = ""; + $delimiter = ","; + $explodeDelimiter = ","; + + // Read the first character as an operator. This determines which + // characters to allow in the match. + $operator = $name[0]; + + switch ($operator) { + case "+": + $name = substr($name, 1); + $pattern = ".*"; + break; + case ".": + $name = substr($name, 1); + $prefix = "\\."; + $delimiter = "\\."; + $explodeDelimiter = "."; + break; + case "/": + $name = substr($name, 1); + $prefix = "\\/"; + $delimiter = "\\/"; + $explodeDelimiter = "/"; + break; + } + + // Explosion + if (substr($name, -1, 1) === "*") { + $name = substr($name, 0, -1); + $pattern = ".*"; + $this->explosions[$name] = $explodeDelimiter; + } + + $names = explode(",", $name); + $results = []; + foreach ($names as $name) { + $results[] = "(?<{$name}>{$pattern})"; + } + return $prefix . join($delimiter, $results); + } } diff --git a/test/tests/unit/Routing/Route/TemplateRouteTest.php b/test/tests/unit/Routing/Route/TemplateRouteTest.php index f3e69a2..cb0785e 100644 --- a/test/tests/unit/Routing/Route/TemplateRouteTest.php +++ b/test/tests/unit/Routing/Route/TemplateRouteTest.php @@ -7,7 +7,8 @@ use WellRESTed\Routing\Route\RouteInterface; use WellRESTed\Routing\Route\TemplateRoute; /** - * @covers WellRESTed\Routing\Route\TemplateRoute + * @coversDefaultClass WellRESTed\Routing\Route\TemplateRoute + * @uses WellRESTed\Routing\Route\TemplateRoute * @uses WellRESTed\Routing\Route\RegexRoute * @uses WellRESTed\Routing\Route\Route * @group route @@ -22,8 +23,34 @@ class TemplateRouteTest extends \PHPUnit_Framework_TestCase $this->methodMap = $this->prophesize('WellRESTed\Routing\MethodMapInterface'); } + private function getExpectedValues($keys) + { + $expectedValues = [ + "var" => "value", + "hello" => "Hello World!", + "x" => "1024", + "y" => "768", + "path" => "/foo/bar", + "who" => "fred", + "half" => "50%", + "empty" => "", + "count" => ["one", "two", "three"], + "list" => ["red", "green", "blue"] + ]; + return array_intersect_key($expectedValues, array_flip($keys)); + } + + private function assertArrayHasSameContents($expected, $actual) + { + ksort($expected); + ksort($actual); + $this->assertEquals($expected, $actual); + } + + // ------------------------------------------------------------------------ + /** - * @coversNothing + * @covers ::getType */ public function testReturnsPatternType() { @@ -31,133 +58,241 @@ class TemplateRouteTest extends \PHPUnit_Framework_TestCase $this->assertSame(RouteInterface::TYPE_PATTERN, $route->getType()); } - /** - * @dataProvider matchingTemplateProvider - */ - public function testMatchesTemplate($template, $requestTarget) - { - $route = new TemplateRoute($template, $this->methodMap->reveal()); - $this->assertTrue($route->matchesRequestTarget($requestTarget)); - } + // ------------------------------------------------------------------------ + // Matching /** - * @dataProvider matchingTemplateProvider + * @covers ::matchesRequestTarget + * @covers ::matchesStartOfRequestTarget + * @covers ::getMatchingPattern + * @dataProvider nonMatchingTargetProvider + * @param string $template + * @param string $target */ - public function testProvidesCapturesAsRequestAttributes($template, $path, $expectedCaptures) + public function testFailsToMatchNonMatchingTarget($template, $target) { - $request = $this->prophesize('Psr\Http\Message\ServerRequestInterface'); - $request->withAttribute(Argument::cetera())->willReturn($request->reveal()); - $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); - $next = function ($request, $response) { - return $response; - }; - - $route = new TemplateRoute($template, $this->methodMap->reveal()); - $route->matchesRequestTarget($path); - $route->dispatch($request->reveal(), $response->reveal(), $next); - - $request->withAttribute("uriVariables", Argument::that(function ($path) use ($expectedCaptures) { - return array_intersect_assoc($path, $expectedCaptures) == $expectedCaptures; - }))->shouldHaveBeenCalled(); + $route = new TemplateRoute($template, $this->methodMap); + $this->assertFalse($route->matchesRequestTarget($target)); } - public function matchingTemplateProvider() + public function nonMatchingTargetProvider() { return [ - ["/cat/{id}", "/cat/12", ["id" => "12"]], - ["/unreserved/{id}", "/unreserved/az0-._~", ["id" => "az0-._~"]], - ["/cat/{catId}/{dogId}", - "/cat/molly/bear", - [ - "catId" => "molly", - "dogId" => "bear" - ] - ], - [ - "/cat/{catId}/{dogId}", - "/cat/molly/bear", - [ - "catId" => "molly", - "dogId" => "bear" - ] - ], - ["/cat/{id}/*", "/cat/12/molly", ["id" => "12"]], - [ - "/cat/{id}-{width}x{height}.jpg", - "/cat/17-200x100.jpg", - [ - "id" => "17", - "width" => "200", - "height" => "100" - ] - ] + ["/foo/{var}", "/bar/12", false, "Mismatch before first template expression"], + ["/foo/{foo}/bar/{bar}", "/foo/12/13", false, "Mismatch after first template expression"], + ["/hello/{hello}", "/hello/Hello%20World!", false, "Requires + operator to match reserver characters"] ]; } - /** - * @dataProvider allowedVariableNamesProvider - */ - public function testMatchesAllowedVariablesNames($template, $path, $expectedCaptures) - { - $request = $this->prophesize('Psr\Http\Message\ServerRequestInterface'); - $request->withAttribute(Argument::cetera())->willReturn($request->reveal()); - $response = $this->prophesize('Psr\Http\Message\ResponseInterface'); - $next = function ($request, $response) { - return $response; - }; - $route = new TemplateRoute($template, $this->methodMap->reveal()); - $route->matchesRequestTarget($path); - $route->dispatch($request->reveal(), $response->reveal(), $next); + // ------------------------------------------------------------------------ + // Matching :: Simple Strings - $request->withAttribute("uriVariables", Argument::that(function ($path) use ($expectedCaptures) { - return array_intersect_assoc($path, $expectedCaptures) == $expectedCaptures; - }))->shouldHaveBeenCalled(); + /** + * @covers ::matchesRequestTarget + * @covers ::getMatchingPattern + * @covers ::uriVariableReplacementCallback + * @dataProvider simpleStringProvider + * @param string $template + * @param string $target + */ + public function testMatchesSimpleStrings($template, $target) + { + $route = new TemplateRoute($template, $this->methodMap); + $this->assertTrue($route->matchesRequestTarget($target)); } - public function allowedVariableNamesProvider() + /** + * @covers ::getPathVariables + * @covers ::processMatches + * @covers ::uriVariableReplacementCallback + * @dataProvider simpleStringProvider + * @param string $template + * @param string $target + * @param string[] List of variables that should be extracted + */ + public function testCapturesFromSimpleStrings($template, $target, $variables) + { + $route = new TemplateRoute($template, $this->methodMap); + $route->matchesRequestTarget($target); + $this->assertArrayHasSameContents($this->getExpectedValues($variables), $route->getPathVariables()); + } + + public function simpleStringProvider() { return [ - ["/{n}", "/lower", ["n" => "lower"]], - ["/{N}", "/UPPER", ["N" => "UPPER"]], - ["/{var1024}", "/digits", ["var1024" => "digits"]], - ["/{variable_name}", "/underscore", ["variable_name" => "underscore"]], + ["/foo", "/foo", []], + ["/{var}", "/value", ["var"]], + ["/{hello}", "/Hello%20World%21", ["hello"]], + ["/{x,hello,y}", "/1024,Hello%20World%21,768", ["x", "hello", "y"]], + ["/{x,hello,y}", "/1024,Hello%20World%21,768", ["x", "hello", "y"]], ]; } + // ------------------------------------------------------------------------ + // Matching :: Reservered + /** - * @dataProvider illegalVariableNamesProvider + * @covers ::matchesRequestTarget + * @covers ::getMatchingPattern + * @covers ::uriVariableReplacementCallback + * @dataProvider reservedStringProvider + * @param string $template + * @param string $target */ - public function testFailsToMatchIllegalVariablesNames($template, $path) + public function testMatchesReserveredStrings($template, $target) { - $route = new TemplateRoute($template, $this->methodMap->reveal()); - $this->assertFalse($route->matchesRequestTarget($path)); + $route = new TemplateRoute($template, $this->methodMap); + $this->assertTrue($route->matchesRequestTarget($target)); } - public function illegalVariableNamesProvider() + /** + * @covers ::getPathVariables + * @covers ::processMatches + * @covers ::uriVariableReplacementCallback + * @dataProvider reservedStringProvider + * @param string $template + * @param string $target + * @param string[] List of variables that should be extracted + */ + public function testCapturesFromReservedStrings($template, $target, $variables) + { + $route = new TemplateRoute($template, $this->methodMap); + $route->matchesRequestTarget($target); + $this->assertSame($this->getExpectedValues($variables), $route->getPathVariables()); + } + + public function reservedStringProvider() { return [ - ["/{not-legal}", "/hyphen"], - ["/{1digitfirst}", "/digitfirst"], - ["/{%2f}", "/percent-encoded"], - ["/{}", "/empty"], - ["/{{nested}}", "/nested"] + ["/{+var}", "/value", ["var"]], + ["/{+hello}", "/Hello%20World!", ["hello"]], + ["{+path}/here", "/foo/bar/here", ["path"]], ]; } + // ------------------------------------------------------------------------ + // Matching :: Label Expansion + /** - * @dataProvider nonmatchingTemplateProvider + * @covers ::matchesRequestTarget + * @covers ::getMatchingPattern + * @covers ::uriVariableReplacementCallback + * @dataProvider labelWithDotPrefixProvider + * @param string $template + * @param string $target */ - public function testFailsToMatchNonmatchingTemplate($template, $path) + public function testMatchesLabelWithDotPrefix($template, $target) { - $route = new TemplateRoute($template, $this->methodMap->reveal()); - $this->assertFalse($route->matchesRequestTarget($path)); + $route = new TemplateRoute($template, $this->methodMap); + $this->assertTrue($route->matchesRequestTarget($target)); } - public function nonmatchingTemplateProvider() + /** + * @covers ::getPathVariables + * @covers ::processMatches + * @covers ::uriVariableReplacementCallback + * @dataProvider labelWithDotPrefixProvider + * @param string $template + * @param string $target + * @param string[] List of variables that should be extracted + */ + public function testCapturesFromLabelWithDotPrefix($template, $target, $variables) + { + $route = new TemplateRoute($template, $this->methodMap); + $route->matchesRequestTarget($target); + $this->assertArrayHasSameContents($this->getExpectedValues($variables), $route->getPathVariables()); + } + + public function labelWithDotPrefixProvider() { return [ - ["/cat/{id}", "/cat/molly/the/cat"], - ["/cat/{catId}/{dogId}", "/dog/12/13"] + ["/{.who}", "/.fred", ["who"]], + ["/{.half,who}", "/.50%25.fred", ["half", "who"]], + ["/X{.empty}", "/X.", ["empty"]] + ]; + } + + // ------------------------------------------------------------------------ + // Matching :: Path Segments + + /** + * @covers ::matchesRequestTarget + * @covers ::getMatchingPattern + * @covers ::uriVariableReplacementCallback + * @dataProvider pathSegmentProvider + * @param string $template + * @param string $target + */ + public function testMatchesPathSegments($template, $target) + { + $route = new TemplateRoute($template, $this->methodMap); + $this->assertTrue($route->matchesRequestTarget($target)); + } + + /** + * @covers ::getPathVariables + * @covers ::processMatches + * @covers ::uriVariableReplacementCallback + * @dataProvider pathSegmentProvider + * @param string $template + * @param string $target + * @param string[] List of variables that should be extracted + */ + public function testCapturesFromPathSegments($template, $target, $variables) + { + $route = new TemplateRoute($template, $this->methodMap); + $route->matchesRequestTarget($target); + $this->assertArrayHasSameContents($this->getExpectedValues($variables), $route->getPathVariables()); + } + + public function pathSegmentProvider() + { + return [ + ["{/who}", "/fred", ["who"]], + ["{/half,who}", "/50%25/fred", ["half", "who"]], + ["{/var,empty}", "/value/", ["var", "empty"]] + ]; + } + + // ------------------------------------------------------------------------ + // Matching :: Explosion + + /** + * @covers ::matchesRequestTarget + * @covers ::getMatchingPattern + * @covers ::uriVariableReplacementCallback + * @dataProvider pathExplosionProvider + * @param string $template + * @param string $target + */ + public function testMatchesExplosion($template, $target) + { + $route = new TemplateRoute($template, $this->methodMap); + $this->assertTrue($route->matchesRequestTarget($target)); + } + + /** + * @covers ::getPathVariables + * @covers ::processMatches + * @covers ::uriVariableReplacementCallback + * @dataProvider pathExplosionProvider + * @param string $template + * @param string $target + * @param string[] List of variables that should be extracted + */ + public function testCapturesFromExplosion($template, $target, $variables) + { + $route = new TemplateRoute($template, $this->methodMap); + $route->matchesRequestTarget($target); + $this->assertArrayHasSameContents($this->getExpectedValues($variables), $route->getPathVariables()); + } + + public function pathExplosionProvider() + { + return [ + ["/{count*}", "/one,two,three", ["count"]], + ["{/count*}", "/one/two/three", ["count"]], + ["X{.list*}", "X.red.green.blue", ["list"]] ]; } }