Skip to content

Instantly share code, notes, and snippets.

@WinterSilence
Last active February 1, 2021 14:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save WinterSilence/a6fb75a990c3b3ecd0a48bd9831780d0 to your computer and use it in GitHub Desktop.
Save WinterSilence/a6fb75a990c3b3ecd0a48bd9831780d0 to your computer and use it in GitHub Desktop.
KF7\Routing classes
<?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);
}
}
<?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()]
);
}
}
<?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