diff --git a/src/Routing/Router.php b/src/Routing/Router.php index 86c6b6d..841b134 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -4,6 +4,7 @@ namespace WellRESTed\Routing; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use WellRESTed\Dispatching\Dispatcher; use WellRESTed\Dispatching\DispatcherInterface; use WellRESTed\Routing\Route\RouteFactory; use WellRESTed\Routing\Route\RouteFactoryInterface; @@ -25,6 +26,8 @@ class Router implements RouterInterface private $prefixRoutes; /** @var RouteInterface[] Hash array mapping path prefixes to routes */ private $patternRoutes; + /** @var mixed[] List array of middleware */ + protected $stack; /** * Create a new Router. @@ -37,35 +40,40 @@ class Router implements RouterInterface * stored with the name. The value will be an array containing all of the * path variables. * - * @param DispatcherInterface $dispatcher Instance to use for dispatching - * middleware. - * @param string|null $pathVariablesAttributeName Attribute name for - * matched path variables. A null value sets attributes directly. + * @param DispatcherInterface $dispatcher + * Instance to use for dispatching middleware and handlers. + * @param string|null $pathVariablesAttributeName + * Attribute name for matched path variables. A null value sets + * attributes directly. */ - public function __construct(DispatcherInterface $dispatcher = null, $pathVariablesAttributeName = null) + public function __construct($dispatcher = null, $pathVariablesAttributeName = null) { - $this->dispatcher = $dispatcher; + $this->dispatcher = $dispatcher ?? $this->getDefaultDispatcher(); $this->pathVariablesAttributeName = $pathVariablesAttributeName; $this->factory = $this->getRouteFactory($this->dispatcher); $this->routes = []; $this->staticRoutes = []; $this->prefixRoutes = []; $this->patternRoutes = []; + $this->stack = []; } - public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) - { + public function __invoke( + ServerRequestInterface $request, + ResponseInterface $response, + $next + ) { // Use only the path for routing. $requestTarget = parse_url($request->getRequestTarget(), PHP_URL_PATH); $route = $this->getStaticRoute($requestTarget); if ($route) { - return $route($request, $response, $next); + return $this->dispatch($route, $request, $response, $next); } $route = $this->getPrefixRoute($requestTarget); if ($route) { - return $route($request, $response, $next); + return $this->dispatch($route, $request, $response, $next); } // Try each of the routes. @@ -79,7 +87,7 @@ class Router implements RouterInterface $request = $request->withAttribute($name, $value); } } - return $route($request, $response, $next); + return $this->dispatch($route, $request, $response, $next); } } @@ -87,6 +95,21 @@ class Router implements RouterInterface return $next($request, $response); } + private function dispatch( + $route, + ServerRequestInterface $request, + ResponseInterface $response, + $next + ) { + if (!$this->stack) { + return $route($request, $response, $next); + } + $stack = array_merge($this->stack, [$route]); + return $this->dispatcher->dispatch( + $stack, $request, $response, $next + ); + } + /** * Register middleware with the router for a given path and method. * @@ -122,6 +145,37 @@ class Router implements RouterInterface return $this; } + /** + * Push a new middleware onto the stack. Middleware for a router runs only + * when the router has a route matching the request. + * + * $middleware may be: + * - An instance implementing MiddlewareInterface + * - A string containing the fully qualified class name of a class + * implementing MiddlewareInterface + * - A callable that returns an instance implementing MiddleInterface + * - A callable matching the signature of MiddlewareInterface::dispatch + * @see DispatchedInterface::dispatch + * + * @param mixed $middleware Middleware to dispatch in sequence + * @return static + */ + public function addMiddleware($middleware) + { + $this->stack[] = $middleware; + return $this; + } + + /** + * Return an instance to dispatch middleware. + * + * @return DispatcherInterface + */ + protected function getDefaultDispatcher() + { + return new Dispatcher(); + } + /** * @param DispatcherInterface * @return RouteFactoryInterface diff --git a/src/Routing/RouterInterface.php b/src/Routing/RouterInterface.php index c37331c..e94e158 100644 --- a/src/Routing/RouterInterface.php +++ b/src/Routing/RouterInterface.php @@ -50,7 +50,24 @@ interface RouterInterface extends MiddlewareInterface * @param string $target Request target or pattern to match * @param string $method HTTP method(s) to match * @param mixed $middleware Middleware to dispatch - * @return self + * @return static */ public function register($method, $target, $middleware); + + /** + * Push a new middleware onto the stack. Middleware for a router runs only + * when the router has a route matching the request. + * + * $middleware may be: + * - An instance implementing MiddlewareInterface + * - A string containing the fully qualified class name of a class + * implementing MiddlewareInterface + * - A callable that returns an instance implementing MiddleInterface + * - A callable matching the signature of MiddlewareInterface::dispatch + * @see DispatchedInterface::dispatch + * + * @param mixed $middleware Middleware to dispatch in sequence + * @return static + */ + public function addMiddleware($middleware); } diff --git a/test/tests/unit/Routing/RouterTest.php b/test/tests/unit/Routing/RouterTest.php index cbf7913..dd51de9 100644 --- a/test/tests/unit/Routing/RouterTest.php +++ b/test/tests/unit/Routing/RouterTest.php @@ -6,6 +6,8 @@ use Prophecy\Argument; use WellRESTed\Dispatching\Dispatcher; use WellRESTed\Message\Response; use WellRESTed\Message\ServerRequest; +use WellRESTed\Routing\MethodMapInterface; +use WellRESTed\Routing\Route\RouteFactory; use WellRESTed\Routing\Route\RouteInterface; use WellRESTed\Routing\Router; use WellRESTed\Test\Doubles\NextMock; @@ -25,31 +27,27 @@ class RouterTest extends TestCase { parent::setUp(); - $this->methodMap = $this->prophesize('WellRESTed\Routing\MethodMapInterface'); + $this->methodMap = $this->prophesize(MethodMapInterface::class); $this->methodMap->register(Argument::cetera()); - $this->route = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); - $this->route->__invoke(Argument::cetera())->willReturn(); + $this->route = $this->prophesize(RouteInterface::class); + $this->route->__invoke(Argument::cetera())->willReturn(new Response()); $this->route->getMethodMap()->willReturn($this->methodMap->reveal()); $this->route->getType()->willReturn(RouteInterface::TYPE_STATIC); $this->route->getTarget()->willReturn("/"); $this->route->getPathVariables()->willReturn([]); - $this->factory = $this->prophesize('WellRESTed\Routing\Route\RouteFactory'); - $this->factory->create(Argument::any())->willReturn($this->route->reveal()); + $this->factory = $this->prophesize(RouteFactory::class); + $this->factory->create(Argument::any()) + ->willReturn($this->route->reveal()); + + RouterWithFactory::$routeFactory = $this->factory->reveal(); + + $this->router = new RouterWithFactory(); $this->request = new ServerRequest(); $this->response = new Response(); $this->next = new NextMock(); - - $this->router = $this->getMockBuilder('WellRESTed\Routing\Router') - ->setMethods(["getRouteFactory"]) - ->disableOriginalConstructor() - ->getMock(); - $this->router->expects($this->any()) - ->method("getRouteFactory") - ->will($this->returnValue($this->factory->reveal())); - $this->router->__construct(new Dispatcher()); } // ------------------------------------------------------------------------ @@ -57,7 +55,7 @@ class RouterTest extends TestCase public function testCreatesInstance() { - $router = new Router(new Dispatcher()); + $router = new Router(); $this->assertNotNull($router); } @@ -67,6 +65,7 @@ class RouterTest extends TestCase public function testCreatesRouteForTarget() { $this->router->register("GET", "/", "middleware"); + $this->factory->create("/")->shouldHaveBeenCalled(); } @@ -74,12 +73,14 @@ class RouterTest extends TestCase { $this->router->register("GET", "/", "middleware"); $this->router->register("POST", "/", "middleware"); + $this->factory->create("/")->shouldHaveBeenCalledTimes(1); } public function testPassesMethodAndMiddlewareToMethodMap() { $this->router->register("GET", "/", "middleware"); + $this->methodMap->register("GET", "middleware")->shouldHaveBeenCalled(); } @@ -97,7 +98,8 @@ class RouterTest extends TestCase $this->router->register("GET", $target, "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $this->route->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $this->route->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesPrefixRoute() @@ -111,7 +113,8 @@ class RouterTest extends TestCase $this->router->register("GET", $target, "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $this->route->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $this->route->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesPatternRoute() @@ -126,22 +129,23 @@ class RouterTest extends TestCase $this->router->register("GET", $target, "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $this->route->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $this->route->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesStaticRouteBeforePrefixRoute() { - $staticRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $staticRoute = $this->prophesize(RouteInterface::class); $staticRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $staticRoute->getTarget()->willReturn("/cats/"); $staticRoute->getType()->willReturn(RouteInterface::TYPE_STATIC); - $staticRoute->__invoke(Argument::cetera())->willReturn(); + $staticRoute->__invoke(Argument::cetera())->willReturn(new Response()); - $prefixRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $prefixRoute = $this->prophesize(RouteInterface::class); $prefixRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $prefixRoute->getTarget()->willReturn("/cats/*"); $prefixRoute->getType()->willReturn(RouteInterface::TYPE_PREFIX); - $prefixRoute->__invoke(Argument::cetera())->willReturn(); + $prefixRoute->__invoke(Argument::cetera())->willReturn(new Response()); $this->request = $this->request->withRequestTarget("/cats/"); @@ -152,26 +156,28 @@ class RouterTest extends TestCase $this->router->register("GET", "/cats/*", "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $staticRoute->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $staticRoute->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesLongestMatchingPrefixRoute() { // Note: The longest route is also good for 2 points in Settlers of Catan. - $shortRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $shortRoute = $this->prophesize(RouteInterface::class); $shortRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $shortRoute->getTarget()->willReturn("/animals/*"); $shortRoute->getType()->willReturn(RouteInterface::TYPE_PREFIX); - $shortRoute->__invoke(Argument::cetera())->willReturn(); + $shortRoute->__invoke(Argument::cetera())->willReturn(new Response()); - $longRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $longRoute = $this->prophesize(RouteInterface::class); $longRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $longRoute->getTarget()->willReturn("/animals/cats/*"); $longRoute->getType()->willReturn(RouteInterface::TYPE_PREFIX); - $longRoute->__invoke(Argument::cetera())->willReturn(); + $longRoute->__invoke(Argument::cetera())->willReturn(new Response()); - $this->request = $this->request->withRequestTarget("/animals/cats/molly"); + $this->request = $this->request + ->withRequestTarget("/animals/cats/molly"); $this->factory->create("/animals/*")->willReturn($shortRoute->reveal()); $this->factory->create("/animals/cats/*")->willReturn($longRoute->reveal()); @@ -180,22 +186,23 @@ class RouterTest extends TestCase $this->router->register("GET", "/animals/cats/*", "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $longRoute->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $longRoute->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesPrefixRouteBeforePatternRoute() { - $prefixRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $prefixRoute = $this->prophesize(RouteInterface::class); $prefixRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $prefixRoute->getTarget()->willReturn("/cats/*"); $prefixRoute->getType()->willReturn(RouteInterface::TYPE_PREFIX); - $prefixRoute->__invoke(Argument::cetera())->willReturn(); + $prefixRoute->__invoke(Argument::cetera())->willReturn(new Response()); - $patternRoute = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $patternRoute = $this->prophesize(RouteInterface::class); $patternRoute->getMethodMap()->willReturn($this->methodMap->reveal()); $patternRoute->getTarget()->willReturn("/cats/{id}"); $patternRoute->getType()->willReturn(RouteInterface::TYPE_PATTERN); - $patternRoute->__invoke(Argument::cetera())->willReturn(); + $patternRoute->__invoke(Argument::cetera())->willReturn(new Response()); $this->request = $this->request->withRequestTarget("/cats/"); @@ -206,26 +213,27 @@ class RouterTest extends TestCase $this->router->register("GET", "/cats/{id}", "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $prefixRoute->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $prefixRoute->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testDispatchesFirstMatchingPatternRoute() { - $patternRoute1 = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $patternRoute1 = $this->prophesize(RouteInterface::class); $patternRoute1->getMethodMap()->willReturn($this->methodMap->reveal()); $patternRoute1->getTarget()->willReturn("/cats/{id}"); $patternRoute1->getType()->willReturn(RouteInterface::TYPE_PATTERN); $patternRoute1->getPathVariables()->willReturn([]); $patternRoute1->matchesRequestTarget(Argument::any())->willReturn(true); - $patternRoute1->__invoke(Argument::cetera())->willReturn(); + $patternRoute1->__invoke(Argument::cetera())->willReturn(new Response()); - $patternRoute2 = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $patternRoute2 = $this->prophesize(RouteInterface::class); $patternRoute2->getMethodMap()->willReturn($this->methodMap->reveal()); $patternRoute2->getTarget()->willReturn("/cats/{name}"); $patternRoute2->getType()->willReturn(RouteInterface::TYPE_PATTERN); $patternRoute2->getPathVariables()->willReturn([]); $patternRoute2->matchesRequestTarget(Argument::any())->willReturn(true); - $patternRoute2->__invoke(Argument::cetera())->willReturn(); + $patternRoute2->__invoke(Argument::cetera())->willReturn(new Response()); $this->request = $this->request->withRequestTarget("/cats/molly"); @@ -236,26 +244,27 @@ class RouterTest extends TestCase $this->router->register("GET", "/cats/{name}", "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $patternRoute1->__invoke($this->request, $this->response, $this->next)->shouldHaveBeenCalled(); + $patternRoute1->__invoke(Argument::cetera()) + ->shouldHaveBeenCalled(); } public function testStopsTestingPatternsAfterFirstSuccessfulMatch() { - $patternRoute1 = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $patternRoute1 = $this->prophesize(RouteInterface::class); $patternRoute1->getMethodMap()->willReturn($this->methodMap->reveal()); $patternRoute1->getTarget()->willReturn("/cats/{id}"); $patternRoute1->getType()->willReturn(RouteInterface::TYPE_PATTERN); $patternRoute1->getPathVariables()->willReturn([]); $patternRoute1->matchesRequestTarget(Argument::any())->willReturn(true); - $patternRoute1->__invoke(Argument::cetera())->willReturn(); + $patternRoute1->__invoke(Argument::cetera())->willReturn(new Response()); - $patternRoute2 = $this->prophesize('WellRESTed\Routing\Route\RouteInterface'); + $patternRoute2 = $this->prophesize(RouteInterface::class); $patternRoute2->getMethodMap()->willReturn($this->methodMap->reveal()); $patternRoute2->getTarget()->willReturn("/cats/{name}"); $patternRoute2->getType()->willReturn(RouteInterface::TYPE_PATTERN); $patternRoute2->getPathVariables()->willReturn([]); $patternRoute2->matchesRequestTarget(Argument::any())->willReturn(true); - $patternRoute2->__invoke(Argument::cetera())->willReturn(); + $patternRoute2->__invoke(Argument::cetera())->willReturn(new Response()); $this->request = $this->request->withRequestTarget("/cats/molly"); @@ -266,7 +275,8 @@ class RouterTest extends TestCase $this->router->register("GET", "/cats/{name}", "middleware"); $this->router->__invoke($this->request, $this->response, $this->next); - $patternRoute2->matchesRequestTarget(Argument::any())->shouldNotHaveBeenCalled(); + $patternRoute2->matchesRequestTarget(Argument::any()) + ->shouldNotHaveBeenCalled(); } public function testMatchesPathAgainstRouteWithoutQuery() @@ -362,4 +372,67 @@ class RouterTest extends TestCase $this->router->__invoke($this->request, $this->response, $this->next); $this->assertTrue($this->next->called); } + + // ------------------------------------------------------------------------ + // Middleware for Routes + + public function testCallsRouterMiddlewareBeforeRouteMiddleware() + { + $middlewareRequest = new ServerRequest(); + $middlewareResponse = new Response(); + + $middleware = function ($rqst, $resp, $next) use ( + $middlewareRequest, + $middlewareResponse + ) { + return $next($middlewareRequest, $middlewareResponse); + }; + + $this->router->addMiddleware($middleware); + $this->router->register("GET", "/", "Handler"); + + $this->router->__invoke($this->request, $this->response, $this->next); + + $this->route->__invoke( + $middlewareRequest, + $middlewareResponse, + Argument::any())->shouldHaveBeenCalled(); + } + + public function testDoesNotCallRouterMiddlewareWhenNoRouteMatches() + { + $middlewareCalled = false; + $middlewareRequest = new ServerRequest(); + $middlewareResponse = new Response(); + + $middleware = function ($rqst, $resp, $next) use ( + $middlewareRequest, + $middlewareResponse, + &$middlewareCalled + ) { + $middlewareCalled = true; + return $next($middlewareRequest, $middlewareResponse); + }; + + $this->request = $this->request->withRequestTarget("/no/match"); + + $this->router->addMiddleware($middleware); + $this->router->register("GET", "/", "Handler"); + + $this->router->__invoke($this->request, $this->response, $this->next); + + $this->assertFalse($middlewareCalled); + } +} + +// ----------------------------------------------------------------------------- + +class RouterWithFactory extends Router +{ + static $routeFactory; + + protected function getRouteFactory($dispatcher) + { + return self::$routeFactory; + } }