diff options
author | Arnold Daniels <arnold@jasny.net> | 2016-10-04 00:32:42 +0200 |
---|---|---|
committer | Arnold Daniels <arnold@jasny.net> | 2016-10-04 00:33:57 +0200 |
commit | 4af5f4e40c34186a0d27ac1c599b01595580e3bc (patch) | |
tree | 8befc1ee2121432bb3bfc645ef131fd53614b1b8 | |
parent | ab73cad56a660e02fab488fe7ecf2c6d4546e40b (diff) | |
download | router-4af5f4e40c34186a0d27ac1c599b01595580e3bc.zip router-4af5f4e40c34186a0d27ac1c599b01595580e3bc.tar.gz router-4af5f4e40c34186a0d27ac1c599b01595580e3bc.tar.bz2 |
Added Route class (WIP)
Added trait for url parsing
Added Glob routes
-rw-r--r-- | composer.json | 3 | ||||
-rw-r--r-- | phpunit.xml.dist | 24 | ||||
-rw-r--r-- | src/Route.php | 47 | ||||
-rw-r--r-- | src/Route/Callback.php | 11 | ||||
-rw-r--r-- | src/Route/PhpScript.php | 73 | ||||
-rw-r--r-- | src/Router.php | 179 | ||||
-rw-r--r-- | src/Router/Routes.php | 27 | ||||
-rw-r--r-- | src/Router/Routes/Glob.php | 384 | ||||
-rw-r--r-- | src/Router/UrlParsing.php | 40 |
9 files changed, 608 insertions, 180 deletions
diff --git a/composer.json b/composer.json index c0dfa04..f2ab7be 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ }, "require": { "php": ">=5.6.0", - "jasny/php-functions": "^2.0" + "jasny/php-functions": "^2.0", + "psr/http-message": "^1.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1861d7c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<phpunit + colors="true" + bootstrap="vendor/autoload.php" + convertErrorsToExceptions="true" + convertNoticesToExceptions="true" + convertWarningsToExceptions="true" +> + <testsuites> + <testsuite> + <directory>tests/</directory> + </testsuite> + </testsuites> + <filter> + <whitelist processUncoveredFilesFromWhitelist="true"> + <directory suffix=".php">src</directory> + </whitelist> + </filter> + <logging> + <log type="coverage-text" target="php://stdout"/> + </logging> +</phpunit> + diff --git a/src/Route.php b/src/Route.php new file mode 100644 index 0000000..96d9866 --- /dev/null +++ b/src/Route.php @@ -0,0 +1,47 @@ +<?php + +namespace Jasny; + +/** + * A route + */ +class Route extends stdClass +{ + /** + * Class constructor + * + * @param array $values + */ + public function __construct(array $values) + { + foreach ($values as $key => $value) { + $this->$key = $value; + } + } + + /** + * Factory method + * + * @param array|\stdClass $values + * @return Route + */ + public static function create($values) + { + if ($values instanceof stdClass) { + $values = get_object_vars($values); + } + + if (isset($values['controller'])) { + $callback = Jasny\array_only($values, ['controller', 'action']) + ['action' => 'default']; + $route = new Route\Callback($callback, $values); + } elseif (isset($values['fn'])) { + $route = new Route\Callback($values['fn'], $values); + } elseif (isset($values['file'])) { + $route = new Route\PhpScript($values['file'], $values); + } else { + throw new \Exception("Route has neither 'controller', 'fn' or 'file' defined"); + } + + return $route; + } +} diff --git a/src/Route/Callback.php b/src/Route/Callback.php new file mode 100644 index 0000000..d4dac0f --- /dev/null +++ b/src/Route/Callback.php @@ -0,0 +1,11 @@ +<?php + +/** + * Description of Callback + * + * @author arnold + */ +class Callback +{ + //put your code here +}
\ No newline at end of file diff --git a/src/Route/PhpScript.php b/src/Route/PhpScript.php new file mode 100644 index 0000000..0ce8fd2 --- /dev/null +++ b/src/Route/PhpScript.php @@ -0,0 +1,73 @@ +<?php + +namespace Jasny\Router\Route; + +use Jasny\Router\Route; +use Psr\Http\Message\ResponseInterface as Response; + +/** + * Route to a PHP script + */ +class PhpScript extends Route +{ + /** + * Route key + * @var string + */ + protected $key; + + /** + * Script path + * @var string + */ + public $file; + + + /** + * Class constructor + * + * @param string $file + * @param string $values + */ + public function __construct($key, $file, $values) + { + parent::__construct($values); + + $this->key = $key; + $this->file = $file; + } + + /** + * Return route key + * + * @return string + */ + public function __toString() + { + echo (string)$this->key; + } + + + /** + * Route to a file + * + * @param object $route + * @return Response|mixed + */ + protected function execute() + { + $file = ltrim($this->file, '/'); + + if (!file_exists($file)) { + trigger_error("Failed to route using '$this': File '$file' doesn't exist.", E_USER_WARNING); + return false; + } + + if ($this->file[0] === '~' || strpos($this->file, '..') !== false || strpos($this->file, ':') !== false) { + trigger_error("Won't route using '$this': '~', '..' and ':' not allowed in filename.", E_USER_WARNING); + return false; + } + + return include $file; + } +}
\ No newline at end of file diff --git a/src/Router.php b/src/Router.php index b5c5190..a0b62eb 100644 --- a/src/Router.php +++ b/src/Router.php @@ -229,39 +229,6 @@ class Router return isset($this->route); } - /** - * Find a matching route - * - * @param string $method - * @param string $url - * @return string - */ - protected function findRoute($method, $url) - { - $this->getRoutes(); // Make sure the routes are initialised - $ret = null; - - if ($url !== '/') $url = rtrim($url, '/'); - if (substr($url, 0, 2) == '/:') $url = substr($url, 2); - - foreach (array_keys($this->routes) as $route) { - if (strpos($route, ' ') !== false && preg_match_all('/\s+\+(\w+)\b|\s+\-(\w+)\b/', $route, $matches)) { - list($path) = preg_split('/\s+/', $route, 2); - $inc = isset($matches[1]) ? array_filter($matches[1]) : []; - $excl = isset($matches[2]) ? array_filter($matches[2]) : []; - } else { - $path = $route; - $inc = $excl = []; - } - - if ($path !== '/') $path = rtrim($path, '/'); - if ($this->fnmatch($path, $url)) { - if ((empty($inc) || in_array($method, $inc)) && !in_array($method, $excl)) return $route; - } - } - - return $ret; - } /** * Get a matching route. @@ -383,30 +350,6 @@ class Router return call_user_func_array($route->fn, $args); } - /** - * Route to a file - * - * @param object $route - * @return mixed|boolean - */ - protected function routeToFile($route) - { - $file = ltrim($route->file, '/'); - - if (!file_exists($file)) { - trigger_error("Failed to route using '{$route->route}': File '$file' doesn't exist.", E_USER_WARNING); - return false; - } - - if ($route->file[0] === '~' || strpos($route->file, '..') !== false || strpos($route->file, ':') !== false) { - $warn = "Won't route using '{$route->route}': '~', '..' and ':' not allowed in filename."; - trigger_error($warn, E_USER_WARNING); - return false; - } - - return include $file; - } - /** * Execute the action. @@ -525,128 +468,6 @@ class Router /** - * Get parts of a URL path - * - * @param string $url - * @return array - */ - public static function splitUrl($url) - { - $path = parse_url(trim($url, '/'), PHP_URL_PATH); - return $path ? explode('/', $path) : array(); - } - - /** - * Match path against wildcard pattern. - * - * @param string $pattern - * @param string $path - * @return boolean - */ - protected static function fnmatch($pattern, $path) - { - return \Jasny\fnmatch($pattern, $path); - } - - /** - * Fill out the routes variables based on the url parts. - * - * @param array|object $vars Route variables - * @param array $parts URL parts - * @return array - */ - protected static function bind($vars, array $parts) - { - $values = []; - $type = is_array($vars) && is_int(reset(array_keys($vars))) ? 'numeric' : 'assoc'; - - foreach ($vars as $key => $var) { - if (!isset($var)) continue; - - if (is_object($var) && !$var instanceof \stdClass) { - $part = array($var); - } elseif (!is_scalar($var)) { - $part = array(static::bind($var, $parts)); - } elseif ($var[0] === '$') { - $options = array_map('trim', explode('|', $var)); - $part = static::bindVar($type, $parts, $options); - } elseif ($var[0] === '~' && substr($var, -1) === '~') { - $pieces = array_map('trim', explode('~', substr($var, 1, -2))); - $bound = array_filter(static::bind($pieces, $parts)); - $part = array(join('', $bound)); - } else { - $part = array($var); - } - - if ($type === 'assoc') { - $values[$key] = $part[0]; - } else { - $values = array_merge($values, $part); - } - } - - if (is_object($vars) && $type === 'assoc') { - $values = (object)$values; - } - - return $values; - } - - /** - * Bind variable - * - * @param string $type 'assoc or 'numeric' - * @param array $parts - * @param array $options - * @return array - */ - protected static function bindVar($type, array $parts, array $options) - { - foreach ($options as $option) { - // Normal string - if ($option[0] !== '$') return [$option]; - - // Super global - if (preg_match('/^(\$_(GET|POST|COOKIE|ENV))\[([^\[]*)\]$/i', $option, $matches)) { - if (isset(${$matches[1]}[$matches[2]])) return array(${$matches[1]}[$matches[2]]); - continue; - } - - // Request header - if (preg_match('/^\$([A-Z_]+)$/', $option, $matches)) { - if (isset($_SERVER[$matches[1]])) return array($_SERVER[$matches[1]]); - continue; - } - - // Multiple parts - if (substr($option, -3) === '...') { - if (!ctype_digit(substr($option, 1, -3))) return [$option]; - - $i = (int)substr($option, 1, -3); - - if ($type === 'assoc') { - trigger_error("Binding multiple parts using '$option'" - . " is only allowed in numeric arrays", E_USER_WARNING); - return array(null); - } - - return array_slice($parts, $i-1); - } - - // Single part - if (!ctype_digit(substr($option, 1))) return [$option]; - - $i = (int)substr($option, 1); - - $part = array_slice($parts, $i-1, 1); - if (!empty($part)) return $part; - } - - return array(null); - } - - - /** * Get the arguments for a function from a route using reflection * * @param object $route diff --git a/src/Router/Routes.php b/src/Router/Routes.php new file mode 100644 index 0000000..26d407c --- /dev/null +++ b/src/Router/Routes.php @@ -0,0 +1,27 @@ +<?php + +namespace Jasny\Router; + +use Psr\Http\Message\ServerRequestInterface as ServerRequest; + +/** + * Collection of routes + */ +interface Routes +{ + /** + * Check if a route for the request exists + * + * @param ServerRequest $request + * @return boolean + */ + public function hasRoute(ServerRequest $request); + + /** + * Get route for the request + * + * @param ServerRequest $request + * @return Route + */ + public function getRoute(ServerRequest $request); +} diff --git a/src/Router/Routes/Glob.php b/src/Router/Routes/Glob.php new file mode 100644 index 0000000..0a05fe0 --- /dev/null +++ b/src/Router/Routes/Glob.php @@ -0,0 +1,384 @@ +<?php + +namespace Jasny\Router\Routes; + +use ArrayObject; +use Jasny\Router\UrlParsing; +use Jasny\Router\Routes; +use Psr\Http\Message\ServerRequestInterface as ServerRequest; + +/** + * Match URL against a shell wildcard pattern. + */ +class Glob extends ArrayObject implements Routes +{ + use UrlParsing; + + /** + * Create a route from an assisiative array or stdClass object + * + * @param Route|\stdClass|array $value + * @return Route + */ + protected function createRoute($value) + { + if ($value instanceof Route) { + return $value; + } + + if (is_array($value)) { + $value = (object)$value; + } + + if (!$value instanceof \stdClass) { + throw new \InvalidArgumentException("Unable to create a Route from value " . var_export($value, true)); + } + + $route = Route::create($value); + + if (!isset($route)) { + throw new \InvalidArgumentException("Unable to create a Route from " . var_export($value, true) . ": " + . "neither 'controller', 'fn' or 'file' key is defined"); + } + + return $route; + } + + /** + * Create routes from input + * + * @param Route[]|array|\Traversable $input + * @return type + */ + protected function createRoutes($input) + { + if ($input instanceof \Traversable) { + $input = iterator_to_array($input, true); + } + + return array_map([$this, 'createRoute'], $input); + } + + /** + * Class constructor + * + * @param Route[]|array|\Traversable $input + * @param type $flags + * @param type $iterator_class + */ + public function __construct($input = [], $flags = 0, $iterator_class = "ArrayIterator") + { + parent::__construct($input, $flags, $iterator_class); + } + + + /** + * {@inheritdoc} + */ + public function append($route) + { + throw new \BadMethodCallException("Unable to append a Route without a pattern"); + } + + /** + * Replace all the routes + * + * @param Route[]|array|\Traversable $input + * @return array the old routes + */ + public function exchangeArray($input) + { + $routes = $this->createRoutes(); + return parent::exchangeArray($routes); + } + + /** + * Add a route + * + * @param string $pattern + * @param Route|\stdClass|array $value + */ + public function offsetSet($pattern, $value) + { + if (!isset($pattern)) { + throw new \BadMethodCallException("Unable to append a Route without a pattern"); + } + + $route = $this->createRoute($value); + parent::offsetSet($pattern, $route); + } + + + /** + * Match url against wildcard pattern. + * + * @param string $pattern + * @param string $url + * @return boolean + */ + public function fnmatch($pattern, $url) + { + $quoted = preg_quote($pattern, '~'); + + $step1 = strtr($quoted, ['\?' => '[^/]', '\*' => '[^/]*', '/\*\*' => '(?:/.*)?', '#' => '\d+', '\[' => '[', + '\]' => ']', '\-' => '-', '\{' => '{', '\}' => '}']); + + $step2 = preg_replace_callback('~{[^}]+}~', function ($part) { + return '(?:' . substr(strtr($part[0], ',', '|'), 1, -1) . ')'; + }, $step1); + + $regex = rawurldecode($step2); + return (boolean)preg_match("~^{$regex}$~", $url); + } + + /** + * Find a matching route + * + * @param string $url + * @param string $method + * @return string + */ + protected function findRoute($url, $method = null) + { + $url = $this->cleanUrl($url); + $ret = null; + + foreach ($this as $pattern => $route) { + if (strpos($pattern, ' ') !== false && preg_match_all('/\s+\+(\w+)\b|\s+\-(\w+)\b/', $pattern, $matches)) { + list($path) = preg_split('/\s+/', $pattern, 2); + $inc = isset($matches[1]) ? array_filter($matches[1]) : []; + $excl = isset($matches[2]) ? array_filter($matches[2]) : []; + } else { + $path = $pattern; + $inc = []; + $excl = []; + } + + if ($path !== '/') $path = rtrim($path, '/'); + + if ($this->fnmatch($path, $url)) { + if ((empty($inc) || in_array($method, $inc)) && !in_array($method, $excl)) { + $ret = $route; + break; + } + } + } + + return $ret; + } + + + /** + * Fill out the routes variables based on the url parts. + * + * @param array|\stdClass $vars Route variables + * @param ServerRequest $request + * @param array $parts URL parts + * @return array + */ + protected function bind($vars, ServerRequest $request, array $parts) + { + $values = []; + $type = is_array($vars) && array_keys($vars) === array_keys(array_keys($vars)) ? 'numeric' : 'assoc'; + + foreach ($vars as $key => $var) { + if (!isset($var)) continue; + + if (is_object($var) && !$var instanceof \stdClass) { + $part = array($var); + } elseif (!is_scalar($var)) { + $part = array($this->bind($var, $parts)); + } elseif ($var[0] === '$') { + $options = array_map('trim', explode('|', $var)); + $part = $this->bindVar($type, $request, $parts, $options); + } elseif ($var[0] === '~' && substr($var, -1) === '~') { + $pieces = array_map('trim', explode('~', substr($var, 1, -2))); + $bound = array_filter($this->bind($pieces, $parts)); + $part = array(join('', $bound)); + } else { + $part = array($var); + } + + if ($type === 'assoc') { + $values[$key] = $part[0]; + } else { + $values = array_merge($values, $part); + } + } + + if ($vars instanceof Route) { + $values = Route::create($values); + } elseif (is_object($vars) && $type === 'assoc') { + $values = (object)$values; + } + + return $values; + } + + /** + * Bind variable + * + * @param string $type 'assoc' or 'numeric' + * @param ServerRequest $request + * @param array $parts + * @param array $options + * @return array + */ + protected function bindVar($type, ServerRequest $request, array $parts, array $options) + { + foreach ($options as $option) { + $value = null; + + $bound = + $this->bindVarString($option, $value) || + $this->bindVarSuperGlobal($option, $value) || + $this->bindVarRequestHeader($option, $request, $value) || + $this->bindVarMultipleUrlParts($option, $type, $parts, $value) || + $this->bindVarSingleUrlPart($option, $value); + + if ($bound && isset($value)) { + return $value; + } + } + + return [null]; + } + + /** + * Bind variable when option is a normal string + * + * @param string $option + * @param mixed $value OUTPUT + * @return boolean + */ + protected function bindVarString($option, $value) + { + if ($option[0] !== '$') { + $value = [$option]; + return true; + } + + return false; + } + + /** + * Bind variable when option is a super global + * + * @param string $option + * @param mixed $value OUTPUT + * @return boolean + */ + protected function bindVarSuperGlobal($option, $value) + { + if (preg_match('/^(\$_(GET|POST|COOKIE|ENV))\[([^\[]*)\]$/i', $option, $matches)) { + list(, $var, $key) = $matches; + $value = isset(${$var}[$key]) ? array(${$var}[$key]) : null; + return true; + } + + return false; + } + + /** + * Bind variable when option is a request header + * + * @param string $option + * @param ServerRequest $request + * @param mixed $value OUTPUT + * @return boolean + */ + protected function bindVarRequestHeader($option, ServerRequest $request, $value) + { + if (preg_match('/^\$(?:HTTP_)?([A-Z_]+)$/', $option, $matches)) { + $sentence = preg_replace('/[\W_]+/', ' ', $matches[1]); + $name = str_replace(' ', '-', ucwords($sentence)); + + $value = $request->getHeaderLine($name); + return true; + } + + return false; + } + + /** + * Bind variable when option contains multiple URL parts + * + * @param string $option + * @param string $type 'assoc' or 'numeric' + * @param array $parts Url parts + * @param mixed $value OUTPUT + * @return boolean + */ + protected function bindVarMultipleUrlParts($option, $type, array $parts, $value) + { + if (substr($option, -3) === '...' && ctype_digit(substr($option, 1, -3))) { + $i = (int)substr($option, 1, -3); + + if ($type === 'assoc') { + trigger_error("Binding multiple parts using '$option' is only allowed in numeric arrays", + E_USER_WARNING); + $value = [null]; + } else { + $value = array_slice($parts, $i - 1); + } + + return true; + } + + return false; + } + + /** + * Bind variable when option contains a single URL part + * + * @param string $option + * @param array $parts Url parts + * @param mixed $value OUTPUT + * @return boolean + */ + protected function bindVarSingleUrlPart($option, array $parts, $value) + { + if (ctype_digit(substr($option, 1))) { + $i = (int)substr($option, 1); + $part = array_slice($parts, $i - 1, 1); + + if (!empty($part)) { + $value = $part; + return true; + } + } + + return false; + } + + + /** + * Check if a route for the URL exists + * + * @param ServerRequest $request + * @return boolean + */ + public function hasRoute(ServerRequest $request) + { + $route = $this->findRoute($request->getUri(), $request->getMethod()); + return isset($route); + } + + /** + * Get route for the request + * + * @param ServerRequest $request + * @return Route + */ + public function getRoute(ServerRequest $request) + { + $url = $request->getUri(); + $route = $this->findRoute($url, $request->getMethod()); + + if ($route) { + $route = $this->bind($route, $request, $this->splitUrl($url)); + } + + return $route; + } +} diff --git a/src/Router/UrlParsing.php b/src/Router/UrlParsing.php new file mode 100644 index 0000000..5727085 --- /dev/null +++ b/src/Router/UrlParsing.php @@ -0,0 +1,40 @@ +<?php + +namespace Jasny\Router; + +/** + * Basic URL parsing helper methods + */ +trait UrlParsing +{ + /** + * Get parts of a URL path + * + * @param string $url + * @return array + */ + protected function splitUrl($url) + { + $path = parse_url(trim($url, '/'), PHP_URL_PATH); + return $path ? explode('/', $path) : array(); + } + + /** + * Clean up the URL + * + * @param string $url + * @return string + */ + protected function cleanUrl($url) + { + if ($url !== '/') { + $url = rtrim($url, '/'); + } + + if (substr($url, 0, 2) == '/:') { + $url = substr($url, 2); + } + + return $url; + } +} |