Skip to content

Instantly share code, notes, and snippets.

@WinterSilence
Last active August 29, 2015 14:11
Show Gist options
  • Save WinterSilence/0faaae0dfb1335afa1c1 to your computer and use it in GitHub Desktop.
Save WinterSilence/0faaae0dfb1335afa1c1 to your computer and use it in GitHub Desktop.
Enso\Core - routing
<?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();
<?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();
}
<?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);
}
<?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;
}
}
<?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