Last active
February 1, 2021 14:54
-
-
Save WinterSilence/a6fb75a990c3b3ecd0a48bd9831780d0 to your computer and use it in GitHub Desktop.
KF7\Routing classes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* @link https://kf7.github.io | |
* @copyright (c) 2006-2020 Kohana team | |
* @license https://kf7.github.io/license/ BSD-3-Clause | |
*/ | |
declare(strict_types=1); | |
namespace KF7\System\Http\Routing; | |
use Throwable; | |
use RuntimeException; | |
use KF7\System\ExceptionInterface; | |
use function strtr; | |
/** | |
* Exception thrown if entry not found. | |
* | |
* @package KF7\System | |
*/ | |
class NotFoundException extends RuntimeException implements ExceptionInterface | |
{ | |
/** | |
* @inheritDoc | |
*/ | |
public function __construct( | |
string $message = '', | |
array $placeholders = [], | |
int $code = 0, | |
Throwable $previous = null | |
) { | |
if ($placeholders) { | |
$message = strtr($message, $placeholders); | |
} | |
parent::__construct($message, $code, $previous); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* @link https://kf7.github.io | |
* @copyright (c) 2006-2020 Kohana team | |
* @license https://kf7.github.io/license/ BSD-3-Clause | |
*/ | |
declare(strict_types=1); | |
namespace KF7\System\Http\Routing; | |
/** | |
* Routes are used to determine the namespace, controller and action for a requested URI. Every route generates a | |
* regular expression which is used to match a URI and a route. Routes may also contain keys which can be used to set | |
* the namespace, controller, action and parameters. | |
* | |
* @package KF7\System | |
*/ | |
class Router | |
{ | |
/** | |
* @var Route[] Stored routes | |
*/ | |
protected $routes = []; | |
/** | |
* Stores a named route. | |
* | |
* @param Route $route Route instance. | |
* @param bool $prepend Prepend route? | |
* @return static | |
*/ | |
public function addRoute(Route $route, bool $prepend = false): Router | |
{ | |
if ($prepend) { | |
$this->routes = [$route->getName() => $route] + $this->routes; | |
} else { | |
$this->routes[$route->getName()] = $route; | |
} | |
return $this; | |
} | |
/** | |
* Returns route or NULL if route not exists. | |
* | |
* @param string|Route $route Route name or instance. | |
* @return Route|null | |
*/ | |
public function getRoute($route): ?Route | |
{ | |
return $this->routes[(string) $route] ?? null; | |
} | |
/** | |
* Deletes stored route. | |
* | |
* @param string|Route $name Route name or route instance | |
* @return static | |
*/ | |
public function deleteRoute($route): Router | |
{ | |
unset($this->routes[(string) $name]); | |
return $this; | |
} | |
/** | |
* Returns all stored routes. | |
* | |
* @return array | |
*/ | |
public function getRoutes(): array | |
{ | |
return $this->routes; | |
} | |
/** | |
* Returns request`s route with dispatched parameters. | |
* | |
* @param Request $request Request instance. | |
* @return array `['route', 'params']` | |
* @throws NotFoundException | |
*/ | |
public function dispatch(Request $request): array | |
{ | |
foreach ($this->getRoutes() as $route) { | |
$params = $route->matches($request); | |
if ($params !== null) { | |
return ['route' => $route, 'params' => $params]; | |
} | |
} | |
throw new NotFoundException( | |
'Request ":uri": route not detected', | |
[':uri' => $request->uri()] | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* @link https://kf7.github.io | |
* @copyright (c) 2006-2020 Kohana team | |
* @license https://kf7.github.io/license/ BSD-3-Clause | |
*/ | |
declare(strict_types=1); | |
namespace KF7\System\Http\Routing; | |
use KF7\System\Http\Request; | |
use function addcslashes; | |
use function array_map; | |
use function array_search; | |
use function in_array; | |
use function is_object; | |
use function implode; | |
use function iterator_to_array; | |
use function strpos; | |
use function strtr; | |
use function strtolower; | |
use function strtoupper; | |
use function str_replace; | |
use function trim; | |
use function rtrim; | |
use function rawurlencode; | |
use function preg_match; | |
use function preg_replace; | |
use function preg_replace_callback; | |
use function ucwords; | |
use const ARRAY_FILTER_USE_KEY; | |
/** | |
* Routes are used to determine the namespace, controller and action for a requested URI. Every route generates a | |
* regular expression which is used to match a URI and a route. Routes may also contain keys which can be used to set | |
* the namespace, controller, action and parameters. | |
* | |
* @package KF7\System | |
*/ | |
class Route | |
{ | |
/** | |
* @var string What must be escaped in the route regex | |
*/ | |
protected const REGEX_ESCAPE = '.\+*?[^]${}=!|:-#'; | |
/** | |
* @var string What can be part of a `<segment>` value | |
*/ | |
public const REGEX_SEGMENT = '[^/.,;?\n]++'; | |
/** | |
* @var string Defines the pattern of a `<segment>` | |
*/ | |
protected const REGEX_PARAM = '<([_a-zA-Z\d]++)>'; | |
/** | |
* @var string Matches a URI group and captures the contents | |
*/ | |
protected const REGEX_GROUP = '\(((?:(?>[^()]+)|(?R))*)\)'; | |
/** | |
* @var string Part pattern | |
*/ | |
protected const REGEX_PORTION = '#(?:' . static::REGEX_PARAM . '|' . static::REGEX_GROUP . ')#u'; | |
/** | |
* @var string Route name. | |
*/ | |
protected $name; | |
/** | |
* @var string URI pattern. | |
*/ | |
protected $uri; | |
/** | |
* @var string Compiled URI regexp. | |
*/ | |
protected $uriRegex; | |
/** | |
* @var string[] Key patterns for URI regex. | |
*/ | |
protected $keys = [ | |
'protocol' => 'http|https', | |
'host' => '[.-_a-z\d]+', | |
'namespace' => '[\\a-zA-Z\d]+', | |
'controller' => '[a-zA-Z\d]+', | |
'action' => '[a-zA-Z\d]+', | |
]; | |
/** | |
* @var string[] Default parameters | |
*/ | |
protected $defaults = [ | |
'protocol' => 'http', | |
'host' => '', | |
'namespace' => 'Enso\\Controller\\', | |
'controller' => '', | |
'action' => 'index', | |
]; | |
/** | |
* @var string[] Available HTTP methods. By default, use RESTful methods. | |
*/ | |
protected $methods = [ | |
Request::GET, | |
Request::POST, | |
Request::PUT, | |
Request::DELETE, | |
]; | |
/** | |
* @var callable Callback filter for parameters. | |
*/ | |
protected $filter; | |
/** | |
* @var callable Closure using in `compileUri()` method. | |
*/ | |
protected $compileUriCallback; | |
/** | |
* @var array[] Compiled URIs as pairs: URI => parameters, simple cache. | |
*/ | |
protected $compiledUris = []; | |
/** | |
* Creates a new route instance. | |
* | |
* @param string $name Route name. | |
* @param string $uri URI pattern. | |
* @param iterable|null $params Parameter preseters. | |
* @params iterable|null $methods Available HTTP methods. | |
* @param callable $filter Callback filter for parameters | |
* @return void | |
*/ | |
public function __construct( | |
string $name, | |
string $uri, | |
?iterable $params = null, | |
?iterable $methods = null, | |
callable $filter = null | |
) { | |
$this->name = $name; | |
$this->uri = trim($uri); | |
if ($params !== null) { | |
$this->setParams($params); | |
} | |
if ($methods !== null) { | |
$this->setMethods($methods); | |
} | |
if ($filter) { | |
$this->setFilter($filter); | |
} | |
} | |
/** | |
* String implementation of route, returns route name. | |
* | |
* @return string | |
*/ | |
public function __toString(): string | |
{ | |
return $this->getName(); | |
} | |
/** | |
* Returns route name/identifier. | |
* | |
* @return string | |
*/ | |
public function getName(): string | |
{ | |
return $this->name; | |
} | |
/** | |
* Returns parameters by default. | |
* | |
* @return array | |
*/ | |
public function getDefaults(): array | |
{ | |
return $this->defaults; | |
} | |
/** | |
* Gets available methods of HTTP request. | |
* | |
* @return string[] | |
*/ | |
public function getMethods(): array | |
{ | |
return $this->methods; | |
} | |
/** | |
* Gets callback filter for parameters. | |
* | |
* @return callable|null | |
*/ | |
public function getFilter(): ?callable | |
{ | |
return $this->filter; | |
} | |
/** | |
* Sets parameter presets. | |
* | |
* @param iterable $params An array of parameters as pairs: name => default value or [default, regex]. | |
* @param bool $reset Reset parameters? | |
* @return static | |
*/ | |
public function setParams(iterable $params, bool $reset = true): Route | |
{ | |
if ($reset) { | |
$this->defaults = $this->keys = []; | |
} | |
// Reset compiled values. | |
$this->uriRegex = null; | |
$this->compiledUris = []; | |
foreach ($params as $key => $value) { | |
$value = (array) $value; | |
[$this->defaults[$key], $this->keys[$key]] = $value + [1 => static::REGEX_SEGMENT]; | |
} | |
return $this; | |
} | |
/** | |
* Sets available HTTP methods. | |
* | |
* @param iterable $methods HTTP methods. | |
* @return static | |
*/ | |
public function setMethods(iterable $methods): Route | |
{ | |
$this->methods = []; | |
foreach ($methods as $method) { | |
$this->methods[] = strtoupper($method); | |
} | |
return $this; | |
} | |
/** | |
* Sets callback filter for parameters. | |
* | |
* @param callable $callback Filter: `function(Route $route, array $params, Request $request): ?array`. | |
* @return static | |
*/ | |
public function setFilter(callable $callback): Route | |
{ | |
$this->filter = $callback; | |
return $this; | |
} | |
/** | |
* Returns the compiled regular expression for the route. This translates keys and optional groups to a proper PCRE | |
* regular expression. | |
* | |
* @return string | |
*/ | |
protected function getUriRegex(): string | |
{ | |
if (! $this->uriRegex) { | |
// The URI should be considered literal except for keys and optional parts. | |
// Escape everything `preg_quote()` would escape except for: '(', ')', '<', '>'. | |
$regex = addcslashes($this->uri, static::REGEX_ESCAPE); | |
if (strpos(')')) { | |
// Make optional parts of the URI non-capturing and optional. | |
$regex = strtr($regex, ['(' => '(?:', ')' => ')?']); | |
} | |
// Insert default regex for keys. | |
$regex = strtr($regex, ['<' => '(?P<', '>' => '>' . static::REGEX_SEGMENT . ')']); | |
if ($this->keys) { | |
$replaces = []; | |
foreach ($this->keys as $key => $value) { | |
$key = '<' . $key . '>'; | |
$replaces[$key . static::REGEX_SEGMENT] = $key . $value; | |
} | |
// Replace the default expression with the user-specified expression. | |
$regex = strtr($regex, $replaces); | |
} | |
// Store the compiled regex locally. | |
$this->uriRegex = '#^' . $regex . '$#uD'; | |
} | |
return $this->uriRegex; | |
} | |
/** | |
* Normalize matched parameter values. | |
* | |
* @param array $params Matched values. | |
* @return array Normalized values. | |
*/ | |
protected function normalizeParams(array $params): array | |
{ | |
if (! empty($params['protocol'])) { | |
$params['protocol'] = strtolower(rtrim($params['protocol'], ':/')); | |
} | |
if (! empty($params['namespace'])) { | |
$params['namespace'] = str_replace(['/', '-', '_'], '\\', $params['namespace']); | |
$params['namespace'] = ucwords(trim($params['namespace'], '\\'), '\\') . '\\'; | |
} | |
if (! empty($params['controller'])) { | |
$params['controller'] = str_replace(['-', '_'], '', ucwords($params['controller'], '-_')); | |
} | |
if (! empty($params['action'])) { | |
$params['action'] = str_replace(['-', '_'], '', ucwords($params['action'], '-_')); | |
} | |
return $params; | |
} | |
/** | |
* Tests if the route matches a given request, returns routed parameters at success or NULL at fail. | |
* | |
* @param Request $request Request instance. | |
* @return array|null | |
*/ | |
public function matches(Request $request): ?array | |
{ | |
if (! in_array($request->method(), $this->methods)) { | |
return null; | |
} | |
if (! preg_match($this->getUriRegex(), $request->uri(), $params)) { | |
return null; | |
} | |
// Delete all unnamed keys. | |
$params = array_filter($params, 'is_string', ARRAY_FILTER_USE_KEY); | |
// Set default values for any key that was not matched. | |
foreach ($this->defaults as $key => $value) { | |
if (! isset($params[$key]) || $params[$key] === '') { | |
$params[$key] = $value; | |
} | |
} | |
$params = $this->normalizeParams($params); | |
if ($this->filter) { | |
// Execute the filter giving it the route, parameters and request. | |
return ($this->filter)($this, $params, $request); | |
} | |
return $params; | |
} | |
/** | |
* Recursively compiles a portion of a URI specification by replacing the specified parameters and any optional | |
* parameters that are needed. | |
* | |
* @param array $params URI parameters. | |
* @param string $portion Part of the URI specification. | |
* @param bool $required Whether or not parameters are required (initially). | |
* @return array Tuple of the compiled portion and whether or not it contained specified parameters. | |
* @throws NotFoundException | |
*/ | |
protected function compileUri(array $params, string $portion, bool $required = true): array | |
{ | |
$missing = []; | |
if (! $this->compileUriCallback) { | |
$this->compileUriCallback = function ($matches) use (&$missing, &$required, $params) { | |
list($portion, $param, $group) = $matches; | |
if ($portion[0] == '<') { | |
// Unwrapped parameter. | |
if (isset($params[$param])) { | |
// This portion is required when a specified parameter does not match the default. | |
if (! $required) { | |
$required = ! isset($this->defaults[$param]) | |
|| $params[$param] !== $this->defaults[$param]; | |
} | |
// Add specified parameter to this result. | |
return $params[$param]; | |
} | |
// Add default parameter to this result. | |
if (isset($this->defaults[$param])) { | |
return $this->defaults[$param]; | |
} | |
// This portion is missing a parameter. | |
$missing[] = $param; | |
} elseif ($group) { | |
// Unwrapped group. | |
$result = $this->compileUri($params, $group, false); | |
if ($result[1]) { | |
// This portion is required when it contains a group that is required. | |
$required = true; | |
// Add required groups to this result. | |
return $result[0]; | |
} | |
// Don't add optional groups to this result! | |
} | |
}; | |
} | |
$result = preg_replace_callback(static::REGEX_PORTION, $this->compileUriCallback, $portion); | |
if ($required && $missing) { | |
throw new NotFoundException( | |
'Route ":name": required parameters(:params) not passed', | |
[':name' => $this->name, ':params' => implode(', ', $missing)] | |
); | |
} | |
return [$result, $required]; | |
} | |
/** | |
* Generates a URI for the current route based on the parameters given. | |
* | |
* @param iterable $params URI parameters | |
* @param bool $encode Encode parameters? | |
* @return string | |
*/ | |
public function getUri(iterable $params = [], bool $encode = true): string | |
{ | |
if (is_object($params)) { | |
$params = iterator_to_array($params); | |
} | |
if ($params && $encode) { | |
$params = array_map('rawurlencode', $params); | |
// Decode slashes back, see Apache docs about AllowEncodedSlashes and AcceptPathInfo. | |
$params = str_replace(['%2F', '%5C'], ['/', '\\'], $params); | |
} | |
// Find cached URI by parameters, strict mode disable to ignore order of parameters | |
$uri = array_search($params, $this->compiledUris); | |
if ($uri) { | |
return $uri; | |
} | |
list($uri) = $this->compileUri($params, $this->uri, true); | |
// Trim all extra slashes from the URI. | |
$uri = preg_replace('#//+#', '/', $uri); | |
// Cache URI. | |
$this->compiledUris[$uri] = $params; | |
return $uri; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment