diff --git a/src/Routing/MethodMap.php b/src/Routing/MethodMap.php new file mode 100644 index 0000000..422c235 --- /dev/null +++ b/src/Routing/MethodMap.php @@ -0,0 +1,98 @@ +map = []; + if ($map) { + foreach ($map as $method => $middleware) { + $this->add($method, $middleware); + } + } + } + + /** + * @param string $method + * @param mixed $middleware + */ + public function add($method, $middleware) + { + $method = strtoupper($method); + $methods = explode(",", $method); + $methods = array_map("trim", $methods); + foreach ($methods as $method) { + $this->map[$method] = $middleware; + } + } + + public function dispatch(ServerRequestInterface $request, ResponseInterface &$response) + { + $method = strtoupper($request->getMethod()); + // Dispatch middleware registered with the explicitly matching method. + if (isset($this->map[$method])) { + $middleware = $this->map[$method]; + $this->disptchMiddleware($middleware, $request, $response); + return; + } + // For HEAD, dispatch GET by default. + if ($method === "HEAD" && isset($this->map["GET"])) { + $middleware = $this->map["GET"]; + $this->disptchMiddleware($middleware, $request, $response); + return; + } + // Method is not defined. Respond describing the allowed methods, + // either as a 405 response or in response to an OPTIONS request. + if ($method === "OPTIONS") { + $response = $response->withStatus(200); + } else { + $response = $response->withStatus(405); + } + $this->addAllowHeader($response); + } + + protected function addAllowHeader(ResponseInterface &$response) + { + $methods = join(",", $this->getAllowedMethods()); + $response = $response->withHeader("Allow", $methods); + } + + protected function getAllowedMethods() + { + $methods = array_keys($this->map); + // Add HEAD if GET is allowed and HEAD is not present. + if (in_array("GET", $methods) && !in_array("HEAD", $methods)) { + $methods[] = "HEAD"; + } + // Add OPTIONS if not already present. + if (!in_array("OPTIONS", $methods)) { + $methods[] = "OPTIONS"; + } + return $methods; + } + + /** + * Return an instance that can dispatch middleware. Uses Dispatcher by default. + * Override to provide a custom class. + */ + protected function getDispatcher() + { + return new Dispatcher(); + } + + private function disptchMiddleware($middleware, ServerRequestInterface $request, ResponseInterface &$response) + { + $dispatcher = $this->getDispatcher(); + $dispatcher->dispatch($middleware, $request, $response); + } +} diff --git a/test/tests/unit/Routing/MethodMapTest.php b/test/tests/unit/Routing/MethodMapTest.php new file mode 100644 index 0000000..ce67c60 --- /dev/null +++ b/test/tests/unit/Routing/MethodMapTest.php @@ -0,0 +1,156 @@ +request = $this->prophesize("\\Psr\\Http\\Message\\ServerRequestInterface"); + $this->response = $this->prophesize("\\Psr\\Http\\Message\\ResponseInterface"); + $this->response->withStatus(Argument::any())->willReturn($this->response->reveal()); + $this->response->withHeader(Argument::cetera())->willReturn($this->response->reveal()); + } + + public function testDispatchesMiddlewareWithMatchingMethod() + { + $this->request->getMethod()->willReturn("GET"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + $middleware->dispatch(Argument::cetera())->willReturn(); + + $map = new MethodMap(["GET" => $middleware->reveal()]); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $middleware->dispatch($this->request->reveal(), $this->response->reveal())->shouldHaveBeenCalled(); + } + + public function testDispatchesGetMiddlewareForHeadByDefault() + { + $this->request->getMethod()->willReturn("HEAD"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + $middleware->dispatch(Argument::cetera())->willReturn(); + + $map = new MethodMap(["GET" => $middleware->reveal()]); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $middleware->dispatch($this->request->reveal(), $this->response->reveal())->shouldHaveBeenCalled(); + } + + public function testRegistersMiddlewareForMultipleMethods() + { + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + $middleware->dispatch(Argument::cetera())->willReturn(); + + $map = new MethodMap(); + $map->add("GET,POST", $middleware->reveal()); + + $this->request->getMethod()->willReturn("GET"); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $this->request->getMethod()->willReturn("POST"); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $middleware->dispatch($this->request->reveal(), $this->response->reveal())->shouldHaveBeenCalledTimes(2); + } + + public function testSetsStatusTo200ForOptions() + { + $this->request->getMethod()->willReturn("OPTIONS"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + + $map = new MethodMap(["GET" => $middleware->reveal()]); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $this->response->withStatus(200)->shouldHaveBeenCalled(); + } + + /** + * @dataProvider allowedMethodProvider + */ + public function testSetsAllowHeaderForOptions($methodsDeclared, $methodsAllowed) + { + $this->request->getMethod()->willReturn("OPTIONS"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + + $map = new MethodMap(); + foreach ($methodsDeclared as $method) { + $map->add($method, $middleware->reveal()); + } + + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $containsAllMethods = function ($headerValue) use ($methodsAllowed) { + foreach ($methodsAllowed as $method) { + if (strpos($headerValue, $method) === false) { + return false; + } + } + return true; + }; + $this->response->withHeader("Allow", Argument::that($containsAllMethods))->shouldHaveBeenCalled(); + } + + public function testSetsStatusTo405ForBadMethod() + { + $this->request->getMethod()->willReturn("POST"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + + $map = new MethodMap(["GET" => $middleware->reveal()]); + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $this->response->withStatus(405)->shouldHaveBeenCalled(); + } + + /** + * @dataProvider allowedMethodProvider + */ + public function testSetsAlloweHeaderForBadMethod($methodsDeclared, $methodsAllowed) + { + $this->request->getMethod()->willReturn("BAD"); + + $middleware = $this->prophesize("\\WellRESTed\\Routing\\MiddlewareInterface"); + + $map = new MethodMap(); + foreach ($methodsDeclared as $method) { + $map->add($method, $middleware->reveal()); + } + + $map->dispatch($this->request->reveal(), $this->response->reveal()); + + $containsAllMethods = function ($headerValue) use ($methodsAllowed) { + foreach ($methodsAllowed as $method) { + if (strpos($headerValue, $method) === false) { + return false; + } + } + return true; + }; + $this->response->withHeader("Allow", Argument::that($containsAllMethods))->shouldHaveBeenCalled(); + } + + public function allowedMethodProvider() + { + return [ + [["GET"], ["GET", "HEAD", "OPTIONS"]], + [["GET","POST"], ["GET", "POST", "HEAD", "OPTIONS"]], + [["POST"], ["POST", "OPTIONS"]], + [["POST"], ["POST", "OPTIONS"]], + [["GET","PUT,DELETE"], ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"]], + ]; + } +}