diff --git a/src/Dumbo.php b/src/Dumbo.php index ed8044c..ea86d91 100644 --- a/src/Dumbo.php +++ b/src/Dumbo.php @@ -193,7 +193,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface } try { - $route = $this->router->findRoute($request); + $dispatch = $this->router->dispatch($request); + $route = $dispatch["route"]; $context = new Context( $request, @@ -215,6 +216,16 @@ public function handle(ServerRequestInterface $request): ResponseInterface ); $handler = $route["handler"]; + } elseif ($dispatch["allowedMethods"] !== []) { + $allowed = implode(", ", $dispatch["allowedMethods"]); + + $handler = function () use ($allowed) { + return new Response( + 405, + ["Allow" => $allowed], + "405 Method Not Allowed" + ); + }; } else { $handler = function () { return new Response(404, [], "404 Not Found"); diff --git a/src/Router.php b/src/Router.php index c297e45..08b9648 100644 --- a/src/Router.php +++ b/src/Router.php @@ -58,12 +58,15 @@ public function addGroup(string $prefix, array $groupRoutes): void } /** - * Find a matching route for the given request + * Dispatch the given request against the registered routes + * + * When the path is registered but not for the request method, the route is + * null and the methods that path does accept are returned instead. * * @param ServerRequestInterface $request The incoming HTTP request - * @return array|null The matched route information or null if no match found + * @return array{route: array|null, allowedMethods: array} The dispatch result */ - public function findRoute(ServerRequestInterface $request): ?array + public function dispatch(ServerRequestInterface $request): array { if (!$this->dispatcher) { $this->buildDispatcher(); @@ -79,14 +82,34 @@ public function findRoute(ServerRequestInterface $request): ?array $vars = $routeInfo[2]; return [ - "handler" => $handler["handler"], - "params" => $vars, - "routePath" => $handler["path"], - "middleware" => $handler["middleware"] ?? [], + "route" => [ + "handler" => $handler["handler"], + "params" => $vars, + "routePath" => $handler["path"], + "middleware" => $handler["middleware"] ?? [], + ], + "allowedMethods" => [], ]; } - return null; + return [ + "route" => null, + "allowedMethods" => + $routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED + ? array_values(array_unique($routeInfo[1])) + : [], + ]; + } + + /** + * Find a matching route for the given request + * + * @param ServerRequestInterface $request The incoming HTTP request + * @return array|null The matched route information or null if no match found + */ + public function findRoute(ServerRequestInterface $request): ?array + { + return $this->dispatch($request)["route"]; } /** diff --git a/tests/DumboTest.php b/tests/DumboTest.php index 35b32a9..8a2bb02 100644 --- a/tests/DumboTest.php +++ b/tests/DumboTest.php @@ -1,7 +1,9 @@ assertEquals(E_ALL, error_reporting()); $this->assertEquals("1", ini_get("display_errors")); } + + public function testMethodNotAllowedReturns405WithAllowHeader() + { + $app = new Dumbo(); + $app->get("/users", fn(Context $context) => $context->text("users")); + $app->post("/users", fn(Context $context) => $context->text("created")); + + $response = $app->handle(new ServerRequest("DELETE", "/users")); + + $this->assertEquals(405, $response->getStatusCode()); + $this->assertEquals("GET, POST", $response->getHeaderLine("Allow")); + $this->assertEquals( + "405 Method Not Allowed", + (string) $response->getBody() + ); + } + + public function testUnknownPathStillReturns404() + { + $app = new Dumbo(); + $app->get("/users", fn(Context $context) => $context->text("users")); + + $response = $app->handle(new ServerRequest("GET", "/nope")); + + $this->assertEquals(404, $response->getStatusCode()); + $this->assertFalse($response->hasHeader("Allow")); + $this->assertEquals("404 Not Found", (string) $response->getBody()); + } + + public function testHeadRequestFallsBackToGetRoute() + { + $app = new Dumbo(); + $app->get("/users", fn(Context $context) => $context->text("users")); + + $response = $app->handle(new ServerRequest("HEAD", "/users")); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testMiddlewareRunsForMethodNotAllowed() + { + $app = new Dumbo(); + $app->use( + fn(Context $context, callable $next) => $next($context)->withHeader( + "X-Middleware", + "ran" + ) + ); + $app->get("/users", fn(Context $context) => $context->text("users")); + + $response = $app->handle(new ServerRequest("DELETE", "/users")); + + $this->assertEquals(405, $response->getStatusCode()); + $this->assertEquals("ran", $response->getHeaderLine("X-Middleware")); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index ed02cb1..0ef62f6 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -122,6 +122,51 @@ public function testRouteNotFound() $this->assertNull($route); } + public function testDispatchReportsAllowedMethodsForMethodMismatch() + { + $handler = function (Context $context) { + return $context->text("Users"); + }; + + $this->router->addRoute("GET", "/users", $handler); + $this->router->addRoute("POST", "/users", $handler); + + $dispatch = $this->router->dispatch( + new ServerRequest("DELETE", "/users") + ); + + $this->assertNull($dispatch["route"]); + $this->assertEquals(["GET", "POST"], $dispatch["allowedMethods"]); + } + + public function testDispatchReportsNoAllowedMethodsForUnknownPath() + { + $dispatch = $this->router->dispatch( + new ServerRequest("GET", "/non-existent-route") + ); + + $this->assertNull($dispatch["route"]); + $this->assertEquals([], $dispatch["allowedMethods"]); + } + + public function testDispatchReturnsMatchedRoute() + { + $this->router->addRoute("GET", "/users/:id", function ( + Context $context + ) { + return $context->text("User"); + }); + + $dispatch = $this->router->dispatch( + new ServerRequest("GET", "/users/123") + ); + + $this->assertNotNull($dispatch["route"]); + $this->assertEquals("/users/:id", $dispatch["route"]["routePath"]); + $this->assertEquals(["id" => "123"], $dispatch["route"]["params"]); + $this->assertEquals([], $dispatch["allowedMethods"]); + } + public function testMultipleMiddleware() { $middleware1 = function (Context $context, callable $next) {