Router.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. <?php
  2. /**
  3. * Lithium: the most rad php framework
  4. *
  5. * @copyright Copyright 2013, Union of RAD (http://union-of-rad.org)
  6. * @license http://opensource.org/licenses/bsd-license.php The BSD License
  7. */
  8. namespace lithium\net\http;
  9. use lithium\util\Inflector;
  10. use lithium\net\http\RoutingException;
  11. /**
  12. * The two primary responsibilities of the `Router` class are to generate URLs from parameter lists,
  13. * and to determine the correct set of dispatch parameters for incoming requests.
  14. *
  15. * Using `Route` objects, these two operations can be handled in a reciprocally consistent way.
  16. * For example, if you wanted the `/login` URL to be routed to
  17. * `myapp\controllers\SessionsController::add()`, you could set up a route like the following in
  18. * `config/routes.php`:
  19. *
  20. * {{{
  21. * use lithium\net\http\Router;
  22. *
  23. * Router::connect('/login', array('controller' => 'Sessions', 'action' => 'add'));
  24. *
  25. * // -- or --
  26. *
  27. * Router::connect('/login', 'Sessions::add');
  28. * }}}
  29. *
  30. * Not only would that correctly route all requests for `/login` to `SessionsController::add()`, but
  31. * any time the framework generated a route with matching parameters, `Router` would return the
  32. * correct short URL.
  33. *
  34. * While most framework components that work with URLs (and utilize routing) handle calling the
  35. * `Router` directly (i.e. controllers doing redirects, or helpers generating links), if you have a
  36. * scenario where you need to call the `Router` directly, you can use the `match()` method.
  37. *
  38. * This allows you to keep your application's URL structure nicely decoupled from the underlying
  39. * software design. For more information on parsing and generating URLs, see the `parse()` and
  40. * `match()` methods.
  41. */
  42. class Router extends \lithium\core\StaticObject {
  43. /**
  44. * An array of loaded `Route` objects used to match Request objects against.
  45. *
  46. * @see lithium\net\http\Route
  47. * @var array
  48. */
  49. protected static $_configurations = array();
  50. /**
  51. * An array of named closures matching up to corresponding route parameter values. Used to
  52. * format those values.
  53. *
  54. * @see lithium\net\http\Router::formatters()
  55. * @var array
  56. */
  57. protected static $_formatters = array();
  58. /**
  59. * Classes used by `Router`.
  60. *
  61. * @var array
  62. */
  63. protected static $_classes = array(
  64. 'route' => 'lithium\net\http\Route'
  65. );
  66. /**
  67. * Flag for generating Unicode-capable routes. Turn this off if you don't need it, or if you're
  68. * using a broken OS distribution (i.e. CentOS).
  69. */
  70. protected static $_unicode = true;
  71. /**
  72. * Modify `Router` configuration settings and dependencies.
  73. *
  74. * @param array $config Optional array to override configuration. Acceptable keys are
  75. * `'classes'` and `'unicode'`.
  76. * @return array Returns the current configuration settings.
  77. */
  78. public static function config($config = array()) {
  79. if (!$config) {
  80. return array('classes' => static::$_classes, 'unicode' => static::$_unicode);
  81. }
  82. if (isset($config['classes'])) {
  83. static::$_classes = $config['classes'] + static::$_classes;
  84. }
  85. if (isset($config['unicode'])) {
  86. static::$_unicode = $config['unicode'];
  87. }
  88. }
  89. /**
  90. * Connects a new route and returns the current routes array. This method creates a new
  91. * `Route` object and registers it with the `Router`. The order in which routes are connected
  92. * matters, since the order of precedence is taken into account in parsing and matching
  93. * operations.
  94. *
  95. * @see lithium\net\http\Route
  96. * @see lithium\net\http\Router::parse()
  97. * @see lithium\net\http\Router::match()
  98. * @param string $template An empty string, or a route string "/"
  99. * @param array $params An array describing the default or required elements of the route
  100. * @param array $options
  101. * @return array Array of routes
  102. */
  103. public static function connect($template, $params = array(), $options = array()) {
  104. if (is_object($template)) {
  105. return (static::$_configurations[] = $template);
  106. }
  107. if (is_string($params)) {
  108. $params = static::_parseString($params, false);
  109. }
  110. if (isset($params[0]) && is_array($tmp = static::_parseString($params[0], false))) {
  111. unset($params[0]);
  112. $params = $tmp + $params;
  113. }
  114. $params = static::_parseController($params);
  115. if (is_callable($options)) {
  116. $options = array('handler' => $options);
  117. }
  118. $config = compact('template', 'params') + $options + array(
  119. 'formatters' => static::formatters(),
  120. 'unicode' => static::$_unicode
  121. );
  122. return (static::$_configurations[] = static::_instance('route', $config));
  123. }
  124. /**
  125. * Wrapper method which takes a `Request` object, parses it through all attached `Route`
  126. * objects, assigns the resulting parameters to the `Request` object, and returns it.
  127. *
  128. * @param object $request A request object, usually an instance of `lithium\action\Request`.
  129. * @return object Returns a copy of the `Request` object with parameters applied.
  130. */
  131. public static function process($request) {
  132. if (!$result = static::parse($request)) {
  133. return $request;
  134. }
  135. return $result;
  136. }
  137. /**
  138. * Used to get or set an array of named formatter closures, which are used to format route
  139. * parameters when generating URLs. For example, for controller/action parameters to be dashed
  140. * instead of underscored or camelBacked, you could do the following:
  141. *
  142. * {{{
  143. * use lithium\util\Inflector;
  144. *
  145. * Router::formatters(array(
  146. * 'controller' => function($value) { return Inflector::slug($value); },
  147. * 'action' => function($value) { return Inflector::slug($value); }
  148. * ));
  149. * }}}
  150. *
  151. * _Note_: Because formatters are copied to `Route` objects on an individual basis, make sure
  152. * you append custom formatters _before_ connecting new routes.
  153. *
  154. * @param array $formatters An array of named formatter closures to append to (or overwrite) the
  155. * existing list.
  156. * @return array Returns the formatters array.
  157. */
  158. public static function formatters(array $formatters = array()) {
  159. if (!static::$_formatters) {
  160. static::$_formatters = array(
  161. 'args' => function($value) {
  162. return is_array($value) ? join('/', $value) : $value;
  163. },
  164. 'controller' => function($value) {
  165. return Inflector::underscore($value);
  166. }
  167. );
  168. }
  169. if ($formatters) {
  170. static::$_formatters = array_filter($formatters + static::$_formatters);
  171. }
  172. return static::$_formatters;
  173. }
  174. /**
  175. * Accepts an instance of `lithium\action\Request` (or a subclass) and matches it against each
  176. * route, in the order that the routes are connected.
  177. *
  178. * @see lithium\action\Request
  179. * @see lithium\net\http\Router::connect()
  180. * @param object $request A request object containing URL and environment data.
  181. * @return array Returns an array of parameters specifying how the given request should be
  182. * routed. The keys returned depend on the `Route` object that was matched, but
  183. * typically include `'controller'` and `'action'` keys.
  184. */
  185. public static function parse($request) {
  186. $orig = $request->params;
  187. $url = $request->url;
  188. foreach (static::$_configurations as $route) {
  189. if (!$match = $route->parse($request, compact('url'))) {
  190. continue;
  191. }
  192. $request = $match;
  193. if ($route->canContinue() && isset($request->params['args'])) {
  194. $url = '/' . join('/', $request->params['args']);
  195. unset($request->params['args']);
  196. continue;
  197. }
  198. return $request;
  199. }
  200. $request->params = $orig;
  201. }
  202. /**
  203. * Attempts to match an array of route parameters (i.e. `'controller'`, `'action'`, etc.)
  204. * against a connected `Route` object. For example, given the following route:
  205. *
  206. * {{{
  207. * Router::connect('/login', array('controller' => 'users', 'action' => 'login'));
  208. * }}}
  209. *
  210. * This will match:
  211. * {{{
  212. * $url = Router::match(array('controller' => 'users', 'action' => 'login'));
  213. * // returns /login
  214. * }}}
  215. *
  216. * For URLs templates with no insert parameters (i.e. elements like `{:id}` that are replaced
  217. * with a value), all parameters must match exactly as they appear in the route parameters.
  218. *
  219. * Alternatively to using a full array, you can specify routes using a more compact syntax. The
  220. * above example can be written as:
  221. *
  222. * {{{ $url = Router::match('Users::login'); // still returns /login }}}
  223. *
  224. * You can combine this with more complicated routes; for example:
  225. * {{{
  226. * Router::connect('/posts/{:id:\d+}', array('controller' => 'posts', 'action' => 'view'));
  227. * }}}
  228. *
  229. * This will match:
  230. * {{{
  231. * $url = Router::match(array('controller' => 'posts', 'action' => 'view', 'id' => '1138'));
  232. * // returns /posts/1138
  233. * }}}
  234. *
  235. * Again, you can specify the same URL with a more compact syntax, as in the following:
  236. * {{{
  237. * $url = Router::match(array('Posts::view', 'id' => '1138'));
  238. * // again, returns /posts/1138
  239. * }}}
  240. *
  241. * You can use either syntax anywhere a URL is accepted, i.e.
  242. * `lithium\action\Controller::redirect()`, or `lithium\template\helper\Html::link()`.
  243. *
  244. * @param string|array $url Options to match to a URL. Optionally, this can be a string
  245. * containing a manually generated URL.
  246. * @param object $context An instance of `lithium\action\Request`. This supplies the context for
  247. * any persistent parameters, as well as the base URL for the application.
  248. * @param array $options Options for the generation of the matched URL. Currently accepted
  249. * values are:
  250. * - `'absolute'` _boolean_: Indicates whether or not the returned URL should be an
  251. * absolute path (i.e. including scheme and host name).
  252. * - `'host'` _string_: If `'absolute'` is `true`, sets the host name to be used,
  253. * or overrides the one provided in `$context`.
  254. * - `'scheme'` _string_: If `'absolute'` is `true`, sets the URL scheme to be
  255. * used, or overrides the one provided in `$context`.
  256. * @return string Returns a generated URL, based on the URL template of the matched route, and
  257. * prefixed with the base URL of the application.
  258. */
  259. public static function match($url = array(), $context = null, array $options = array()) {
  260. if (is_string($url = static::_prepareParams($url, $context, $options))) {
  261. return $url;
  262. }
  263. $defaults = array('action' => 'index');
  264. $url += $defaults;
  265. $stack = array();
  266. $base = isset($context) ? $context->env('base') : '';
  267. $suffix = isset($url['#']) ? "#{$url['#']}" : null;
  268. unset($url['#']);
  269. foreach (static::$_configurations as $route) {
  270. if (!$match = $route->match($url, $context)) {
  271. continue;
  272. }
  273. if ($route->canContinue()) {
  274. $stack[] = $match;
  275. $export = $route->export();
  276. $keys = $export['match'] + $export['keys'] + $export['defaults'];
  277. unset($keys['args']);
  278. $url = array_diff_key($url, $keys);
  279. continue;
  280. }
  281. if ($stack) {
  282. $stack[] = $match;
  283. $match = static::_compileStack($stack);
  284. }
  285. $path = rtrim("{$base}{$match}{$suffix}", '/') ?: '/';
  286. $path = ($options) ? static::_prefix($path, $context, $options) : $path;
  287. return $path ?: '/';
  288. }
  289. $url = static::_formatError($url);
  290. throw new RoutingException("No parameter match found for URL `{$url}`.");
  291. }
  292. protected static function _compileStack($stack) {
  293. $result = null;
  294. foreach (array_reverse($stack) as $fragment) {
  295. if ($result) {
  296. $result = str_replace('{:args}', ltrim($result, '/'), $fragment);
  297. continue;
  298. }
  299. $result = $fragment;
  300. }
  301. return $result;
  302. }
  303. protected static function _formatError($url) {
  304. $match = array("\n", 'array (', ',)', '=> NULL', '( \'', ', ');
  305. $replace = array('', '(', ')', '=> null', '(\'', ', ');
  306. return str_replace($match, $replace, var_export($url, true));
  307. }
  308. protected static function _parseController(array $params) {
  309. if (!isset($params['controller'])) {
  310. return $params;
  311. }
  312. if (strpos($params['controller'], '.')) {
  313. $separated = explode('.', $params['controller'], 2);
  314. list($params['library'], $params['controller']) = $separated;
  315. }
  316. if (strpos($params['controller'], '\\') === false) {
  317. $params['controller'] = Inflector::camelize($params['controller']);
  318. }
  319. return $params;
  320. }
  321. protected static function _prepareParams($url, $context, array $options) {
  322. if (is_string($url)) {
  323. if (strpos($url, '://')) {
  324. return $url;
  325. }
  326. foreach (array('#', '//', 'mailto') as $prefix) {
  327. if (strpos($url, $prefix) === 0) {
  328. return $url;
  329. }
  330. }
  331. if (is_string($url = static::_parseString($url, $context))) {
  332. return static::_prefix($url, $context, $options);
  333. }
  334. }
  335. if (isset($url[0]) && is_array($params = static::_parseString($url[0], $context))) {
  336. unset($url[0]);
  337. $url = $params + $url;
  338. }
  339. return static::_persist(static::_parseController($url), $context);
  340. }
  341. /**
  342. * Returns the prefix (scheme + hostname) for a URL based on the passed `$options` and the
  343. * `$context`.
  344. *
  345. * @param string $path The URL to be prefixed.
  346. * @param object $context The request context.
  347. * @param array $options Options for generating the proper prefix. Currently accepted values
  348. * are: `'absolute' => true|false`, `'host' => string` and `'scheme' => string`.
  349. * @return string The prefixed URL, depending on the passed options.
  350. */
  351. protected static function _prefix($path, $context = null, array $options = array()) {
  352. $defaults = array('scheme' => null, 'host' => null, 'absolute' => false);
  353. if ($context) {
  354. $defaults['host'] = $context->env('HTTP_HOST');
  355. $defaults['scheme'] = $context->env('HTTPS') ? 'https://' : 'http://';
  356. }
  357. $options += $defaults;
  358. return ($options['absolute']) ? "{$options['scheme']}{$options['host']}{$path}" : $path;
  359. }
  360. /**
  361. * Copies persistent parameters (parameters in the request which have been designated to
  362. * persist) to the current URL, unless the parameter has been explicitly disabled from
  363. * persisting by setting the value in the URL to `null`, or by assigning some other value.
  364. *
  365. * For example:
  366. *
  367. * {{{ embed:lithium\tests\cases\net\http\RouterTest::testParameterPersistence(1-10) }}}
  368. *
  369. * @see lithium\action\Request::$persist
  370. * @param array $url The parameters that define the URL to be matched.
  371. * @param object $context Typically an instance of `lithium\action\Request`, which contains a
  372. * `$persist` property, which is an array of keys to be persisted in URLs between
  373. * requests.
  374. * @return array Returns the modified URL array.
  375. */
  376. protected static function _persist($url, $context) {
  377. if (!$context || !isset($context->persist)) {
  378. return $url;
  379. }
  380. foreach ($context->persist as $key) {
  381. $url += array($key => $context->params[$key]);
  382. if ($url[$key] === null) {
  383. unset($url[$key]);
  384. }
  385. }
  386. return $url;
  387. }
  388. /**
  389. * Returns a route from the loaded configurations, by name.
  390. *
  391. * @param string $route Name of the route to request.
  392. * @return lithium\net\http\Route
  393. */
  394. public static function get($route = null) {
  395. if ($route === null) {
  396. return static::$_configurations;
  397. }
  398. return isset(static::$_configurations[$route]) ? static::$_configurations[$route] : null;
  399. }
  400. /**
  401. * Resets the `Router` to its default state, unloading all routes.
  402. *
  403. * @return void
  404. */
  405. public static function reset() {
  406. static::$_configurations = array();
  407. }
  408. /**
  409. * Helper function for taking a path string and parsing it into a controller and action array.
  410. *
  411. * @param string $path Path string to parse.
  412. * @param boolean $context
  413. * @return array
  414. */
  415. protected static function _parseString($path, $context) {
  416. if (!preg_match('/^[A-Za-z0-9_]+::[A-Za-z0-9_]+$/', $path)) {
  417. $base = $context ? $context->env('base') : '';
  418. $path = trim($path, '/');
  419. return $context !== false ? "{$base}/{$path}" : null;
  420. }
  421. list($controller, $action) = explode('::', $path, 2);
  422. return compact('controller', 'action');
  423. }
  424. }
  425. ?>