RouteCollection.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.0.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Routing;
  16. use Cake\Routing\Exception\DuplicateNamedRouteException;
  17. use Cake\Routing\Exception\MissingRouteException;
  18. use Cake\Routing\Route\Route;
  19. use Psr\Http\Message\ServerRequestInterface;
  20. use RuntimeException;
  21. /**
  22. * Contains a collection of routes.
  23. *
  24. * Provides an interface for adding/removing routes
  25. * and parsing/generating URLs with the routes it contains.
  26. *
  27. * @internal
  28. */
  29. class RouteCollection
  30. {
  31. /**
  32. * The routes connected to this collection.
  33. *
  34. * @var array
  35. */
  36. protected $_routeTable = [];
  37. /**
  38. * The routes connected to this collection.
  39. *
  40. * @var \Cake\Routing\Route\Route[]
  41. */
  42. protected $_routes = [];
  43. /**
  44. * The hash map of named routes that are in this collection.
  45. *
  46. * @var \Cake\Routing\Route\Route[]
  47. */
  48. protected $_named = [];
  49. /**
  50. * Routes indexed by path prefix.
  51. *
  52. * @var array
  53. */
  54. protected $_paths = [];
  55. /**
  56. * A map of middleware names and the related objects.
  57. *
  58. * @var array
  59. */
  60. protected $_middleware = [];
  61. /**
  62. * A map of paths and the list of applicable middleware.
  63. *
  64. * @var array
  65. */
  66. protected $_middlewarePaths = [];
  67. /**
  68. * Route extensions
  69. *
  70. * @var array
  71. */
  72. protected $_extensions = [];
  73. /**
  74. * Add a route to the collection.
  75. *
  76. * @param \Cake\Routing\Route\Route $route The route object to add.
  77. * @param array $options Additional options for the route. Primarily for the
  78. * `_name` option, which enables named routes.
  79. * @return void
  80. */
  81. public function add(Route $route, array $options = [])
  82. {
  83. $this->_routes[] = $route;
  84. // Explicit names
  85. if (isset($options['_name'])) {
  86. if (isset($this->_named[$options['_name']])) {
  87. $matched = $this->_named[$options['_name']];
  88. throw new DuplicateNamedRouteException([
  89. 'name' => $options['_name'],
  90. 'url' => $matched->template,
  91. 'duplicate' => $matched,
  92. ]);
  93. }
  94. $this->_named[$options['_name']] = $route;
  95. }
  96. // Generated names.
  97. $name = $route->getName();
  98. if (!isset($this->_routeTable[$name])) {
  99. $this->_routeTable[$name] = [];
  100. }
  101. $this->_routeTable[$name][] = $route;
  102. // Index path prefixes (for parsing)
  103. $path = $route->staticPath();
  104. if (empty($this->_paths[$path])) {
  105. $this->_paths[$path] = [];
  106. krsort($this->_paths);
  107. }
  108. $this->_paths[$path][] = $route;
  109. $extensions = $route->getExtensions();
  110. if (count($extensions) > 0) {
  111. $this->extensions($extensions);
  112. }
  113. }
  114. /**
  115. * Takes the URL string and iterates the routes until one is able to parse the route.
  116. *
  117. * @param string $url URL to parse.
  118. * @param string $method The HTTP method to use.
  119. * @return array An array of request parameters parsed from the URL.
  120. * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
  121. */
  122. public function parse($url, $method = '')
  123. {
  124. $decoded = urldecode($url);
  125. foreach (array_keys($this->_paths) as $path) {
  126. if (strpos($decoded, $path) !== 0) {
  127. continue;
  128. }
  129. $queryParameters = null;
  130. if (strpos($url, '?') !== false) {
  131. list($url, $queryParameters) = explode('?', $url, 2);
  132. parse_str($queryParameters, $queryParameters);
  133. }
  134. /* @var \Cake\Routing\Route\Route $route */
  135. foreach ($this->_paths[$path] as $route) {
  136. $r = $route->parse($url, $method);
  137. if ($r === false) {
  138. continue;
  139. }
  140. if ($queryParameters) {
  141. $r['?'] = $queryParameters;
  142. }
  143. return $r;
  144. }
  145. }
  146. $exceptionProperties = ['url' => $url];
  147. if ($method !== '') {
  148. // Ensure that if the method is included, it is the first element of
  149. // the array, to match the order that the strings are printed in the
  150. // MissingRouteException error message, $_messageTemplateWithMethod.
  151. $exceptionProperties = array_merge(['method' => $method], $exceptionProperties);
  152. }
  153. throw new MissingRouteException($exceptionProperties);
  154. }
  155. /**
  156. * Takes the ServerRequestInterface, iterates the routes until one is able to parse the route.
  157. *
  158. * @param \Psr\Http\Messages\ServerRequestInterface $request The request to parse route data from.
  159. * @return array An array of request parameters parsed from the URL.
  160. * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
  161. */
  162. public function parseRequest(ServerRequestInterface $request)
  163. {
  164. $uri = $request->getUri();
  165. $urlPath = urldecode($uri->getPath());
  166. foreach (array_keys($this->_paths) as $path) {
  167. if (strpos($urlPath, $path) !== 0) {
  168. continue;
  169. }
  170. /* @var \Cake\Routing\Route\Route $route */
  171. foreach ($this->_paths[$path] as $route) {
  172. $r = $route->parseRequest($request);
  173. if ($r === false) {
  174. continue;
  175. }
  176. if ($uri->getQuery()) {
  177. parse_str($uri->getQuery(), $queryParameters);
  178. $r['?'] = $queryParameters;
  179. }
  180. return $r;
  181. }
  182. }
  183. throw new MissingRouteException(['url' => $urlPath]);
  184. }
  185. /**
  186. * Get the set of names from the $url. Accepts both older style array urls,
  187. * and newer style urls containing '_name'
  188. *
  189. * @param array $url The url to match.
  190. * @return array The set of names of the url
  191. */
  192. protected function _getNames($url)
  193. {
  194. $plugin = false;
  195. if (isset($url['plugin']) && $url['plugin'] !== false) {
  196. $plugin = strtolower($url['plugin']);
  197. }
  198. $prefix = false;
  199. if (isset($url['prefix']) && $url['prefix'] !== false) {
  200. $prefix = strtolower($url['prefix']);
  201. }
  202. $controller = strtolower($url['controller']);
  203. $action = strtolower($url['action']);
  204. $names = [
  205. "${controller}:${action}",
  206. "${controller}:_action",
  207. "_controller:${action}",
  208. '_controller:_action',
  209. ];
  210. // No prefix, no plugin
  211. if ($prefix === false && $plugin === false) {
  212. return $names;
  213. }
  214. // Only a plugin
  215. if ($prefix === false) {
  216. return [
  217. "${plugin}.${controller}:${action}",
  218. "${plugin}.${controller}:_action",
  219. "${plugin}._controller:${action}",
  220. "${plugin}._controller:_action",
  221. "_plugin.${controller}:${action}",
  222. "_plugin.${controller}:_action",
  223. "_plugin._controller:${action}",
  224. '_plugin._controller:_action',
  225. ];
  226. }
  227. // Only a prefix
  228. if ($plugin === false) {
  229. return [
  230. "${prefix}:${controller}:${action}",
  231. "${prefix}:${controller}:_action",
  232. "${prefix}:_controller:${action}",
  233. "${prefix}:_controller:_action",
  234. "_prefix:${controller}:${action}",
  235. "_prefix:${controller}:_action",
  236. "_prefix:_controller:${action}",
  237. '_prefix:_controller:_action',
  238. ];
  239. }
  240. // Prefix and plugin has the most options
  241. // as there are 4 factors.
  242. return [
  243. "${prefix}:${plugin}.${controller}:${action}",
  244. "${prefix}:${plugin}.${controller}:_action",
  245. "${prefix}:${plugin}._controller:${action}",
  246. "${prefix}:${plugin}._controller:_action",
  247. "${prefix}:_plugin.${controller}:${action}",
  248. "${prefix}:_plugin.${controller}:_action",
  249. "${prefix}:_plugin._controller:${action}",
  250. "${prefix}:_plugin._controller:_action",
  251. "_prefix:${plugin}.${controller}:${action}",
  252. "_prefix:${plugin}.${controller}:_action",
  253. "_prefix:${plugin}._controller:${action}",
  254. "_prefix:${plugin}._controller:_action",
  255. "_prefix:_plugin.${controller}:${action}",
  256. "_prefix:_plugin.${controller}:_action",
  257. "_prefix:_plugin._controller:${action}",
  258. '_prefix:_plugin._controller:_action',
  259. ];
  260. }
  261. /**
  262. * Reverse route or match a $url array with the defined routes.
  263. * Returns either the string URL generate by the route, or false on failure.
  264. *
  265. * @param array $url The url to match.
  266. * @param array $context The request context to use. Contains _base, _port,
  267. * _host, _scheme and params keys.
  268. * @return string|false Either a string on match, or false on failure.
  269. * @throws \Cake\Routing\Exception\MissingRouteException when a route cannot be matched.
  270. */
  271. public function match($url, $context)
  272. {
  273. // Named routes support optimization.
  274. if (isset($url['_name'])) {
  275. $name = $url['_name'];
  276. unset($url['_name']);
  277. $out = false;
  278. if (isset($this->_named[$name])) {
  279. $route = $this->_named[$name];
  280. $out = $route->match($url + $route->defaults, $context);
  281. if ($out) {
  282. return $out;
  283. }
  284. throw new MissingRouteException([
  285. 'url' => $name,
  286. 'context' => $context,
  287. 'message' => 'A named route was found for "%s", but matching failed.',
  288. ]);
  289. }
  290. throw new MissingRouteException(['url' => $name, 'context' => $context]);
  291. }
  292. foreach ($this->_getNames($url) as $name) {
  293. if (empty($this->_routeTable[$name])) {
  294. continue;
  295. }
  296. /* @var \Cake\Routing\Route\Route $route */
  297. foreach ($this->_routeTable[$name] as $route) {
  298. $match = $route->match($url, $context);
  299. if ($match) {
  300. return strlen($match) > 1 ? trim($match, '/') : $match;
  301. }
  302. }
  303. }
  304. throw new MissingRouteException(['url' => var_export($url, true), 'context' => $context]);
  305. }
  306. /**
  307. * Get all the connected routes as a flat list.
  308. *
  309. * @return \Cake\Routing\Route\Route[]
  310. */
  311. public function routes()
  312. {
  313. return $this->_routes;
  314. }
  315. /**
  316. * Get the connected named routes.
  317. *
  318. * @return \Cake\Routing\Route\Route[]
  319. */
  320. public function named()
  321. {
  322. return $this->_named;
  323. }
  324. /**
  325. * Get/set the extensions that the route collection could handle.
  326. *
  327. * @param null|string|array $extensions Either the list of extensions to set,
  328. * or null to get.
  329. * @param bool $merge Whether to merge with or override existing extensions.
  330. * Defaults to `true`.
  331. * @return array The valid extensions.
  332. */
  333. public function extensions($extensions = null, $merge = true)
  334. {
  335. if ($extensions === null) {
  336. return $this->_extensions;
  337. }
  338. $extensions = (array)$extensions;
  339. if ($merge) {
  340. $extensions = array_unique(array_merge(
  341. $this->_extensions,
  342. $extensions
  343. ));
  344. }
  345. return $this->_extensions = $extensions;
  346. }
  347. /**
  348. * Register a middleware with the RouteCollection.
  349. *
  350. * Once middleware has been registered, it can be applied to the current routing
  351. * scope or any child scopes that share the same RoutingCollection.
  352. *
  353. * @param string $name The name of the middleware. Used when applying middleware to a scope.
  354. * @param callable $middleware The middleware object to register.
  355. * @return $this
  356. */
  357. public function registerMiddleware($name, callable $middleware)
  358. {
  359. if (is_string($middleware)) {
  360. throw new RuntimeException("The '$name' middleware is not a callable object.");
  361. }
  362. $this->_middleware[$name] = $middleware;
  363. return $this;
  364. }
  365. /**
  366. * Check if the named middleware has been registered.
  367. *
  368. * @param string $name The name of the middleware to check.
  369. * @return void
  370. */
  371. public function hasMiddleware($name)
  372. {
  373. return isset($this->_middleware[$name]);
  374. }
  375. /**
  376. * Enable a registered middleware(s) for the provided path
  377. *
  378. * @param string $path The URL path to register middleware for.
  379. * @param string[] $names The middleware names to add for the path.
  380. * @return $this
  381. */
  382. public function enableMiddleware($path, array $middleware)
  383. {
  384. foreach ($middleware as $name) {
  385. if (!$this->hasMiddleware($name)) {
  386. $message = "Cannot apply '$name' middleware to path '$path'. It has not been registered.";
  387. throw new RuntimeException($message);
  388. }
  389. }
  390. // Matches route element pattern in Cake\Routing\Route
  391. $path = '#^' . preg_quote($path, '#') . '#';
  392. $path = preg_replace('/\\\\:([a-z0-9-_]+(?<![-_]))/i', '[^/]+', $path);
  393. if (!isset($this->_middlewarePaths[$path])) {
  394. $this->_middlewarePaths[$path] = [];
  395. }
  396. $this->_middlewarePaths[$path] = array_merge($this->_middlewarePaths[$path], $middleware);
  397. return $this;
  398. }
  399. /**
  400. * Get an array of middleware that matches the provided URL.
  401. *
  402. * All middleware lists that match the URL will be merged together from shortest
  403. * path to longest path. If a middleware would be added to the set more than
  404. * once because it is connected to multiple path substrings match, it will only
  405. * be added once at its first occurrence.
  406. *
  407. * @param string $needle The URL path to find middleware for.
  408. * @return array
  409. */
  410. public function getMatchingMiddleware($needle)
  411. {
  412. $matching = [];
  413. foreach ($this->_middlewarePaths as $pattern => $middleware) {
  414. if (preg_match($pattern, $needle)) {
  415. $matching = array_merge($matching, $middleware);
  416. }
  417. }
  418. $resolved = [];
  419. foreach ($matching as $name) {
  420. if (!isset($resolved[$name])) {
  421. $resolved[$name] = $this->_middleware[$name];
  422. }
  423. }
  424. return array_values($resolved);
  425. }
  426. }