summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArnold Daniels <arnold@jasny.net>2016-10-04 00:32:42 +0200
committerArnold Daniels <arnold@jasny.net>2016-10-04 00:33:57 +0200
commit4af5f4e40c34186a0d27ac1c599b01595580e3bc (patch)
tree8befc1ee2121432bb3bfc645ef131fd53614b1b8
parentab73cad56a660e02fab488fe7ecf2c6d4546e40b (diff)
downloadrouter-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.json3
-rw-r--r--phpunit.xml.dist24
-rw-r--r--src/Route.php47
-rw-r--r--src/Route/Callback.php11
-rw-r--r--src/Route/PhpScript.php73
-rw-r--r--src/Router.php179
-rw-r--r--src/Router/Routes.php27
-rw-r--r--src/Router/Routes/Glob.php384
-rw-r--r--src/Router/UrlParsing.php40
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;
+ }
+}