diff options
-rw-r--r-- | src/Router.php | 150 | ||||
-rw-r--r-- | src/Router/Router.php | 553 | ||||
-rw-r--r-- | src/Router/Routes.php | 10 | ||||
-rw-r--r-- | src/Router/Runner.php | 38 | ||||
-rw-r--r-- | src/Router/Runner/Callback.php | 26 | ||||
-rw-r--r-- | src/Router/Runner/Controller.php | 39 | ||||
-rw-r--r-- | src/Router/Runner/PhpScript.php | 66 | ||||
-rw-r--r-- | tests/Router/Routes/GlobTest.php | 5 | ||||
-rw-r--r-- | tests/Router/Runner/CallbackTest.php | 64 | ||||
-rw-r--r-- | tests/Router/Runner/ControllerTest.php | 140 | ||||
-rw-r--r-- | tests/Router/Runner/PhpScriptTest.php | 94 | ||||
-rw-r--r-- | tests/Router/RunnerTest.php | 79 | ||||
-rw-r--r-- | tests/RouterTest.php | 227 |
13 files changed, 876 insertions, 615 deletions
diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..955e1e1 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,150 @@ +<?php + +namespace Jasny; + +use Jasny\Router\Runner; +use Jasny\Router\Routes\Glob; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +/** + * Route pretty URLs to correct controller + */ +class Router +{ + /** + * Specific routes + * @var array + */ + protected $routes = []; + + /** + * Middlewares actions + * @var array + **/ + protected $middlewares = []; + + /** + * Class constructor + * + * @param array $routes + */ + public function __construct(array $routes) + { + $this->routes = $routes; + } + + /** + * Get a list of all routes + * + * @return object + */ + public function getRoutes() + { + return $this->routes; + } + + /** + * Get middlewares + * + * @return array + */ + public function getMiddlewares() + { + return $this->middlewares; + } + + /** + * Add middleware call to router + * + * @param callback $middleware + * @return Router $this + */ + public function add($middleware) + { + if (!is_callable($middleware)) { + throw new \InvalidArgumentException("Middleware should be a callable"); + } + + $this->middlewares[] = $middleware; + + return $this; + } + + /** + * Run the action for the request + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + final public function run(ServerRequestInterface $request, ResponseInterface $response) + { + return $this->__invoke($request, $response); + } + + /** + * Run the action for the request (optionally as middleware), previously running middlewares, if any + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callback $next + * @return ResponseInterface + */ + public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next = null) + { + $handle = [$this, 'handle']; + + #Call to $this->handle will be executed last in the chain of middlewares + $next = function(ServerRequestInterface $request, ResponseInterface $response) use ($next, $handle) { + return call_user_func($handle, $request, $response, $next); + }; + + #Build middlewares call chain, so that the last added was executed in first place + foreach ($this->middlewares as $middleware) { + $next = function(ServerRequestInterface $request, ResponseInterface $response) use ($next, $middleware) { + return $middleware($request, $response, $next); + }; + } + + return $next($request, $response); + } + + /** + * Run the action + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callback $next + * @return ResponseInterface + */ + protected function handle(ServerRequestInterface $request, ResponseInterface $response, $next = null) + { + $glob = new Glob($this->routes); + $route = $glob->getRoute($request); + + if (!$route) return $this->notFound($response); + + $runner = Runner::create($route); + + return $runner($request, $response, $next); + } + + /** + * Return 'Not Found' response + * + * @param ResponseInterface $response + * @return ResponseInterface + */ + protected function notFound(ResponseInterface $response) + { + $message = 'Not Found'; + + $body = $response->getBody(); + $body->rewind(); + $body->write($message); + + return $response->withStatus(404, $message)->withBody($body); + } +} + diff --git a/src/Router/Router.php b/src/Router/Router.php deleted file mode 100644 index a0b62eb..0000000 --- a/src/Router/Router.php +++ /dev/null @@ -1,553 +0,0 @@ -<?php - -namespace Jasny; - -/** - * Route pretty URLs to correct controller - * - * Wildcards: - * ? Single character - * # One or more digits - * * One or more characters - * ** Any number of subdirs - * [abc] Match character 'a', 'b' or 'c' - * [a-z] Match character 'a' to 'z' - * {png,gif} Match 'png' or 'gif' - * - * Escape characters using URL encode (so %5B for '[') - */ -class Router -{ - /** - * Specific routes - * @var array - */ - protected $routes = []; - - /** - * Method for route - * @var string - */ - protected $method; - - /** - * Webroot subdir from DOCUMENT_ROOT. - * @var string - */ - protected $base; - - /** - * URL to route - * @var string - */ - protected $url; - - /** - * Variables from matched route (cached) - * @var object - */ - protected $route; - - - /** - * Class constructor - * - * @param array $routes Array with route objects - */ - public function __construct($routes=null) - { - if (isset($routes)) $this->setRoutes($routes); - } - - /** - * Set the routes - * - * @param array $routes Array with route objects - * @return Router - */ - public function setRoutes($routes) - { - if (is_object($routes)) $routes = get_object_vars($routes); - - foreach ($routes as &$route) { - if ($route instanceof \Closure) $route = (object)['fn' => $route]; - } - - $this->routes = $routes; - $this->route = null; - - return $this; - } - - /** - * Add routes to existing list - * - * @param array $routes Array with route objects - * @param string $root Specify the root dir for routes - * @return Router - */ - public function addRoutes($routes, $root=null) - { - if (is_object($routes)) $routes = get_object_vars($routes); - - foreach ($routes as $path=>$route) { - if (!empty($root)) $path = $root . $path; - - if (isset($this->routes[$path])) { - trigger_error("Route $path is already defined.", E_USER_WARNING); - continue; - } - - if ($route instanceof \Closure) $route = (object)['fn' => $route]; - - $this->routes[$path] = $route; - } - - return $this; - } - - /** - * Get a list of all routes - * - * @return object - */ - public function getRoutes() - { - return $this->routes; - } - - - /** - * Set the method to route - * - * @param string $method - * @return Router - */ - public function setMethod($method) - { - $this->method = $method; - $this->route = null; - - return $this; - } - - /** - * Get the method to route. - * Defaults to REQUEST_METHOD, which can be overwritten by $_POST['_method']. - * - * @return string - */ - public function getMethod() - { - if (!isset($this->method)) $this->method = Request::getMethod(); - return $this->method; - } - - - /** - * Set the webroot subdir from DOCUMENT_ROOT. - * - * @param string $dir - * @return Router - */ - public function setBase($dir) - { - $this->base = rtrim($dir, '/'); - $this->route = null; - - return $this; - } - - /** - * Get the webroot subdir from DOCUMENT_ROOT. - * - * @return string - */ - public function getBase() - { - return $this->base; - } - - /** - * Add a base path to the URL if the webroot isn't the same as the webservers document root - * - * @param string $url - * @return string - */ - public function rebase($url) - { - return ($this->getBase() ?: '/') . ltrim($url, '/'); - } - - - /** - * Set the URL to route - * - * @param string $url - * @return Router - */ - public function setUrl($url) - { - $this->url = $url; - $this->route = null; - - return $this; - } - - /** - * Get the URL to route. - * Defaults to REQUEST_URI. - * - * @return string - */ - public function getUrl() - { - if (!isset($this->url)) $this->url = urldecode(preg_replace('/\?.*$/', '', $_SERVER['REQUEST_URI'])); - return $this->url; - } - - /** - * Split the URL and return a part - * - * @param int $i Part number, starts at 1 - * @return string - */ - public function getUrlPart($i) - { - $parts = $this->splitUrl($this->getUrl()); - return $parts[$i - 1]; - } - - - /** - * Check if the router has been used. - * - * @return boolean - */ - public function isUsed() - { - return isset($this->route); - } - - - /** - * Get a matching route. - * - * @return object - */ - public function getRoute() - { - if (isset($this->route)) return $this->route; - - $method = $this->getMethod(); - $url = $this->getUrl(); - - if ($this->getBase()) { - $url = '/' . preg_replace('~^' . preg_quote(trim($this->getBase(), '/'), '~') . '~', '', ltrim($url, '/')); - } - - $match = $this->findRoute($method, $url); - - if ($match) { - $this->route = $this->bind($this->routes[$match], $this->splitUrl($url)); - $this->route->route = $match; - } else { - $this->route = false; - } - - return $this->route; - } - - /** - * Get a property of the matching route. - * - * @param string $prop Property name - * @return mixed - */ - public function get($prop) - { - $route = $this->getRoute(); - return isset($route->$prop) ? $route->$prop : null; - } - - - /** - * Execute the action of the given route. - * - * @param object $route - * @param object $overwrite - * @return boolean|mixed Whatever the controller returns or true on success - */ - public function routeTo($route, $overwrite=[]) - { - if (!is_object($route)) { - $match = $this->findRoute(null, $route); - if (!isset($match) || !isset($this->routes[$match])) return false; - $route = $this->routes[$match]; - } - - foreach ($overwrite as $key=>$value) { - $route->$key = $value; - } - - if (isset($route->controller)) return $this->routeToController($route); - if (isset($route->fn)) return $this->routeToCallback($route); - if (isset($route->file)) return $this->routeToFile($route); - - $warn = "Failed to route using '{$route->route}': Neither 'controller', 'fn' or 'file' is set"; - trigger_error($warn, E_USER_WARNING); - - return false; - } - - /** - * Route to controller action - * - * @param object $route - * @return mixed|boolean - */ - protected function routeToController($route) - { - $class = $this->getControllerClass($route->controller); - $method = $this->getActionMethod(isset($route->action) ? $route->action : null); - - if (!class_exists($class)) return false; - - $controller = new $class($this); - if (!is_callable([$controller, $method])) return false; - - if (isset($route->args)) { - $args = $route->args; - } elseif (method_exists($controller, $method)) { - $args = static::getFunctionArgs($route, new \ReflectionMethod($controller, $method)); - } - - $ret = call_user_func_array([$controller, $method], $args); - return isset($ret) ? $ret : true; - } - - /** - * Route to a callback function - * - * @param object $route - * @return mixed|boolean - */ - protected function routeToCallback($route) - { - if (!is_callable($route->fn)) { - trigger_error("Failed to route using '{$route->route}': Invalid callback.", E_USER_WARNING); - return false; - } - - if (isset($route->args)) { - $args = $route->args; - } elseif (is_array($route->fn)) { - $args = static::getFunctionArgs($route, new \ReflectionMethod($route->fn[0], $route->fn[1])); - } elseif (function_exists($route->fn)) { - $args = static::getFunctionArgs($route, new \ReflectionFunction($route->fn)); - } - - return call_user_func_array($route->fn, $args); - } - - - /** - * Execute the action. - * - * @todo Check if route would be available for other HTTP methods to respond with a 405 - * - * @return mixed Whatever the controller returns - */ - public function execute() - { - $route = $this->getRoute(); - if ($route) $ret = $this->routeTo($route); - - $httpCode = 404; // or 405? - - if (!isset($ret) || $ret === false) return $this->notFound(null, $httpCode); - return $ret; - } - - - /** - * Redirect to another page - * - * @param string $url - * @param int $httpCode 301 (Moved Permanently), 303 (See Other) or 307 (Temporary Redirect) - */ - public function redirect($url, $httpCode=303) - { - if (ob_get_level() > 1) ob_end_clean(); - - if ($url[0] === '/' && substr($url, 0, 2) !== '//') $url = $this->rebase($url); - - http_response_code((int)$httpCode); - header("Location: $url"); - - header('Content-Type: text/html'); - echo 'You are being redirected to <a href="' . $url . '">' . $url . '</a>'; - } - - /** - * Give a 400 Bad Request response - * - * @param string $message - * @param int $httpCode Alternative HTTP status code, eg. 406 (Not Acceptable) - * @param mixed $.. Additional arguments are passed to action - */ - public function badRequest($message, $httpCode=400) - { - if (!$this->routeTo(400, ['args'=>func_get_args()])) { - self::outputError($httpCode, $message); - } - } - - /** - * Route to 401, otherwise result in a 403 forbidden. - * Note: While the 401 route is used, we don't respond with a 401 http status code. - */ - public function requireLogin() - { - $this->routeTo(401) || $this->forbidden(); - } - - /** - * Give a 403 Forbidden response and exit - * - * @param string $message - * @param int $httpCode Alternative HTTP status code - * @param mixed $.. Additional arguments are passed to action - */ - public function forbidden($message=null, $httpCode=403) - { - if (ob_get_level() > 1) ob_end_clean(); - - if (!$this->routeTo(403, ['args'=>func_get_args()])) { - if (!isset($message)) $message = "Sorry, you are not allowed to view this page"; - self::outputError($httpCode, $message); - } - } - - /** - * Give a 404 Not Found response - * - * @param string $message - * @param int $httpCode Alternative HTTP status code, eg. 410 (Gone) - * @param mixed $.. Additional arguments are passed to action - */ - public function notFound($message=null, $httpCode=404) - { - if (ob_get_level() > 1) ob_end_clean(); - - if (!$this->routeTo(404, ['args'=>func_get_args()])) { - if (!isset($message)) $message = $httpCode === 405 ? - "Sorry, this action isn't supported" : - "Sorry, this page does not exist"; - - self::outputError($httpCode, $message); - } - } - - /** - * Give a 5xx Server Error response - * - * @param string $message - * @param int|string $httpCode HTTP status code, eg. "500 Internal Server Error" or 503 - * @param mixed $.. Additional arguments are passed to action - */ - public function error($message=null, $httpCode=500) - { - if (ob_get_level() > 1) ob_end_clean(); - - if (!$this->routeTo(500, ['args'=>func_get_args()])) { - if (!isset($message)) $message = "Sorry, an unexpected error occured"; - self::outputError($httpCode, $message); - } - } - - - /** - * Get the arguments for a function from a route using reflection - * - * @param object $route - * @param \ReflectionFunctionAbstract $refl - * @return array - */ - protected static function getFunctionArgs($route, \ReflectionFunctionAbstract $refl) - { - $args = []; - $params = $refl->getParameters(); - - foreach ($params as $param) { - $key = $param->name; - - if (property_exists($route, $key)) { - $value = $route->{$key}; - } else { - if (!$param->isOptional()) { - $fn = $refl instanceof \ReflectionMethod - ? $refl->getDeclaringClass()->getName() . ':' . $refl->getName() - : $refl->getName(); - - trigger_error("Missing argument '$key' for $fn()", E_USER_WARNING); - } - - $value = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; - } - - $args[$key] = $value; - } - - return $args; - } - - /** - * Get the class name of the controller - * - * @param string $controller - * @return string - */ - protected static function getControllerClass($controller) - { - return \Jasny\studlycase($controller) . 'Controller'; - } - - /** - * Get the method name of the action - * - * @param string $action - * @return string - */ - protected static function getActionMethod($action) - { - return \Jasny\camelcase($action) . 'Action'; - } - - - // Proxy methods for Jasny\DB\Request. Allows overloading for customized Request class. - - /** - * Get the output format. - * Tries 'Content-Type' response header, otherwise uses 'Accept' request header. - * - * @param string $as 'short' or 'mime' - * @return string - */ - protected static function getOutputFormat($as) - { - return Request::getOutputFormat($as); - } - - /** - * Output an HTTP error - * - * @param int $httpCode HTTP status code - * @param string|object $message - * @param string $format The output format (auto detect by default) - */ - protected static function outputError($httpCode, $message, $format=null) - { - return Request::outputError($httpCode, $message, $format); - } -} diff --git a/src/Router/Routes.php b/src/Router/Routes.php index 26d407c..ce4fa1a 100644 --- a/src/Router/Routes.php +++ b/src/Router/Routes.php @@ -2,7 +2,7 @@ namespace Jasny\Router; -use Psr\Http\Message\ServerRequestInterface as ServerRequest; +use Psr\Http\Message\ServerRequestInterface; /** * Collection of routes @@ -12,16 +12,16 @@ interface Routes /** * Check if a route for the request exists * - * @param ServerRequest $request + * @param ServerRequestInterface $request * @return boolean */ - public function hasRoute(ServerRequest $request); + public function hasRoute(ServerRequestInterface $request); /** * Get route for the request * - * @param ServerRequest $request + * @param ServerRequestInterface $request * @return Route */ - public function getRoute(ServerRequest $request); + public function getRoute(ServerRequestInterface $request); } diff --git a/src/Router/Runner.php b/src/Router/Runner.php index 40c8112..51300a4 100644 --- a/src/Router/Runner.php +++ b/src/Router/Runner.php @@ -2,7 +2,7 @@ namespace Jasny\Router; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Jasny\Router\Route; @@ -25,18 +25,45 @@ abstract class Runner { $this->route = $route; } + + /** + * Get runner route + * + * @return Route + */ + public function getRoute() + { + return $this->route; + } /** * Invoke the action specified in the route * - * @param RequestInterface $request + * @param ServerRequestInterface $request * @param ResponseInterface $response - * @param callback $next Callback for if runner is used as middleware * @return ResponseInterface */ - abstract public function __invoke(RequestInterface $request, ResponseInterface $response, $next = null); + abstract public function run(ServerRequestInterface $request, ResponseInterface $response); - + /** + * Invoke the action specified in the route and call the next method + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callback $next Callback for if runner is used as middleware + * @return ResponseInterface + */ + public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next = null) + { + $response = $this->run($request, $response); + + if (isset($next)) { + $response = call_user_func($next, $request, $response); + } + + return $response; + } + /** * Factory method * @@ -59,3 +86,4 @@ abstract class Runner return new $class($route); } } + diff --git a/src/Router/Runner/Callback.php b/src/Router/Runner/Callback.php index 031dcb1..34ebc4c 100644 --- a/src/Router/Runner/Callback.php +++ b/src/Router/Runner/Callback.php @@ -1,15 +1,33 @@ <?php -namespace Jasny\Router\Route; +namespace Jasny\Router\Runner; -use Jasny\Router\Route; +use Jasny\Router\Runner; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; /** * Description of Callback * * @author arnold */ -class Callback extends Route +class Callback extends Runner { - //put your code here + /** + * Use function to handle request and response + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface|mixed + */ + public function run(ServerRequestInterface $request, ResponseInterface $response) + { + $callback = !empty($this->route->fn) ? $this->route->fn : null; + + if (!is_callable($callback)) { + throw new \RuntimeException("'fn' property of route shoud be a callable"); + } + + return call_user_func($callback, $request, $response); + } } diff --git a/src/Router/Runner/Controller.php b/src/Router/Runner/Controller.php new file mode 100644 index 0000000..0488be5 --- /dev/null +++ b/src/Router/Runner/Controller.php @@ -0,0 +1,39 @@ +<?php + +namespace Jasny\Router\Runner; + +use Jasny\Router\Runner; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +/** + * Description of Controller + * + * @author arnold + */ +class Controller extends Runner +{ + /** + * Route to a controller + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface|mixed + */ + public function run(ServerRequestInterface $request, ResponseInterface $response) + { + $class = !empty($this->route->controller) ? $this->route->controller : null; + + if (!class_exists($class)) { + throw new \RuntimeException("Can not route to controller '$class': class not exists"); + } + + if (!method_exists($class, '__invoke')) { + throw new \RuntimeException("Can not route to controller '$class': class does not have '__invoke' method"); + } + + $controller = new $class($this->route); + + return $controller($request, $response); + } +} diff --git a/src/Router/Runner/PhpScript.php b/src/Router/Runner/PhpScript.php index 93cac8a..f6595a3 100644 --- a/src/Router/Runner/PhpScript.php +++ b/src/Router/Runner/PhpScript.php @@ -1,73 +1,47 @@ <?php -namespace Jasny\Router\Route; +namespace Jasny\Router\Runner; -use Jasny\Router\Route; -use Psr\Http\Message\ResponseInterface as Response; +use Jasny\Router\Runner; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; /** * Route to a PHP script */ -class PhpScript extends Route -{ +class PhpScript extends Runner +{ /** - * 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 route file path * * @return string */ public function __toString() { - echo (string)$this->key; - } - + return (string)$this->route->file; + } /** * Route to a file * - * @param object $route - * @return Response|mixed + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface|mixed */ - protected function execute() + public function run(ServerRequestInterface $request, ResponseInterface $response) { - $file = ltrim($this->file, '/'); + $file = !empty($this->route->file) ? ltrim($this->route->file, '/') : ''; if (!file_exists($file)) { - trigger_error("Failed to route using '$this': File '$file' doesn't exist.", E_USER_WARNING); - return false; + throw new \RuntimeException("Failed to route using '$file': File '$file' doesn't exist."); } - 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; + if ($file[0] === '~' || strpos($file, '..') !== false) { + throw new \RuntimeException("Won't route using '$file': '~', '..' are not allowed in filename."); } - return include $file; + $result = include $file; + + return $result === true ? $response : $result; } } diff --git a/tests/Router/Routes/GlobTest.php b/tests/Router/Routes/GlobTest.php index fef0f64..e1ab551 100644 --- a/tests/Router/Routes/GlobTest.php +++ b/tests/Router/Routes/GlobTest.php @@ -107,7 +107,7 @@ class GlobTest extends \PHPUnit_Framework_TestCase */ public function testOffsetSet($pattern, $options, $exception) { - if ($exception) $this->setExpectedException($exception); + if ($exception) $this->expectException($exception); $glob = new Glob(); $glob->offsetSet($pattern, $options); @@ -133,6 +133,7 @@ class GlobTest extends \PHPUnit_Framework_TestCase ['/foo/*', ['file' => 'bar'], ''], ['', ['controller' => 'bar'], BadMethodCallException::class], ['foo', 'bar', InvalidArgumentException::class], + ['', '', BadMethodCallException::class] ]; } @@ -325,7 +326,7 @@ class GlobTest extends \PHPUnit_Framework_TestCase */ public function testBindVarMultipleUrlParts($uri, $options, $positive, $exception) { - if ($exception) $this->setExpectedException(InvalidArgumentException::class); + if ($exception) $this->expectException(InvalidArgumentException::class); $values = [$uri => $options]; $glob = new Glob($values); diff --git a/tests/Router/Runner/CallbackTest.php b/tests/Router/Runner/CallbackTest.php new file mode 100644 index 0000000..b4ee4ef --- /dev/null +++ b/tests/Router/Runner/CallbackTest.php @@ -0,0 +1,64 @@ +<?php + +use Jasny\Router\Route; +use Jasny\Router\Runner\Callback; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +class CallbackTest extends PHPUnit_Framework_TestCase +{ + /** + * Test creating Callback runner + * + * @dataProvider callbackProvider + * @param Route $route + * @param boolean $positive + */ + public function testCallback($route, $positive) + { + $runner = new Callback($route); + $this->assertEquals($route, $runner->getRoute(), "Route was not set correctly"); + + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + if (!$positive) $this->expectException(\RuntimeException::class); + $result = $runner->run($request, $response); + + if (!$positive) return; + + $this->assertEquals($request, $result['request'], "Request object was not passed correctly to result"); + $this->assertEquals($response, $result['response'], "Response object was not passed correctly to result"); + } + + + /** + * Provide data fpr testing 'create' method + */ + public function callbackProvider() + { + $callback = function($request, $response) { + return ['request' => $request, 'response' => $response]; + }; + + return [ + [Route::create(['fn' => $callback, 'value' => 'test']), true], + [Route::create(['fn' => [$this, 'getCallback'], 'value' => 'test']), true], + [Route::create(['controller' => 'TestController', 'value' => 'test']), false], + [Route::create(['file' => 'some_file.php', 'value' => 'test']), false], + [Route::create(['test' => 'test']), false], + ]; + } + + /** + * Testable callback for creating Route + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return array + */ + public function getCallback($request, $response) + { + return ['request' => $request, 'response' => $response]; + } +} diff --git a/tests/Router/Runner/ControllerTest.php b/tests/Router/Runner/ControllerTest.php new file mode 100644 index 0000000..1d64622 --- /dev/null +++ b/tests/Router/Runner/ControllerTest.php @@ -0,0 +1,140 @@ +<?php + +use Jasny\Router\Route; +use Jasny\Router\Runner\Controller; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +class ControllerTest extends PHPUnit_Framework_TestCase +{ + /** + * Tmp scripts + * @var array + **/ + public static $files = []; + + /** + * Test creating Controller runner + * + * @dataProvider phpScriptProvider + * @param Route $route + * @param boolean $positive + */ + public function testPhpScript($route, $positive) + { + $runner = new Controller($route); + $this->assertEquals($route, $runner->getRoute(), "Route was not set correctly"); + + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + if (!$positive) $this->expectException(\RuntimeException::class); + $result = $runner->run($request, $response); + + $this->assertEquals($request, $result['request'], "Request object was not passed correctly to result"); + $this->assertEquals($response, $result['response'], "Response object was not passed correctly to result"); + } + + /** + * Provide data for testing 'create' method + */ + public function phpScriptProvider() + { + foreach (['noInvoke', 'withInvoke'] as $type) { + list($class, $path) = static::createTmpScript($type); + static::$files[$type] = compact('class', 'path'); + } + + return [ + [Route::create(['test' => 'test']), false], + [Route::create(['fn' => 'testFunction', 'value' => 'test']), false], + [Route::create(['controller' => 'TestController', 'value' => 'test']), false], + [Route::create(['controller' => '', 'value' => 'test']), false], + [Route::create(['controller' => static::$files['noInvoke']['class'], 'path' => static::$files['noInvoke']['path']]), false], + [Route::create(['controller' => static::$files['withInvoke']['class'], 'path' => static::$files['withInvoke']['path']]), true], + ]; + } + + /** + * Delete tmp test scripts + */ + public static function tearDownAfterClass() + { + foreach (static::$files as $path) { + unlink($path['path']); + } + } + + /** + * Create single tmp script file for testing + * + * @param string $type ('returnTrue', 'returnNotTrue') + * @return string $path + */ + public static function createTmpScript($type) + { + $dir = rtrim(sys_get_temp_dir(), '/'); + + do { + $name = static::getRandomString() . '-test-script.php'; + $path = $dir . '/' . $name; + + if (!file_exists($path)) break; + } while (true); + + if ($type === 'noInvoke') { + $class = 'RunnerTestConrtollerInvalid'; + $content = +<<<CONTENT +<?php + +class $class { + public \$route = null; + + public function __construct(\$route) + { + \$this->route = \$route; + } +} +CONTENT; + } else { + $class = 'RunnerTestConrtoller'; + $content = +<<<CONTENT +<?php + +class $class { + public \$route = null; + + public function __construct(\$route) + { + \$this->route = \$route; + } + + public function __invoke(Psr\Http\Message\ServerRequestInterface \$request, Psr\Http\Message\ResponseInterface \$response) + { + return ['request' => \$request, 'response' => \$response]; + } +} +CONTENT; + } + + $bytes = file_put_contents($path, $content); + static::assertTrue((int)$bytes > 0); + + require_once $path; + + return [$class, $path]; + } + + /** + * Get random string of given length (no more then length of md5 hash) + * + * @param int $length + * @return string + */ + public static function getRandomString($length = 10) + { + return substr(md5(microtime(true) * mt_rand()), 0, $length); + } +} diff --git a/tests/Router/Runner/PhpScriptTest.php b/tests/Router/Runner/PhpScriptTest.php new file mode 100644 index 0000000..12b9219 --- /dev/null +++ b/tests/Router/Runner/PhpScriptTest.php @@ -0,0 +1,94 @@ +<?php + +use Jasny\Router\Route; +use Jasny\Router\Runner\PhpScript; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +class PhpScriptTest extends PHPUnit_Framework_TestCase +{ + /** + * Test creating PhpScript runner + * + * @dataProvider phpScriptProvider + * @param Route $route + * @param boolean $positive + */ + public function testPhpScript($route, $positive) + { + $runner = new PhpScript($route); + $this->assertEquals($route, $runner->getRoute(), "Route was not set correctly"); + + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + if (!$positive) $this->expectException(\RuntimeException::class); + $result = $runner->run($request, $response); + + if (!$positive) return; + + $this->assertEquals($runner->getRoute()->file, (string)$runner); + + if ($route->type === 'returnTrue') { + $this->assertEquals($response, $result, "Request object was not returned as result"); + } else { + $this->assertEquals($request, $result['request'], "Request object was not passed correctly to result"); + $this->assertEquals($response, $result['response'], "Response object was not passed correctly to result"); + } + + unlink($route->file); + } + + /** + * Provide data fpr testing 'create' method + */ + public function phpScriptProvider() + { + return [ + [Route::create(['test' => 'test']), false], + [Route::create(['fn' => 'testFunction', 'value' => 'test']), false], + [Route::create(['controller' => 'TestController', 'value' => 'test']), false], + [Route::create(['file' => '', 'value' => 'test']), false], + [Route::create(['file' => 'some_file.php', 'value' => 'test']), false], + [Route::create(['file' => '../' . basename(getcwd()), 'value' => 'test']), false], + [Route::create(['file' => $this->createTmpScript('returnTrue'), 'type' => 'returnTrue']), true], + [Route::create(['file' => $this->createTmpScript('returnNotTrue'), 'type' => 'returnNotTrue']), true] + ]; + } + + /** + * Create single tmp script file for testing + * + * @param string $type ('returnTrue', 'returnNotTrue') + * @return string $path + */ + public function createTmpScript($type) + { + $dir = rtrim(sys_get_temp_dir(), '/'); + + do { + $name = $this->getRandomString() . '-test-script.php'; + $path = $dir . '/' . $name; + + if (!file_exists($path)) break; + } while (true); + + $content = $type === 'returnTrue' ? "<?php\n return true;" : "<?php\n return ['request' => \$request, 'response' => \$response];"; + $bytes = file_put_contents($path, $content); + + $this->assertTrue((int)$bytes > 0); + + return $path; + } + + /** + * Get random string of given length (no more then length of md5 hash) + * + * @param int $length + * @return string + */ + public function getRandomString($length = 10) + { + return substr(md5(microtime(true) * mt_rand()), 0, $length); + } +} diff --git a/tests/Router/RunnerTest.php b/tests/Router/RunnerTest.php new file mode 100644 index 0000000..60b80b9 --- /dev/null +++ b/tests/Router/RunnerTest.php @@ -0,0 +1,79 @@ +<?php + +use Jasny\Router\Route; +use Jasny\Router\Runner; +use Jasny\Router\Runner\Controller; +use Jasny\Router\Runner\Callback; +use Jasny\Router\Runner\PhpScript; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; + +class RunnerTest extends PHPUnit_Framework_TestCase +{ + /** + * Test creating Runner object using factory method + * + * @dataProvider createProvider + * @param Route $route + * @param string $class Runner class to use + * @param boolean $positive + */ + public function testCreate($route, $class, $positive) + { + if (!$positive) $this->expectException(\RuntimeException::class); + + $runner = Runner::create($route); + + if (!$positive) return; + + $this->assertInstanceOf($class, $runner, "Runner object has invalid class"); + $this->assertEquals($route, $runner->getRoute(), "Route was not set correctly"); + } + + /** + * Provide data fpr testing 'create' method + */ + public function createProvider() + { + return [ + [Route::create(['controller' => 'TestController', 'value' => 'test']), Controller::class, true], + [Route::create(['fn' => 'testFunction', 'value' => 'test']), Callback::class, true], + [Route::create(['file' => 'some_file.php', 'value' => 'test']), PhpScript::class, true], + [Route::create(['test' => 'test']), '', false], + ]; + } + + /** + * Test runner __invoke method + */ + public function testInvoke() + { + $runner = $this->getMockBuilder('Jasny\Router\Runner')->disableOriginalConstructor()->getMockForAbstractClass(); + $queries = [ + 'request' => $this->createMock(ServerRequestInterface::class), + 'response' => $this->createMock(ResponseInterface::class) + ]; + + #Test that 'run' receives correct arguments inside '__invoke' + $runner->method('run')->will($this->returnCallback(function($arg1, $arg2) { + return ['request' => $arg1, 'response' => $arg2]; + })); + + $result = $runner($queries['request'], $queries['response']); + $this->assertEquals($result['request'], $queries['request'], "Request was not returned correctly from 'run'"); + $this->assertEquals($result['response'], $queries['response'], "Response was not returned correctly from 'run'"); + + #The same test with calling 'next' callback + $result = $runner($queries['request'], $queries['response'], function($request, $prevResponse) use ($queries) { + $this->assertEquals($request, $queries['request'], "Request is not correct in 'next'"); + $this->assertEquals($prevResponse['request'], $queries['request'], "Prev response was not passed correctly to 'next'"); + $this->assertEquals($prevResponse['response'], $queries['response'], "Prev response was not passed correctly to 'next'"); + + return $queries + ['next_called' => true]; + }); + + $this->assertTrue($result['next_called'], "'Next' callback was not called"); + $this->assertEquals($result['request'], $queries['request'], "Request was not returned correctly from 'run' with 'next'"); + $this->assertEquals($result['response'], $queries['response'], "Request was not returned correctly from 'run' with 'next'"); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..9b63360 --- /dev/null +++ b/tests/RouterTest.php @@ -0,0 +1,227 @@ +<?php + +use Jasny\Router; +use Jasny\Router\Route; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; + +class RouterTest extends PHPUnit_Framework_TestCase +{ + /** + * Test creating Router + */ + public function testConstruct() + { + $routes = [ + '/foo' => ['fn' => 'test_function'], + '/foo/bar' => ['controller' => 'TestController'] + ]; + + $router = new Router($routes); + $this->assertEquals($routes, $router->getRoutes(), "Routes were not set correctly"); + } + + /** + * Test that on router 'run', method '__invoke' is called + */ + public function testRun() + { + $router = $this->createMock(Router::class, ['__invoke']); + list($request, $response) = $this->getRequests(); + + $router->method('__invoke')->will($this->returnCallback(function($arg1, $arg2) { + return ['request' => $arg1, 'response' => $arg2]; + })); + + $result = $router->run($request, $response); + + $this->assertEquals($request, $result['request'], "Request was not processed correctly"); + $this->assertEquals($response, $result['response'], "Response was not processed correctly"); + } + + /** + * Test '__invoke' method + */ + public function testInvoke() + { + $routes = [ + '/foo/bar' => Route::create(['controller' => 'TestController']), + '/foo' => Route::create(['fn' => function($arg1, $arg2) { + return ['request' => $arg1, 'response' => $arg2]; + }]) + ]; + + list($request, $response) = $this->getRequests(); + $router = new Router($routes); + $result = $router($request, $response); + + $this->assertEquals($request, $result['request'], "Request was not processed correctly"); + $this->assertEquals($response, $result['response'], "Response was not processed correctly"); + } + + /** + * Test '__invoke' method with 'next' callback + */ + public function testInvokeNext() + { + $routes = [ + '/foo/bar' => Route::create(['controller' => 'TestController']), + '/foo' => Route::create(['fn' => function($request, $response) { + return $response; + }]) + ]; + + list($request, $response) = $this->getRequests(); + $router = new Router($routes); + $result = $router($request, $response, function($arg1, $arg2) { + return ['request' => $arg1, 'response' => $arg2]; + }); + + $this->assertEquals($request, $result['request'], "Request was not processed correctly"); + $this->assertEquals($response, $result['response'], "Response was not processed correctly"); + } + + /** + * Test case when route is not found + */ + public function testNotFound() + { + $routes = [ + '/foo/bar' => Route::create(['controller' => 'TestController']) + ]; + + list($request, $response) = $this->getRequests(); + $this->expectNotFound($response); + + $router = new Router($routes); + $result = $router($request, $response); + + $this->assertEquals(get_class($response), get_class($result), "Returned result is not an instance of 'ServerRequestInterface'"); + } + + /** + * Test adding middleware action + * + * @dataProvider addProvider + * @param mixed $middleware1 + * @param callable $middleware2 + * @param boolean $positive + */ + public function testAdd($middleware1, $middleware2, $positive) + { + $router = new Router([]); + $this->assertEquals(0, count($router->getMiddlewares()), "Middlewares array should be empty"); + + if (!$positive) $this->expectException(\InvalidArgumentException::class); + + $result = $router->add($middleware1); + $this->assertEquals(1, count($router->getMiddlewares()), "There should be only one item in middlewares array"); + $this->assertEquals($middleware1, reset($router->getMiddlewares()), "Wrong item in middlewares array"); + $this->assertEquals($router, $result, "'Add' should return '\$this'"); + + if (!$middleware2) return; + + $router->add($middleware2); + $this->assertEquals(2, count($router->getMiddlewares()), "There should be two items in middlewares array"); + foreach ($router->getMiddlewares() as $action) { + $this->assertTrue($action == $middleware1 || $action == $middleware2, "Wrong item in middlewares array"); + } + } + + /** + * Provide data for testing 'add' method + */ + public function addProvider() + { + return [ + ['wrong_callback', null, false], + [[$this, 'getMiddlewareCalledFirst'], null, true], + [[$this, 'getMiddlewareCalledFirst'], [$this, 'getMiddlewareCalledLast'], true] + ]; + } + + /** + * Test executing router with middlewares chain (test only execution order) + */ + public function testRunMiddlewares() + { + $routes = [ + '/foo' => Route::create(['fn' => function($request, $response) { + $response->testMiddlewareCalls[] = 'handle'; + return $response; + }]) + ]; + + list($request, $response) = $this->getRequests(); + $router = new Router($routes); + $router->add([$this, 'getMiddlewareCalledLast'])->add([$this, 'getMiddlewareCalledFirst']); + + $result = $router($request, $response, function($request, $response) { + $response->testMiddlewareCalls[] = 'outer'; + return $response; + }); + + $this->assertEquals(['first','last','handle','outer'], $response->testMiddlewareCalls, "Actions were executed in wrong order"); + } + + /** + * Get requests for testing + * + * @return array + */ + public function getRequests() + { + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $request->method('getUri')->will($this->returnValue('/foo')); + $request->method('getMethod')->will($this->returnValue('GET')); + + return [$request, $response]; + } + + /** + * Get middleware action, that should ba called first in middleware chain + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callback $next + * @return ResponseInterface + */ + public function getMiddlewareCalledFirst(ServerRequestInterface $request, ResponseInterface $response, $next) + { + $response->testMiddlewareCalls[] = 'first'; + return $next($request, $response); + } + + /** + * Get middleware action, that should be called last in middleware chain + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callback $next + * @return ResponseInterface + */ + public function getMiddlewareCalledLast(ServerRequestInterface $request, ResponseInterface $response, $next) + { + $response->testMiddlewareCalls[] = 'last'; + return $next($request, $response); + } + + /** + * Expect 'not found' response + * + * @param ResponseInterface + */ + public function expectNotFound(ResponseInterface $response) + { + $stream = $this->createMock(StreamInterface::class); + $stream->expects($this->once())->method('rewind'); + $stream->expects($this->once())->method('write')->with($this->equalTo('Not Found')); + + $response->method('getBody')->will($this->returnValue($stream)); + $response->expects($this->once())->method('withBody')->with($this->equalTo($stream))->will($this->returnSelf()); + $response->expects($this->once())->method('withStatus')->with($this->equalTo(404), $this->equalTo('Not Found'))->will($this->returnSelf()); + } +} |