Last active
August 29, 2015 14:11
-
-
Save WinterSilence/0faaae0dfb1335afa1c1 to your computer and use it in GitHub Desktop.
Enso\Core - routing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Enso; | |
$cache = new Cache\Manager; | |
$cache->attach(new Cache\Adapter\File('file')); | |
$router = new Core\Router($cache->get('file')); | |
if (!$router->exists('account')) { | |
$router->attach(new Core\Route('account', '<action>', ['action' => 'login|logout|register'], ['controller' => 'Account'])); | |
} | |
if (!$router->exists('default')) { | |
$router->attach(new Core\Route('default', '(<controller>(/<action>))', [], ['controller' => 'Home'])); | |
} | |
$request = new Core\Request(null, $router); | |
$response = $request->execute(); | |
echo $response->getBody(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Enso\Core; | |
/** | |
* Interface for request. | |
* | |
* @package Enso\Core | |
* @copyright (c) 2014 WinterSilence | |
* @license MIT License | |
*/ | |
interface IRequest | |
{ | |
public function uri(); | |
public function execute(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Enso\Core; | |
/** | |
* Interface for routes. | |
* | |
* @package Enso\Core | |
* @copyright (c) 2014 WinterSilence | |
* @license MIT License | |
*/ | |
interface IRoute | |
{ | |
public function getName(); | |
public function isSerializable(); | |
public function getUriRegex(); | |
public function matches(IRequest $request) | |
public function uri(array $params); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Enso\Core; | |
use Closure; | |
/** | |
* Routes are used to determine the controller and action for a requested URI. | |
* Every route generates a regular expression which is used to match a URI | |
* and a route. Routes may also contain keys which can be used to set the | |
* controller, action, and parameters. | |
* | |
* @package Enso\Core | |
* @copyright (c) 2014 WinterSilence | |
* @license MIT License | |
*/ | |
class Route implements IRoute | |
{ | |
// What must be escaped in the route regex | |
const REGEX_ESCAPE = '[.\\+*?[^\\]${}=!|]'; | |
// What can be part of a <segment> value | |
const REGEX_SEGMENT = '[^/.,;?\n]++'; | |
// Matches a URI group and captures the contents | |
const REGEX_GROUP = '\(((?:(?>[^()]+)|(?R))*)\)'; | |
// Defines the pattern of a <segment> | |
const REGEX_KEY = '<([a-zA-Z0-9_]++)>'; | |
/** | |
* @var string Route name | |
*/ | |
protected $name; | |
/** | |
* @var string URI pattern | |
*/ | |
protected $uri; | |
/** | |
* @var string Compiled URI regex | |
*/ | |
protected $uriRegex; | |
/** | |
* @var array Key patterns for URI pattern | |
*/ | |
protected $keys = [ | |
// @todo Add base keys? | |
// 'controller' => '[a-zA-Z]{1}[a-zA-Z0-9_]+', | |
// 'action' => '[a-zA-Z]{1}[a-zA-Z0-9_]+' | |
]; | |
/** | |
* @var array | |
*/ | |
protected $defaults = [ | |
'protocol' => 'http', | |
'host' => false, | |
'namespace' => 'Enso\Core\Controller', | |
'controller' => null, | |
'action' => 'index' | |
]; | |
/** | |
* @var callable Callback to filter parameters | |
*/ | |
protected $filter; | |
/** | |
* @var Closure Function for compile URI's | |
*/ | |
protected $uriCompiler; | |
/** | |
* @var bool Serializable? | |
*/ | |
protected $serializable = true; | |
/** | |
* Creates a new route. | |
* | |
* @param string $name Route name | |
* @param string $uri Route URI pattern | |
* @param array $keys Key patterns for URI pattern | |
* @param array $defaults Default values for keys | |
* @param callable $filter Filter object or function for values | |
* @return void | |
*/ | |
public function __construct($name, $uri, array $keys = [], array $defaults = [], callable $filter = null) | |
{ | |
$this->name = (string) $name; | |
$this->uri = (string) $uri; | |
$this->keys = array_merge($this->keys, $keys); | |
$this->defaults = array_merge($this->defaults, $defaults); | |
if ($filter) { | |
$this->filter = $filter; | |
$this->serializable = !($this->filter instanceof Closure); | |
} | |
} | |
/** | |
* | |
* | |
* @return string | |
*/ | |
public function getName() | |
{ | |
return $this->name; | |
} | |
/** | |
* | |
* | |
* @return string | |
*/ | |
public function __toString() | |
{ | |
return $this->name; | |
} | |
/** | |
* | |
* | |
* @return array | |
*/ | |
public function getDefaults() | |
{ | |
return $this->defaults; | |
} | |
/** | |
* | |
* | |
* @return callable|null | |
*/ | |
public function getFilter() | |
{ | |
return $this->filter; | |
} | |
/** | |
* Is serializable route? | |
* | |
* @return bool | |
*/ | |
public function isSerializable() | |
{ | |
return $this->serializable; | |
} | |
/** | |
* Returns the compiled regular expression for the route. This translates | |
* keys and optional groups to a proper PCRE regular expression. | |
* | |
* @return string | |
*/ | |
public function getUriRegex() | |
{ | |
if (!$this->uriRegex) { | |
// The URI should be considered literal except for keys and optional parts. | |
// Escape everything preg_quote would escape except for: ( ) < > | |
$regex = preg_replace('#' . self::REGEX_ESCAPE . '#', '\\\\$0', $this->uri); | |
if (strpos($regex, '(') !== false) { | |
// Make optional parts of the URI non-capturing and optional | |
$regex = str_replace(['(', ')'], ['(?:', ')?'], $regex); | |
} | |
// Insert default regex for keys | |
$regex = str_replace(['<', '>'], ['(?P<', '>' . self::REGEX_SEGMENT . ')'], $regex); | |
if ($this->keys) { | |
$search = $replace = []; | |
foreach ($this->keys as $key => $value) { | |
$key = '<' . $key . '>'; | |
$search[] = $key . self::REGEX_SEGMENT; | |
$replace[] = $key . $value; | |
} | |
// Replace the default regex with the user-specified regex | |
$regex = str_replace($search, $replace, $regex); | |
} | |
// Store the compiled regex locally | |
$this->uriRegex = '#^' . $regex . '$#uD'; | |
} | |
return $this->uriRegex; | |
} | |
/** | |
* Tests if the route matches a given request. A successful match will return | |
* all of the routed parameters as an array, a failed match will return false. | |
* | |
* @param IRequest $request | |
* @return array|false | |
*/ | |
public function matches(IRequest $request) | |
{ | |
// Get the URI from the Request | |
$uri = trim($request->uri(), '/'); | |
if (!preg_match($this->getUriRegex(), $uri, $params)) { | |
return false; | |
} | |
foreach ($params as $key => $value) { | |
// Delete all unnamed keys | |
if (is_numeric($key)) { | |
unset($params[$key]); | |
} | |
} | |
foreach ($this->defaults as $key => $value) { | |
if (!isset($params[$key]) || $params[$key] === '') { | |
// Set default values for any key that was not matched | |
$params[$key] = $value; | |
} | |
} | |
if (!empty($params['namespace'])) { | |
$params['namespace'] = ucwords(str_replace('\\', ' ', $params['namespace'])); | |
$params['namespace'] = str_replace(' ', '\\', $params['namespace']); | |
} | |
if (!empty($params['controller'])) { | |
$params['controller'] = ucfirst($params['controller']); | |
} | |
if ($this->filter) { | |
// Execute the filter giving it the route, params, and request | |
$params = call_user_func($this->filter, $this, $params, $request); | |
if (!is_array($params)) { | |
return false; | |
} | |
} | |
if (!isset($params['namespace']) || !isset($params['controller']) || !isset($params['action'])]) { | |
throw new Exception( | |
'Route {name}: some basic parameters (namespace, controller, action) not set for URI {uri}', | |
['name' => $this->getName(), 'uri' => $uri] | |
); | |
} | |
return $params; | |
} | |
/** | |
* Recursively compiles a portion of a URI specification by replacing | |
* the specified parameters and any optional parameters that are needed. | |
* | |
* @param string $portion Part of the URI specification | |
* @param bool $required Whether or not parameters are required (initially) | |
* @param array $params URI parameters | |
* @return array Tuple of the compiled portion and whether or not it contained | |
* specified parameters | |
* @throws Exception | |
*/ | |
protected function compileUri($portion, $required, array $params) | |
{ | |
$missing = []; | |
if (!$this->uriCompiler) { | |
// @todo Replace to method | |
$this->uriCompiler = function ($matches) use (&$missing, $params, &$required) | |
{ | |
if ($matches[0][0] == '<') { | |
// Parameter, unwrapped | |
$param = $matches[1]; | |
if (isset($params[$param])) { | |
// This portion is required when a specified | |
// parameter does not match the default | |
$required = ( | |
$required | |
|| !isset($this->defaults[$param]) | |
|| $params[$param] !== $this->defaults[$param] | |
); | |
// Add specified parameter to this result | |
return $params[$param]; | |
} | |
// Add default parameter to this result | |
if (isset($this->defaults[$param])) { | |
return $this->defaults[$param]; | |
} | |
// This portion is missing a parameter | |
$missing[] = $param; | |
} else { | |
// Group, unwrapped | |
$result = $this->compileUri($matches[2], false, $params); | |
if ($result[1]) { | |
// This portion is required when it contains a group that is required | |
$required = true; | |
// Add required groups to this result | |
return $result[0]; | |
} | |
// [!!] Do not add optional groups to this result | |
} | |
}; | |
} | |
$result = preg_replace_callback( | |
'#(?:' . self::REGEX_KEY . '|' . self::REGEX_GROUP . ')#', | |
$this->uriCompiler, | |
$portion | |
); | |
if ($required && $missing) { | |
throw new Exception( | |
'Route {name}: required parameter {param} not passed', | |
['name' => $this->getName(), 'param' => reset($missing)] | |
); | |
} | |
return array($result, $required, $params); | |
} | |
/** | |
* Generates a URI for the current route based on the parameters given. | |
* | |
* // Using the "default" route: "users/edit/7" | |
* $route->uri(['controller' => 'users', 'action' => 'edit', 'id' => 7]); | |
* | |
* @param array $params URI parameters | |
* @return string | |
*/ | |
public function uri(array $params = []) | |
{ | |
if ($params) { | |
// All non-alphanumeric characters except -_.~ have been replaced | |
// with a percent (%) sign followed by two hex digits | |
$params = array_map('rawurlencode', $params); | |
// Decode slashes back, see Apache manual about | |
// AllowEncodedSlashes and AcceptPathInfo | |
$params = str_replace(['%2F', '%5C'], ['/', '\\'], $params); | |
} | |
list($uri) = $this->compileUri($this->uri, true, $params); | |
// Trim all extra slashes from the URI | |
$uri = preg_replace('#//+#', '/', $uri); | |
// Need to add the host to the URI | |
if (isset($params['host']) || $this->defaults['host']) { | |
$host = isset($params['host']) ? $params['host'] : $this->defaults['host']; | |
if (strpos($host, '://') === false) { | |
// Use the default defined protocol | |
$protocol = isset($params['protocol']) ? $params['protocol'] : $this->defaults['protocol']; | |
$host = rtrim($protocol, '://') . '://' . $host; | |
} | |
// Clean up the host and prepend it to the URI | |
$uri = rtrim($host, '/') . '/' . $uri; | |
} | |
return $uri; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Enso\Core; | |
/** | |
* Routes are used to determine the controller and action for a requested URI. | |
* Every route generates a regular expression which is used to match a URI | |
* and a route. Routes may also contain keys which can be used to set the | |
* controller, action, and parameters. | |
* | |
* @package Enso\Core | |
* @copyright (c) 2014 WinterSilence | |
* @license MIT License | |
*/ | |
class Router | |
{ | |
/** | |
* @var array List of routes | |
*/ | |
protected $routes = []; | |
/** | |
* @var ICache Cache instance | |
*/ | |
protected $cache; | |
/** | |
* @var boolean Recache routes? | |
*/ | |
protected $updateCache = false; | |
/** | |
* Create a new instance and load cached routes. | |
* | |
* @param ICache|null $cache | |
* @return void | |
*/ | |
public function __construct(ICache $cache = null) | |
{ | |
if ($cache) { | |
$this->cache = $cache; | |
$this->routes = $this->cache->get(__CLASS__, []); | |
} | |
} | |
/** | |
* Update cache at object's shutdown. | |
* | |
* @return void | |
*/ | |
public function __destruct() | |
{ | |
if ($this->updateCache && $this->cache) { | |
$serializableRoutes = []; | |
foreach ($this->routes as $key => $route) { | |
if ($route->isSerializable()) { | |
// "Lazy" compilation URI's regex | |
$serializableRoutes[$key] = $route->getUriRegex(); | |
} | |
} | |
$this->cache->set(__CLASS__, $serializableRoutes); | |
} | |
} | |
/** | |
* Add route. | |
* | |
* @param IRoute $route Route instance | |
* @param bool $prepend Prepend or append route? | |
* @return Router | |
*/ | |
public function attach(IRoute $route, $prepend = false) | |
{ | |
if (!$this->updateCache && $this->cache && !$this->exists($route) && $route->isSerializable()) { | |
$this->updateCache = true; | |
} | |
if ($prepend) { | |
$this->routes = [$route->getName() => $route] + $this->routes; | |
} else { | |
$this->routes[$route->getName()] = $route; | |
} | |
return $this; | |
} | |
/** | |
* Delete route. | |
* | |
* @param IRoute $route Route instance | |
* @return Router | |
*/ | |
public function detach(IRoute $route) | |
{ | |
if (!$this->updateCache && $this->cache && $this->exists($route) && $route->isSerializable()) { | |
$this->updateCache = true; | |
} | |
unset($this->routes[$route]); | |
return $this; | |
} | |
/** | |
* Return route by name. | |
* | |
* @param string $route Route name | |
* @return Route | |
*/ | |
public function get($name) | |
{ | |
return $this->routes[$name]; | |
} | |
/** | |
* Return all routes. | |
* | |
* @return array | |
*/ | |
public function getAll() | |
{ | |
return $this->routes; | |
} | |
/** | |
* Determine if a route is set. | |
* | |
* @param string|IRoute $route Route name or object | |
* @return bool | |
*/ | |
public function exists($route) | |
{ | |
// Check by object | |
if (is_object($route)) { | |
return in_array($route, $this->routes); | |
} | |
// Check by name | |
return isset($this->routes[$route]); | |
} | |
/** | |
* Process a request to find a matching route. | |
* | |
* @param IRequest $request | |
* @return array|false | |
*/ | |
public function process(IRequest $request) | |
{ | |
foreach ($this->getAll() as $route) { | |
/* | |
@todo Detect external routes | |
// Use external routes for reverse routing only | |
if ($route->isExternal()) { | |
continue; | |
} | |
*/ | |
$params = $route->matches($request); | |
if ($params) { | |
// We found something suitable | |
return ['params' => $params, 'route' => $route]; | |
} | |
} | |
return false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment