770 lines
17 KiB
PHP
770 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* @package awf
|
|
* @copyright Copyright (c)2014-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
|
|
* @license GNU GPL version 3 or later
|
|
*/
|
|
|
|
namespace Awf\Router;
|
|
|
|
use Awf\Uri\Uri;
|
|
|
|
class Rule
|
|
{
|
|
|
|
/**
|
|
* The routing path to use
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $path = '';
|
|
|
|
/**
|
|
* The named variable types
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $types = array();
|
|
|
|
/**
|
|
* The match vars that have to be satisfied for this rule
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $matchVars = array();
|
|
|
|
/**
|
|
* The push vars to use when parsing this rule
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $pushVars = array();
|
|
|
|
/**
|
|
* The routing callback to use instead of the built-in router
|
|
*
|
|
* @var callable|null
|
|
*/
|
|
protected $routeCallable = null;
|
|
|
|
/**
|
|
* The route parsing callback to use instead of the built-in parser
|
|
*
|
|
* @var callable|null
|
|
*/
|
|
protected $parseCallable = null;
|
|
|
|
/**
|
|
* Should I use a callback for routing URLs?
|
|
*
|
|
* @var boolean
|
|
*/
|
|
protected $useCallableForRoute = false;
|
|
|
|
/**
|
|
* Should I use a callback for parsing URLs?
|
|
*
|
|
* @var boolean
|
|
*/
|
|
protected $useCallableForParse = false;
|
|
|
|
/**
|
|
* Create a routing rule, optionally initialising it from the $definition array. The known keys are:
|
|
* - path @see \Awf\Route\Rule::setPath
|
|
* - types @see \Awf\Route\Rule::setTypes
|
|
* - matchVars @see \Awf\Route\Rule::setMatchVars
|
|
* - pushVars @see \Awf\Route\Rule::setPushVars
|
|
* - routeCallable @see \Awf\Route\Rule::setRouteCallable
|
|
* - parseCallable @see \Awf\Route\Rule::setParseCallable
|
|
*
|
|
* @param array $definition See above
|
|
*
|
|
* @return Rule
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function __construct($definition = array())
|
|
{
|
|
$knownKeys = array('path', 'types', 'matchVars', 'pushVars', 'routeCallable', 'parseCallable');
|
|
|
|
foreach ($knownKeys as $key)
|
|
{
|
|
if (isset($definition[$key]))
|
|
{
|
|
$method = 'set' . ucfirst($key);
|
|
|
|
$this->$method($definition[$key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Routes a non-SEF URL to its SEF counterpart.
|
|
*
|
|
* If this rule is not applicable to this URL we return null
|
|
*
|
|
* If this rule applies to this URL we return a hash array with the keys 'segments' and 'vars' containing the SEF
|
|
* URL's paths and any remaining query string parameters respectively.
|
|
*
|
|
* @param string $url The non-SEF URL
|
|
*
|
|
* @return null|array
|
|
*/
|
|
public function route($url)
|
|
{
|
|
if ($this->useCallableForRoute)
|
|
{
|
|
return call_user_func($this->routeCallable, $url);
|
|
}
|
|
else
|
|
{
|
|
// Extract the query parameters
|
|
$uri = new Uri($url);
|
|
$params = $uri->getQuery(true);
|
|
|
|
// Make sure the "match variables" do match
|
|
if (!$this->matchesVars($params))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Get the route segments
|
|
$segments = $this->buildRoute($params);
|
|
|
|
// If we got null instead of segments we are not an applicable router to this URL
|
|
if (is_null($segments))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Otherwise return the path segments and the remaining vars
|
|
return array(
|
|
'segments' => $segments,
|
|
'vars' => $params,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a SEF URL path into URL parameters
|
|
*
|
|
* @param string $path The path to parse, e.g. /foo/bar/1/2/3
|
|
*
|
|
* @return array|null The URL parameters or null if we can't parse the route with this rule
|
|
*/
|
|
public function parse($path)
|
|
{
|
|
$extraParams = array();
|
|
|
|
if (strpos($path, '?') !== false)
|
|
{
|
|
$uri = new Uri($path);
|
|
$path = $uri->getPath();
|
|
$extraParams = $uri->getQuery(true);
|
|
}
|
|
|
|
if ($this->useCallableForParse)
|
|
{
|
|
$ret = call_user_func($this->parseCallable, $path);
|
|
|
|
if (is_null($ret))
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Explode the path parts
|
|
$segments = explode('/', $path);
|
|
|
|
// Try to extract the URL parameters by parsing the segments
|
|
$params = $this->parseRoute($segments);
|
|
|
|
// If we got null back we can't parse this route
|
|
if (is_null($params))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Mix in the push variables
|
|
$params = array_merge($this->pushVars, $params);
|
|
|
|
// Return the URL parameters
|
|
$ret = $params;
|
|
}
|
|
|
|
return array_merge($ret, $extraParams);
|
|
}
|
|
|
|
/**
|
|
* Set the "match variables" for this routing rule. These variables must be present in the URL being routed for this
|
|
* routing rule to be used. You have to provide a hashed array, e.g.
|
|
* array( 'foo' => 1, 'bar' => null )
|
|
*
|
|
* When a match variable has a non-null value, this exact value MUST be present in the URL to trigger this rule.
|
|
* Moreover this variable will be removed from the query parameters used for building the route.
|
|
*
|
|
* When a match variable has a null value, this rule will be triggered if the variable is present in the URL, no
|
|
* matter what its value is. The variable will be available to the query parameters used for building the route.
|
|
*
|
|
* @param array $matchVars
|
|
*
|
|
* @return void
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function setMatchVars($matchVars)
|
|
{
|
|
$this->matchVars = $matchVars;
|
|
}
|
|
|
|
/**
|
|
* Get the "match variables" for this routing rule
|
|
*
|
|
* @return array
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getMatchVars()
|
|
{
|
|
return $this->matchVars;
|
|
}
|
|
|
|
/**
|
|
* Set the callable to use for parsing routes. The callable must return null if it can't parse the URL (it's not
|
|
* applicable) or an array of the same format as the Rule::parse() method.
|
|
*
|
|
* @param callable|null $parseCallable
|
|
*
|
|
* @return void
|
|
*/
|
|
public function setParseCallable($parseCallable)
|
|
{
|
|
$this->useCallableForParse = !(is_null($parseCallable) || empty($parseCallable));
|
|
|
|
$this->parseCallable = $parseCallable;
|
|
}
|
|
|
|
/**
|
|
* Get the callable to use for parsing routes
|
|
*
|
|
* @return null|callable
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getParseCallable()
|
|
{
|
|
return $this->parseCallable;
|
|
}
|
|
|
|
/**
|
|
* Set the "push variables" for routing rule. These variables are pushed to the input when a URL is successfully
|
|
* parsed by this rule. You have to provide a hash array, e.g.
|
|
* array( 'foo' => 1, 'bar' => 2 )
|
|
*
|
|
* @param array $pushVars The push variables to set
|
|
*
|
|
* @return void
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function setPushVars($pushVars)
|
|
{
|
|
$this->pushVars = $pushVars;
|
|
}
|
|
|
|
/**
|
|
* Get the "push variables" for this routing rule
|
|
*
|
|
* @return array
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getPushVars()
|
|
{
|
|
return $this->pushVars;
|
|
}
|
|
|
|
/**
|
|
* Set the callable to use for routing a URL using this rule. The callable must return null if it can't route the
|
|
* URL (it's not applicable) or an array of the same format as the Rule::route() method.
|
|
*
|
|
* @param callable|null $routeCallable The callable to use for routing URLs
|
|
*
|
|
* @return void
|
|
*/
|
|
public function setRouteCallable($routeCallable)
|
|
{
|
|
$this->useCallableForRoute = !(is_null($routeCallable) || empty($routeCallable));
|
|
|
|
$this->routeCallable = $routeCallable;
|
|
}
|
|
|
|
/**
|
|
* Get the callable to use for routing a URL using this rule
|
|
*
|
|
* @return null|callable
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getRouteCallable()
|
|
{
|
|
return $this->routeCallable;
|
|
}
|
|
|
|
/**
|
|
* Set the routing path. A routing path looks like this: foo/bar/ * /:id/:cat?/:tags* (the spaces are not part of
|
|
* the example, they are added to prevent PHP from breaking the comment block). It is a series of path segments:
|
|
*
|
|
* - any static string not starting with colon means that we are looking for an exact match
|
|
* - :something means that the value in this position will be assigned to query parameter "something"
|
|
* - :something? means that if a value exists in this position it will be assigned to query parameter "something"
|
|
* - :something* means that zero or more values in this position will be assigned to query array parameter "something"
|
|
* - * will match any value, but it will be ignored (not assigned to a query parameter)
|
|
*
|
|
* By default any kind of value is matched, unless there is a type regex for the named variable. The matching is
|
|
* greedy, therefore having :foo* / :bar? where both foo and bar are of the same type will always result in bar not
|
|
* being matched. In case bar is not followed by a question mark this means that the rule itself won't be matched!
|
|
*
|
|
* The same word of caution applies to the lone star operator. It will match anything that doesn't match the next
|
|
* segment. If the next segment doesn't have a type specified and it's not a static string, the lone start operator
|
|
* will never match anything at all.
|
|
*
|
|
* Moreover, you should NEVER use two or more lone stars in succession. This will completely screw up the route
|
|
* parsing.
|
|
*
|
|
* A lone start followed by an optional parameter will always try to match the optional parameter's type,
|
|
* essentially ignoring the fact that it's optional. This is because the forward type lookup is limited to a single
|
|
* position to improve performance. Therefore NEVER put an optional parameter after a lone star.
|
|
*
|
|
* @param string $routePath The routing path to set
|
|
*
|
|
* @return void
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function setPath($routePath)
|
|
{
|
|
$this->path = $routePath;
|
|
}
|
|
|
|
/**
|
|
* Get the routing path.
|
|
*
|
|
* @return string
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getPath()
|
|
{
|
|
return $this->path;
|
|
}
|
|
|
|
/**
|
|
* Set the types (matching RegEx) for named parameters of the routing path (the :something strings)
|
|
*
|
|
* @param array $types The types to set
|
|
*
|
|
* @return void
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function setTypes($types)
|
|
{
|
|
$this->types = $types;
|
|
}
|
|
|
|
/**
|
|
* Get the types for named parameters of the routing path
|
|
*
|
|
* @return array
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
public function getTypes()
|
|
{
|
|
return $this->types;
|
|
}
|
|
|
|
/**
|
|
* Check whether the query string parameters $params match the "match variables" of this rule
|
|
*
|
|
* @param array $params
|
|
*
|
|
* @return boolean True on success
|
|
*/
|
|
protected function matchesVars(array &$params)
|
|
{
|
|
// If we don't have match vars we assume this URL can be routed by our rule
|
|
if (empty($this->matchVars))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
foreach ($this->matchVars as $k => $v)
|
|
{
|
|
// If the variable doesn't exist we don't have a match; break and return false
|
|
if (!isset($params[$k]))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// We have a match for a variable with a null value. Nothing else to do for it, continue to the next one
|
|
if (is_null($v))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if ($params[$k] == $v)
|
|
{
|
|
// We have an exact variable match. Remove it from the $params array.
|
|
unset($params[$k]);
|
|
}
|
|
else
|
|
{
|
|
// Exact match failed. We don't have a match.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// All checks passed, we were successful!
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Build a route segment based on the provided URL parameters and the routing path of this rule
|
|
*
|
|
* @param array $params
|
|
*
|
|
* @return array|null An array of path segments, or null if this rule is not applicable
|
|
*/
|
|
protected function buildRoute(array &$params)
|
|
{
|
|
$pathRules = explode('/', $this->path);
|
|
$segments = array();
|
|
|
|
foreach ($pathRules as $rule)
|
|
{
|
|
$rule = trim($rule);
|
|
|
|
if (substr($rule, 0, 1) == ':')
|
|
{
|
|
// Init
|
|
$rule = substr($rule, 1);
|
|
$isArray = false;
|
|
$isOptional = false;
|
|
|
|
// Is this an array or optional variable?
|
|
$lastChar = substr($rule, -1);
|
|
if ($lastChar == '*')
|
|
{
|
|
$isArray = true;
|
|
}
|
|
elseif ($lastChar == '?')
|
|
{
|
|
$isOptional = true;
|
|
}
|
|
|
|
if ($isArray || $isOptional)
|
|
{
|
|
$rule = substr($rule, 0, -1);
|
|
}
|
|
|
|
// What happens if this variable doesn't exist in my variables list?
|
|
if (!isset($params[$rule]))
|
|
{
|
|
// If it's optional, skip it
|
|
if ($isOptional)
|
|
{
|
|
continue;
|
|
}
|
|
// If it's not optional return null, meaning that we can't parse this routing rule
|
|
else
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Get the value
|
|
$value = $params[$rule];
|
|
|
|
// Make sure the type is right
|
|
if ($isArray && !is_array($value))
|
|
{
|
|
if (is_object($value))
|
|
{
|
|
$value = (array)$value;
|
|
}
|
|
else
|
|
{
|
|
$value = array($value);
|
|
}
|
|
}
|
|
elseif (!$isArray && (is_array($value)))
|
|
{
|
|
$value = array_shift($value);
|
|
}
|
|
elseif (!$isArray && (is_object($value)))
|
|
{
|
|
$value = (array)$value;
|
|
$value = array_shift($value);
|
|
}
|
|
|
|
// Push the value of the variable to the segments
|
|
if (!$isArray)
|
|
{
|
|
$segments[] = (string)$value;
|
|
}
|
|
else
|
|
{
|
|
foreach ($value as $v)
|
|
{
|
|
$segments[] = (string)$v;
|
|
}
|
|
}
|
|
|
|
// Finally, unset the variable
|
|
unset ($params[$rule]);
|
|
}
|
|
elseif ($rule == '*')
|
|
{
|
|
// Lone star rules are ignored during route building
|
|
}
|
|
else
|
|
{
|
|
// Static string, append verbose
|
|
$segments[] = $rule;
|
|
}
|
|
}
|
|
|
|
return $segments;
|
|
}
|
|
|
|
/**
|
|
* Parse the segments of the SEF URL and convert them to query parameters
|
|
*
|
|
* @param array $segments
|
|
*
|
|
* @return array|null The query parameters, or null if we can't parse this route
|
|
*/
|
|
protected function parseRoute(array $segments)
|
|
{
|
|
$pathRules = explode('/', $this->path);
|
|
$vars = array();
|
|
|
|
$isGreedy = false;
|
|
$rule = null;
|
|
$varName = null;
|
|
$varType = null;
|
|
$isArray = false;
|
|
$isOptional = false;
|
|
$segment = null;
|
|
|
|
while (!empty($segments))
|
|
{
|
|
if (is_null($segment))
|
|
{
|
|
$segment = array_shift($segments);
|
|
}
|
|
|
|
// No current rule. Let's fetch one.
|
|
if (is_null($rule))
|
|
{
|
|
// Do we have more path parts but no more rules?
|
|
if (empty($pathRules))
|
|
{
|
|
if (!$isGreedy)
|
|
{
|
|
// If we are not in a star rule, we shouldn't be parsing this route.
|
|
return null;
|
|
}
|
|
else
|
|
{
|
|
// It's a star rule. The rest of the query is ignored. We can just return now.
|
|
return $vars;
|
|
}
|
|
}
|
|
|
|
// Re-initialise
|
|
$isGreedy = false;
|
|
$rule = null;
|
|
$varName = null;
|
|
$varType = null;
|
|
$isArray = false;
|
|
$isOptional = false;
|
|
|
|
// Get the next rule
|
|
$rule = array_shift($pathRules);
|
|
$rule = trim($rule);
|
|
|
|
if ($rule == '*')
|
|
{
|
|
// We have a greedy lonely star. Set the greedy flag and fetch the next rule
|
|
$isGreedy = true;
|
|
$rule = array_shift($pathRules);
|
|
$rule = trim($rule);
|
|
}
|
|
}
|
|
|
|
// Do we have a rule with a variable name in it?
|
|
if (substr($rule, 0, 1) == ':')
|
|
{
|
|
// Do I have to parse the rule first?
|
|
if (empty($varName))
|
|
{
|
|
$varName = substr($rule, 1);
|
|
$varType = null;
|
|
$isArray = false;
|
|
$isOptional = false;
|
|
|
|
// Is this an array or optional variable?
|
|
$lastChar = substr($varName, -1);
|
|
if ($lastChar == '*')
|
|
{
|
|
$isArray = true;
|
|
}
|
|
elseif ($lastChar == '?')
|
|
{
|
|
$isOptional = true;
|
|
}
|
|
|
|
if ($isArray || $isOptional)
|
|
{
|
|
$varName = substr($varName, 0, -1);
|
|
}
|
|
|
|
// Get the variable type
|
|
if (isset($this->types[$varName]))
|
|
{
|
|
$varType = $this->types[$varName];
|
|
}
|
|
}
|
|
|
|
// Try to match the variable type
|
|
if (!empty($varType))
|
|
{
|
|
// What to do if we don't have a match?
|
|
$matched = preg_match($varType, $segment);
|
|
|
|
if (!$matched)
|
|
{
|
|
if ($isGreedy)
|
|
{
|
|
// If we are in a greedy match ignore this segment
|
|
$segment = null;
|
|
continue;
|
|
}
|
|
elseif ($isOptional)
|
|
{
|
|
// If it's an optional variable, ignore this rule
|
|
$rule = null;
|
|
}
|
|
elseif ($isArray && count($vars[$varName]))
|
|
{
|
|
// It's an array variable and we're done parsing it
|
|
$rule = null;
|
|
$segments[] = $segment;
|
|
$segment = null;
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
// We can't parse this rule
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The type matched. First, unset the greedy flag.
|
|
$isGreedy = false;
|
|
|
|
// Extract the variable.
|
|
if ($isArray)
|
|
{
|
|
// Make sure the array variable is an array
|
|
if (!isset($vars[$varName]))
|
|
{
|
|
$vars[$varName] = array();
|
|
}
|
|
|
|
// Push the segment to the array variable
|
|
$vars[$varName][] = $segment;
|
|
}
|
|
else
|
|
{
|
|
// Push the segment to the variable
|
|
$vars[$varName] = $segment;
|
|
|
|
// Since this is a single match rule, go to the next rule
|
|
$rule = null;
|
|
}
|
|
|
|
// Unset the segment so that we can proceed
|
|
$segment = null;
|
|
}
|
|
// Do we have an exact match rule (warning: case sensitive!!)?
|
|
else
|
|
{
|
|
if ($segment !== $rule)
|
|
{
|
|
// No match and we already have a greedy lone star? Ignore the segment!
|
|
if ($isGreedy)
|
|
{
|
|
$segment = null;
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
// Not greedy and no match? We can't parse this rule.
|
|
return null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// We have a match. Kill the greedy flag and mark this segment as complete
|
|
$isGreedy = false;
|
|
$rule = null;
|
|
$segment = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// We ran out of segments. Have we also run out of rules?
|
|
if (empty($pathRules) || ((count($pathRules) == 1) && ($pathRules[0] == '*')))
|
|
{
|
|
// No rules left, or just a lone star. All good!
|
|
return $vars;
|
|
}
|
|
else
|
|
{
|
|
// Unmatched rules left. Are all the rules left optional or greedy?
|
|
$canSkip = true;
|
|
|
|
foreach($pathRules as $rule)
|
|
{
|
|
$firstChar = substr($rule, 0, 1);
|
|
$lastChar = substr($rule, -1);
|
|
|
|
if ($firstChar == '*')
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if ($firstChar != ':')
|
|
{
|
|
$canSkip = false;
|
|
break;
|
|
}
|
|
|
|
if (($lastChar != '*') && ($lastChar != '?'))
|
|
{
|
|
$canSkip = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $canSkip ? $vars : null;
|
|
}
|
|
}
|
|
}
|