diff --git a/CHANGELOG.md b/CHANGELOG.md index 949b3a56..65ef1b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 4.0.2 under development -- no changes in this release. +- New #249: Add option to ignore method failure handler to the 'Router' middleware (@olegbaturin) +- New #249: Add custom response handlers for the method failure responses to the 'Router' middleware (@olegbaturin) ## 4.0.1 September 23, 2025 diff --git a/README.md b/README.md index 2db4c8d2..9789bc3e 100644 --- a/README.md +++ b/README.md @@ -106,22 +106,6 @@ $response = $result->process($request, $notFoundHandler); > to specific adapter documentation. All examples in this document are for > [FastRoute adapter](https://github.com/yiisoft/router-fastroute). -### Middleware usage - -In order to simplify usage in PSR-middleware based application, there is a ready to use middleware provided: - -```php -$router = $container->get(Yiisoft\Router\UrlMatcherInterface::class); -$responseFactory = $container->get(\Psr\Http\Message\ResponseFactoryInterface::class); - -$routerMiddleware = new Yiisoft\Router\Middleware\Router($router, $responseFactory, $container); - -// Add middleware to your middleware handler of choice. -``` - -In case of a route match router middleware executes handler middleware attached to the route. If there is no match, next -application middleware processes the request. - ### Routes Route could match for one or more HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. There are @@ -233,17 +217,97 @@ and `disableMiddleware()`. These middleware are executed prior to matched route' If host is specified, all routes in the group would match only if the host match. -### Automatic OPTIONS response and CORS +### Middleware usage -By default, router responds automatically to OPTIONS requests based on the routes defined: +To simplify usage in PSR-middleware based application, there is a ready to use `Yiisoft\Router\Middleware\Router` middleware provided: +```php +use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; +use Yiisoft\Router\CurrentRoute; +use Yiisoft\Router\MethodFailureHandlerInterface; +use Yiisoft\Router\Middleware\Router; +use Yiisoft\Router\UrlMatcherInterface; + +$matcher = $container->get(UrlMatcherInterface::class); +$middlewareFactory = $container->get(MiddlewareFactory::class); +$currentRoute = $container->get(CurrentRoute::class); +$methodFailureHandler = $container->get(MethodFailureHandlerInterface::class); + +$routerMiddleware = new Router($matcher, $middlewareFactory, $currentRoute, $methodFailureHandler); + +// Add middleware to your middleware handler of choice. +``` + +When a route matches router middleware executes handler middleware attached to the route. If there is no match, next +application middleware processes the request. + +### Handling method failure error + +To handle method failure error, pass an instance of `Yiisoft\Router\MethodFailureHandlerInterface` to the `Yiisoft\Router\Middleware\Router` middleware's constructor. +The [Yii Router](yiisoft/router) package provides a default method failure handler, `Yiisoft\Router\MethodFailureHandler`. + +`Yiisoft\Router\MethodFailureHandler` responds based on the HTTP methods supported by the request's resource: + +- For `OPTIONS` requests: ``` HTTP/1.1 204 No Content Allow: GET, HEAD ``` -Generally that is fine unless you need [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). In this -case, you can add a middleware for handling it such as [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware): +- For requests with methods that are not supported by the target resource: +``` +HTTP/1.1 405 Method Not Allowed +Allow: GET, HEAD +``` + +To use `Yiisoft\Router\MethodFailureHandler`, pass it to the `Yiisoft\Router\Middleware\Router` middleware constructor. + +```php +use Psr\Http\Message\ResponseFactoryInterface; +use Yiisoft\Router\MethodFailureHandler; +use Yiisoft\Router\Middleware\Router; + +$responseFactory = $container->get(ResponseFactoryInterface::class); +$methodFailureHandler = new MethodFailureHandler($responseFactory); + +$middleware = new Router( + $matcher, + $middlewareFactory, + $currentRoute, + $methodFailureHandler // pass the handler here +); +``` + +or define the `MethodFailureHandlerInterface` configuration in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/router.php + +use Yiisoft\Router\MethodFailureHandler; +use Yiisoft\Router\MethodFailureHandlerInterface; + +return [ + MethodFailureHandlerInterface::class => MethodFailureHandler::class, +]; +``` + +> In case [Yii Router](yiisoft/router) package is used along with [Yii Config](https://github.com/yiisoft/config) plugin, the package is [configured](./config/di-web.php) +automatically to use `Yiisoft\Router\MethodFailureHandler`. + +To disable method failure error handling pass `null` as the `methodFailureHandler` parameter of the `Yiisoft\Router\Middleware\Router` middleware constructor: + +```php +$middleware = new Router( + $matcher, + $middlewareFactory, + $currentRoute, + null // disables method failure handling +); +``` + +### CORS protocol + +If you need [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) you can add a middleware for handling it such as [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware): ```php use Yiisoft\Router\Group; diff --git a/composer.json b/composer.json index ddd318ba..013a6996 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "roave/infection-static-analysis-plugin": "^1.35", "spatie/phpunit-watcher": "^1.24", "vimeo/psalm": "^5.26.1 || ^6.8.6", - "yiisoft/di": "^1.3", + "yiisoft/di": "^1.4", "yiisoft/dummy-provider": "^1.0.1", "yiisoft/hydrator": "^1.5", "yiisoft/test-support": "^3.0.1", @@ -73,6 +73,7 @@ }, "config-plugin": { "di": "di.php", + "di-web": "di-web.php", "params": "params.php" } }, diff --git a/config/di-web.php b/config/di-web.php new file mode 100644 index 00000000..9156d48b --- /dev/null +++ b/config/di-web.php @@ -0,0 +1,18 @@ + [ + 'reset' => function () { + $this->route = null; + $this->uri = null; + $this->arguments = []; + }, + ], + MethodFailureHandlerInterface::class => MethodFailureHandler::class, +]; diff --git a/config/di.php b/config/di.php index 8ed505f5..66993952 100644 --- a/config/di.php +++ b/config/di.php @@ -4,15 +4,7 @@ use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteCollectorInterface; -use Yiisoft\Router\CurrentRoute; return [ RouteCollectorInterface::class => RouteCollector::class, - CurrentRoute::class => [ - 'reset' => function () { - $this->route = null; - $this->uri = null; - $this->arguments = []; - }, - ], ]; diff --git a/src/MethodFailureHandler.php b/src/MethodFailureHandler.php new file mode 100644 index 00000000..b33423f7 --- /dev/null +++ b/src/MethodFailureHandler.php @@ -0,0 +1,36 @@ +getMethod() === Method::OPTIONS ? Status::NO_CONTENT : Status::METHOD_NOT_ALLOWED; + + return $this->responseFactory + ->createResponse($status) + ->withHeader(Header::ALLOW, implode(', ', $allowedMethods)); + } +} diff --git a/src/MethodFailureHandlerInterface.php b/src/MethodFailureHandlerInterface.php new file mode 100644 index 00000000..92793e1b --- /dev/null +++ b/src/MethodFailureHandlerInterface.php @@ -0,0 +1,21 @@ +dispatcher = new MiddlewareDispatcher($middlewareFactory, $eventDispatcher); } @@ -37,15 +35,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $this->currentRoute->setUri($request->getUri()); - if ($result->isMethodFailure()) { - if ($request->getMethod() === Method::OPTIONS) { - return $this->responseFactory - ->createResponse(Status::NO_CONTENT) - ->withHeader('Allow', implode(', ', $result->methods())); - } - return $this->responseFactory - ->createResponse(Status::METHOD_NOT_ALLOWED) - ->withHeader('Allow', implode(', ', $result->methods())); + if ($result->isMethodFailure() && $this->methodFailureHandler !== null) { + return $this->methodFailureHandler->handle($request, $result->methods()); } if (!$result->isSuccess()) { diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index c8f797a5..4c1947bf 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -5,11 +5,15 @@ namespace Yiisoft\Router\Tests; use Nyholm\Psr7\Uri; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseFactoryInterface; use Yiisoft\Di\Container; use Yiisoft\Di\ContainerConfig; use Yiisoft\Di\StateResetter; use Yiisoft\Router\CurrentRoute; +use Yiisoft\Router\MethodFailureHandler; +use Yiisoft\Router\MethodFailureHandlerInterface; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteCollectorInterface; @@ -26,9 +30,17 @@ public function testRouteCollector(): void $this->assertInstanceOf(RouteCollector::class, $routerCollector); } + public function testMethodFailureHandler(): void + { + $container = $this->createContainer('web'); + + $methodFailureHandler = $container->get(MethodFailureHandlerInterface::class); + $this->assertInstanceOf(MethodFailureHandler::class, $methodFailureHandler); + } + public function testCurrentRoute(): void { - $container = $this->createContainer(); + $container = $this->createContainer('web'); $currentRoute = $container->get(CurrentRoute::class); $currentRoute->setRouteWithArguments(Route::get('/main'), ['name' => 'hello']); @@ -43,18 +55,19 @@ public function testCurrentRoute(): void $this->assertSame([], $currentRoute->getArguments()); } - private function createContainer(): Container + private function createContainer(?string $postfix = null): Container { return new Container( - ContainerConfig::create()->withDefinitions( - $this->getContainerDefinitions() - ) + ContainerConfig::create()->withDefinitions([ + ResponseFactoryInterface::class => Psr17Factory::class, + ...$this->getDiConfig($postfix), + ]) ); } - private function getContainerDefinitions(): array + private function getDiConfig(?string $postfix = null): array { $params = require dirname(__DIR__) . '/config/params.php'; - return require dirname(__DIR__) . '/config/di.php'; + return require dirname(__DIR__) . '/config/di' . ($postfix !== null ? '-' . $postfix : '') . '.php'; } } diff --git a/tests/Middleware/MethodFailureHandlerTest.php b/tests/Middleware/MethodFailureHandlerTest.php new file mode 100644 index 00000000..586815a3 --- /dev/null +++ b/tests/Middleware/MethodFailureHandlerTest.php @@ -0,0 +1,56 @@ +createHandler() + ->handle($this->createRequest(Method::OPTIONS), ['GET', 'HEAD']); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + } + + public function testShouldReturnCode405(): void + { + $response = $this + ->createHandler() + ->handle($this->createRequest(Method::POST), ['GET', 'HEAD']); + + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + } + + public function testThrownExceptionWithEmptyMethods(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Allowed methods can't be empty array."); + + $response = $this + ->createHandler() + ->handle($this->createRequest(), []); + } + + private function createHandler(): MethodFailureHandler + { + return new MethodFailureHandler(new Psr17Factory()); + } + + private function createRequest(string $method = Method::GET, string $uri = '/'): ServerRequestInterface + { + return new ServerRequest($method, $uri); + } +} diff --git a/tests/Middleware/RouterTest.php b/tests/Middleware/RouterTest.php index 2ec3efa2..21f0a691 100644 --- a/tests/Middleware/RouterTest.php +++ b/tests/Middleware/RouterTest.php @@ -18,6 +18,8 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Group; use Yiisoft\Router\MatchingResult; +use Yiisoft\Router\MethodFailureHandler; +use Yiisoft\Router\MethodFailureHandlerInterface; use Yiisoft\Router\Middleware\Router; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; @@ -43,30 +45,65 @@ public function testMissingRouteRespondWith404(): void $this->assertSame(404, $response->getStatusCode()); } - public function testMethodMismatchRespondWith405(): void + public function testWithoutMethodFailureHandlerRespondWith404(): void { $request = new ServerRequest('POST', '/'); $response = $this->processWithRouter($request); - $this->assertSame(405, $response->getStatusCode()); - $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + $this->assertSame(404, $response->getStatusCode()); } - public function testAutoResponseOptions(): void + public function testDefaultFailureHandlerResponseOptions(): void { $request = new ServerRequest('OPTIONS', '/'); - $response = $this->processWithRouter($request); + $response = $this + ->createRouterMiddleware(methodFailureHandler: $this->creatDefaultMethodFailureHandler()) + ->process($request, $this->createRequestHandler()); $this->assertSame(204, $response->getStatusCode()); $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); } - public function testAutoResponseOptionsWithOrigin(): void + public function testDefaultFailureHandlerResponseOptionsWithOrigin(): void { $request = new ServerRequest('OPTIONS', 'http://test.local/', ['Origin' => 'http://test.com']); - $response = $this->processWithRouter($request); + $response = $this + ->createRouterMiddleware(methodFailureHandler: $this->creatDefaultMethodFailureHandler()) + ->process($request, $this->createRequestHandler()); $this->assertSame(204, $response->getStatusCode()); $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); } + public function testCustomFailureHandlerResponseOptions(): void + { + $request = new ServerRequest('OPTIONS', '/'); + $response = $this + ->createRouterMiddleware(methodFailureHandler: $this->createMethodFailureHandler(200)) + ->process($request, $this->createRequestHandler()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + $this->assertSame('JGURDA', $response->getHeaderLine('X-JGURDA')); + } + + public function testDefaultFailureHandlerRespondWith405(): void + { + $request = new ServerRequest('POST', '/'); + $response = $this + ->createRouterMiddleware(methodFailureHandler: $this->creatDefaultMethodFailureHandler()) + ->process($request, $this->createRequestHandler()); + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + } + + public function testCustomFailureHandlerRespondWith400(): void + { + $request = new ServerRequest('POST', '/'); + $response = $this + ->createRouterMiddleware(methodFailureHandler: $this->createMethodFailureHandler(400)) + ->process($request, $this->createRequestHandler()); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + $this->assertSame('JGURDA', $response->getHeaderLine('X-JGURDA')); + } + public function testWithCorsHandlers(): void { $group = Group::create() @@ -265,6 +302,7 @@ public function createResponse(int $code = 200, string $reasonPhrase = ''): Resp private function createRouterMiddleware( ?RouteCollectionInterface $routeCollection = null, ?CurrentRoute $currentRoute = null, + ?MethodFailureHandlerInterface $methodFailureHandler = null, array $containerDefinitions = [], ): Router { $container = new SimpleContainer( @@ -276,9 +314,10 @@ private function createRouterMiddleware( return new Router( $this->getMatcher($routeCollection), - new Psr17Factory(), new MiddlewareFactory($container), - $currentRoute ?? new CurrentRoute() + $currentRoute ?? new CurrentRoute(), + $methodFailureHandler, + null ); } @@ -289,7 +328,7 @@ private function processWithRouter( array $containerDefinitions = [], ): ResponseInterface { return $this - ->createRouterMiddleware($routes, $currentRoute, $containerDefinitions) + ->createRouterMiddleware($routes, $currentRoute, containerDefinitions: $containerDefinitions) ->process($request, $this->createRequestHandler()); } @@ -307,4 +346,25 @@ private function createRouteMiddleware(): callable { return static fn () => new Response(201); } + + private function creatDefaultMethodFailureHandler(): MethodFailureHandler + { + return new MethodFailureHandler(new Psr17Factory()); + } + + private function createMethodFailureHandler(int $code): MethodFailureHandlerInterface + { + return new class ($code) implements MethodFailureHandlerInterface { + public function __construct(private readonly int $code) + { + } + + public function handle(ServerRequestInterface $request, array $allowedMethods): ResponseInterface + { + return (new Response($this->code)) + ->withHeader('Allow', implode(', ', $allowedMethods)) + ->withHeader('X-JGURDA', 'JGURDA'); + } + }; + } }