<?php
error_reporting(E_ALL | E_STRICT);
!defined("APPPATH") && define("APPPATH", dirname(__FILE__));
!defined("EXT") && define("EXT", '.php');
/**
* Deal with parsing routes. This class accepts programmer defined route
* remappings. With these remappings, the router groups them by their longest
* prefix of terminals (things that won't change such as /'s and words) and
* then matches against those. When the router can't match against a route
* in memory, it will simply scan the document tree until it can find a
* controller.
* @author Peter Goodman
*/
class Router implements ArrayAccess {
// storage and other things
protected $macro_keys = array(), // instead of constantly doing
$macro_vals = array(), // array_keys and array_values
$routes = array(), // stored routes, see addRoute
$path = array(); // information about the current path
// so that it will fit nicely into CodeIgniter / Kohana
public $allowed_chars = "a-zA-Z0-9_-",
$arguments = array(), // arguments passed through the route
$directory = '', // the directory where the controller is
$controller, // the controller class to instantiate
$method; // the method of the controller to call
/**
* Constructor, build up some default macros.
*/
public function __construct() {
$this->addMacro('alpha', '[a-zA-Z]+');
$this->addMacro('num', '[0-9]+');
$this->addMacro('alphanum', '[a-zA-Z0-9]+');
$this->addMacro('any', '.*');
$this->addMacro('word', '\w+');
$this->addMacro('year', '[12][0-9]{3}');
$this->addMacro('month', '0[1-9]|1[012]');
$this->addMacro('day', '0[1-9]|[12][0-9]|3[01]');
$this->addMacro('id', '[0-9]+');
$this->addMacro('uuid', '[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]'.
'{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}');
}
/**
* Add a macro that should be expanded into a regular expression.
*/
public function addMacro($id, $regex) {
$this->macro_keys[] = '(:'. $id .')';
$this->macro_vals[] = '('. $regex .')'; // force it to be a subpattern
}
/**
* Clean up the route a bit: take away useless spaces and /'s.
*/
protected function cleanPath($route) {
return '/'. trim(preg_replace('~\s*[/]+\s*~', '/', $route), '/');
}
/**
* Get the longest prefix of terminals for a given route added through
* addRoute.
*/
protected function calculateLongestPrefix($route) {
$matches = array();
$bad = '/'; // worst-case prefix
// get the longest prefix of terminals in this route
if(preg_match("~(([". $this->allowed_chars ."]|/)+)~", $route, $matches))
return !empty($matches[0]) ? $matches[0] : $bad;
return $bad;
}
/**
* Given all of the prefixes, find the longest matching one for a given
* route.
*/
protected function getLongestMatchingPrefix($route) {
// we break the route up into segments, and we also set those segments
// and the $segments array
if(0 == count($parts = explode('/', $route)))
return '/';
$prefix = $temp = '';
$i = 0;
// build up the longest prefix incrementally
do {
$prefix = $temp;
$temp .= '/'. $parts[$i++];
} while(isset($parts[$i]) && isset($this->routes[$temp]));
return '/';
}
/**
* Add a route for parsing. Remapping has two uses. First, we might be
* mapping a route to itself so that we can properly sanitize the
* incoming data, or we might be remapping a route, that is: creating a
* special way to access a controller's action without necessarily
* having a one-to-one relationship between the route and the path to the
* controller+action.
*/
public function addRoute($route, $maps_to) {
// clean up the route and what it maps to
$route = $this->cleanPath($route);
$maps_to = $this->cleanPath($maps_to);
// get the prefix of the route that is the sum of terminals
$prefix = $this->calculateLongestPrefix($route);
// replace macros. Even though the normal macro can't be matched as
// a terminal, it's possible that the macro is in fact a terminal,
// even though this scenario seems redundant, we will replace macros
// right now.
$route = str_replace($this->macro_keys, $this->macro_vals, $route);
// make sure we group all routes with the same prefix together, that
// way when we encounter a route, we only search given its prefix
if(!isset($this->routes[$prefix]))
$this->routes[$prefix] = array();
// add in the route to others with similar prefixes. we can also
// disgard the prefix from the route as it is useless to us.
$route = substr($route, strlen($prefix));
$this->routes[$prefix][] = array($route, $maps_to);
}
/**
* Parse a route into several segments. We make one central assumption:
* that the only routes with ordered arguments are ones that have been
* added in memory. Otherwise, we will just take anything after the
* method (action) of the controller and consider it an argument.
*/
public function parseRoute($route) {
// clean up the incoming route and calculate its prefix
$route = $path = trim($this->cleanPath($route), '/');
$prefix = $this->getLongestMatchingPrefix($route);
// this will hold intermediate arguments
$dynamic = array();
// we're dealing with a route remapping so we want to find the route
// we should actually be parsing. At the same time, we need to realize
// that there might be dynamic elements in this incoming route that
// need to be passed into the proper route mapping. We'll assume that
// such dynamic elements are ordered correctly.
if(isset($this->routes[$prefix])) {
// we don't want to mangle the actual route because we might not
// find what we're looking for in here, so we'll work with a
// temporary copy of the route
$temp = substr($route, strlen($prefix)-1);
$matches = array();
// lets see if we have this route in memory, if not we will fall
// through this if and assume that the route points directly to
// a controller and an action.
foreach($this->routes[$prefix] as $suffix) {
// remember, we store the route and what it maps to
list($pattern, $maps_to) = $suffix;
// does this route match any patterns?
if(1 > preg_match('~'.$pattern.'~', $temp, $matches))
continue;
// make sure we get the arguments in the right order
$this->reorderArguments($maps_to, $matches);
$path = trim($maps_to, '/');
break;
}
}
// now that we (might) have collected the arguments, lets try to find
// what controller it should belong to by searching through $path. The
// extra /'s act as centinels to make sure that controller and method
// will be found.
$path_parts = explode('/', ltrim($path, '/') .'///');
$i = -1;
$base = APPPATH .'/controllers/';
// build up the directory to the controller, making sure to ignore
// empty sub-directories
while(!empty($path_parts[++$i]) &&
is_dir($base . $this->directory . $path_parts[$i]))
$this->directory .= '/'. $path_parts[++$i];
// now populate the rest of the rsegments array
$this->controller = $path_parts[$i++];
$this->method = $path_parts[$i];
// does the controller file exist?
if(!file_exists($this->directory .'/'. $this->controller . EXT))
return FALSE;
return TRUE;
}
/**
* It's possible that in the mapped route the arguments are in a different
* order than they are in the actual route, and thus need to be sent to
* the controller in a corrected order.
*/
protected function reorderArguments($route, array &$sub) {
// get rid of extraneous dollar signs that don't represent substituted
// arguments (eg: $$1 -> $1)
$route = preg_replace('~(\$(?![0-9]))~x', '', $route);
// make sure we end up with the right number of arguments
$this->arguments = array_fill(0, substr_count($route, '$'), NULL);
// find all the variables within the route (in order)
$matches = array();
if(!preg_match_all('~\$([0-9]+)~', $route, $matches))
return;
// the controller expects a certain number of arguments, we might not
// actually get that many though, but that's no longer an issue
$count = count($this->arguments);
$i = -1;
// iterate over the found variables
while(isset($matches[1][++$i])) {
// we will use this index to look into the $sub array for the
// value of the ith argument.
$index = (int)$matches[1][$i];
// the route references an incorrect argument number, ignore it
if(!isset($sub[$index]))
continue;
// put the argument in order.
$this->arguments[$i] = $sub[$index];
}
}
/**
* Convenient way to add a route.
*/
final public function offsetSet($route, $maps_to) {
$this->addRoute($route, $maps_to);
}
final public function offsetGet($route) { return NULL; }
final public function offsetExists($route) { return FALSE; }
final public function offsetUnset($route) { }
}
$router = new Router;
$router['/controller/moo'] = '/controller/method/bar';
$router['/(:num)/(:alpha)'] = '/controller/method/$2/$$1';
if($router->parseRoute('/100/abcdefghi'))
echo 'found controller';
else
echo 'unable to locate controller, suggest 404 error.';
echo "\n";
print_r($router);