1: <?php
2: /**
3: * Rozšířená verze. Hlavní změny:
4: * - zjednodušené generování URL, které se liší jen hodnotou jediného parametru
5: * - umožněny nepovinné části routy
6: * - možnost vynechání části URL před zpracováním, doplnění prefixu do výstupní adresy
7: * - automatické přesměrování z nepoužívaných URL na nové
8: * - předá řízení libovolnému callbacku (funkce nebo třída+metoda; nadefinuje se jen přibližně, hodnoty se doplní z URL)
9: *
10: * @author Jakub Kulhan (původní verze), http://bukaj.netuje.cz/blog/jednoduchy-routing-v-php
11: * @author Viktorie Halasů (rozšíření), http://projekty.vize.name/router/
12: * @version 1.3
13: * @package Router
14: */
15:
16:
17: class Router implements ArrayAccess {
18:
19:
20: /** @var string Pro přesměrování, do tohoto parametru se má zkopírovat hodnota ze staré URL. */
21: const COPY_OLD_VALUE = '@@copy';
22:
23: /** @var string Určuje, že tato část výstupní adresy se bude dynamicky dosazovat (polotovar). */
24: const PREPARE = "\032";
25:
26: /** @var string Název klíče v poli $_GET, do kterého se v .htaccess ukládá URL (nepovinné). */
27: protected $urlRouteKey = 'route';
28:
29: /** @var string Základ URL. */
30: protected $baseUrl = '';
31:
32: /** @var string Část vstupní URL, která má být odstraněna před hledáním vhodné routy. */
33: protected $ignoredUrlPart = '';
34:
35: /** @var string Řetězec, který se má přidat před každou výstupní URL. */
36: protected $outputUrlPrefix = '';
37:
38: /** @var string Aktuální URL (to, co bylo předané metodě parseURL). */
39: protected $currentUrl = '';
40:
41: /** @var array Parametry z rozebrané URL. */
42: protected $params = array();
43:
44: /** @var array Výchozí hodnoty parametrů, pokud bude URL prázdná. */
45: protected $defaultParams = array();
46:
47:
48: /** @var array Parsované routy. */
49: protected $routes = array();
50:
51: /** @var array Připravené šablony URL. */
52: protected $urlTemplates = array();
53:
54: /** @var string Maska pro jméno kontroleru / metody / funkce, kterou bude volat metoda delegate. */
55: protected $callbackMask = '';
56:
57: /** @var string Přeložené jméno callbacku. */
58: protected $callback = '';
59:
60: /** @var string Callback pro případ chyby (neexistující metoda/třída/funkce). */
61: protected $errorCallback = '';
62:
63:
64: /**
65: * Konstruktor
66: * @param array $defaults Výchozí parametry pro celý router.
67: */
68: public function __construct(array $defaults = array()) {
69: $this->setBaseUrl();
70: $this->defaultParams = $defaults;
71: }
72:
73:
74: /**
75: * Parsuje a přidá další routu.
76: * @param string $route
77: * @param array $defaults Pole výchozích hodnot.
78: * @param int $flags Modifikátory routy (bitmask). FIXED | CI | REDIR
79: * @param array $redir Pokud se přesměrovávají nepoužívané URL na nové (flag REDIR), musí obsahovat buď pole hodnot
80: * pro vytvoření nové URL, anebo callback, který toto pole vrací.
81: * @see Route::__construct()
82: * @return Router (fluent interface)
83: *
84: * @throws InvalidArgumentException Pokud je nastavený flag REDIR, ale chybí údaj, kam se má přesměrovávat.
85: */
86: final public function addRoute($route, array $defaults = array(), $flags=0, $redir = null) {
87: $class = $flags & Route::SIMPLE ? 'SimpleRoute' : 'Route';
88: $this->routes[] = new $class($route, $defaults, $flags, $redir);
89: return $this;
90: }
91:
92:
93: /**
94: * Připraví URL jako pojmenovanou šablonu (= jedna hodnota se bude dynamicky doplňovat, ostatní zůstanou stejné).
95: * @param string $name Jméno šablony
96: * @param array $params Parametry pro vytvoření URL.
97: * Variabilní parametr musí mít jako hodnotu konstantu Router::PREPARE. Pokud se pro tuto část routy používá vlastní regulár, je potřeba ke konstantě připojit i řetězec, který tomuto reguláru odpovídá.
98: * @param mixed $query_string Query string (volitelně). Buď hotový řetězec, nebo pole [název]=>hodnota.
99: * @param string $fragment Kotva.
100: * @return Router (fluent interface)
101: *
102: * @throws InvalidArgumentException Pokud požadované jméno už existuje nebo zadané parametry neodpovídají žádné
103: * routě nebo zadané jméno není řetězec.
104: * @throws UnexpectedValueException Pokud nebyla určená variabilní část.
105: */
106: final public function prepareUrlTemplate($tplName, array $params, $queryString = '', $fragment = '') {
107:
108: if ($this->templateExists($tplName)) {
109: throw new InvalidArgumentException("Router: Pojmenovaná šablona URL '$tplName' již existuje!");
110: } elseif (!is_string($tplName)) {
111: throw new InvalidArgumentException("Router: Jméno pro šablonu URL je špatného typu (očekáván řetězec).");
112: }
113:
114: $queryString = $this->createQueryString($queryString);
115:
116: /* Najde parametry, které se budou dynamicky doplňovat. */
117: $variables = array();
118: foreach ($params as $name => $value) {
119: /* Pokud nebyla zadaná hodnota pro regulár této části, použije se výchozí */
120: if (strpos($value, self::PREPARE) !== false) {
121: $value = str_replace(self::PREPARE, '', $value);
122: $params[$name] = empty($value) ? Route::DEFAULT_VALUE : $value;
123: $variables[$name] = self::PREPARE;
124: }
125: }
126:
127: /* Pokud nebyly zadané proměnné části ani v parametrech, ani v query string, varuje uživatele. */
128: if (empty($variables) && strpos($queryString, self::PREPARE) === false) {
129: trigger_error("Varování routeru: Šablona URL '$tplName' neobsahuje proměnnou část.", E_USER_WARNING);
130: }
131:
132: /* Najde vhodnou routu a vytvoří šablonu */
133: foreach ($this->routes as $route) {
134: if ($route->hasFlag(Route::FIXED)) {
135: continue;
136: }
137:
138: if ($route->createUrlFromParams($params)) {
139: if (!empty($variables)) {
140: $route->addVariableUrlParts($variables);
141: }
142:
143: $url = $this->outputUrlPrefix . $route->getLastCreatedUrl();
144: if (!empty($queryString)) {
145: $url = $this->insertParamsToUrl($url, $queryString);
146: }
147: if (!empty($fragment) || $fragment === 0) {
148: $url .= '#' . $fragment;
149: }
150: $this->urlTemplates[$tplName] = $url;
151:
152: return $this;
153: }
154: }
155: throw new InvalidArgumentException("Router: Zadané parametry neodpovídají žádné routě, URL nelze vytvořit.");
156: }
157:
158:
159: /**
160: * Vytvoří hotovou URL podle pojmenované šablony
161: * @param string $tplName Jméno šablony
162: * @param string $value Řetězec, který se má doplnit
163: * @return string URL
164: *
165: * @throws InvalidArgumentException Pokud neexistuje polotovar s tímto jménem, nebo zadaná hodnota není řetězec/číslo.
166: */
167: final public function urlFromTemplate($tplName, $value) {
168: if (!is_scalar($value) || (is_object($value) && !method_exists($value, '__toString'))) {
169: throw new InvalidArgumentException("Router: Zadaná hodnota je špatného typu (očekáván řetězec nebo objekt s metodou __toString).");
170: } elseif (!$this->templateExists($tplName)) {
171: throw new InvalidArgumentException("Router: Pojmenovaná URL '$tplName' neexistuje!");
172: }
173:
174: return str_replace(self::PREPARE, (string) $value, $this->urlTemplates[$tplName]);
175: }
176:
177:
178: /**
179: * Podle zadaných parametrů složí URL.
180: * @param array $params Parametry. Musí být uvedeny i výchozí hodnoty (2.param addRoute), nepovinné části routy lze vynechat.
181: * @param string|array $queryString Buď hotový řetězec, nebo pole [název]=>hodnota.
182: * @param string $fragment Kotva
183: * @return string
184: */
185: final public function url(array $params, $queryString = '', $fragment = '') {
186:
187: foreach ($this->routes as $route) {
188: if ($route->createUrlFromParams($params)) {
189: $url = $this->outputUrlPrefix . $route->getLastCreatedUrl();
190:
191: $queryString = $this->createQueryString($queryString);
192: if (!empty($queryString)) {
193: $url = $this->insertParamsToUrl($url, $queryString);
194: }
195: if (!empty($fragment) || $fragment === 0) {
196: $url .= '#' . $fragment;
197: }
198: return $url;
199: }
200: }
201: trigger_error("Router: Zadané parametry neodpovídají žádné routě, URL nelze vytvořit.", E_USER_WARNING);
202: return '';
203: }
204:
205:
206: /**
207: * Zpracuje dodatečné parametry do URL (query string), neescapuje, otazník nepřidává.
208: * Vyhodí parametr, ve kterém je routa z mod_rewrite.
209: * @param string|array $params Může být pole parametrů i hotový query string.
210: * @return string
211: */
212: final protected function createQueryString($params) {
213: if (!empty($params) && is_string($params)) {
214: parse_str(ltrim($params, '?&'), $parsed);
215: unset($parsed[$this->urlRouteKey]);
216: return (!empty($parsed) ? urldecode(http_build_query($parsed, '', '&')) : '');
217: }
218: elseif (is_array($params)) {
219: unset($params[$this->urlRouteKey]);
220: return (!empty($params) ? urldecode(http_build_query($params, '', '&')) : '');
221: }
222: else
223: return '';
224: }
225:
226: /**
227: * Vloží do URL další parametry.
228: * @param string $url
229: * @param string $queryString
230: * @return string
231: */
232: final protected function insertParamsToUrl($url, $queryString) {
233: $queryString = (strpos($url, '?') === false ? '?' : '&') . $queryString;
234: if (($pos = strpos($url, '#')) === false) {
235: return $url . $queryString;
236: } else {
237: return substr_replace($url, $queryString, $pos, 0);
238: }
239: }
240:
241:
242: /**
243: * Rozebere URL na parametry podle první vyhovující routy a vrátí výsledek. Pokud zadaná URL neodpovídá žádné routě, vyvolá varování a nastaví výchozí hodnoty.
244: * @param string $url
245: * @return array
246: *
247: * @throws UnexpectedValueException Pokud je tato routa přesměrovávaná a cíl je špatně zadaný.
248: */
249: final public function parseUrl($url) {
250:
251: $url = rtrim($url, '/\\');
252: if (!empty($this->ignoredUrlPart)) {
253: $url = str_replace($this->ignoredUrlPart, '', $url);
254: }
255: $this->currentUrl = $url;
256:
257: /* Nezadaná cesta nebo nejvyšší adresář webu - použijí se výchozí hodnoty (viz konstruktor). */
258: if (empty($this->currentUrl)) {
259: $this->setDefaultParams();
260:
261: } else {
262: foreach ($this->routes as $route) {
263: if ($route->matchUrl($this->currentUrl)) {
264: $this->params = $route->getParsedUrl();
265:
266: if ($route->isRedirected()) {
267: $this->redirectRoute($route);
268: } else {
269: break;
270: }
271: }
272: }
273:
274: if (empty($this->params)) {
275: trigger_error("Router: Zadaná URL neodpovídá žádné routě.", E_USER_WARNING);
276: $this->setDefaultParams();
277: }
278: }
279:
280: $this->resolveCallbackName();
281: return $this->params;
282: }
283:
284:
285: /**
286: * Nastaví výchozí požadavek (podle hodnoty callbacku).
287: */
288: final protected function setDefaultParams() {
289: $this->params = $this->defaultParams;
290: }
291:
292:
293: /**
294: * Nastaví masku pro callback. Tam, kde se má doplnit parametr z URL, napište jeho jméno v ostrých závorkách.
295: * Lze zadat jak funkci: "<func>", tak třídu + metodu: "<controller>Controller:<action>". Více hodnot oddělte čárkou.
296: * @param string $mask
297: * @return Router (fluent interface)
298: *
299: * @throws InvalidArgumentException Pokud vstup není řetězec.
300: */
301: final public function setCallbackMask($mask) {
302: if (!is_string($mask)) {
303: throw new InvalidArgumentException("Router::setCallbackMask() - Vstup je špatného typu (očekáván řetězec).");
304: }
305: $this->callbackMask = str_replace(' ', '', $mask);
306:
307: /* Pokud byla už dřív volaná parseUrl(), doplň hodnoty pro callback */
308: if (!empty($this->params)) {
309: $this->resolveCallbackName();
310: }
311: return $this;
312: }
313:
314:
315: /**
316: * Nastaví callback, který se má použít v případě chyby (neexistující třídy/metody apod.) Syntax stejná jako pro setCallbackMask, ale bez doplňovaných hodnot.
317: * @param string $callback
318: * @return Router (fluent interface)
319: *
320: * @throws InvalidArgumentException Pokud vstup není řetězec.
321: */
322: final public function setErrorCallback($callback) {
323: if (!is_string($callback)) {
324: throw new InvalidArgumentException("Router::setErrorCallback() - Vstup je špatného typu (očekáván řetězec).");
325: }
326: $this->errorCallback = str_replace(' ', '', $callback);
327: return $this;
328: }
329:
330:
331: /**
332: * Vytvoří callback pro metodu delegate (z parametrů z URL podle zadané masky).
333: *
334: * @throws RuntimeException Pokud jsou v callbacku požadované parametry, které v routě chybí.
335: */
336: final protected function resolveCallbackName() {
337: $tmp = $this->callbackMask;
338: if (preg_match_all('~<([^>]+)>~', $tmp, $m, PREG_PATTERN_ORDER)) {
339: foreach ($m[1] as $name) {
340: if (!isset($this->params[$name])) {
341: throw new RuntimeException("Router: V callbacku je požadovaný parametr '$name', ale v parametrech z URL chybí.");
342: }
343: $tmp = str_replace("<$name>", $this->params[$name], $tmp);
344: }
345: }
346: $this->callback = $tmp;
347: }
348:
349:
350: /**
351: * Vytvoří spustitelné callbacky z řetězce. Pokud se jméno třídy opakuje, použije jen jedinou instanci.
352: * @param string $str
353: * @return array
354: *
355: * @throws BadFunctionCallException Pokud callback neexistuje.
356: */
357: protected function callbackFromString($str) {
358:
359: $cb = array();
360: $instances = array();
361: foreach (explode(',', $str) as $part) {
362: $tmp = explode(':', $part);
363:
364: /* Volá se třída + metoda */
365: if (isset($tmp[1])) {
366: list($class, $method) = $tmp;
367: if (!class_exists($class)) {
368: throw new BadFunctionCallException("Router: Třída '$class' neexistuje.");
369: }
370: if (!isset($instances[$class])) {
371: $instances[$class] = new $class;
372: }
373: if (!method_exists($instances[$class], $method)) {
374: throw new BadFunctionCallException("Router: Metoda '$class::$method' neexistuje.");
375: }
376: $cb[] = array($instances[$class], $method);
377:
378: /* Volá se funkce */
379: } else {
380: $func = $tmp[0];
381: if (!function_exists($func)) {
382: throw new BadFunctionCallException("Router: Funkce '$func' neexistuje.");
383: }
384: /* Funkce dostane všechny parametry z rozebrané URL. */
385: $cb[] = $func;
386: }
387: }
388:
389: return $cb;
390: }
391:
392:
393: /**
394: * Předá řízení. Nastavené metody/funkce dostanou parametry z rozebrané URL.
395: *
396: * @throws BadFunctionCallException Pokud callback neexistuje.
397: */
398: public function delegate() {
399:
400: try {
401: foreach ($this->callbackFromString($this->callback) as $cb) {
402: call_user_func($cb, $this->params);
403: }
404: } catch (BadFunctionCallException $e) {
405: if (!empty($this->errorCallback)) {
406: foreach ($this->callbackFromString($this->errorCallback) as $cb) {
407: call_user_func($cb, $this->params);
408: }
409: } else {
410: throw $e;
411: }
412: }
413: }
414:
415:
416: /**
417: * Přesměruje na URL podle nastavení zadané routy.
418: * @param Route $route
419: *
420: * @throws UnexpectedValueException Pokud něco selže během nastavení cílové URL.
421: */
422: private function redirectRoute(Route $route) {
423: try {
424: $redirParams = $route->getRedirParams();
425: /* Zkopírování požadovaných hodnot ze současné URL */
426: foreach ($redirParams as $name => $value) {
427: if ($value === self::COPY_OLD_VALUE) {
428: $redirParams[$name] = !empty($this->params[$name]) ? $this->params[$name] : '';
429: }
430: }
431:
432: $newUrl = $this->url($redirParams);
433: $this->redirect($newUrl);
434: die();
435:
436: } catch (Exception $e) {
437: throw new UnexpectedValueException("Nelze přesměrovat routu. Popis chyby: ".$e->getMessage());
438: }
439: }
440:
441:
442: /**
443: * Přesměruje na zadanou URL.
444: * @param string $newUrl
445: * @param int $code HTTP kód odpovědi, výchozí hodnota odpovídá stavu "Moved Permanently".
446: */
447: protected function redirect($newUrl, $code = 301) {
448: if (headers_sent()) {
449: @ header("refresh:1;url=$newUrl");
450: } else {
451: @ header("Location: ".$newUrl, true, $code);
452: }
453: exit;
454: }
455:
456:
457: /**
458: * Zjistí, jestli už existuje šablona URL s tímto jménem.
459: * @param string $tplName Název
460: * @return bool
461: */
462: final public function templateExists($tplName) {
463: return isset($this->urlTemplates[$tplName]);
464: }
465:
466:
467: /**
468: * Zjistí základní URL aplikace (bez koncového lomítka)
469: */
470: protected function setBaseUrl() {
471: $dirName = dirname($_SERVER['PHP_SELF']);
472: $dirName = rtrim($dirName, '/\\');
473: $this->baseUrl = "http://$_SERVER[SERVER_NAME]" . $dirName;
474: $this->baseUrl = str_replace($this->ignoredUrlPart, '', $this->baseUrl);
475: }
476:
477:
478: /**
479: * Nastaví část, která se má ve všech vstupních URL ignorovat.
480: * @param string $part
481: * @return Router (fluent interface)
482: *
483: * @throws InvalidArgumentException Pokud vstup není řetězec.
484: */
485: final public function setIgnoredUrlPart($part) {
486: if (!is_string($part)) {
487: throw new InvalidArgumentException("Router: Zadaná část URL, která se má vynechat, je špatného typu (očekáván řetězec).");
488: }
489: $this->ignoredUrlPart = $part;
490: $this->setBaseUrl();
491: return $this;
492: }
493:
494:
495: /**
496: * Nastaví prefix pro všechny výstupní URL.
497: * @param string $prefix
498: * @return Router (fluent interface)
499: *
500: * @throws InvalidArgumentException Pokud vstup není řetězec.
501: */
502: final public function setOutputUrlPrefix($prefix) {
503: if (!is_string($prefix)) {
504: throw new InvalidArgumentException("Router: Prefix pro výstupní URL je špatného typu (očekáván řetězec).");
505: }
506: $this->outputUrlPrefix = $prefix;
507: return $this;
508: }
509:
510:
511: /**
512: * Nastaví název parametru $_GET, do kterého se v .htaccess ukládá požadovaná URL. Výchozí hodnota je "route".
513: * @param string $param
514: * @return Router (fluent interface)
515: *
516: * @throws InvalidArgumentException Pokud vstup není řetězec.
517: */
518: final public function setRouteKey($param) {
519: if (!is_string($param)) {
520: throw new InvalidArgumentException("Router::setRouteKey() - Zadaná hodnota je špatného typu (očekáván řetězec).");
521: }
522: $this->urlRouteKey = $param;
523: return $this;
524: }
525:
526:
527: /**
528: * Vrací základní adresu aplikace (bez koncového lomítka)
529: * @return string
530: */
531: final public function getBaseUrl() {
532: return $this->baseUrl;
533: }
534:
535:
536: /**
537: * Vrací URL aktuálního požadavku (bez koncového lomítka)
538: * @return string
539: */
540: final public function getCurrentUrl() {
541: return $this->currentUrl;
542: }
543:
544:
545: /**
546: * Vrátí rozebranou URL.
547: * @return array
548: */
549: final public function getParams() {
550: return $this->params;
551: }
552:
553:
554: /**
555: * Getter
556: * @param string $propertyName
557: * @return mixed
558: */
559: public function __get($propertyName) {
560: $func = 'get'.ucfirst($propertyName);
561: if (method_exists($this, $func)) {
562: return $this->$func();
563: } else {
564: trigger_error("Router: Vlastnost '$propertyName' neexistuje, nebo není přístupná.", E_USER_NOTICE);
565: }
566: }
567:
568:
569: /**
570: * Setter není povolen.
571: * @param string $propertyName
572: * @param string $propertyValue
573: */
574: final public function __set($propertyName, $propertyValue) {
575: trigger_error("Router: Vlastnost '$propertyName' neexistuje, nebo není veřejně přístupná.", E_USER_WARNING);
576: }
577:
578: /* array access: -------------------- */
579:
580: /**
581: * @see ArrayAccess::offsetSet()
582: */
583: public function offsetSet($offset, $value) {
584: if ($value instanceof Route) {
585: $this->routes[] = $value;
586: } else {
587: throw new InvalidArgumentException("Zadaná hodnota musí být instance třídy Route.");
588: }
589: }
590:
591:
592: /**
593: * @see ArrayAccess::offsetExists()
594: */
595: public function offsetExists($offset) {
596: return isset($this->routes[$offset]);
597: }
598:
599:
600: /**
601: * Odstranění rout není povoleno.
602: * @see ArrayAccess::offsetUnset()
603: */
604: public function offsetUnset($offset) {
605: throw new LogicException("Odstraňování rout není povoleno.");
606: }
607:
608:
609: /**
610: * @see ArrayAccess::offsetGet()
611: */
612: public function offsetGet($offset) {
613: return isset($this->routes[$offset]) ? $this->routes[$offset] : null;
614: }
615: }
616: