diff --git a/CHANGELOG.md b/CHANGELOG.md index a37995d..83b0f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 3.1.1 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 factories for the method failure responses to the 'Router' middleware (@olegbaturin) ## 3.1.0 February 20, 2024 diff --git a/README.md b/README.md index 744a901..f73195d 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,127 @@ 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 + +To simplify usage in PSR-middleware based application, there is a ready to use `Yiisoft\Router\Middleware\Router` 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. +``` + +When a route matches router middleware executes handler middleware attached to the route. If there is no match, next +application middleware processes the request. + +### Automatic responses + +`Yiisoft\Router\Middleware\Router` middleware responds automatically to: +- `OPTIONS` requests; +- requests with methods that are not supported by the target resource. + +You can disable this behavior by calling the `Yiisoft\Router\Middleware\Router::ignoreMethodFailureHandler()` method: + +```php +use Yiisoft\Router\Middleware\Router; + +$routerMiddleware = new Router($router, $responseFactory, $middlewareFactory, $currentRoute); + +// Returns a new instance with the turned off method failure error handler. +$routerMiddleware = $routerMiddleware->ignoreMethodFailureHandler(); +``` + +or define the `Yiisoft\Router\Middleware\Router` configuration in the DI container: + +`config/common/di/router.php` + +```php +use Yiisoft\Router\Middleware\Router; + +return [ + Router::class => [ + 'ignoreMethodFailureHandler()' => [], + ], +]; +``` -By default, router responds automatically to OPTIONS requests based on the routes defined: +#### OPTIONS requests + +By default, `Yiisoft\Router\Middleware\Router` middleware responds to `OPTIONS` requests based on the routes defined: ``` 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): +You can setup a custom response factory by calling the `Yiisoft\Router\Middleware\Router::withOptionsResponseFactory()` method: + +```php +use Yiisoft\Router\Middleware\Router; + +$routerMiddleware = new Router($router, $responseFactory, $middlewareFactory, $currentRoute); +$optionsResponseFactory = new OptionsResponseFactory(); + +// Returns a new instance with the response factory. +$routerMiddleware = $routerMiddleware->withOptionsResponseFactory($optionsResponseFactory); +``` + +or define the `Yiisoft\Router\Middleware\Router` configuration in the DI container: + +`config/common/di/router.php` + +```php +use Yiisoft\Router\Middleware\Router; + +return [ + Router::class => [ + 'withOptionsResponseFactory()' => [Reference::to(OptionsResponseFactory::class)], + ], +]; +``` + + +#### Method not allowed + +By default, `Yiisoft\Router\Middleware\Router` middleware responds to requests with methods that are not supported by the target resource based on the routes defined: + +``` +HTTP/1.1 405 Method Not Allowed +Allow: GET, HEAD +``` + +You can setup a custom response factory by calling `Yiisoft\Router\Middleware\Router::withNotAllowedResponseFactory()` method: + +```php +use Yiisoft\Router\Middleware\Router; + +$routerMiddleware = new Router($router, $responseFactory, $middlewareFactory, $currentRoute); +$notAllowedResponseFactory = new NotAllowedResponseFactory(); + +// Returns a new instance with the response factory. +$routerMiddleware = $routerMiddleware->withNotAllowedResponseFactory($notAllowedResponseFactory); +``` + +or define the `Yiisoft\Router\Middleware\Router` configuration in the DI container: + +`config/common/di/router.php` + +```php +use Yiisoft\Router\Middleware\Router; + +return [ + Router::class => [ + 'withNotAllowedResponseFactory()' => [Reference::to(NotAllowedResponseFactory::class)], + ], +]; +``` + +### 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/src/MethodsResponseFactoryInterface.php b/src/MethodsResponseFactoryInterface.php new file mode 100644 index 0000000..fc708e7 --- /dev/null +++ b/src/MethodsResponseFactoryInterface.php @@ -0,0 +1,21 @@ +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 (!$this->ignoreMethodFailureHandler && $result->isMethodFailure()) { + return $request->getMethod() === Method::OPTIONS + ? $this->getOptionsResponse($request, $result->methods()) + : $this->getMethodNotAllowedResponse($request, $result->methods()); } if (!$result->isSuccess()) { @@ -58,4 +58,49 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface ->withDispatcher($this->dispatcher) ->process($request, $handler); } + + public function ignoreMethodFailureHandler(): self + { + $new = clone $this; + $new->ignoreMethodFailureHandler = true; + return $new; + } + + public function withOptionsResponseFactory(MethodsResponseFactoryInterface $optionsResponseFactory): self + { + $new = clone $this; + $new->optionsResponseFactory = $optionsResponseFactory; + return $new; + } + + public function withNotAllowedResponseFactory(MethodsResponseFactoryInterface $notAllowedResponseFactory): self + { + $new = clone $this; + $new->notAllowedResponseFactory = $notAllowedResponseFactory; + return $new; + } + + /** + * @param string[] $methods + */ + private function getOptionsResponse(ServerRequestInterface $request, array $methods): ResponseInterface + { + return $this->optionsResponseFactory !== null + ? $this->optionsResponseFactory->create($methods, $request) + : $this->responseFactory + ->createResponse(Status::NO_CONTENT) + ->withHeader(Header::ALLOW, implode(', ', $methods)); + } + + /** + * @param string[] $methods + */ + private function getMethodNotAllowedResponse(ServerRequestInterface $request, array $methods): ResponseInterface + { + return $this->notAllowedResponseFactory !== null + ? $this->notAllowedResponseFactory->create($methods, $request) + : $this->responseFactory + ->createResponse(Status::METHOD_NOT_ALLOWED) + ->withHeader(Header::ALLOW, implode(', ', $methods)); + } } diff --git a/tests/Middleware/RouterTest.php b/tests/Middleware/RouterTest.php index 13c7695..42df4cd 100644 --- a/tests/Middleware/RouterTest.php +++ b/tests/Middleware/RouterTest.php @@ -17,6 +17,7 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Group; use Yiisoft\Router\MatchingResult; +use Yiisoft\Router\MethodsResponseFactoryInterface; use Yiisoft\Router\Middleware\Router; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; @@ -50,6 +51,17 @@ public function testMethodMismatchRespondWith405(): void $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); } + public function testMethodMismatchFactoryRespondWith400(): void + { + $request = new ServerRequest('POST', '/'); + $response = $this + ->createRouterMiddleware() + ->withNotAllowedResponseFactory($this->createNotAllowedResponseFactory()) + ->process($request, $this->createRequestHandler()); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('test from options handler', $response->getHeaderLine('Test')); + } + public function testAutoResponseOptions(): void { $request = new ServerRequest('OPTIONS', '/'); @@ -58,6 +70,28 @@ public function testAutoResponseOptions(): void $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); } + public function testAutoResponseOptionsFactory(): void + { + $request = new ServerRequest('OPTIONS', '/'); + $response = $this + ->createRouterMiddleware() + ->withOptionsResponseFactory($this->createOptionsResponseFactory()) + ->process($request, $this->createRequestHandler()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('GET, HEAD', $response->getHeaderLine('Allow')); + $this->assertSame('test from options handler', $response->getHeaderLine('Test')); + } + + public function testIgnoreMethodFailureHandlerRespondWith404(): void + { + $request = new ServerRequest('POST', '/'); + $response = $this + ->createRouterMiddleware() + ->ignoreMethodFailureHandler() + ->process($request, $this->createRequestHandler()); + $this->assertSame(404, $response->getStatusCode()); + } + public function testAutoResponseOptionsWithOrigin(): void { $request = new ServerRequest('OPTIONS', 'http://test.local/', ['Origin' => 'http://test.com']); @@ -206,6 +240,15 @@ public function testRouteMiddleware(int $expectedCode, mixed $middleware): void $this->assertSame($expectedCode, $response->getStatusCode()); } + public function testImmutability(): void + { + $original = $this->createRouterMiddleware(); + + $this->assertNotSame($original, $original->ignoreMethodFailureHandler()); + $this->assertNotSame($original, $original->withNotAllowedResponseFactory($this->createNotAllowedResponseFactory())); + $this->assertNotSame($original, $original->withOptionsResponseFactory($this->createOptionsResponseFactory())); + } + private function getMatcher(?RouteCollectionInterface $routeCollection = null): UrlMatcherInterface { $middleware = $this->createRouteMiddleware(); @@ -262,9 +305,9 @@ public function createResponse(int $code = 200, string $reasonPhrase = ''): Resp } private function createRouterMiddleware( - ?RouteCollectionInterface $routeCollection = null, - ?CurrentRoute $currentRoute = null, - array $containerDefinitions = [], + RouteCollectionInterface $routeCollection = null, + CurrentRoute $currentRoute = null, + array $containerDefinitions = [] ): Router { $container = new SimpleContainer( array_merge( @@ -283,8 +326,8 @@ private function createRouterMiddleware( private function processWithRouter( ServerRequestInterface $request, - ?RouteCollectionInterface $routes = null, - ?CurrentRoute $currentRoute = null, + RouteCollectionInterface $routes = null, + CurrentRoute $currentRoute = null, array $containerDefinitions = [], ): ResponseInterface { return $this @@ -306,4 +349,27 @@ private function createRouteMiddleware(): callable { return static fn () => new Response(201); } + + private function createNotAllowedResponseFactory(): MethodsResponseFactoryInterface + { + return new class () implements MethodsResponseFactoryInterface { + public function create(array $methods, ServerRequestInterface $request): ResponseInterface + { + return (new Response(400)) + ->withHeader('Test', 'test from options handler'); + } + }; + } + + private function createOptionsResponseFactory(): MethodsResponseFactoryInterface + { + return new class () implements MethodsResponseFactoryInterface { + public function create(array $methods, ServerRequestInterface $request): ResponseInterface + { + return (new Response(200)) + ->withHeader('Allow', implode(', ', $methods)) + ->withHeader('Test', 'test from options handler'); + } + }; + } }