RouteCollection.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
  11. * @link https://cakephp.org CakePHP(tm) Project
  12. * @since 3.0.0
  13. * @license https://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 middleware group names and the related middleware names.
  63. *
  64. * @var array
  65. */
  66. protected $_middlewareGroups = [];
  67. /**
  68. * A map of paths and the list of applicable middleware.
  69. *
  70. * @var array
  71. */
  72. protected $_middlewarePaths = [];
  73. /**
  74. * Route extensions
  75. *
  76. * @var array
  77. */
  78. protected $_extensions = [];
  79. /**
  80. * Add a route to the collection.
  81. *
  82. * @param \Cake\Routing\Route\Route $route The route object to add.
  83. * @param array $options Additional options for the route. Primarily for the
  84. * `_name` option, which enables named routes.
  85. * @return void
  86. */
  87. public function add(Route $route, array $options = [])
  88. {
  89. $this->_routes[] = $route;
  90. // Explicit names
  91. if (isset($options['_name'])) {
  92. if (isset($this->_named[$options['_name']])) {
  93. $matched = $this->_named[$options['_name']];
  94. throw new DuplicateNamedRouteException([
  95. 'name' => $options['_name'],
  96. 'url' => $matched->template,
  97. 'duplicate' => $matched,
  98. ]);
  99. }
  100. $this->_named[$options['_name']] = $route;
  101. }
  102. // Generated names.
  103. $name = $route->getName();
  104. if (!isset($this->_routeTable[$name])) {
  105. $this->_routeTable[$name] = [];
  106. }
  107. $this->_routeTable[$name][] = $route;
  108. // Index path prefixes (for parsing)
  109. $path = $route->staticPath();
  110. if (empty($this->_paths[$path])) {
  111. $this->_paths[$path] = [];
  112. krsort($this->_paths);
  113. }
  114. $this->_paths[$path][] = $route;
  115. $extensions = $route->getExtensions();
  116. if (count($extensions) > 0) {
  117. $this->setExtensions($extensions);
  118. }
  119. }
  120. /**
  121. * Takes the URL string and iterates the routes until one is able to parse the route.
  122. *
  123. * @param string $url URL to parse.
  124. * @param string $method The HTTP method to use.
  125. * @return array An array of request parameters parsed from the URL.
  126. * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
  127. */
  128. public function parse($url, $method = '')
  129. {
  130. $decoded = urldecode($url);
  131. foreach (array_keys($this->_paths) as $path) {
  132. if (strpos($decoded, $path) !== 0) {
  133. continue;
  134. }
  135. $queryParameters = null;
  136. if (strpos($url, '?') !== false) {
  137. list($url, $queryParameters) = explode('?', $url, 2);
  138. parse_str($queryParameters, $queryParameters);
  139. }
  140. /* @var \Cake\Routing\Route\Route $route */
  141. foreach ($this->_paths[$path] as $route) {
  142. $r = $route->parse($url, $method);
  143. if ($r === false) {
  144. continue;
  145. }
  146. if ($queryParameters) {
  147. $r['?'] = $queryParameters;
  148. }
  149. return $r;
  150. }
  151. }
  152. $exceptionProperties = ['url' => $url];
  153. if ($method !== '') {
  154. // Ensure that if the method is included, it is the first element of
  155. // the array, to match the order that the strings are printed in the
  156. // MissingRouteException error message, $_messageTemplateWithMethod.
  157. $exceptionProperties = array_merge(['method' => $method], $exceptionProperties);
  158. }
  159. throw new MissingRouteException($exceptionProperties);
  160. }
  161. /**
  162. * Takes the ServerRequestInterface, iterates the routes until one is able to parse the route.
  163. *
  164. * @param \Psr\Http\Messages\ServerRequestInterface $request The request to parse route data from.
  165. * @return array An array of request parameters parsed from the URL.
  166. * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route.
  167. */
  168. public function parseRequest(ServerRequestInterface $request)
  169. {
  170. $uri = $request->getUri();
  171. $urlPath = urldecode($uri->getPath());
  172. foreach (array_keys($this->_paths) as $path) {
  173. if (strpos($urlPath, $path) !== 0) {
  174. continue;
  175. }
  176. /* @var \Cake\Routing\Route\Route $route */
  177. foreach ($this->_paths[$path] as $route) {
  178. $r = $route->parseRequest($request);
  179. if ($r === false) {
  180. continue;
  181. }
  182. if ($uri->getQuery()) {
  183. parse_str($uri->getQuery(), $queryParameters);
  184. $r['?'] = $queryParameters;
  185. }
  186. return $r;
  187. }
  188. }
  189. throw new MissingRouteException(['url' => $urlPath]);
  190. }
  191. /**
  192. * Get the set of names from the $url. Accepts both older style array urls,
  193. * and newer style urls containing '_name'
  194. *
  195. * @param array $url The url to match.
  196. * @return array The set of names of the url
  197. */
  198. protected function _getNames($url)
  199. {
  200. $plugin = false;
  201. if (isset($url['plugin']) && $url['plugin'] !== false) {
  202. $plugin = strtolower($url['plugin']);
  203. }
  204. $prefix = false;
  205. if (isset($url['prefix']) && $url['prefix'] !== false) {
  206. $prefix = strtolower($url['prefix']);
  207. }
  208. $controller = strtolower($url['controller']);
  209. $action = strtolower($url['action']);
  210. $names = [
  211. "${controller}:${action}",
  212. "${controller}:_action",
  213. "_controller:${action}",
  214. '_controller:_action',
  215. ];
  216. // No prefix, no plugin
  217. if ($prefix === false && $plugin === false) {
  218. return $names;
  219. }
  220. // Only a plugin
  221. if ($prefix === false) {
  222. return [
  223. "${plugin}.${controller}:${action}",
  224. "${plugin}.${controller}:_action",
  225. "${plugin}._controller:${action}",
  226. "${plugin}._controller:_action",
  227. "_plugin.${controller}:${action}",
  228. "_plugin.${controller}:_action",
  229. "_plugin._controller:${action}",
  230. '_plugin._controller:_action',
  231. ];
  232. }
  233. // Only a prefix
  234. if ($plugin === false) {
  235. return [
  236. "${prefix}:${controller}:${action}",
  237. "${prefix}:${controller}:_action",
  238. "${prefix}:_controller:${action}",
  239. "${prefix}:_controller:_action",
  240. "_prefix:${controller}:${action}",
  241. "_prefix:${controller}:_action",
  242. "_prefix:_controller:${action}",
  243. '_prefix:_controller:_action',
  244. ];
  245. }
  246. // Prefix and plugin has the most options
  247. // as there are 4 factors.
  248. return [
  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. "_prefix:${plugin}._controller:${action}",
  260. "_prefix:${plugin}._controller:_action",
  261. "_prefix:_plugin.${controller}:${action}",
  262. "_prefix:_plugin.${controller}:_action",
  263. "_prefix:_plugin._controller:${action}",
  264. '_prefix:_plugin._controller:_action',
  265. ];
  266. }
  267. /**
  268. * Reverse route or match a $url array with the connected routes.
  269. *
  270. * Returns either the URL string generated by the route,
  271. * or throws an exception on failure.
  272. *
  273. * @param array $url The URL to match.
  274. * @param array $context The request context to use. Contains _base, _port,
  275. * _host, _scheme and params keys.
  276. * @return string The URL string on match.
  277. * @throws \Cake\Routing\Exception\MissingRouteException When no route could be matched.
  278. */
  279. public function match($url, $context)
  280. {
  281. // Named routes support optimization.
  282. if (isset($url['_name'])) {
  283. $name = $url['_name'];
  284. unset($url['_name']);
  285. if (isset($this->_named[$name])) {
  286. $route = $this->_named[$name];
  287. $out = $route->match($url + $route->defaults, $context);
  288. if ($out) {
  289. return $out;
  290. }
  291. throw new MissingRouteException([
  292. 'url' => $name,
  293. 'context' => $context,
  294. 'message' => 'A named route was found for "%s", but matching failed.',
  295. ]);
  296. }
  297. throw new MissingRouteException(['url' => $name, 'context' => $context]);
  298. }
  299. foreach ($this->_getNames($url) as $name) {
  300. if (empty($this->_routeTable[$name])) {
  301. continue;
  302. }
  303. /* @var \Cake\Routing\Route\Route $route */
  304. foreach ($this->_routeTable[$name] as $route) {
  305. $match = $route->match($url, $context);
  306. if ($match) {
  307. return strlen($match) > 1 ? trim($match, '/') : $match;
  308. }
  309. }
  310. }
  311. throw new MissingRouteException(['url' => var_export($url, true), 'context' => $context]);
  312. }
  313. /**
  314. * Get all the connected routes as a flat list.
  315. *
  316. * @return \Cake\Routing\Route\Route[]
  317. */
  318. public function routes()
  319. {
  320. return $this->_routes;
  321. }
  322. /**
  323. * Get the connected named routes.
  324. *
  325. * @return \Cake\Routing\Route\Route[]
  326. */
  327. public function named()
  328. {
  329. return $this->_named;
  330. }
  331. /**
  332. * Get/set the extensions that the route collection could handle.
  333. *
  334. * @param null|string|array $extensions Either the list of extensions to set,
  335. * or null to get.
  336. * @param bool $merge Whether to merge with or override existing extensions.
  337. * Defaults to `true`.
  338. * @return array The valid extensions.
  339. * @deprecated 3.5.0 Use getExtensions()/setExtensions() instead.
  340. */
  341. public function extensions($extensions = null, $merge = true)
  342. {
  343. if ($extensions !== null) {
  344. $this->setExtensions((array)$extensions, $merge);
  345. }
  346. return $this->getExtensions();
  347. }
  348. /**
  349. * Get the extensions that can be handled.
  350. *
  351. * @return array The valid extensions.
  352. */
  353. public function getExtensions()
  354. {
  355. return $this->_extensions;
  356. }
  357. /**
  358. * Set the extensions that the route collection can handle.
  359. *
  360. * @param array $extensions The list of extensions to set.
  361. * @param bool $merge Whether to merge with or override existing extensions.
  362. * Defaults to `true`.
  363. * @return $this
  364. */
  365. public function setExtensions(array $extensions, $merge = true)
  366. {
  367. if ($merge) {
  368. $extensions = array_unique(array_merge(
  369. $this->_extensions,
  370. $extensions
  371. ));
  372. }
  373. $this->_extensions = $extensions;
  374. return $this;
  375. }
  376. /**
  377. * Register a middleware with the RouteCollection.
  378. *
  379. * Once middleware has been registered, it can be applied to the current routing
  380. * scope or any child scopes that share the same RouteCollection.
  381. *
  382. * @param string $name The name of the middleware. Used when applying middleware to a scope.
  383. * @param callable $middleware The middleware object to register.
  384. * @return $this
  385. */
  386. public function registerMiddleware($name, callable $middleware)
  387. {
  388. if (is_string($middleware)) {
  389. throw new RuntimeException("The '$name' middleware is not a callable object.");
  390. }
  391. $this->_middleware[$name] = $middleware;
  392. return $this;
  393. }
  394. /**
  395. * Add middleware to a middleware group
  396. *
  397. * @param string $name Name of the middleware group
  398. * @param array $names Names of the middleware
  399. * @return $this
  400. */
  401. public function middlewareGroup($name, $middlewareNames)
  402. {
  403. if ($this->hasMiddleware($name)) {
  404. $message = "Cannot add middle ware group '$name' . A middleware by this name has already been registered.";
  405. throw new RuntimeException($message);
  406. }
  407. if ($this->hasMiddlewareGroup($name)) {
  408. $message = "Cannot add middle ware group '$name' . A middleware group by this name has already been added.";
  409. throw new RuntimeException($message);
  410. }
  411. foreach ($middlewareNames as $middlewareName) {
  412. if (!$this->hasMiddleware($middlewareName)) {
  413. $message = "Cannot add '$middlewareName' middleware to group '$name'. It has not been registered.";
  414. throw new RuntimeException($message);
  415. }
  416. }
  417. $this->_middlewareGroups[$name] = $middlewareNames;
  418. return $this;
  419. }
  420. /**
  421. * Check if the named middleware group has been created.
  422. *
  423. * @param string $name The name of the middleware group to check.
  424. * @return bool
  425. */
  426. public function hasMiddlewareGroup($name)
  427. {
  428. return array_key_exists($name, $this->_middlewareGroups);
  429. }
  430. /**
  431. * Check if the named middleware has been registered.
  432. *
  433. * @param string $name The name of the middleware to check.
  434. * @return bool
  435. */
  436. public function hasMiddleware($name)
  437. {
  438. return isset($this->_middleware[$name]);
  439. }
  440. /**
  441. * Apply a registered middleware(s) for the provided path
  442. *
  443. * @param string $path The URL path to register middleware for.
  444. * @param string[] $middleware The middleware names to add for the path.
  445. * @return $this
  446. */
  447. public function applyMiddleware($path, array $middleware)
  448. {
  449. foreach ($middleware as $name) {
  450. if (!$this->hasMiddleware($name) && !$this->hasMiddlewareGroup($name)) {
  451. if (!$this->hasMiddleware($name)) {
  452. $message = "Cannot apply '$name' middleware to path '$path'. It has not been registered.";
  453. throw new RuntimeException($message);
  454. }
  455. if (!$this->hasMiddlewareGroup($name)) {
  456. $message = "Cannot apply '$name' middleware group to path '$path'. It has not been added.";
  457. throw new RuntimeException($message);
  458. }
  459. }
  460. }
  461. // Matches route element pattern in Cake\Routing\Route
  462. $path = '#^' . preg_quote($path, '#') . '#';
  463. $path = preg_replace('/\\\\:([a-z0-9-_]+(?<![-_]))/i', '[^/]+', $path);
  464. if (!isset($this->_middlewarePaths[$path])) {
  465. $this->_middlewarePaths[$path] = [];
  466. }
  467. $this->_middlewarePaths[$path] = array_merge($this->_middlewarePaths[$path], $middleware);
  468. return $this;
  469. }
  470. /**
  471. * Get an array of middleware that matches the provided URL.
  472. *
  473. * All middleware lists that match the URL will be merged together from shortest
  474. * path to longest path. If a middleware would be added to the set more than
  475. * once because it is connected to multiple path substrings match, it will only
  476. * be added once at its first occurrence.
  477. *
  478. * @param string $needle The URL path to find middleware for.
  479. * @return array
  480. */
  481. public function getMatchingMiddleware($needle)
  482. {
  483. $matching = [];
  484. foreach ($this->_middlewarePaths as $pattern => $middleware) {
  485. if (preg_match($pattern, $needle)) {
  486. $matching = array_merge($matching, $middleware);
  487. }
  488. }
  489. $resolved = [];
  490. foreach ($matching as $name) {
  491. if (!isset($resolved[$name])) {
  492. $resolved[$name] = $this->_middleware[$name];
  493. }
  494. }
  495. return array_values($resolved);
  496. }
  497. }