<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\Routing;
use Cake\Routing\Exception\DuplicateNamedRouteException;
use Cake\Routing\Exception\MissingRouteException;
use Cake\Routing\Route\Route;
use Psr\Http\Message\ServerRequestInterface;
use RuntimeException;
/**
* Contains a collection of routes.
*
* Provides an interface for adding/removing routes
* and parsing/generating URLs with the routes it contains.
*
* @internal
*/
class RouteCollection
{
/**
* The routes connected to this collection.
*
* @var array
*/
protected $_routeTable = [];
/**
* The routes connected to this collection.
*
* @var \Cake\Routing\Route\Route[]
*/
protected $_routes = [];
/**
* The hash map of named routes that are in this collection.
*
* @var \Cake\Routing\Route\Route[]
*/
protected $_named = [];
/**
* Routes indexed by path prefix.
*
* @var array
*/
protected $_paths = [];
/**
* A map of middleware names and the related objects.
*
* @var array
*/
protected $_middleware = [];
/**
* A map of middleware group names and the related middleware names.
*
* @var array
*/
protected $_middlewareGroups = [];
/**
* A map of paths and the list of applicable middleware.
*
* @var array
*/
protected $_middlewarePaths = [];
/**
* Route extensions
*
* @var string[]
*/
protected $_extensions = [];
/**
* Add a route to the collection.
*
* @param \Cake\Routing\Route\Route $route The route object to add.
* @param array $options Additional options for the route. Primarily for the
* `_name` option, which enables named routes.
* @return void
*/
public function add(Route $route, array $options = [])
{
$this->_routes[] = $route;
// Explicit names
if (isset($options['_name'])) {
if (isset($this->_named[$options['_name']])) {
$matched = $this->_named[$options['_name']];
throw new DuplicateNamedRouteException([
'name' => $options['_name'],
'url' => $matched->template,
'duplicate' => $matched,
]);
}
$this->_named[$options['_name']] = $route;
}
// Generated names.
$name = $route->getName();
if (!isset($this->_routeTable[$name])) {
$this->_routeTable[$name] = [];
}
$this->_routeTable[$name][] = $route;
// Index path prefixes (for parsing)
$path = $route->staticPath();
$this->_paths[$path][] = $route;
$extensions = $route->getExtensions();
if (count($extensions) > 0) {
$this->setExtensions($extensions);
}
}
/**
* Takes the URL string and iterates the routes until one is able to parse the route.
*
* @param string $url URL to parse.
* @param string $method The HTTP method to use.
* @return array An array of request parameters parsed from the URL.
* @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
*/
public function parse($url, $method = '')
{
$decoded = urldecode($url);
// Sort path segments matching longest paths first.
$paths = array_keys($this->_paths);
rsort($paths);
foreach ($paths as $path) {
if (strpos($decoded, $path) !== 0) {
continue;
}
$queryParameters = null;
if (strpos($url, '?') !== false) {
list($url, $queryParameters) = explode('?', $url, 2);
parse_str($queryParameters, $queryParameters);
}
/** @var \Cake\Routing\Route\Route $route */
foreach ($this->_paths[$path] as $route) {
$r = $route->parse($url, $method);
if ($r === false) {
continue;
}
if ($queryParameters) {
$r['?'] = $queryParameters;
}
return $r;
}
}
$exceptionProperties = ['url' => $url];
if ($method !== '') {
// Ensure that if the method is included, it is the first element of
// the array, to match the order that the strings are printed in the
// MissingRouteException error message, $_messageTemplateWithMethod.
$exceptionProperties = array_merge(['method' => $method], $exceptionProperties);
}
throw new MissingRouteException($exceptionProperties);
}
/**
* Takes the ServerRequestInterface, iterates the routes until one is able to parse the route.
*
* @param \Psr\Http\Message\ServerRequestInterface $request The request to parse route data from.
* @return array An array of request parameters parsed from the URL.
* @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
*/
public function parseRequest(ServerRequestInterface $request)
{
$uri = $request->getUri();
$urlPath = urldecode($uri->getPath());
// Sort path segments matching longest paths first.
$paths = array_keys($this->_paths);
rsort($paths);
foreach ($paths as $path) {
if (strpos($urlPath, $path) !== 0) {
continue;
}
/** @var \Cake\Routing\Route\Route $route */
foreach ($this->_paths[$path] as $route) {
$r = $route->parseRequest($request);
if ($r === false) {
continue;
}
if ($uri->getQuery()) {
parse_str($uri->getQuery(), $queryParameters);
$r['?'] = $queryParameters;
}
return $r;
}
}
throw new MissingRouteException(['url' => $urlPath]);
}
/**
* Get the set of names from the $url. Accepts both older style array urls,
* and newer style urls containing '_name'
*
* @param array $url The url to match.
* @return string[] The set of names of the url
*/
protected function _getNames($url)
{
$plugin = false;
if (isset($url['plugin']) && $url['plugin'] !== false) {
$plugin = strtolower($url['plugin']);
}
$prefix = false;
if (isset($url['prefix']) && $url['prefix'] !== false) {
$prefix = strtolower($url['prefix']);
}
$controller = strtolower($url['controller']);
$action = strtolower($url['action']);
$names = [
"${controller}:${action}",
"${controller}:_action",
"_controller:${action}",
'_controller:_action',
];
// No prefix, no plugin
if ($prefix === false && $plugin === false) {
return $names;
}
// Only a plugin
if ($prefix === false) {
return [
"${plugin}.${controller}:${action}",
"${plugin}.${controller}:_action",
"${plugin}._controller:${action}",
"${plugin}._controller:_action",
"_plugin.${controller}:${action}",
"_plugin.${controller}:_action",
"_plugin._controller:${action}",
'_plugin._controller:_action',
];
}
// Only a prefix
if ($plugin === false) {
return [
"${prefix}:${controller}:${action}",
"${prefix}:${controller}:_action",
"${prefix}:_controller:${action}",
"${prefix}:_controller:_action",
"_prefix:${controller}:${action}",
"_prefix:${controller}:_action",
"_prefix:_controller:${action}",
'_prefix:_controller:_action',
];
}
// Prefix and plugin has the most options
// as there are 4 factors.
return [
"${prefix}:${plugin}.${controller}:${action}",
"${prefix}:${plugin}.${controller}:_action",
"${prefix}:${plugin}._controller:${action}",
"${prefix}:${plugin}._controller:_action",
"${prefix}:_plugin.${controller}:${action}",
"${prefix}:_plugin.${controller}:_action",
"${prefix}:_plugin._controller:${action}",
"${prefix}:_plugin._controller:_action",
"_prefix:${plugin}.${controller}:${action}",
"_prefix:${plugin}.${controller}:_action",
"_prefix:${plugin}._controller:${action}",
"_prefix:${plugin}._controller:_action",
"_prefix:_plugin.${controller}:${action}",
"_prefix:_plugin.${controller}:_action",
"_prefix:_plugin._controller:${action}",
'_prefix:_plugin._controller:_action',
];
}
/**
* Reverse route or match a $url array with the connected routes.
*
* Returns either the URL string generated by the route,
* or throws an exception on failure.
*
* @param array $url The URL to match.
* @param array $context The request context to use. Contains _base, _port,
* _host, _scheme and params keys.
* @return string The URL string on match.
* @throws \Cake\Routing\Exception\MissingRouteException When no route could be matched.
*/
public function match($url, $context)
{
// Named routes support optimization.
if (isset($url['_name'])) {
$name = $url['_name'];
unset($url['_name']);
if (isset($this->_named[$name])) {
$route = $this->_named[$name];
$out = $route->match($url + $route->defaults, $context);
if ($out) {
return $out;
}
throw new MissingRouteException([
'url' => $name,
'context' => $context,
'message' => 'A named route was found for "%s", but matching failed.',
]);
}
throw new MissingRouteException(['url' => $name, 'context' => $context]);
}
foreach ($this->_getNames($url) as $name) {
if (empty($this->_routeTable[$name])) {
continue;
}
/** @var \Cake\Routing\Route\Route $route */
foreach ($this->_routeTable[$name] as $route) {
$match = $route->match($url, $context);
if ($match) {
return strlen($match) > 1 ? trim($match, '/') : $match;
}
}
}
throw new MissingRouteException(['url' => var_export($url, true), 'context' => $context]);
}
/**
* Get all the connected routes as a flat list.
*
* @return \Cake\Routing\Route\Route[]
*/
public function routes()
{
return $this->_routes;
}
/**
* Get the connected named routes.
*
* @return \Cake\Routing\Route\Route[]
*/
public function named()
{
return $this->_named;
}
/**
* Get/set the extensions that the route collection could handle.
*
* @param string[]|string|null $extensions Either the list of extensions to set,
* or null to get.
* @param bool $merge Whether to merge with or override existing extensions.
* Defaults to `true`.
* @return string[] The valid extensions.
* @deprecated 3.5.0 Use getExtensions()/setExtensions() instead.
*/
public function extensions($extensions = null, $merge = true)
{
deprecationWarning(
'RouteCollection::extensions() is deprecated. ' .
'Use RouteCollection::setExtensions()/getExtensions() instead.'
);
if ($extensions !== null) {
$this->setExtensions((array)$extensions, $merge);
}
return $this->getExtensions();
}
/**
* Get the extensions that can be handled.
*
* @return string[] The valid extensions.
*/
public function getExtensions()
{
return $this->_extensions;
}
/**
* Set the extensions that the route collection can handle.
*
* @param string[] $extensions The list of extensions to set.
* @param bool $merge Whether to merge with or override existing extensions.
* Defaults to `true`.
* @return $this
*/
public function setExtensions(array $extensions, $merge = true)
{
if ($merge) {
$extensions = array_unique(array_merge(
$this->_extensions,
$extensions
));
}
$this->_extensions = $extensions;
return $this;
}
/**
* Register a middleware with the RouteCollection.
*
* Once middleware has been registered, it can be applied to the current routing
* scope or any child scopes that share the same RouteCollection.
*
* @param string $name The name of the middleware. Used when applying middleware to a scope.
* @param callable|string $middleware The middleware callable or class name to register.
* @return $this
* @throws \RuntimeException
*/
public function registerMiddleware($name, $middleware)
{
$this->_middleware[$name] = $middleware;
return $this;
}
/**
* Add middleware to a middleware group
*
* @param string $name Name of the middleware group
* @param string[] $middlewareNames Names of the middleware
* @return $this
* @throws \RuntimeException
*/
public function middlewareGroup($name, array $middlewareNames)
{
if ($this->hasMiddleware($name)) {
$message = "Cannot add middleware group '$name'. A middleware by this name has already been registered.";
throw new RuntimeException($message);
}
foreach ($middlewareNames as $middlewareName) {
if (!$this->hasMiddleware($middlewareName)) {
$message = "Cannot add '$middlewareName' middleware to group '$name'. It has not been registered.";
throw new RuntimeException($message);
}
}
$this->_middlewareGroups[$name] = $middlewareNames;
return $this;
}
/**
* Check if the named middleware group has been created.
*
* @param string $name The name of the middleware group to check.
* @return bool
*/
public function hasMiddlewareGroup($name)
{
return array_key_exists($name, $this->_middlewareGroups);
}
/**
* Check if the named middleware has been registered.
*
* @param string $name The name of the middleware to check.
* @return bool
*/
public function hasMiddleware($name)
{
return isset($this->_middleware[$name]);
}
/**
* Check if the named middleware or middleware group has been registered.
*
* @param string $name The name of the middleware to check.
* @return bool
*/
public function middlewareExists($name)
{
return $this->hasMiddleware($name) || $this->hasMiddlewareGroup($name);
}
/**
* Apply a registered middleware(s) for the provided path
*
* @param string $path The URL path to register middleware for.
* @param string[] $middleware The middleware names to add for the path.
* @return $this
* @throws \RuntimeException
*/
public function applyMiddleware($path, array $middleware)
{
foreach ($middleware as $name) {
if (!$this->hasMiddleware($name) && !$this->hasMiddlewareGroup($name)) {
$message = "Cannot apply '$name' middleware or middleware group to path '$path'. It has not been registered.";
throw new RuntimeException($message);
}
}
// Matches route element pattern in Cake\Routing\Route
$path = '#^' . preg_quote($path, '#') . '#';
$path = preg_replace('/\\\\:([a-z0-9-_]+(?<![-_]))/i', '[^/]+', $path);
if (!isset($this->_middlewarePaths[$path])) {
$this->_middlewarePaths[$path] = [];
}
$this->_middlewarePaths[$path] = array_merge($this->_middlewarePaths[$path], $middleware);
return $this;
}
/**
* Get an array of middleware given a list of names
*
* @param string[] $names The names of the middleware or groups to fetch
* @return array An array of middleware. If any of the passed names are groups,
* the groups middleware will be flattened into the returned list.
* @throws \RuntimeException when a requested middleware does not exist.
*/
public function getMiddleware(array $names)
{
$out = [];
foreach ($names as $name) {
if ($this->hasMiddlewareGroup($name)) {
$out = array_merge($out, $this->getMiddleware($this->_middlewareGroups[$name]));
continue;
}
if (!$this->hasMiddleware($name)) {
$message = "The middleware named '$name' has not been registered. Use registerMiddleware() to define it.";
throw new RuntimeException($message);
}
$out[] = $this->_middleware[$name];
}
return $out;
}
}