1202 lines
41 KiB
PHP
1202 lines
41 KiB
PHP
<?php
|
|
/**
|
|
* Horde Routes package
|
|
*
|
|
* This package is heavily inspired by the Python "Routes" library
|
|
* by Ben Bangert (http://routes.groovie.org). Routes is based
|
|
* largely on ideas from Ruby on Rails (http://www.rubyonrails.org).
|
|
*
|
|
* @author Maintainable Software, LLC. (http://www.maintainable.com)
|
|
* @author Mike Naberezny <mike@maintainable.com>
|
|
* @license http://www.horde.org/licenses/bsd BSD
|
|
* @package Routes
|
|
*/
|
|
|
|
/**
|
|
* The mapper class handles URL generation and recognition for web applications
|
|
*
|
|
* The mapper class is built by handling associated arrays of information and passing
|
|
* associated arrays back to the application for it to handle and dispatch the
|
|
* appropriate scripts.
|
|
*
|
|
* @package Routes
|
|
*/
|
|
class Horde_Routes_Mapper
|
|
{
|
|
/**
|
|
* Filtered request environment with keys like SCRIPT_NAME
|
|
* @var array
|
|
*/
|
|
public $environ = array();
|
|
|
|
/**
|
|
* Callback function used to get array of controller names
|
|
* @var callback
|
|
*/
|
|
public $controllerScan;
|
|
|
|
/**
|
|
* Path to controller directory passed to controllerScan function
|
|
* @var string
|
|
*/
|
|
public $directory;
|
|
|
|
/**
|
|
* Call controllerScan callback before every route match?
|
|
* @var boolean
|
|
*/
|
|
public $alwaysScan;
|
|
|
|
/**
|
|
* Disable route memory and implicit defaults?
|
|
* @var boolean
|
|
*/
|
|
public $explicit;
|
|
|
|
/**
|
|
* Collect debug information during route match?
|
|
* @var boolean
|
|
*/
|
|
public $debug = false;
|
|
|
|
/**
|
|
* Use sub-domain support?
|
|
* @var boolean
|
|
*/
|
|
public $subDomains = false;
|
|
|
|
/**
|
|
* Array of sub-domains to ignore if using sub-domain support
|
|
* @var array
|
|
*/
|
|
public $subDomainsIgnore = array();
|
|
|
|
/**
|
|
* Append trailing slash ('/') to generated routes?
|
|
* @var boolean
|
|
*/
|
|
public $appendSlash = false;
|
|
|
|
/**
|
|
* Prefix to strip during matching and to append during generation
|
|
* @var null|string
|
|
*/
|
|
public $prefix = null;
|
|
|
|
/**
|
|
* Array of connected routes
|
|
* @var array
|
|
*/
|
|
public $matchList = array();
|
|
|
|
/**
|
|
* Array of connected named routes, indexed by name
|
|
* @var array
|
|
*/
|
|
public $routeNames = array();
|
|
|
|
/**
|
|
* Cache of URLs used in generate()
|
|
* @var array
|
|
*/
|
|
public $urlCache = array();
|
|
|
|
/**
|
|
* Encoding of routes URLs (not yet supported)
|
|
* @var string
|
|
*/
|
|
public $encoding = 'utf-8';
|
|
|
|
/**
|
|
* What to do on decoding errors? 'ignore' or 'replace'
|
|
* @var string
|
|
*/
|
|
public $decodeErrors = 'ignore';
|
|
|
|
/**
|
|
* Partial regexp used to match domain part of the end of URLs to match
|
|
* @var string
|
|
*/
|
|
public $domainMatch = '[^\.\/]+?\.[^\.\/]+';
|
|
|
|
/**
|
|
* Array of all connected routes, indexed by the serialized array of all
|
|
* keys that each route could utilize.
|
|
* @var array
|
|
*/
|
|
public $maxKeys = array();
|
|
|
|
/**
|
|
* Array of all connected routes, indexed by the serialized array of the
|
|
* minimum keys that each route needs.
|
|
* @var array
|
|
*/
|
|
public $minKeys = array();
|
|
|
|
/**
|
|
* Utility functions like urlFor() and redirectTo() for this Mapper
|
|
* @var Horde_Routes_Utils
|
|
*/
|
|
public $utils;
|
|
|
|
/**
|
|
* Cache
|
|
* @var Horde_Cache
|
|
*/
|
|
public $cache;
|
|
|
|
/**
|
|
* Cache lifetime for the same value of $this->matchList
|
|
* @var integer
|
|
*/
|
|
public $cacheLifetime = 86400;
|
|
|
|
/**
|
|
* Have regular expressions been created for all connected routes?
|
|
* @var boolean
|
|
*/
|
|
protected $_createdRegs = false;
|
|
|
|
/**
|
|
* Have generation hashes been created for all connected routes?
|
|
* @var boolean
|
|
*/
|
|
protected $_createdGens = false;
|
|
|
|
/**
|
|
* Generation hashes created for all connected routes
|
|
* @var array
|
|
*/
|
|
protected $_gendict;
|
|
|
|
/**
|
|
* Temporary variable used to pass array of keys into _keysort() callback
|
|
* @var array
|
|
*/
|
|
protected $_keysortTmp;
|
|
|
|
/**
|
|
* Regular expression generated to match after the prefix
|
|
* @var string
|
|
*/
|
|
protected $_regPrefix = null;
|
|
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* Keyword arguments ($kargs):
|
|
* ``controllerScan`` (callback)
|
|
* Function to return an array of valid controllers
|
|
*
|
|
* ``redirect`` (callback)
|
|
* Function to perform a redirect for Horde_Routes_Utils->redirectTo()
|
|
*
|
|
* ``directory`` (string)
|
|
* Path to the directory that will be passed to the
|
|
* controllerScan callback
|
|
*
|
|
* ``alwaysScan`` (boolean)
|
|
* Should the controllerScan callback be called
|
|
* before every URL match?
|
|
*
|
|
* ``explicit`` (boolean)
|
|
* Should routes be connected with the implicit defaults of
|
|
* array('controller'=>'content', 'action'=>'index', 'id'=>null)?
|
|
* When set to True, these will not be added to route connections.
|
|
*/
|
|
public function __construct($kargs = array())
|
|
{
|
|
$callback = array('Horde_Routes_Utils', 'controllerScan');
|
|
|
|
$defaultKargs = array('controllerScan' => $callback,
|
|
'directory' => null,
|
|
'alwaysScan' => false,
|
|
'explicit' => false);
|
|
$kargs = array_merge($defaultKargs, $kargs);
|
|
|
|
// Most default assignments that were in the construct in the Python
|
|
// version have been moved to outside the constructor unless they were variable
|
|
|
|
$this->directory = $kargs['directory'];
|
|
$this->alwaysScan = $kargs['alwaysScan'];
|
|
$this->controllerScan = $kargs['controllerScan'];
|
|
$this->explicit = $kargs['explicit'];
|
|
|
|
$this->utils = new Horde_Routes_Utils($this);
|
|
}
|
|
|
|
/**
|
|
* Create and connect a new Route to the Mapper.
|
|
*
|
|
* Usage:
|
|
* $m = new Horde_Routes_Mapper();
|
|
* $m->connect(':controller/:action/:id');
|
|
* $m->connect('date/:year/:month/:day', array('controller' => "blog", 'action' => 'view');
|
|
* $m->connect('archives/:page', array('controller' => 'blog', 'action' => 'by_page',
|
|
* ' requirements' => array('page' => '\d{1,2}')));
|
|
* $m->connect('category_list',
|
|
* 'archives/category/:section', array('controller' => 'blog', 'action' => 'category',
|
|
* 'section' => 'home', 'type' => 'list'));
|
|
* $m->connect('home',
|
|
* '',
|
|
* array('controller' => 'blog', 'action' => 'view', 'section' => 'home'));
|
|
*
|
|
* @param mixed $first First argument in vargs, see usage above.
|
|
* @param mixed $second Second argument in varags
|
|
* @param mixed $third Third argument in varargs
|
|
* @return void
|
|
*/
|
|
public function connect($first, $second = null, $third = null)
|
|
{
|
|
if ($third !== null) {
|
|
// 3 args given
|
|
// connect('route_name', ':/controller/:action/:id', array('kargs'=>'here'))
|
|
$routeName = $first;
|
|
$routePath = $second;
|
|
$kargs = $third;
|
|
} else if ($second !== null) {
|
|
// 2 args given
|
|
if (is_array($second)) {
|
|
// connect(':/controller/:action/:id', array('kargs'=>'here'))
|
|
$routeName = null;
|
|
$routePath = $first;
|
|
$kargs = $second;
|
|
} else {
|
|
// connect('route_name', ':/controller/:action/:id')
|
|
$routeName = $first;
|
|
$routePath = $second;
|
|
$kargs = array();
|
|
}
|
|
} else {
|
|
// 1 arg given
|
|
// connect('/:controller/:action/:id')
|
|
$routeName = null;
|
|
$routePath = $first;
|
|
$kargs = array();
|
|
}
|
|
|
|
if (!in_array('_explicit', $kargs)) {
|
|
$kargs['_explicit'] = $this->explicit;
|
|
}
|
|
|
|
$route = new Horde_Routes_Route($routePath, $kargs);
|
|
|
|
if ($this->encoding != 'utf-8' || $this->decodeErrors != 'ignore') {
|
|
$route->encoding = $this->encoding;
|
|
$route->decodeErrors = $this->decodeErrors;
|
|
}
|
|
|
|
$this->matchList[] = $route;
|
|
|
|
if (isset($routeName)) {
|
|
$this->routeNames[$routeName] = $route;
|
|
}
|
|
|
|
if ($route->static) {
|
|
return;
|
|
}
|
|
|
|
$exists = false;
|
|
foreach ($this->maxKeys as $key => $value) {
|
|
if (unserialize($key) == $route->maxKeys) {
|
|
$this->maxKeys[$key][] = $route;
|
|
$exists = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$exists) {
|
|
$this->maxKeys[serialize($route->maxKeys)] = array($route);
|
|
}
|
|
|
|
$this->_createdGens = false;
|
|
}
|
|
|
|
/**
|
|
* Set an optional Horde_Cache object for the created rules.
|
|
*
|
|
* @param Horde_Cache $cache Cache object
|
|
*/
|
|
public function setCache(Horde_Cache $cache)
|
|
{
|
|
$this->cache = $cache;
|
|
}
|
|
|
|
/**
|
|
* Create the generation hashes (arrays) for route lookups
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function _createGens()
|
|
{
|
|
// Checked for a cached generator dictionary for $this->matchList
|
|
if ($this->cache) {
|
|
$cacheKey = 'horde.routes.' . sha1(serialize($this->matchList));
|
|
$cachedDict = $cache->get($cacheKey, $this->cacheLifetime);
|
|
if ($gendict = @unserialize($cachedDict)) {
|
|
$this->_gendict = $gendict;
|
|
$this->_createdGens = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Use keys temporarily to assemble the list to avoid excessive
|
|
// list iteration testing with foreach. We include the '*' in the
|
|
// case that a generate contains a controller/action that has no
|
|
// hardcodes.
|
|
$actionList = $controllerList = array('*' => true);
|
|
|
|
// Assemble all the hardcoded/defaulted actions/controllers used
|
|
foreach ($this->matchList as $route) {
|
|
if ($route->static) {
|
|
continue;
|
|
}
|
|
if (isset($route->defaults['controller'])) {
|
|
$controllerList[$route->defaults['controller']] = true;
|
|
}
|
|
if (isset($route->defaults['action'])) {
|
|
$actionList[$route->defaults['action']] = true;
|
|
}
|
|
}
|
|
|
|
$actionList = array_keys($actionList);
|
|
$controllerList = array_keys($controllerList);
|
|
|
|
// Go through our list again, assemble the controllers/actions we'll
|
|
// add each route to. If its hardcoded, we only add it to that dict key.
|
|
// Otherwise we add it to every hardcode since it can be changed.
|
|
$gendict = array(); // Our generated two-deep hash
|
|
foreach ($this->matchList as $route) {
|
|
if ($route->static) {
|
|
continue;
|
|
}
|
|
$clist = $controllerList;
|
|
$alist = $actionList;
|
|
if (in_array('controller', $route->hardCoded)) {
|
|
$clist = array($route->defaults['controller']);
|
|
}
|
|
if (in_array('action', $route->hardCoded)) {
|
|
$alist = array($route->defaults['action']);
|
|
}
|
|
foreach ($clist as $controller) {
|
|
foreach ($alist as $action) {
|
|
if (in_array($controller, array_keys($gendict))) {
|
|
$actiondict = &$gendict[$controller];
|
|
} else {
|
|
$gendict[$controller] = array();
|
|
$actiondict = &$gendict[$controller];
|
|
}
|
|
if (in_array($action, array_keys($actiondict))) {
|
|
$tmp = $actiondict[$action];
|
|
} else {
|
|
$tmp = array(array(), array());
|
|
}
|
|
$tmp[0][] = $route;
|
|
$actiondict[$action] = $tmp;
|
|
}
|
|
}
|
|
}
|
|
if (!isset($gendict['*'])) {
|
|
$gendict['*'] = array();
|
|
}
|
|
|
|
// Write to the cache
|
|
if ($this->cache) {
|
|
$this->cache->set($cacheKey, serialize($gendict), $this->cacheLifetime);
|
|
}
|
|
|
|
$this->_gendict = $gendict;
|
|
$this->_createdGens = true;
|
|
}
|
|
|
|
/**
|
|
* Creates the regexes for all connected routes
|
|
*
|
|
* @param array $clist controller list, controller_scan will be used otherwise
|
|
* @return void
|
|
*/
|
|
public function createRegs($clist = null)
|
|
{
|
|
if ($clist === null) {
|
|
if ($this->directory === null) {
|
|
$clist = call_user_func($this->controllerScan);
|
|
} else {
|
|
$clist = call_user_func($this->controllerScan, $this->directory);
|
|
}
|
|
}
|
|
|
|
foreach ($this->maxKeys as $key => $val) {
|
|
foreach ($val as $route) {
|
|
$route->makeRegexp($clist);
|
|
}
|
|
}
|
|
|
|
// Create our regexp to strip the prefix
|
|
if (!empty($this->prefix)) {
|
|
$this->_regPrefix = $this->prefix . '(.*)';
|
|
}
|
|
$this->_createdRegs = true;
|
|
}
|
|
|
|
/**
|
|
* Internal Route matcher
|
|
*
|
|
* Matches a URL against a route, and returns a tuple (array) of the
|
|
* match dict (array) and the route object if a match is successful,
|
|
* otherwise it returns null.
|
|
*
|
|
* @param string $url URL to match
|
|
* @return null|array Match data if matched, otherwise null
|
|
*/
|
|
protected function _match($url)
|
|
{
|
|
if (!$this->_createdRegs && !empty($this->controllerScan)) {
|
|
$this->createRegs();
|
|
} elseif (!$this->_createdRegs) {
|
|
$msg = 'You must generate the regular expressions before matching.';
|
|
throw new Horde_Routes_Exception($msg);
|
|
}
|
|
|
|
if ($this->alwaysScan) {
|
|
$this->createRegs();
|
|
}
|
|
|
|
$matchLog = array();
|
|
if (!empty($this->prefix)) {
|
|
if (preg_match('@' . $this->_regPrefix . '@', $url)) {
|
|
$url = preg_replace('@' . $this->_regPrefix . '@', '$1', $url);
|
|
if (empty($url)) {
|
|
$url = '/';
|
|
}
|
|
} else {
|
|
return array(null, null, $matchLog);
|
|
}
|
|
}
|
|
|
|
foreach ($this->matchList as $route) {
|
|
if ($route->static) {
|
|
if ($this->debug) {
|
|
$matchLog[] = array('route' => $route, 'static' => true);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$match = $route->match($url, array('environ' => $this->environ,
|
|
'subDomains' => $this->subDomains,
|
|
'subDomainsIgnore' => $this->subDomainsIgnore,
|
|
'domainMatch' => $this->domainMatch));
|
|
if ($this->debug) {
|
|
$matchLog[] = array('route' => $route, 'regexp' => (bool)$match);
|
|
}
|
|
if ($match) {
|
|
return array($match, $route, $matchLog);
|
|
}
|
|
}
|
|
|
|
return array(null, null, $matchLog);
|
|
}
|
|
|
|
/**
|
|
* Match a URL against one of the routes contained.
|
|
* It will return null if no valid match is found.
|
|
*
|
|
* Usage:
|
|
* $resultdict = $m->match('/joe/sixpack');
|
|
*
|
|
* @param string $url URL to match
|
|
* @param array|null Array if matched, otherwise null
|
|
*/
|
|
public function match($url)
|
|
{
|
|
if (!strlen($url)) {
|
|
$msg = 'No URL provided, the minimum URL necessary to match is "/"';
|
|
throw new Horde_Routes_Exception($msg);
|
|
}
|
|
|
|
$result = $this->_match($url);
|
|
|
|
if ($this->debug) {
|
|
return array($result[0], $result[1], $result[2]);
|
|
}
|
|
|
|
return ($result[0]) ? $result[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Match a URL against one of the routes contained.
|
|
* It will return null if no valid match is found, otherwise
|
|
* a result dict (array) and a route object is returned.
|
|
*
|
|
* Usage:
|
|
* list($resultdict, $resultobj) = $m->match('/joe/sixpack');
|
|
*
|
|
* @param string $url URL to match
|
|
* @param array|null Array if matched, otherwise null
|
|
*/
|
|
public function routematch($url)
|
|
{
|
|
$result = $this->_match($url);
|
|
|
|
if ($this->debug) {
|
|
return array($result[0], $result[1], $result[2]);
|
|
}
|
|
|
|
return ($result[0]) ? array($result[0], $result[1]) : null;
|
|
}
|
|
|
|
/**
|
|
* Generates the URL from a given set of keywords
|
|
* Returns the URL text, or null if no URL could be generated.
|
|
*
|
|
* Usage:
|
|
* $m->generate(array('controller' => 'content', 'action' => 'view', 'id' => 10));
|
|
*
|
|
* @param array $routeArgs Optional explicit route list
|
|
* @param array $kargs Keyword arguments (key/value pairs)
|
|
* @return null|string URL text or null
|
|
*/
|
|
public function generate($first = null, $second = null)
|
|
{
|
|
if ($second) {
|
|
$routeArgs = $first;
|
|
$kargs = is_null($second) ? array() : $second;
|
|
} else {
|
|
$routeArgs = array();
|
|
$kargs = is_null($first) ? array() : $first;
|
|
}
|
|
|
|
// Generate ourself if we haven't already
|
|
if (!$this->_createdGens) {
|
|
$this->_createGens();
|
|
}
|
|
|
|
if ($this->appendSlash) {
|
|
$kargs['_appendSlash'] = true;
|
|
}
|
|
|
|
if (!$this->explicit) {
|
|
if (!in_array('controller', array_keys($kargs))) {
|
|
$kargs['controller'] = 'content';
|
|
}
|
|
if (!in_array('action', array_keys($kargs))) {
|
|
$kargs['action'] = 'index';
|
|
}
|
|
}
|
|
|
|
$environ = $this->environ;
|
|
$controller = isset($kargs['controller']) ? $kargs['controller'] : null;
|
|
$action = isset($kargs['action']) ? $kargs['action'] : null;
|
|
|
|
// If the URL didn't depend on the SCRIPT_NAME, we'll cache it
|
|
// keyed by just the $kargs; otherwise we need to cache it with
|
|
// both SCRIPT_NAME and $kargs:
|
|
$cacheKey = serialize($kargs);
|
|
if (!empty($environ['SCRIPT_NAME'])) {
|
|
$cacheKeyScriptName = sprintf('%s:%s', $environ['SCRIPT_NAME'], $cacheKey);
|
|
} else {
|
|
$cacheKeyScriptName = $cacheKey;
|
|
}
|
|
|
|
// Check the URL cache to see if it exists, use it if it does.
|
|
foreach (array($cacheKey, $cacheKeyScriptName) as $key) {
|
|
if (in_array($key, array_keys($this->urlCache))) {
|
|
return $this->urlCache[$key];
|
|
}
|
|
}
|
|
|
|
if ($routeArgs) {
|
|
$keyList = $routeArgs;
|
|
} else {
|
|
$actionList = isset($this->_gendict[$controller]) ? $this->_gendict[$controller] : $this->_gendict['*'];
|
|
list($keyList, $sortCache) =
|
|
(isset($actionList[$action])) ? $actionList[$action] : ((isset($actionList['*'])) ? $actionList['*'] : array(null, null));
|
|
if ($keyList === null) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
$keys = array_keys($kargs);
|
|
|
|
// necessary to pass $keys to _keysort() callback used by PHP's usort()
|
|
$this->_keysortTmp = $keys;
|
|
|
|
$newList = array();
|
|
foreach ($keyList as $route) {
|
|
$tmp = Horde_Routes_Utils::arraySubtract($route->minKeys, $keys);
|
|
if (count($tmp) == 0) {
|
|
$newList[] = $route;
|
|
}
|
|
}
|
|
$keyList = $newList;
|
|
|
|
// inline python function keysort() moved below as _keycmp()
|
|
|
|
$this->_keysort($keyList);
|
|
|
|
foreach ($keyList as $route) {
|
|
$fail = false;
|
|
foreach ($route->hardCoded as $key) {
|
|
$kval = isset($kargs[$key]) ? $kargs[$key] : null;
|
|
if ($kval == null) {
|
|
continue;
|
|
}
|
|
|
|
if ($kval != $route->defaults[$key]) {
|
|
$fail = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($fail) {
|
|
continue;
|
|
}
|
|
|
|
$path = $route->generate($kargs);
|
|
|
|
if ($path) {
|
|
if ($this->prefix) {
|
|
$path = $this->prefix . $path;
|
|
}
|
|
if (!empty($environ['SCRIPT_NAME']) && !$route->absolute) {
|
|
$path = $environ['SCRIPT_NAME'] . $path;
|
|
$key = $cacheKeyScriptName;
|
|
} else {
|
|
$key = $cacheKey;
|
|
}
|
|
if ($this->urlCache != null) {
|
|
$this->urlCache[$key] = $path;
|
|
}
|
|
return $path;
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Generate routes for a controller resource
|
|
*
|
|
* The $memberName name should be the appropriate singular version of the
|
|
* resource given your locale and used with members of the collection.
|
|
*
|
|
* The $collectionName name will be used to refer to the resource
|
|
* collection methods and should be a plural version of the $memberName
|
|
* argument. By default, the $memberName name will also be assumed to map
|
|
* to a controller you create.
|
|
*
|
|
* The concept of a web resource maps somewhat directly to 'CRUD'
|
|
* operations. The overlying things to keep in mind is that mapping a
|
|
* resource is about handling creating, viewing, and editing that
|
|
* resource.
|
|
*
|
|
* All keyword arguments ($kargs) are optional.
|
|
*
|
|
* ``controller``
|
|
* If specified in the keyword args, the controller will be the actual
|
|
* controller used, but the rest of the naming conventions used for
|
|
* the route names and URL paths are unchanged.
|
|
*
|
|
* ``collection``
|
|
* Additional action mappings used to manipulate/view the entire set of
|
|
* resources provided by the controller.
|
|
*
|
|
* Example::
|
|
*
|
|
* $map->resource('message', 'messages',
|
|
* array('collection' => array('rss' => 'GET)));
|
|
* # GET /message;rss (maps to the rss action)
|
|
* # also adds named route "rss_message"
|
|
*
|
|
* ``member``
|
|
* Additional action mappings used to access an individual 'member'
|
|
* of this controllers resources.
|
|
*
|
|
* Example::
|
|
*
|
|
* $map->resource('message', 'messages',
|
|
* array('member' => array('mark' => 'POST')));
|
|
* # POST /message/1;mark (maps to the mark action)
|
|
* # also adds named route "mark_message"
|
|
*
|
|
* ``new``
|
|
* Action mappings that involve dealing with a new member in the
|
|
* controller resources.
|
|
*
|
|
* Example::
|
|
*
|
|
* $map->resource('message', 'messages',
|
|
* array('new' => array('preview' => 'POST')));
|
|
* # POST /message/new;preview (maps to the preview action)
|
|
* # also adds a url named "preview_new_message"
|
|
*
|
|
* ``pathPrefix``
|
|
* Prepends the URL path for the Route with the pathPrefix given.
|
|
* This is most useful for cases where you want to mix resources
|
|
* or relations between resources.
|
|
*
|
|
* ``namePrefix``
|
|
* Perpends the route names that are generated with the namePrefix
|
|
* given. Combined with the pathPrefix option, it's easy to
|
|
* generate route names and paths that represent resources that are
|
|
* in relations.
|
|
*
|
|
* Example::
|
|
*
|
|
* map.resource('message', 'messages',
|
|
* array('controller' => 'categories',
|
|
* 'pathPrefix' => '/category/:category_id',
|
|
* 'namePrefix' => 'category_')));
|
|
* # GET /category/7/message/1
|
|
* # has named route "category_message"
|
|
*
|
|
* ``parentResource``
|
|
* An assoc. array containing information about the parent resource,
|
|
* for creating a nested resource. It should contain the ``$memberName``
|
|
* and ``collectionName`` of the parent resource. This assoc. array will
|
|
* be available via the associated ``Route`` object which can be
|
|
* accessed during a request via ``request.environ['routes.route']``
|
|
*
|
|
* If ``parentResource`` is supplied and ``pathPrefix`` isn't,
|
|
* ``pathPrefix`` will be generated from ``parentResource`` as
|
|
* "<parent collection name>/:<parent member name>_id".
|
|
*
|
|
* If ``parentResource`` is supplied and ``namePrefix`` isn't,
|
|
* ``namePrefix`` will be generated from ``parentResource`` as
|
|
* "<parent member name>_".
|
|
*
|
|
* Example::
|
|
*
|
|
* $m = new Horde_Routes_Mapper();
|
|
* $utils = $m->utils;
|
|
*
|
|
* $m->resource('location', 'locations',
|
|
* array('parentResource' =>
|
|
* array('memberName' => 'region',
|
|
* 'collectionName' => 'regions'))));
|
|
* # pathPrefix is "regions/:region_id"
|
|
* # namePrefix is "region_"
|
|
*
|
|
* $utils->urlFor('region_locations', array('region_id'=>13));
|
|
* # '/regions/13/locations'
|
|
*
|
|
* $utils->urlFor('region_new_location', array('region_id'=>13));
|
|
* # '/regions/13/locations/new'
|
|
*
|
|
* $utils->urlFor('region_location',
|
|
* array('region_id'=>13, 'id'=>60));
|
|
* # '/regions/13/locations/60'
|
|
*
|
|
* $utils->urlFor('region_edit_location',
|
|
* array('region_id'=>13, 'id'=>60));
|
|
* # '/regions/13/locations/60/edit'
|
|
*
|
|
* Overriding generated ``pathPrefix``::
|
|
*
|
|
* $m = new Horde_Routes_Mapper();
|
|
* $utils = new Horde_Routes_Utils();
|
|
*
|
|
* $m->resource('location', 'locations',
|
|
* array('parentResource' =>
|
|
* array('memberName' => 'region',
|
|
* 'collectionName' => 'regions'),
|
|
* 'pathPrefix' => 'areas/:area_id')));
|
|
* # name prefix is "region_"
|
|
*
|
|
* $utils->urlFor('region_locations', array('area_id'=>51));
|
|
* # '/areas/51/locations'
|
|
*
|
|
* Overriding generated ``namePrefix``::
|
|
*
|
|
* $m = new Horde_Routes_Mapper
|
|
* $m->resource('location', 'locations',
|
|
* array('parentResource' =>
|
|
* array('memberName' => 'region',
|
|
* 'collectionName' => 'regions'),
|
|
* 'namePrefix' => '')));
|
|
* # pathPrefix is "regions/:region_id"
|
|
*
|
|
* $utils->urlFor('locations', array('region_id'=>51));
|
|
* # '/regions/51/locations'
|
|
*
|
|
* Note: Since Horde Routes 0.2.0 and Python Routes 1.8, this method is
|
|
* not compatible with earlier versions inasmuch as the semicolon is no
|
|
* longer used to delimit custom actions. This was a change in Rails
|
|
* itself (http://dev.rubyonrails.org/changeset/6485) and adopting it
|
|
* here allows us to keep parity with Rails and ActiveResource.
|
|
*
|
|
* @param string $memberName Singular version of the resource name
|
|
* @param string $collectionName Collection name (plural of $memberName)
|
|
* @param array $kargs Keyword arguments (see above)
|
|
* @return void
|
|
*/
|
|
public function resource($memberName, $collectionName, $kargs = array())
|
|
{
|
|
$defaultKargs = array('collection' => array(),
|
|
'member' => array(),
|
|
'new' => array(),
|
|
'pathPrefix' => null,
|
|
'namePrefix' => null,
|
|
'parentResource' => null);
|
|
$kargs = array_merge($defaultKargs, $kargs);
|
|
|
|
// Generate ``pathPrefix`` if ``pathPrefix`` wasn't specified and
|
|
// ``parentResource`` was. Likewise for ``namePrefix``. Make sure
|
|
// that ``pathPrefix`` and ``namePrefix`` *always* take precedence if
|
|
// they are specified--in particular, we need to be careful when they
|
|
// are explicitly set to "".
|
|
if ($kargs['parentResource'] !== null) {
|
|
if ($kargs['pathPrefix'] === null) {
|
|
$kargs['pathPrefix'] = $kargs['parentResource']['collectionName'] . '/:'
|
|
. $kargs['parentResource']['memberName'] . '_id';
|
|
}
|
|
if ($kargs['namePrefix'] === null) {
|
|
$kargs['namePrefix'] = $kargs['parentResource']['memberName'] . '_';
|
|
}
|
|
} else {
|
|
if ($kargs['pathPrefix'] === null) {
|
|
$kargs['pathPrefix'] = '';
|
|
}
|
|
if ($kargs['namePrefix'] === null) {
|
|
$kargs['namePrefix'] = '';
|
|
}
|
|
}
|
|
|
|
// Ensure the edit and new actions are in and GET
|
|
$kargs['member']['edit'] = 'GET';
|
|
$kargs['new']['new'] = 'GET';
|
|
|
|
// inline python method swap() moved below as _swap()
|
|
|
|
$collectionMethods = $this->_swap($kargs['collection'], array());
|
|
$memberMethods = $this->_swap($kargs['member'], array());
|
|
$newMethods = $this->_swap($kargs['new'], array());
|
|
|
|
// Insert create, update, and destroy methods
|
|
if (!isset($collectionMethods['POST'])) {
|
|
$collectionMethods['POST'] = array();
|
|
}
|
|
array_unshift($collectionMethods['POST'], 'create');
|
|
|
|
if (!isset($memberMethods['PUT'])) {
|
|
$memberMethods['PUT'] = array();
|
|
}
|
|
array_unshift($memberMethods['PUT'], 'update');
|
|
|
|
if (!isset($memberMethods['DELETE'])) {
|
|
$memberMethods['DELETE'] = array();
|
|
}
|
|
array_unshift($memberMethods['DELETE'], 'delete');
|
|
|
|
// If there's a path prefix option, use it with the controller
|
|
$controller = $this->_stripSlashes($collectionName);
|
|
$kargs['pathPrefix'] = $this->_stripSlashes($kargs['pathPrefix']);
|
|
if ($kargs['pathPrefix']) {
|
|
$path = $kargs['pathPrefix'] . '/' . $controller;
|
|
} else {
|
|
$path = $controller;
|
|
}
|
|
$collectionPath = $path;
|
|
$newPath = $path . '/new';
|
|
$memberPath = $path . '/:(id)';
|
|
|
|
$options = array(
|
|
'controller' => (isset($kargs['controller']) ? $kargs['controller'] : $controller),
|
|
'_memberName' => $memberName,
|
|
'_collectionName' => $collectionName,
|
|
'_parentResource' => $kargs['parentResource']
|
|
);
|
|
|
|
// inline python method requirements_for() moved below as _requirementsFor()
|
|
|
|
// Add the routes for handling collection methods
|
|
foreach ($collectionMethods as $method => $lst) {
|
|
$primary = ($method != 'GET' && isset($lst[0])) ? array_shift($lst) : null;
|
|
$routeOptions = $this->_requirementsFor($method, $options);
|
|
|
|
foreach ($lst as $action) {
|
|
$routeOptions['action'] = $action;
|
|
$routeName = sprintf('%s%s_%s', $kargs['namePrefix'], $action, $collectionName);
|
|
|
|
$this->connect($routeName,
|
|
sprintf("%s/%s", $collectionPath, $action),
|
|
$routeOptions);
|
|
$this->connect('formatted_' . $routeName,
|
|
sprintf("%s/%s.:(format)", $collectionPath, $action),
|
|
$routeOptions);
|
|
}
|
|
if ($primary) {
|
|
$routeOptions['action'] = $primary;
|
|
$this->connect($collectionPath, $routeOptions);
|
|
$this->connect($collectionPath . '.:(format)', $routeOptions);
|
|
}
|
|
}
|
|
|
|
// Specifically add in the built-in 'index' collection method and its
|
|
// formatted version
|
|
$connectkargs = array('action' => 'index',
|
|
'conditions' => array('method' => array('GET')));
|
|
$this->connect($kargs['namePrefix'] . $collectionName,
|
|
$collectionPath,
|
|
array_merge($connectkargs, $options));
|
|
$this->connect('formatted_' . $kargs['namePrefix'] . $collectionName,
|
|
$collectionPath . '.:(format)',
|
|
array_merge($connectkargs, $options));
|
|
|
|
// Add the routes that deal with new resource methods
|
|
foreach ($newMethods as $method => $lst) {
|
|
$routeOptions = $this->_requirementsFor($method, $options);
|
|
foreach ($lst as $action) {
|
|
if ($action == 'new' && $newPath) {
|
|
$path = $newPath;
|
|
} else {
|
|
$path = sprintf('%s/%s', $newPath, $action);
|
|
}
|
|
|
|
$name = 'new_' . $memberName;
|
|
if ($action != 'new') {
|
|
$name = $action . '_' . $name;
|
|
}
|
|
$routeOptions['action'] = $action;
|
|
$this->connect($kargs['namePrefix'] . $name, $path, $routeOptions);
|
|
|
|
if ($action == 'new' && $newPath) {
|
|
$path = $newPath . '.:(format)';
|
|
} else {
|
|
$path = sprintf('%s/%s.:(format)', $newPath, $action);
|
|
}
|
|
|
|
$this->connect('formatted_' . $kargs['namePrefix'] . $name,
|
|
$path, $routeOptions);
|
|
}
|
|
}
|
|
|
|
$requirementsRegexp = '[\w\-_]+';
|
|
|
|
// Add the routes that deal with member methods of a resource
|
|
foreach ($memberMethods as $method => $lst) {
|
|
$routeOptions = $this->_requirementsFor($method, $options);
|
|
$routeOptions['requirements'] = array('id' => $requirementsRegexp);
|
|
|
|
if (!in_array($method, array('POST', 'GET', 'any'))) {
|
|
$primary = array_shift($lst);
|
|
} else {
|
|
$primary = null;
|
|
}
|
|
|
|
foreach ($lst as $action) {
|
|
$routeOptions['action'] = $action;
|
|
$this->connect(sprintf('%s%s_%s', $kargs['namePrefix'], $action, $memberName),
|
|
sprintf('%s/%s', $memberPath, $action),
|
|
$routeOptions);
|
|
$this->connect(sprintf('formatted_%s%s_%s', $kargs['namePrefix'], $action, $memberName),
|
|
sprintf('%s/%s.:(format)', $memberPath, $action),
|
|
$routeOptions);
|
|
}
|
|
|
|
if ($primary) {
|
|
$routeOptions['action'] = $primary;
|
|
$this->connect($memberPath, $routeOptions);
|
|
$this->connect($memberPath . '.:(format)', $routeOptions);
|
|
}
|
|
}
|
|
|
|
// Specifically add the member 'show' method
|
|
$routeOptions = $this->_requirementsFor('GET', $options);
|
|
$routeOptions['action'] = 'show';
|
|
$routeOptions['requirements'] = array('id' => $requirementsRegexp);
|
|
$this->connect($kargs['namePrefix'] . $memberName, $memberPath, $routeOptions);
|
|
$this->connect('formatted_' . $kargs['namePrefix'] . $memberName,
|
|
$memberPath . '.:(format)', $routeOptions);
|
|
}
|
|
|
|
/**
|
|
* Returns a new dict to be used for all route creation as
|
|
* the route options.
|
|
* @see resource()
|
|
*
|
|
* @param string $method Request method ('get', 'post', etc.) or 'any'
|
|
* @param array $options Assoc. array to populate with 'conditions' key
|
|
* @return $options populated
|
|
*/
|
|
protected function _requirementsFor($meth, $options)
|
|
{
|
|
if ($meth != 'any') {
|
|
$options['conditions'] = array('method' => array(Horde_String::upper($meth)));
|
|
}
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* Swap the keys and values in the dict, and uppercase the values
|
|
* from the dict during the swap.
|
|
* @see resource()
|
|
*
|
|
* @param array $dct Input dict (assoc. array)
|
|
* @param array $newdct Output dict to populate
|
|
* @return array $newdct populated
|
|
*/
|
|
protected function _swap($dct, $newdct)
|
|
{
|
|
foreach ($dct as $key => $val) {
|
|
$newkey = Horde_String::upper($val);
|
|
if (!isset($newdct[$newkey])) {
|
|
$newdct[$newkey] = array();
|
|
}
|
|
$newdct[$newkey][] = $key;
|
|
}
|
|
return $newdct;
|
|
}
|
|
|
|
/**
|
|
* Sort an array of Horde_Routes_Routes to using _keycmp() for the comparision
|
|
* to order them ideally for matching.
|
|
*
|
|
* An unfortunate property of PHP's usort() is that if two members compare
|
|
* equal, their order in the sorted array is undefined (see PHP manual).
|
|
* This is unsuitable for us because the order that the routes were
|
|
* connected to the mapper is significant.
|
|
*
|
|
* Uses this method uses merge sort algorithm based on the
|
|
* comments in http://www.php.net/usort
|
|
*
|
|
* @param array $array Array Horde_Routes_Route objects to sort (by reference)
|
|
* @return void
|
|
*/
|
|
protected function _keysort(&$array)
|
|
{
|
|
// arrays of size < 2 require no action.
|
|
if (count($array) < 2) { return; }
|
|
|
|
// split the array in half
|
|
$halfway = count($array) / 2;
|
|
$array1 = array_slice($array, 0, $halfway);
|
|
$array2 = array_slice($array, $halfway);
|
|
|
|
// recurse to sort the two halves
|
|
$this->_keysort($array1);
|
|
$this->_keysort($array2);
|
|
|
|
// if all of $array1 is <= all of $array2, just append them.
|
|
if ($this->_keycmp(end($array1), $array2[0]) < 1) {
|
|
$array = array_merge($array1, $array2);
|
|
return;
|
|
}
|
|
|
|
// merge the two sorted arrays into a single sorted array
|
|
$array = array();
|
|
$ptr1 = 0;
|
|
$ptr2 = 0;
|
|
while ($ptr1 < count($array1) && $ptr2 < count($array2)) {
|
|
if ($this->_keycmp($array1[$ptr1], $array2[$ptr2]) < 1) {
|
|
$array[] = $array1[$ptr1++];
|
|
}
|
|
else {
|
|
$array[] = $array2[$ptr2++];
|
|
}
|
|
}
|
|
|
|
// merge the remainder
|
|
while ($ptr1 < count($array1)) { $array[] = $array1[$ptr1++]; }
|
|
while ($ptr2 < count($array2)) { $array[] = $array2[$ptr2++]; }
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Compare two Horde_Route_Routes objects by their keys against
|
|
* the instance variable $keysortTmp. Used by _keysort().
|
|
*
|
|
* @param array $a First dict (assoc. array)
|
|
* @param array $b Second dict
|
|
* @return integer
|
|
*/
|
|
protected function _keycmp($a, $b)
|
|
{
|
|
$keys = $this->_keysortTmp;
|
|
$am = $a->minKeys;
|
|
$a = $a->maxKeys;
|
|
$b = $b->maxKeys;
|
|
|
|
$lendiffa = count(array_diff($keys, $a));
|
|
$lendiffb = count(array_diff($keys, $b));
|
|
|
|
// If they both match, don't switch them
|
|
if ($lendiffa == 0 && $lendiffb == 0) {
|
|
return 0;
|
|
}
|
|
|
|
// First, if $a matches exactly, use it
|
|
if ($lendiffa == 0) {
|
|
return -1;
|
|
}
|
|
|
|
// Or $b matches exactly, use it
|
|
if ($lendiffb == 0) {
|
|
return 1;
|
|
}
|
|
|
|
// Neither matches exactly, return the one with the most in common
|
|
if ($this->_cmp($lendiffa, $lendiffb) != 0) {
|
|
return $this->_cmp($lendiffa, $lendiffb);
|
|
}
|
|
|
|
// Neither matches exactly, but if they both have just as much in common
|
|
if (count($this->_arrayUnion($keys, $b)) == count($this->_arrayUnion($keys, $a))) {
|
|
return $this->_cmp(count($a), count($b));
|
|
|
|
// Otherwise, we return the one that has the most in common
|
|
} else {
|
|
return $this->_cmp(count($this->_arrayUnion($keys, $b)), count($this->_arrayUnion($keys, $a)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a union of two arrays.
|
|
*
|
|
* @param array $a First array
|
|
* @param array $b Second array
|
|
* @return array Union of $a and $b
|
|
*/
|
|
protected function _arrayUnion($a, $b)
|
|
{
|
|
return array_merge(array_diff($a, $b), array_diff($b, $a), array_intersect($a, $b));
|
|
}
|
|
|
|
/**
|
|
* Equivalent of Python's cmp() function.
|
|
*
|
|
* @param integer|float $a First item to compare
|
|
* @param integer|flot $b Second item to compare
|
|
* @param integer Result of comparison
|
|
*/
|
|
protected function _cmp($a, $b)
|
|
{
|
|
if ($a < $b) {
|
|
return -1;
|
|
}
|
|
if ($a == $b) {
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Trims slashes from the beginning or end of a part/URL.
|
|
*
|
|
* @param string $name Part or URL with slash at begin/end
|
|
* @return string Part or URL with begin/end slashes removed
|
|
*/
|
|
protected function _stripSlashes($name)
|
|
{
|
|
if (substr($name, 0, 1) == '/') {
|
|
$name = substr($name, 1);
|
|
}
|
|
if (substr($name, -1, 1) == '/') {
|
|
$name = substr($name, 0, -1);
|
|
}
|
|
return $name;
|
|
}
|
|
|
|
}
|
|
|