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