Overview

Packages

  • Horde
    • Routes
  • Routes

Classes

  • Horde_Routes_Exception
  • Horde_Routes_Mapper
  • Horde_Routes_Printer
  • Horde_Routes_Route
  • Horde_Routes_Utils
  • Overview
  • Package
  • Class
  • Tree
   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: 
API documentation generated by ApiGen