Cake/Routing/Route/CakeRoute.php

1 <?php
2 /**
3 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
4 * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
5 *
6 * Licensed under The MIT License
7 * Redistributions of files must retain the above copyright notice.
8 *
9 * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
10 * @link http://cakephp.org CakePHP(tm) Project
11 * @since CakePHP(tm) v 1.3
12 * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
13 */
14  
15 App::uses('Set', 'Utility');
16  
17 /**
18 * A single Route used by the Router to connect requests to
19 * parameter maps.
20 *
21 * Not normally created as a standalone. Use Router::connect() to create
22 * Routes for your application.
23 *
24 * @package Cake.Routing.Route
25 */
26 class CakeRoute {
27  
28 /**
29 * An array of named segments in a Route.
30 * `/:controller/:action/:id` has 3 key elements
31 *
32 * @var array
33 */
34 public $keys = array();
35  
36 /**
37 * An array of additional parameters for the Route.
38 *
39 * @var array
40 */
41 public $options = array();
42  
43 /**
44 * Default parameters for a Route
45 *
46 * @var array
47 */
48 public $defaults = array();
49  
50 /**
51 * The routes template string.
52 *
53 * @var string
54 */
55 public $template = null;
56  
57 /**
58 * Is this route a greedy route? Greedy routes have a `/*` in their
59 * template
60 *
61 * @var string
62 */
63 protected $_greedy = false;
64  
65 /**
66 * The compiled route regular expression
67 *
68 * @var string
69 */
70 protected $_compiledRoute = null;
71  
72 /**
73 * HTTP header shortcut map. Used for evaluating header-based route expressions.
74 *
75 * @var array
76 */
77 protected $_headerMap = array(
78 'type' => 'content_type',
79 'method' => 'request_method',
80 'server' => 'server_name'
81 );
82  
83 /**
84 * Constructor for a Route
85 *
86 * @param string $template Template string with parameter placeholders
87 * @param array $defaults Array of defaults for the route.
88 * @param array $options Array of additional options for the Route
89 */
90 public function __construct($template, $defaults = array(), $options = array()) {
91 $this->template = $template;
92 $this->defaults = (array)$defaults;
93 $this->options = (array)$options;
94 }
95  
96 /**
97 * Check if a Route has been compiled into a regular expression.
98 *
99 * @return boolean
100 */
101 public function compiled() {
102 return !empty($this->_compiledRoute);
103 }
104  
105 /**
106 * Compiles the route's regular expression. Modifies defaults property so all necessary keys are set
107 * and populates $this->names with the named routing elements.
108 *
109 * @return array Returns a string regular expression of the compiled route.
110 */
111 public function compile() {
112 if ($this->compiled()) {
113 return $this->_compiledRoute;
114 }
115 $this->_writeRoute();
116 return $this->_compiledRoute;
117 }
118  
119 /**
120 * Builds a route regular expression. Uses the template, defaults and options
121 * properties to compile a regular expression that can be used to parse request strings.
122 *
123 * @return void
124 */
125 protected function _writeRoute() {
126 if (empty($this->template) || ($this->template === '/')) {
127 $this->_compiledRoute = '#^/*$#';
128 $this->keys = array();
129 return;
130 }
131 $route = $this->template;
132 $names = $routeParams = array();
133 $parsed = preg_quote($this->template, '#');
134  
135 preg_match_all('#:([A-Za-z0-9_-]+[A-Z0-9a-z])#', $route, $namedElements);
136 foreach ($namedElements[1] as $i => $name) {
137 $search = '\\' . $namedElements[0][$i];
138 if (isset($this->options[$name])) {
139 $option = null;
140 if ($name !== 'plugin' && array_key_exists($name, $this->defaults)) {
141 $option = '?';
142 }
143 $slashParam = '/\\' . $namedElements[0][$i];
144 if (strpos($parsed, $slashParam) !== false) {
145 $routeParams[$slashParam] = '(?:/(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option;
146 } else {
147 $routeParams[$search] = '(?:(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option;
148 }
149 } else {
150 $routeParams[$search] = '(?:(?P<' . $name . '>[^/]+))';
151 }
152 $names[] = $name;
153 }
154 if (preg_match('#\/\*\*$#', $route)) {
155 $parsed = preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed);
156 $this->_greedy = true;
157 } elseif (preg_match('#\/\*$#', $route)) {
158 $parsed = preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed);
159 $this->_greedy = true;
160 }
161 krsort($routeParams);
162 $parsed = str_replace(array_keys($routeParams), array_values($routeParams), $parsed);
163 $this->_compiledRoute = '#^' . $parsed . '[/]*$#';
164 $this->keys = $names;
165  
166 //remove defaults that are also keys. They can cause match failures
167 foreach ($this->keys as $key) {
168 unset($this->defaults[$key]);
169 }
170 }
171  
172 /**
173 * Checks to see if the given URL can be parsed by this route.
174 * If the route can be parsed an array of parameters will be returned; if not
175 * false will be returned. String urls are parsed if they match a routes regular expression.
176 *
177 * @param string $url The url to attempt to parse.
178 * @return mixed Boolean false on failure, otherwise an array or parameters
179 */
180 public function parse($url) {
181 if (!$this->compiled()) {
182 $this->compile();
183 }
184 if (!preg_match($this->_compiledRoute, $url, $route)) {
185 return false;
186 }
187 foreach ($this->defaults as $key => $val) {
188 $key = (string)$key;
189 if ($key[0] === '[' && preg_match('/^\[(\w+)\]$/', $key, $header)) {
190 if (isset($this->_headerMap[$header[1]])) {
191 $header = $this->_headerMap[$header[1]];
192 } else {
193 $header = 'http_' . $header[1];
194 }
195 $header = strtoupper($header);
196  
197 $val = (array)$val;
198 $h = false;
199  
200 foreach ($val as $v) {
201 if (env($header) === $v) {
202 $h = true;
203 }
204 }
205 if (!$h) {
206 return false;
207 }
208 }
209 }
210 array_shift($route);
211 $count = count($this->keys);
212 for ($i = 0; $i <= $count; $i++) {
213 unset($route[$i]);
214 }
215 $route['pass'] = $route['named'] = array();
216  
217 // Assign defaults, set passed args to pass
218 foreach ($this->defaults as $key => $value) {
219 if (isset($route[$key])) {
220 continue;
221 }
222 if (is_integer($key)) {
223 $route['pass'][] = $value;
224 continue;
225 }
226 $route[$key] = $value;
227 }
228  
229 foreach ($this->keys as $key) {
230 if (isset($route[$key])) {
231 $route[$key] = rawurldecode($route[$key]);
232 }
233 }
234  
235 if (isset($route['_args_'])) {
236 list($pass, $named) = $this->_parseArgs($route['_args_'], $route);
237 $route['pass'] = array_merge($route['pass'], $pass);
238 $route['named'] = $named;
239 unset($route['_args_']);
240 }
241  
242 if (isset($route['_trailing_'])) {
243 $route['pass'][] = rawurldecode($route['_trailing_']);
244 unset($route['_trailing_']);
245 }
246  
247 // restructure 'pass' key route params
248 if (isset($this->options['pass'])) {
249 $j = count($this->options['pass']);
250 while ($j--) {
251 if (isset($route[$this->options['pass'][$j]])) {
252 array_unshift($route['pass'], $route[$this->options['pass'][$j]]);
253 }
254 }
255 }
256 return $route;
257 }
258  
259 /**
260 * Parse passed and Named parameters into a list of passed args, and a hash of named parameters.
261 * The local and global configuration for named parameters will be used.
262 *
263 * @param string $args A string with the passed & named params. eg. /1/page:2
264 * @param string $context The current route context, which should contain controller/action keys.
265 * @return array Array of ($pass, $named)
266 */
267 protected function _parseArgs($args, $context) {
268 $pass = $named = array();
269 $args = explode('/', $args);
270  
271 $namedConfig = Router::namedConfig();
272 $greedy = $namedConfig['greedyNamed'];
273 $rules = $namedConfig['rules'];
274 if (!empty($this->options['named'])) {
275 $greedy = isset($this->options['greedyNamed']) && $this->options['greedyNamed'] === true;
276 foreach ((array)$this->options['named'] as $key => $val) {
277 if (is_numeric($key)) {
278 $rules[$val] = true;
279 continue;
280 }
281 $rules[$key] = $val;
282 }
283 }
284  
285 foreach ($args as $param) {
286 if (empty($param) && $param !== '0' && $param !== 0) {
287 continue;
288 }
289  
290 $separatorIsPresent = strpos($param, $namedConfig['separator']) !== false;
291 if ((!isset($this->options['named']) || !empty($this->options['named'])) && $separatorIsPresent) {
292 list($key, $val) = explode($namedConfig['separator'], $param, 2);
293 $key = rawurldecode($key);
294 $val = rawurldecode($val);
295 $hasRule = isset($rules[$key]);
296 $passIt = (!$hasRule && !$greedy) || ($hasRule && !$this->_matchNamed($val, $rules[$key], $context));
297 if ($passIt) {
298 $pass[] = rawurldecode($param);
299 } else {
300 if (preg_match_all('/\[([A-Za-z0-9_-]+)?\]/', $key, $matches, PREG_SET_ORDER)) {
301 $matches = array_reverse($matches);
302 $parts = explode('[', $key);
303 $key = array_shift($parts);
304 $arr = $val;
305 foreach ($matches as $match) {
306 if (empty($match[1])) {
307 $arr = array($arr);
308 } else {
309 $arr = array(
310 $match[1] => $arr
311 );
312 }
313 }
314 $val = $arr;
315 }
316 $named = array_merge_recursive($named, array($key => $val));
317 }
318 } else {
319 $pass[] = rawurldecode($param);
320 }
321 }
322 return array($pass, $named);
323 }
324  
325 /**
326 * Return true if a given named $param's $val matches a given $rule depending on $context. Currently implemented
327 * rule types are controller, action and match that can be combined with each other.
328 *
329 * @param string $val The value of the named parameter
330 * @param array $rule The rule(s) to apply, can also be a match string
331 * @param string $context An array with additional context information (controller / action)
332 * @return boolean
333 */
334 protected function _matchNamed($val, $rule, $context) {
335 if ($rule === true || $rule === false) {
336 return $rule;
337 }
338 if (is_string($rule)) {
339 $rule = array('match' => $rule);
340 }
341 if (!is_array($rule)) {
342 return false;
343 }
344  
345 $controllerMatches = (
346 !isset($rule['controller'], $context['controller']) ||
347 in_array($context['controller'], (array)$rule['controller'])
348 );
349 if (!$controllerMatches) {
350 return false;
351 }
352 $actionMatches = (
353 !isset($rule['action'], $context['action']) ||
354 in_array($context['action'], (array)$rule['action'])
355 );
356 if (!$actionMatches) {
357 return false;
358 }
359 return (!isset($rule['match']) || preg_match('/' . $rule['match'] . '/', $val));
360 }
361  
362 /**
363 * Apply persistent parameters to a url array. Persistent parameters are a special
364 * key used during route creation to force route parameters to persist when omitted from
365 * a url array.
366 *
367 * @param array $url The array to apply persistent parameters to.
368 * @param array $params An array of persistent values to replace persistent ones.
369 * @return array An array with persistent parameters applied.
370 */
371 public function persistParams($url, $params) {
372 foreach ($this->options['persist'] as $persistKey) {
373 if (array_key_exists($persistKey, $params) && !isset($url[$persistKey])) {
374 $url[$persistKey] = $params[$persistKey];
375 }
376 }
377 return $url;
378 }
379  
380 /**
381 * Attempt to match a url array. If the url matches the route parameters and settings, then
382 * return a generated string url. If the url doesn't match the route parameters, false will be returned.
383 * This method handles the reverse routing or conversion of url arrays into string urls.
384 *
385 * @param array $url An array of parameters to check matching with.
386 * @return mixed Either a string url for the parameters if they match or false.
387 */
388 public function match($url) {
389 if (!$this->compiled()) {
390 $this->compile();
391 }
392 $defaults = $this->defaults;
393  
394 if (isset($defaults['prefix'])) {
395 $url['prefix'] = $defaults['prefix'];
396 }
397  
398 //check that all the key names are in the url
399 $keyNames = array_flip($this->keys);
400 if (array_intersect_key($keyNames, $url) !== $keyNames) {
401 return false;
402 }
403  
404 // Missing defaults is a fail.
405 if (array_diff_key($defaults, $url) !== array()) {
406 return false;
407 }
408  
409 $namedConfig = Router::namedConfig();
410 $prefixes = Router::prefixes();
411 $greedyNamed = $namedConfig['greedyNamed'];
412 $allowedNamedParams = $namedConfig['rules'];
413  
414 $named = $pass = array();
415  
416 foreach ($url as $key => $value) {
417  
418 // keys that exist in the defaults and have different values is a match failure.
419 $defaultExists = array_key_exists($key, $defaults);
420 if ($defaultExists && $defaults[$key] != $value) {
421 return false;
422 } elseif ($defaultExists) {
423 continue;
424 }
425  
426 // If the key is a routed key, its not different yet.
427 if (array_key_exists($key, $keyNames)) {
428 continue;
429 }
430  
431 // pull out passed args
432 $numeric = is_numeric($key);
433 if ($numeric && isset($defaults[$key]) && $defaults[$key] == $value) {
434 continue;
435 } elseif ($numeric) {
436 $pass[] = $value;
437 unset($url[$key]);
438 continue;
439 }
440  
441 // pull out named params if named params are greedy or a rule exists.
442 if (
443 ($greedyNamed || isset($allowedNamedParams[$key])) &&
444 ($value !== false && $value !== null) &&
445 (!in_array($key, $prefixes))
446 ) {
447 $named[$key] = $value;
448 continue;
449 }
450  
451 // keys that don't exist are different.
452 if (!$defaultExists && !empty($value)) {
453 return false;
454 }
455 }
456  
457 //if a not a greedy route, no extra params are allowed.
458 if (!$this->_greedy && (!empty($pass) || !empty($named))) {
459 return false;
460 }
461  
462 //check patterns for routed params
463 if (!empty($this->options)) {
464 foreach ($this->options as $key => $pattern) {
465 if (array_key_exists($key, $url) && !preg_match('#^' . $pattern . '$#', $url[$key])) {
466 return false;
467 }
468 }
469 }
470 return $this->_writeUrl(array_merge($url, compact('pass', 'named')));
471 }
472  
473 /**
474 * Converts a matching route array into a url string. Composes the string url using the template
475 * used to create the route.
476 *
477 * @param array $params The params to convert to a string url.
478 * @return string Composed route string.
479 */
480 protected function _writeUrl($params) {
481 if (isset($params['prefix'])) {
482 $prefixed = $params['prefix'] . '_';
483 }
484 if (isset($prefixed, $params['action']) && strpos($params['action'], $prefixed) === 0) {
485 $params['action'] = substr($params['action'], strlen($prefixed) * -1);
486 unset($params['prefix']);
487 }
488  
489 if (is_array($params['pass'])) {
490 $params['pass'] = implode('/', array_map('rawurlencode', $params['pass']));
491 }
492  
493 $namedConfig = Router::namedConfig();
494 $separator = $namedConfig['separator'];
495  
496 if (!empty($params['named']) && is_array($params['named'])) {
497 $named = array();
498 foreach ($params['named'] as $key => $value) {
499 if (is_array($value)) {
500 $flat = Set::flatten($value, '][');
501 foreach ($flat as $namedKey => $namedValue) {
502 $named[] = $key . "[$namedKey]" . $separator . rawurlencode($namedValue);
503 }
504 } else {
505 $named[] = $key . $separator . rawurlencode($value);
506 }
507 }
508 $params['pass'] = $params['pass'] . '/' . implode('/', $named);
509 }
510 $out = $this->template;
511  
512 $search = $replace = array();
513 foreach ($this->keys as $key) {
514 $string = null;
515 if (isset($params[$key])) {
516 $string = $params[$key];
517 } elseif (strpos($out, $key) != strlen($out) - strlen($key)) {
518 $key .= '/';
519 }
520 $search[] = ':' . $key;
521 $replace[] = $string;
522 }
523 $out = str_replace($search, $replace, $out);
524  
525 if (strpos($this->template, '*')) {
526 $out = str_replace('*', $params['pass'], $out);
527 }
528 $out = str_replace('//', '/', $out);
529 return $out;
530 }
531  
532 }
533  
534