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
This commit is contained in:
PJ Dietz 2015-05-13 18:49:49 -04:00
parent 1bb93434b2
commit 61fd0f3354
2 changed files with 360 additions and 119 deletions

View File

@ -2,54 +2,160 @@
namespace WellRESTed\Routing\Route; namespace WellRESTed\Routing\Route;
class TemplateRoute extends RegexRoute class TemplateRoute extends Route
{ {
private $pathVariables;
private $explosions;
/** /**
* Regular expression matching 1 or more unreserved characters. * Regular expression matching 1 or more unreserved characters.
* ALPHA / DIGIT / "-" / "." / "_" / "~" * 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}) */ /** 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); return RouteInterface::TYPE_PATTERN;
parent::__construct($pattern, $methodMap); }
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 * @param string $requestTarget
* @return string * @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 // Convert the template into the pattern
$pattern = $template; $pattern = $this->target;
// Escape allowable characters with regex meaning. // Escape allowable characters with regex meaning.
$pattern = str_replace( $escape = [
array("-", "."), "." => "\\.",
array("\\-", "\\."), "-" => "\\-",
$pattern); "+" => "\\+",
"*" => "\\*"
// Replace * with .* AFTER escaping to avoid escaping .* ];
$pattern = str_replace("*", ".*", $pattern); $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. // Surround the pattern with delimiters.
$pattern = "~^{$pattern}$~"; $pattern = "~^{$pattern}$~";
// Replace all template variables with matching subpatterns. $pattern = preg_replace_callback(
$callback = function ($matches) { self::URI_TEMPLATE_EXPRESSION_RE,
$key = $matches[1]; [$this, "uriVariableReplacementCallback"],
// TODO Check for reserved characters, etc. $pattern
$pattern = self::RE_UNRESERVED; );
return "(?<{$key}>{$pattern})";
};
$pattern = preg_replace_callback(self::URI_TEMPLATE_EXPRESSION_RE, $callback, $pattern);
return $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);
}
} }

View File

@ -7,7 +7,8 @@ use WellRESTed\Routing\Route\RouteInterface;
use WellRESTed\Routing\Route\TemplateRoute; 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\RegexRoute
* @uses WellRESTed\Routing\Route\Route * @uses WellRESTed\Routing\Route\Route
* @group route * @group route
@ -22,8 +23,34 @@ class TemplateRouteTest extends \PHPUnit_Framework_TestCase
$this->methodMap = $this->prophesize('WellRESTed\Routing\MethodMapInterface'); $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() public function testReturnsPatternType()
{ {
@ -31,133 +58,241 @@ class TemplateRouteTest extends \PHPUnit_Framework_TestCase
$this->assertSame(RouteInterface::TYPE_PATTERN, $route->getType()); $this->assertSame(RouteInterface::TYPE_PATTERN, $route->getType());
} }
/** // ------------------------------------------------------------------------
* @dataProvider matchingTemplateProvider // Matching
*/
public function testMatchesTemplate($template, $requestTarget)
{
$route = new TemplateRoute($template, $this->methodMap->reveal());
$this->assertTrue($route->matchesRequestTarget($requestTarget));
}
/** /**
* @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'); $route = new TemplateRoute($template, $this->methodMap);
$request->withAttribute(Argument::cetera())->willReturn($request->reveal()); $this->assertFalse($route->matchesRequestTarget($target));
$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();
} }
public function matchingTemplateProvider() public function nonMatchingTargetProvider()
{ {
return [ return [
["/cat/{id}", "/cat/12", ["id" => "12"]], ["/foo/{var}", "/bar/12", false, "Mismatch before first template expression"],
["/unreserved/{id}", "/unreserved/az0-._~", ["id" => "az0-._~"]], ["/foo/{foo}/bar/{bar}", "/foo/12/13", false, "Mismatch after first template expression"],
["/cat/{catId}/{dogId}", ["/hello/{hello}", "/hello/Hello%20World!", false, "Requires + operator to match reserver characters"]
"/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"
]
]
]; ];
} }
/** // ------------------------------------------------------------------------
* @dataProvider allowedVariableNamesProvider // Matching :: Simple Strings
*/
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);
$request->withAttribute("uriVariables", Argument::that(function ($path) use ($expectedCaptures) { /**
return array_intersect_assoc($path, $expectedCaptures) == $expectedCaptures; * @covers ::matchesRequestTarget
}))->shouldHaveBeenCalled(); * @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 [ return [
["/{n}", "/lower", ["n" => "lower"]], ["/foo", "/foo", []],
["/{N}", "/UPPER", ["N" => "UPPER"]], ["/{var}", "/value", ["var"]],
["/{var1024}", "/digits", ["var1024" => "digits"]], ["/{hello}", "/Hello%20World%21", ["hello"]],
["/{variable_name}", "/underscore", ["variable_name" => "underscore"]], ["/{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()); $route = new TemplateRoute($template, $this->methodMap);
$this->assertFalse($route->matchesRequestTarget($path)); $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 [ return [
["/{not-legal}", "/hyphen"], ["/{+var}", "/value", ["var"]],
["/{1digitfirst}", "/digitfirst"], ["/{+hello}", "/Hello%20World!", ["hello"]],
["/{%2f}", "/percent-encoded"], ["{+path}/here", "/foo/bar/here", ["path"]],
["/{}", "/empty"],
["/{{nested}}", "/nested"]
]; ];
} }
// ------------------------------------------------------------------------
// 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()); $route = new TemplateRoute($template, $this->methodMap);
$this->assertFalse($route->matchesRequestTarget($path)); $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 [ return [
["/cat/{id}", "/cat/molly/the/cat"], ["/{.who}", "/.fred", ["who"]],
["/cat/{catId}/{dogId}", "/dog/12/13"] ["/{.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"]]
]; ];
} }
} }