Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add options to control method failure handling in the Router middleware #250

Open
wants to merge 12 commits into
base: 3.x
Choose a base branch
from
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
134 changes: 114 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some examples or explanation are needed, since it is not clear how exactly it responds. It's documented below but I think it could be moved here somehow.

- 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;
Expand Down
21 changes: 21 additions & 0 deletions src/MethodsResponseFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Router;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* `MethodsResponseFactoryInterface` produces a response with a list of the target resource's supported methods.
*/
interface MethodsResponseFactoryInterface
{
/**
* Produces a response listing resource's allowed methods.
*
* @param array $methods a list of the HTTP methods supported by the request's resource
*/
public function create(array $methods, ServerRequestInterface $request): ResponseInterface;
}
63 changes: 54 additions & 9 deletions src/Middleware/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\Http\Header;
use Yiisoft\Http\Method;
use Yiisoft\Http\Status;
use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher;
use Yiisoft\Middleware\Dispatcher\MiddlewareFactory;
use Yiisoft\Router\CurrentRoute;
use Yiisoft\Router\MethodsResponseFactoryInterface;
use Yiisoft\Router\UrlMatcherInterface;

final class Router implements MiddlewareInterface
{
private MiddlewareDispatcher $dispatcher;
private bool $ignoreMethodFailureHandler = false;
private ?MethodsResponseFactoryInterface $optionsResponseFactory = null;
private ?MethodsResponseFactoryInterface $notAllowedResponseFactory = null;

public function __construct(
private UrlMatcherInterface $matcher,
Expand All @@ -37,15 +42,10 @@ 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 (!$this->ignoreMethodFailureHandler && $result->isMethodFailure()) {
return $request->getMethod() === Method::OPTIONS
? $this->getOptionsResponse($request, $result->methods())
: $this->getMethodNotAllowedResponse($request, $result->methods());
}

if (!$result->isSuccess()) {
Expand All @@ -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));
Comment on lines +90 to +92
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract it to new response factory class and use it by default for optionsResponseFactory.

}

/**
* @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));
Comment on lines +102 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract it to new response factory class and use it by default for notAllowedResponseFactory.

}
}
Loading
Loading