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;
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);
}
}

View File

@ -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"]
];
}
// ------------------------------------------------------------------------
// Matching :: Simple Strings
/**
* @dataProvider allowedVariableNamesProvider
* @covers ::matchesRequestTarget
* @covers ::getMatchingPattern
* @covers ::uriVariableReplacementCallback
* @dataProvider simpleStringProvider
* @param string $template
* @param string $target
*/
public function testMatchesAllowedVariablesNames($template, $path, $expectedCaptures)
public function testMatchesSimpleStrings($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();
}
public function allowedVariableNamesProvider()
{
return [
["/{n}", "/lower", ["n" => "lower"]],
["/{N}", "/UPPER", ["N" => "UPPER"]],
["/{var1024}", "/digits", ["var1024" => "digits"]],
["/{variable_name}", "/underscore", ["variable_name" => "underscore"]],
];
$route = new TemplateRoute($template, $this->methodMap);
$this->assertTrue($route->matchesRequestTarget($target));
}
/**
* @dataProvider illegalVariableNamesProvider
* @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 testFailsToMatchIllegalVariablesNames($template, $path)
public function testCapturesFromSimpleStrings($template, $target, $variables)
{
$route = new TemplateRoute($template, $this->methodMap->reveal());
$this->assertFalse($route->matchesRequestTarget($path));
$route = new TemplateRoute($template, $this->methodMap);
$route->matchesRequestTarget($target);
$this->assertArrayHasSameContents($this->getExpectedValues($variables), $route->getPathVariables());
}
public function illegalVariableNamesProvider()
public function simpleStringProvider()
{
return [
["/{not-legal}", "/hyphen"],
["/{1digitfirst}", "/digitfirst"],
["/{%2f}", "/percent-encoded"],
["/{}", "/empty"],
["/{{nested}}", "/nested"]
["/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
/**
* @covers ::matchesRequestTarget
* @covers ::getMatchingPattern
* @covers ::uriVariableReplacementCallback
* @dataProvider reservedStringProvider
* @param string $template
* @param string $target
*/
public function testMatchesReserveredStrings($template, $target)
{
$route = new TemplateRoute($template, $this->methodMap);
$this->assertTrue($route->matchesRequestTarget($target));
}
/**
* @dataProvider nonmatchingTemplateProvider
* @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 testFailsToMatchNonmatchingTemplate($template, $path)
public function testCapturesFromReservedStrings($template, $target, $variables)
{
$route = new TemplateRoute($template, $this->methodMap->reveal());
$this->assertFalse($route->matchesRequestTarget($path));
$route = new TemplateRoute($template, $this->methodMap);
$route->matchesRequestTarget($target);
$this->assertSame($this->getExpectedValues($variables), $route->getPathVariables());
}
public function nonmatchingTemplateProvider()
public function reservedStringProvider()
{
return [
["/cat/{id}", "/cat/molly/the/cat"],
["/cat/{catId}/{dogId}", "/dog/12/13"]
["/{+var}", "/value", ["var"]],
["/{+hello}", "/Hello%20World!", ["hello"]],
["{+path}/here", "/foo/bar/here", ["path"]],
];
}
// ------------------------------------------------------------------------
// Matching :: Label Expansion
/**
* @covers ::matchesRequestTarget
* @covers ::getMatchingPattern
* @covers ::uriVariableReplacementCallback
* @dataProvider labelWithDotPrefixProvider
* @param string $template
* @param string $target
*/
public function testMatchesLabelWithDotPrefix($template, $target)
{
$route = new TemplateRoute($template, $this->methodMap);
$this->assertTrue($route->matchesRequestTarget($target));
}
/**
* @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 [
["/{.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"]]
];
}
}