1: <?php
2: /**
3: * Horde Routes package
4: *
5: * This package is heavily inspired by the Python "Routes" library
6: * by Ben Bangert (http://routes.groovie.org). Routes is based
7: * largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
8: *
9: * @author Maintainable Software, LLC. (http://www.maintainable.com)
10: * @author Mike Naberezny <mike@maintainable.com>
11: * @license http://www.horde.org/licenses/bsd BSD
12: * @package Routes
13: */
14:
15: /**
16: * The mapper class handles URL generation and recognition for web applications
17: *
18: * The mapper class is built by handling associated arrays of information and passing
19: * associated arrays back to the application for it to handle and dispatch the
20: * appropriate scripts.
21: *
22: * @package Routes
23: */
24: class Horde_Routes_Mapper
25: {
26: /**
27: * Filtered request environment with keys like SCRIPT_NAME
28: * @var array
29: */
30: public $environ = array();
31:
32: /**
33: * Callback function used to get array of controller names
34: * @var callback
35: */
36: public $controllerScan;
37:
38: /**
39: * Path to controller directory passed to controllerScan function
40: * @var string
41: */
42: public $directory;
43:
44: /**
45: * Call controllerScan callback before every route match?
46: * @var boolean
47: */
48: public $alwaysScan;
49:
50: /**
51: * Disable route memory and implicit defaults?
52: * @var boolean
53: */
54: public $explicit;
55:
56: /**
57: * Collect debug information during route match?
58: * @var boolean
59: */
60: public $debug = false;
61:
62: /**
63: * Use sub-domain support?
64: * @var boolean
65: */
66: public $subDomains = false;
67:
68: /**
69: * Array of sub-domains to ignore if using sub-domain support
70: * @var array
71: */
72: public $subDomainsIgnore = array();
73:
74: /**
75: * Append trailing slash ('/') to generated routes?
76: * @var boolean
77: */
78: public $appendSlash = false;
79:
80: /**
81: * Prefix to strip during matching and to append during generation
82: * @var null|string
83: */
84: public $prefix = null;
85:
86: /**
87: * Array of connected routes
88: * @var array
89: */
90: public $matchList = array();
91:
92: /**
93: * Array of connected named routes, indexed by name
94: * @var array
95: */
96: public $routeNames = array();
97:
98: /**
99: * Cache of URLs used in generate()
100: * @var array
101: */
102: public $urlCache = array();
103:
104: /**
105: * Encoding of routes URLs (not yet supported)
106: * @var string
107: */
108: public $encoding = 'utf-8';
109:
110: /**
111: * What to do on decoding errors? 'ignore' or 'replace'
112: * @var string
113: */
114: public $decodeErrors = 'ignore';
115:
116: /**
117: * Partial regexp used to match domain part of the end of URLs to match
118: * @var string
119: */
120: public $domainMatch = '[^\.\/]+?\.[^\.\/]+';
121:
122: /**
123: * Array of all connected routes, indexed by the serialized array of all
124: * keys that each route could utilize.
125: * @var array
126: */
127: public $maxKeys = array();
128:
129: /**
130: * Array of all connected routes, indexed by the serialized array of the
131: * minimum keys that each route needs.
132: * @var array
133: */
134: public $minKeys = array();
135:
136: /**
137: * Utility functions like urlFor() and redirectTo() for this Mapper
138: * @var Horde_Routes_Utils
139: */
140: public $utils;
141:
142: /**
143: * Cache
144: * @var Horde_Cache
145: */
146: public $cache;
147:
148: /**
149: * Cache lifetime for the same value of $this->matchList
150: * @var integer
151: */
152: public $cacheLifetime = 86400;
153:
154: /**
155: * Have regular expressions been created for all connected routes?
156: * @var boolean
157: */
158: protected $_createdRegs = false;
159:
160: /**
161: * Have generation hashes been created for all connected routes?
162: * @var boolean
163: */
164: protected $_createdGens = false;
165:
166: /**
167: * Generation hashes created for all connected routes
168: * @var array
169: */
170: protected $_gendict;
171:
172: /**
173: * Temporary variable used to pass array of keys into _keysort() callback
174: * @var array
175: */
176: protected $_keysortTmp;
177:
178: /**
179: * Regular expression generated to match after the prefix
180: * @var string
181: */
182: protected $_regPrefix = null;
183:
184:
185: /**
186: * Constructor.
187: *
188: * Keyword arguments ($kargs):
189: * ``controllerScan`` (callback)
190: * Function to return an array of valid controllers
191: *
192: * ``redirect`` (callback)
193: * Function to perform a redirect for Horde_Routes_Utils->redirectTo()
194: *
195: * ``directory`` (string)
196: * Path to the directory that will be passed to the
197: * controllerScan callback
198: *
199: * ``alwaysScan`` (boolean)
200: * Should the controllerScan callback be called
201: * before every URL match?
202: *
203: * ``explicit`` (boolean)
204: * Should routes be connected with the implicit defaults of
205: * array('controller'=>'content', 'action'=>'index', 'id'=>null)?
206: * When set to True, these will not be added to route connections.
207: */
208: public function __construct($kargs = array())
209: {
210: $callback = array('Horde_Routes_Utils', 'controllerScan');
211:
212: $defaultKargs = array('controllerScan' => $callback,
213: 'directory' => null,
214: 'alwaysScan' => false,
215: 'explicit' => false);
216: $kargs = array_merge($defaultKargs, $kargs);
217:
218: // Most default assignments that were in the construct in the Python
219: // version have been moved to outside the constructor unless they were variable
220:
221: $this->directory = $kargs['directory'];
222: $this->alwaysScan = $kargs['alwaysScan'];
223: $this->controllerScan = $kargs['controllerScan'];
224: $this->explicit = $kargs['explicit'];
225:
226: $this->utils = new Horde_Routes_Utils($this);
227: }
228:
229: /**
230: * Create and connect a new Route to the Mapper.
231: *
232: * Usage:
233: * $m = new Horde_Routes_Mapper();
234: * $m->connect(':controller/:action/:id');
235: * $m->connect('date/:year/:month/:day', array('controller' => "blog", 'action' => 'view');
236: * $m->connect('archives/:page', array('controller' => 'blog', 'action' => 'by_page',
237: * ' requirements' => array('page' => '\d{1,2}')));
238: * $m->connect('category_list',
239: * 'archives/category/:section', array('controller' => 'blog', 'action' => 'category',
240: * 'section' => 'home', 'type' => 'list'));
241: * $m->connect('home',
242: * '',
243: * array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
244: *
245: * @param mixed $first First argument in vargs, see usage above.
246: * @param mixed $second Second argument in varags
247: * @param mixed $third Third argument in varargs
248: * @return void
249: */
250: public function connect($first, $second = null, $third = null)
251: {
252: if ($third !== null) {
253: // 3 args given
254: // connect('route_name', ':/controller/:action/:id', array('kargs'=>'here'))
255: $routeName = $first;
256: $routePath = $second;
257: $kargs = $third;
258: } else if ($second !== null) {
259: // 2 args given
260: if (is_array($second)) {
261: // connect(':/controller/:action/:id', array('kargs'=>'here'))
262: $routeName = null;
263: $routePath = $first;
264: $kargs = $second;
265: } else {
266: // connect('route_name', ':/controller/:action/:id')
267: $routeName = $first;
268: $routePath = $second;
269: $kargs = array();
270: }
271: } else {
272: // 1 arg given
273: // connect('/:controller/:action/:id')
274: $routeName = null;
275: $routePath = $first;
276: $kargs = array();
277: }
278:
279: if (!in_array('_explicit', $kargs)) {
280: $kargs['_explicit'] = $this->explicit;
281: }
282:
283: $route = new Horde_Routes_Route($routePath, $kargs);
284:
285: if ($this->encoding != 'utf-8' || $this->decodeErrors != 'ignore') {
286: $route->encoding = $this->encoding;
287: $route->decodeErrors = $this->decodeErrors;
288: }
289:
290: $this->matchList[] = $route;
291:
292: if (isset($routeName)) {
293: $this->routeNames[$routeName] = $route;
294: }
295:
296: if ($route->static) {
297: return;
298: }
299:
300: $exists = false;
301: foreach ($this->maxKeys as $key => $value) {
302: if (unserialize($key) == $route->maxKeys) {
303: $this->maxKeys[$key][] = $route;
304: $exists = true;
305: break;
306: }
307: }
308:
309: if (!$exists) {
310: $this->maxKeys[serialize($route->maxKeys)] = array($route);
311: }
312:
313: $this->_createdGens = false;
314: }
315:
316: /**
317: * Set an optional Horde_Cache object for the created rules.
318: *
319: * @param Horde_Cache $cache Cache object
320: */
321: public function setCache(Horde_Cache $cache)
322: {
323: $this->cache = $cache;
324: }
325:
326: /**
327: * Create the generation hashes (arrays) for route lookups
328: *
329: * @return void
330: */
331: protected function _createGens()
332: {
333: // Checked for a cached generator dictionary for $this->matchList
334: if ($this->cache) {
335: $cacheKey = 'horde.routes.' . sha1(serialize($this->matchList));
336: $cachedDict = $cache->get($cacheKey, $this->cacheLifetime);
337: if ($gendict = @unserialize($cachedDict)) {
338: $this->_gendict = $gendict;
339: $this->_createdGens = true;
340: return;
341: }
342: }
343:
344: // Use keys temporarily to assemble the list to avoid excessive
345: // list iteration testing with foreach. We include the '*' in the
346: // case that a generate contains a controller/action that has no
347: // hardcodes.
348: $actionList = $controllerList = array('*' => true);
349:
350: // Assemble all the hardcoded/defaulted actions/controllers used
351: foreach ($this->matchList as $route) {
352: if ($route->static) {
353: continue;
354: }
355: if (isset($route->defaults['controller'])) {
356: $controllerList[$route->defaults['controller']] = true;
357: }
358: if (isset($route->defaults['action'])) {
359: $actionList[$route->defaults['action']] = true;
360: }
361: }
362:
363: $actionList = array_keys($actionList);
364: $controllerList = array_keys($controllerList);
365:
366: // Go through our list again, assemble the controllers/actions we'll
367: // add each route to. If its hardcoded, we only add it to that dict key.
368: // Otherwise we add it to every hardcode since it can be changed.
369: $gendict = array(); // Our generated two-deep hash
370: foreach ($this->matchList as $route) {
371: if ($route->static) {
372: continue;
373: }
374: $clist = $controllerList;
375: $alist = $actionList;
376: if (in_array('controller', $route->hardCoded)) {
377: $clist = array($route->defaults['controller']);
378: }
379: if (in_array('action', $route->hardCoded)) {
380: $alist = array($route->defaults['action']);
381: }
382: foreach ($clist as $controller) {
383: foreach ($alist as $action) {
384: if (in_array($controller, array_keys($gendict))) {
385: $actiondict = &$gendict[$controller];
386: } else {
387: $gendict[$controller] = array();
388: $actiondict = &$gendict[$controller];
389: }
390: if (in_array($action, array_keys($actiondict))) {
391: $tmp = $actiondict[$action];
392: } else {
393: $tmp = array(array(), array());
394: }
395: $tmp[0][] = $route;
396: $actiondict[$action] = $tmp;
397: }
398: }
399: }
400: if (!isset($gendict['*'])) {
401: $gendict['*'] = array();
402: }
403:
404: // Write to the cache
405: if ($this->cache) {
406: $this->cache->set($cacheKey, serialize($gendict), $this->cacheLifetime);
407: }
408:
409: $this->_gendict = $gendict;
410: $this->_createdGens = true;
411: }
412:
413: /**
414: * Creates the regexes for all connected routes
415: *
416: * @param array $clist controller list, controller_scan will be used otherwise
417: * @return void
418: */
419: public function createRegs($clist = null)
420: {
421: if ($clist === null) {
422: if ($this->directory === null) {
423: $clist = call_user_func($this->controllerScan);
424: } else {
425: $clist = call_user_func($this->controllerScan, $this->directory);
426: }
427: }
428:
429: foreach ($this->maxKeys as $key => $val) {
430: foreach ($val as $route) {
431: $route->makeRegexp($clist);
432: }
433: }
434:
435: // Create our regexp to strip the prefix
436: if (!empty($this->prefix)) {
437: $this->_regPrefix = $this->prefix . '(.*)';
438: }
439: $this->_createdRegs = true;
440: }
441:
442: /**
443: * Internal Route matcher
444: *
445: * Matches a URL against a route, and returns a tuple (array) of the
446: * match dict (array) and the route object if a match is successful,
447: * otherwise it returns null.
448: *
449: * @param string $url URL to match
450: * @return null|array Match data if matched, otherwise null
451: */
452: protected function _match($url)
453: {
454: if (!$this->_createdRegs && !empty($this->controllerScan)) {
455: $this->createRegs();
456: } elseif (!$this->_createdRegs) {
457: $msg = 'You must generate the regular expressions before matching.';
458: throw new Horde_Routes_Exception($msg);
459: }
460:
461: if ($this->alwaysScan) {
462: $this->createRegs();
463: }
464:
465: $matchLog = array();
466: if (!empty($this->prefix)) {
467: if (preg_match('@' . $this->_regPrefix . '@', $url)) {
468: $url = preg_replace('@' . $this->_regPrefix . '@', '$1', $url);
469: if (empty($url)) {
470: $url = '/';
471: }
472: } else {
473: return array(null, null, $matchLog);
474: }
475: }
476:
477: foreach ($this->matchList as $route) {
478: if ($route->static) {
479: if ($this->debug) {
480: $matchLog[] = array('route' => $route, 'static' => true);
481: }
482: continue;
483: }
484:
485: $match = $route->match($url, array('environ' => $this->environ,
486: 'subDomains' => $this->subDomains,
487: 'subDomainsIgnore' => $this->subDomainsIgnore,
488: 'domainMatch' => $this->domainMatch));
489: if ($this->debug) {
490: $matchLog[] = array('route' => $route, 'regexp' => (bool)$match);
491: }
492: if ($match) {
493: return array($match, $route, $matchLog);
494: }
495: }
496:
497: return array(null, null, $matchLog);
498: }
499:
500: /**
501: * Match a URL against one of the routes contained.
502: * It will return null if no valid match is found.
503: *
504: * Usage:
505: * $resultdict = $m->match('/joe/sixpack');
506: *
507: * @param string $url URL to match
508: * @param array|null Array if matched, otherwise null
509: */
510: public function match($url)
511: {
512: if (!strlen($url)) {
513: $msg = 'No URL provided, the minimum URL necessary to match is "/"';
514: throw new Horde_Routes_Exception($msg);
515: }
516:
517: $result = $this->_match($url);
518:
519: if ($this->debug) {
520: return array($result[0], $result[1], $result[2]);
521: }
522:
523: return ($result[0]) ? $result[0] : null;
524: }
525:
526: /**
527: * Match a URL against one of the routes contained.
528: * It will return null if no valid match is found, otherwise
529: * a result dict (array) and a route object is returned.
530: *
531: * Usage:
532: * list($resultdict, $resultobj) = $m->match('/joe/sixpack');
533: *
534: * @param string $url URL to match
535: * @param array|null Array if matched, otherwise null
536: */
537: public function routematch($url)
538: {
539: $result = $this->_match($url);
540:
541: if ($this->debug) {
542: return array($result[0], $result[1], $result[2]);
543: }
544:
545: return ($result[0]) ? array($result[0], $result[1]) : null;
546: }
547:
548: /**
549: * Generates the URL from a given set of keywords
550: * Returns the URL text, or null if no URL could be generated.
551: *
552: * Usage:
553: * $m->generate(array('controller' => 'content', 'action' => 'view', 'id' => 10));
554: *
555: * @param array $routeArgs Optional explicit route list
556: * @param array $kargs Keyword arguments (key/value pairs)
557: * @return null|string URL text or null
558: */
559: public function generate($first = null, $second = null)
560: {
561: if ($second) {
562: $routeArgs = $first;
563: $kargs = is_null($second) ? array() : $second;
564: } else {
565: $routeArgs = array();
566: $kargs = is_null($first) ? array() : $first;
567: }
568:
569: // Generate ourself if we haven't already
570: if (!$this->_createdGens) {
571: $this->_createGens();
572: }
573:
574: if ($this->appendSlash) {
575: $kargs['_appendSlash'] = true;
576: }
577:
578: if (!$this->explicit) {
579: if (!in_array('controller', array_keys($kargs))) {
580: $kargs['controller'] = 'content';
581: }
582: if (!in_array('action', array_keys($kargs))) {
583: $kargs['action'] = 'index';
584: }
585: }
586:
587: $environ = $this->environ;
588: $controller = isset($kargs['controller']) ? $kargs['controller'] : null;
589: $action = isset($kargs['action']) ? $kargs['action'] : null;
590:
591: // If the URL didn't depend on the SCRIPT_NAME, we'll cache it
592: // keyed by just the $kargs; otherwise we need to cache it with
593: // both SCRIPT_NAME and $kargs:
594: $cacheKey = $kargs;
595: if (!empty($environ['SCRIPT_NAME'])) {
596: $cacheKeyScriptName = sprintf('%s:%s', $environ['SCRIPT_NAME'], $cacheKey);
597: } else {
598: $cacheKeyScriptName = $cacheKey;
599: }
600:
601: // Check the URL cache to see if it exists, use it if it does.
602: foreach (array($cacheKey, $cacheKeyScriptName) as $key) {
603: if (in_array($key, array_keys($this->urlCache))) {
604: return $this->urlCache[$key];
605: }
606: }
607:
608: if ($routeArgs) {
609: $keyList = $routeArgs;
610: } else {
611: $actionList = isset($this->_gendict[$controller]) ? $this->_gendict[$controller] : $this->_gendict['*'];
612: list($keyList, $sortCache) =
613: (isset($actionList[$action])) ? $actionList[$action] : ((isset($actionList['*'])) ? $actionList['*'] : array(null, null));
614: if ($keyList === null) {
615: return null;
616: }
617: }
618:
619: $keys = array_keys($kargs);
620:
621: // necessary to pass $keys to _keysort() callback used by PHP's usort()
622: $this->_keysortTmp = $keys;
623:
624: $newList = array();
625: foreach ($keyList as $route) {
626: $tmp = Horde_Routes_Utils::arraySubtract($route->minKeys, $keys);
627: if (count($tmp) == 0) {
628: $newList[] = $route;
629: }
630: }
631: $keyList = $newList;
632:
633: // inline python function keysort() moved below as _keycmp()
634:
635: $this->_keysort($keyList);
636:
637: foreach ($keyList as $route) {
638: $fail = false;
639: foreach ($route->hardCoded as $key) {
640: $kval = isset($kargs[$key]) ? $kargs[$key] : null;
641: if ($kval == null) {
642: continue;
643: }
644:
645: if ($kval != $route->defaults[$key]) {
646: $fail = true;
647: break;
648: }
649: }
650: if ($fail) {
651: continue;
652: }
653:
654: $path = $route->generate($kargs);
655:
656: if ($path) {
657: if ($this->prefix) {
658: $path = $this->prefix . $path;
659: }
660: if (!empty($environ['SCRIPT_NAME']) && !$route->absolute) {
661: $path = $environ['SCRIPT_NAME'] . $path;
662: $key = $cacheKeyScriptName;
663: } else {
664: $key = $cacheKey;
665: }
666: if ($this->urlCache != null) {
667: $this->urlCache[$key] = $path;
668: }
669: return $path;
670: } else {
671: continue;
672: }
673: }
674: return null;
675: }
676:
677: /**
678: * Generate routes for a controller resource
679: *
680: * The $memberName name should be the appropriate singular version of the
681: * resource given your locale and used with members of the collection.
682: *
683: * The $collectionName name will be used to refer to the resource
684: * collection methods and should be a plural version of the $memberName
685: * argument. By default, the $memberName name will also be assumed to map
686: * to a controller you create.
687: *
688: * The concept of a web resource maps somewhat directly to 'CRUD'
689: * operations. The overlying things to keep in mind is that mapping a
690: * resource is about handling creating, viewing, and editing that
691: * resource.
692: *
693: * All keyword arguments ($kargs) are optional.
694: *
695: * ``controller``
696: * If specified in the keyword args, the controller will be the actual
697: * controller used, but the rest of the naming conventions used for
698: * the route names and URL paths are unchanged.
699: *
700: * ``collection``
701: * Additional action mappings used to manipulate/view the entire set of
702: * resources provided by the controller.
703: *
704: * Example::
705: *
706: * $map->resource('message', 'messages',
707: * array('collection' => array('rss' => 'GET)));
708: * # GET /message;rss (maps to the rss action)
709: * # also adds named route "rss_message"
710: *
711: * ``member``
712: * Additional action mappings used to access an individual 'member'
713: * of this controllers resources.
714: *
715: * Example::
716: *
717: * $map->resource('message', 'messages',
718: * array('member' => array('mark' => 'POST')));
719: * # POST /message/1;mark (maps to the mark action)
720: * # also adds named route "mark_message"
721: *
722: * ``new``
723: * Action mappings that involve dealing with a new member in the
724: * controller resources.
725: *
726: * Example::
727: *
728: * $map->resource('message', 'messages',
729: * array('new' => array('preview' => 'POST')));
730: * # POST /message/new;preview (maps to the preview action)
731: * # also adds a url named "preview_new_message"
732: *
733: * ``pathPrefix``
734: * Prepends the URL path for the Route with the pathPrefix given.
735: * This is most useful for cases where you want to mix resources
736: * or relations between resources.
737: *
738: * ``namePrefix``
739: * Perpends the route names that are generated with the namePrefix
740: * given. Combined with the pathPrefix option, it's easy to
741: * generate route names and paths that represent resources that are
742: * in relations.
743: *
744: * Example::
745: *
746: * map.resource('message', 'messages',
747: * array('controller' => 'categories',
748: * 'pathPrefix' => '/category/:category_id',
749: * 'namePrefix' => 'category_')));
750: * # GET /category/7/message/1
751: * # has named route "category_message"
752: *
753: * ``parentResource``
754: * An assoc. array containing information about the parent resource,
755: * for creating a nested resource. It should contain the ``$memberName``
756: * and ``collectionName`` of the parent resource. This assoc. array will
757: * be available via the associated ``Route`` object which can be
758: * accessed during a request via ``request.environ['routes.route']``
759: *
760: * If ``parentResource`` is supplied and ``pathPrefix`` isn't,
761: * ``pathPrefix`` will be generated from ``parentResource`` as
762: * "<parent collection name>/:<parent member name>_id".
763: *
764: * If ``parentResource`` is supplied and ``namePrefix`` isn't,
765: * ``namePrefix`` will be generated from ``parentResource`` as
766: * "<parent member name>_".
767: *
768: * Example::
769: *
770: * $m = new Horde_Routes_Mapper();
771: * $utils = $m->utils;
772: *
773: * $m->resource('location', 'locations',
774: * array('parentResource' =>
775: * array('memberName' => 'region',
776: * 'collectionName' => 'regions'))));
777: * # pathPrefix is "regions/:region_id"
778: * # namePrefix is "region_"
779: *
780: * $utils->urlFor('region_locations', array('region_id'=>13));
781: * # '/regions/13/locations'
782: *
783: * $utils->urlFor('region_new_location', array('region_id'=>13));
784: * # '/regions/13/locations/new'
785: *
786: * $utils->urlFor('region_location',
787: * array('region_id'=>13, 'id'=>60));
788: * # '/regions/13/locations/60'
789: *
790: * $utils->urlFor('region_edit_location',
791: * array('region_id'=>13, 'id'=>60));
792: * # '/regions/13/locations/60/edit'
793: *
794: * Overriding generated ``pathPrefix``::
795: *
796: * $m = new Horde_Routes_Mapper();
797: * $utils = new Horde_Routes_Utils();
798: *
799: * $m->resource('location', 'locations',
800: * array('parentResource' =>
801: * array('memberName' => 'region',
802: * 'collectionName' => 'regions'),
803: * 'pathPrefix' => 'areas/:area_id')));
804: * # name prefix is "region_"
805: *
806: * $utils->urlFor('region_locations', array('area_id'=>51));
807: * # '/areas/51/locations'
808: *
809: * Overriding generated ``namePrefix``::
810: *
811: * $m = new Horde_Routes_Mapper
812: * $m->resource('location', 'locations',
813: * array('parentResource' =>
814: * array('memberName' => 'region',
815: * 'collectionName' => 'regions'),
816: * 'namePrefix' => '')));
817: * # pathPrefix is "regions/:region_id"
818: *
819: * $utils->urlFor('locations', array('region_id'=>51));
820: * # '/regions/51/locations'
821: *
822: * Note: Since Horde Routes 0.2.0 and Python Routes 1.8, this method is
823: * not compatible with earlier versions inasmuch as the semicolon is no
824: * longer used to delimit custom actions. This was a change in Rails
825: * itself (http://dev.rubyonrails.org/changeset/6485) and adopting it
826: * here allows us to keep parity with Rails and ActiveResource.
827: *
828: * @param string $memberName Singular version of the resource name
829: * @param string $collectionName Collection name (plural of $memberName)
830: * @param array $kargs Keyword arguments (see above)
831: * @return void
832: */
833: public function resource($memberName, $collectionName, $kargs = array())
834: {
835: $defaultKargs = array('collection' => array(),
836: 'member' => array(),
837: 'new' => array(),
838: 'pathPrefix' => null,
839: 'namePrefix' => null,
840: 'parentResource' => null);
841: $kargs = array_merge($defaultKargs, $kargs);
842:
843: // Generate ``pathPrefix`` if ``pathPrefix`` wasn't specified and
844: // ``parentResource`` was. Likewise for ``namePrefix``. Make sure
845: // that ``pathPrefix`` and ``namePrefix`` *always* take precedence if
846: // they are specified--in particular, we need to be careful when they
847: // are explicitly set to "".
848: if ($kargs['parentResource'] !== null) {
849: if ($kargs['pathPrefix'] === null) {
850: $kargs['pathPrefix'] = $kargs['parentResource']['collectionName'] . '/:'
851: . $kargs['parentResource']['memberName'] . '_id';
852: }
853: if ($kargs['namePrefix'] === null) {
854: $kargs['namePrefix'] = $kargs['parentResource']['memberName'] . '_';
855: }
856: } else {
857: if ($kargs['pathPrefix'] === null) {
858: $kargs['pathPrefix'] = '';
859: }
860: if ($kargs['namePrefix'] === null) {
861: $kargs['namePrefix'] = '';
862: }
863: }
864:
865: // Ensure the edit and new actions are in and GET
866: $kargs['member']['edit'] = 'GET';
867: $kargs['new']['new'] = 'GET';
868:
869: // inline python method swap() moved below as _swap()
870:
871: $collectionMethods = $this->_swap($kargs['collection'], array());
872: $memberMethods = $this->_swap($kargs['member'], array());
873: $newMethods = $this->_swap($kargs['new'], array());
874:
875: // Insert create, update, and destroy methods
876: if (!isset($collectionMethods['POST'])) {
877: $collectionMethods['POST'] = array();
878: }
879: array_unshift($collectionMethods['POST'], 'create');
880:
881: if (!isset($memberMethods['PUT'])) {
882: $memberMethods['PUT'] = array();
883: }
884: array_unshift($memberMethods['PUT'], 'update');
885:
886: if (!isset($memberMethods['DELETE'])) {
887: $memberMethods['DELETE'] = array();
888: }
889: array_unshift($memberMethods['DELETE'], 'delete');
890:
891: // If there's a path prefix option, use it with the controller
892: $controller = $this->_stripSlashes($collectionName);
893: $kargs['pathPrefix'] = $this->_stripSlashes($kargs['pathPrefix']);
894: if ($kargs['pathPrefix']) {
895: $path = $kargs['pathPrefix'] . '/' . $controller;
896: } else {
897: $path = $controller;
898: }
899: $collectionPath = $path;
900: $newPath = $path . '/new';
901: $memberPath = $path . '/:(id)';
902:
903: $options = array(
904: 'controller' => (isset($kargs['controller']) ? $kargs['controller'] : $controller),
905: '_memberName' => $memberName,
906: '_collectionName' => $collectionName,
907: '_parentResource' => $kargs['parentResource']
908: );
909:
910: // inline python method requirements_for() moved below as _requirementsFor()
911:
912: // Add the routes for handling collection methods
913: foreach ($collectionMethods as $method => $lst) {
914: $primary = ($method != 'GET' && isset($lst[0])) ? array_shift($lst) : null;
915: $routeOptions = $this->_requirementsFor($method, $options);
916:
917: foreach ($lst as $action) {
918: $routeOptions['action'] = $action;
919: $routeName = sprintf('%s%s_%s', $kargs['namePrefix'], $action, $collectionName);
920:
921: $this->connect($routeName,
922: sprintf("%s/%s", $collectionPath, $action),
923: $routeOptions);
924: $this->connect('formatted_' . $routeName,
925: sprintf("%s/%s.:(format)", $collectionPath, $action),
926: $routeOptions);
927: }
928: if ($primary) {
929: $routeOptions['action'] = $primary;
930: $this->connect($collectionPath, $routeOptions);
931: $this->connect($collectionPath . '.:(format)', $routeOptions);
932: }
933: }
934:
935: // Specifically add in the built-in 'index' collection method and its
936: // formatted version
937: $connectkargs = array('action' => 'index',
938: 'conditions' => array('method' => array('GET')));
939: $this->connect($kargs['namePrefix'] . $collectionName,
940: $collectionPath,
941: array_merge($connectkargs, $options));
942: $this->connect('formatted_' . $kargs['namePrefix'] . $collectionName,
943: $collectionPath . '.:(format)',
944: array_merge($connectkargs, $options));
945:
946: // Add the routes that deal with new resource methods
947: foreach ($newMethods as $method => $lst) {
948: $routeOptions = $this->_requirementsFor($method, $options);
949: foreach ($lst as $action) {
950: if ($action == 'new' && $newPath) {
951: $path = $newPath;
952: } else {
953: $path = sprintf('%s/%s', $newPath, $action);
954: }
955:
956: $name = 'new_' . $memberName;
957: if ($action != 'new') {
958: $name = $action . '_' . $name;
959: }
960: $routeOptions['action'] = $action;
961: $this->connect($kargs['namePrefix'] . $name, $path, $routeOptions);
962:
963: if ($action == 'new' && $newPath) {
964: $path = $newPath . '.:(format)';
965: } else {
966: $path = sprintf('%s/%s.:(format)', $newPath, $action);
967: }
968:
969: $this->connect('formatted_' . $kargs['namePrefix'] . $name,
970: $path, $routeOptions);
971: }
972: }
973:
974: $requirementsRegexp = '[\w\-_]+';
975:
976: // Add the routes that deal with member methods of a resource
977: foreach ($memberMethods as $method => $lst) {
978: $routeOptions = $this->_requirementsFor($method, $options);
979: $routeOptions['requirements'] = array('id' => $requirementsRegexp);
980:
981: if (!in_array($method, array('POST', 'GET', 'any'))) {
982: $primary = array_shift($lst);
983: } else {
984: $primary = null;
985: }
986:
987: foreach ($lst as $action) {
988: $routeOptions['action'] = $action;
989: $this->connect(sprintf('%s%s_%s', $kargs['namePrefix'], $action, $memberName),
990: sprintf('%s/%s', $memberPath, $action),
991: $routeOptions);
992: $this->connect(sprintf('formatted_%s%s_%s', $kargs['namePrefix'], $action, $memberName),
993: sprintf('%s/%s.:(format)', $memberPath, $action),
994: $routeOptions);
995: }
996:
997: if ($primary) {
998: $routeOptions['action'] = $primary;
999: $this->connect($memberPath, $routeOptions);
1000: $this->connect($memberPath . '.:(format)', $routeOptions);
1001: }
1002: }
1003:
1004: // Specifically add the member 'show' method
1005: $routeOptions = $this->_requirementsFor('GET', $options);
1006: $routeOptions['action'] = 'show';
1007: $routeOptions['requirements'] = array('id' => $requirementsRegexp);
1008: $this->connect($kargs['namePrefix'] . $memberName, $memberPath, $routeOptions);
1009: $this->connect('formatted_' . $kargs['namePrefix'] . $memberName,
1010: $memberPath . '.:(format)', $routeOptions);
1011: }
1012:
1013: /**
1014: * Returns a new dict to be used for all route creation as
1015: * the route options.
1016: * @see resource()
1017: *
1018: * @param string $method Request method ('get', 'post', etc.) or 'any'
1019: * @param array $options Assoc. array to populate with 'conditions' key
1020: * @return $options populated
1021: */
1022: protected function _requirementsFor($meth, $options)
1023: {
1024: if ($meth != 'any') {
1025: $options['conditions'] = array('method' => array(strtoupper($meth)));
1026: }
1027: return $options;
1028: }
1029:
1030: /**
1031: * Swap the keys and values in the dict, and uppercase the values
1032: * from the dict during the swap.
1033: * @see resource()
1034: *
1035: * @param array $dct Input dict (assoc. array)
1036: * @param array $newdct Output dict to populate
1037: * @return array $newdct populated
1038: */
1039: protected function _swap($dct, $newdct)
1040: {
1041: foreach ($dct as $key => $val) {
1042: $newkey = strtoupper($val);
1043: if (!isset($newdct[$newkey])) {
1044: $newdct[$newkey] = array();
1045: }
1046: $newdct[$newkey][] = $key;
1047: }
1048: return $newdct;
1049: }
1050:
1051: /**
1052: * Sort an array of Horde_Routes_Routes to using _keycmp() for the comparision
1053: * to order them ideally for matching.
1054: *
1055: * An unfortunate property of PHP's usort() is that if two members compare
1056: * equal, their order in the sorted array is undefined (see PHP manual).
1057: * This is unsuitable for us because the order that the routes were
1058: * connected to the mapper is significant.
1059: *
1060: * Uses this method uses merge sort algorithm based on the
1061: * comments in http://www.php.net/usort
1062: *
1063: * @param array $array Array Horde_Routes_Route objects to sort (by reference)
1064: * @return void
1065: */
1066: protected function _keysort(&$array)
1067: {
1068: // arrays of size < 2 require no action.
1069: if (count($array) < 2) { return; }
1070:
1071: // split the array in half
1072: $halfway = count($array) / 2;
1073: $array1 = array_slice($array, 0, $halfway);
1074: $array2 = array_slice($array, $halfway);
1075:
1076: // recurse to sort the two halves
1077: $this->_keysort($array1);
1078: $this->_keysort($array2);
1079:
1080: // if all of $array1 is <= all of $array2, just append them.
1081: if ($this->_keycmp(end($array1), $array2[0]) < 1) {
1082: $array = array_merge($array1, $array2);
1083: return;
1084: }
1085:
1086: // merge the two sorted arrays into a single sorted array
1087: $array = array();
1088: $ptr1 = 0;
1089: $ptr2 = 0;
1090: while ($ptr1 < count($array1) && $ptr2 < count($array2)) {
1091: if ($this->_keycmp($array1[$ptr1], $array2[$ptr2]) < 1) {
1092: $array[] = $array1[$ptr1++];
1093: }
1094: else {
1095: $array[] = $array2[$ptr2++];
1096: }
1097: }
1098:
1099: // merge the remainder
1100: while ($ptr1 < count($array1)) { $array[] = $array1[$ptr1++]; }
1101: while ($ptr2 < count($array2)) { $array[] = $array2[$ptr2++]; }
1102: return;
1103: }
1104:
1105: /**
1106: * Compare two Horde_Route_Routes objects by their keys against
1107: * the instance variable $keysortTmp. Used by _keysort().
1108: *
1109: * @param array $a First dict (assoc. array)
1110: * @param array $b Second dict
1111: * @return integer
1112: */
1113: protected function _keycmp($a, $b)
1114: {
1115: $keys = $this->_keysortTmp;
1116: $am = $a->minKeys;
1117: $a = $a->maxKeys;
1118: $b = $b->maxKeys;
1119:
1120: $lendiffa = count(array_diff($keys, $a));
1121: $lendiffb = count(array_diff($keys, $b));
1122:
1123: // If they both match, don't switch them
1124: if ($lendiffa == 0 && $lendiffb == 0) {
1125: return 0;
1126: }
1127:
1128: // First, if $a matches exactly, use it
1129: if ($lendiffa == 0) {
1130: return -1;
1131: }
1132:
1133: // Or $b matches exactly, use it
1134: if ($lendiffb == 0) {
1135: return 1;
1136: }
1137:
1138: // Neither matches exactly, return the one with the most in common
1139: if ($this->_cmp($lendiffa, $lendiffb) != 0) {
1140: return $this->_cmp($lendiffa, $lendiffb);
1141: }
1142:
1143: // Neither matches exactly, but if they both have just as much in common
1144: if (count($this->_arrayUnion($keys, $b)) == count($this->_arrayUnion($keys, $a))) {
1145: return $this->_cmp(count($a), count($b));
1146:
1147: // Otherwise, we return the one that has the most in common
1148: } else {
1149: return $this->_cmp(count($this->_arrayUnion($keys, $b)), count($this->_arrayUnion($keys, $a)));
1150: }
1151: }
1152:
1153: /**
1154: * Create a union of two arrays.
1155: *
1156: * @param array $a First array
1157: * @param array $b Second array
1158: * @return array Union of $a and $b
1159: */
1160: protected function _arrayUnion($a, $b)
1161: {
1162: return array_merge(array_diff($a, $b), array_diff($b, $a), array_intersect($a, $b));
1163: }
1164:
1165: /**
1166: * Equivalent of Python's cmp() function.
1167: *
1168: * @param integer|float $a First item to compare
1169: * @param integer|flot $b Second item to compare
1170: * @param integer Result of comparison
1171: */
1172: protected function _cmp($a, $b)
1173: {
1174: if ($a < $b) {
1175: return -1;
1176: }
1177: if ($a == $b) {
1178: return 0;
1179: }
1180: return 1;
1181: }
1182:
1183: /**
1184: * Trims slashes from the beginning or end of a part/URL.
1185: *
1186: * @param string $name Part or URL with slash at begin/end
1187: * @return string Part or URL with begin/end slashes removed
1188: */
1189: protected function _stripSlashes($name)
1190: {
1191: if (substr($name, 0, 1) == '/') {
1192: $name = substr($name, 1);
1193: }
1194: if (substr($name, -1, 1) == '/') {
1195: $name = substr($name, 0, -1);
1196: }
1197: return $name;
1198: }
1199:
1200: }
1201:
1202: