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 */
24class 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 = serialize($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(Horde_String::upper($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 = Horde_String::upper($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