Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 84 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,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;
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -73,6 +73,7 @@
},
"config-plugin": {
"di": "di.php",
"di-web": "di-web.php",
"params": "params.php"
}
},
Expand Down
18 changes: 18 additions & 0 deletions config/di-web.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

use Yiisoft\Router\CurrentRoute;
use Yiisoft\Router\MethodFailureHandler;
use Yiisoft\Router\MethodFailureHandlerInterface;

return [
CurrentRoute::class => [
'reset' => function () {
$this->route = null;
$this->uri = null;
$this->arguments = [];
},
],
MethodFailureHandlerInterface::class => MethodFailureHandler::class,
];
8 changes: 0 additions & 8 deletions config/di.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
},
],
];
36 changes: 36 additions & 0 deletions src/MethodFailureHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Router;

use InvalidArgumentException;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Http\Header;
use Yiisoft\Http\Method;
use Yiisoft\Http\Status;

/**
* Default handler that produces a response with a list of the target resource's supported methods.
*/
final class MethodFailureHandler implements MethodFailureHandlerInterface
{
public function __construct(private readonly ResponseFactoryInterface $responseFactory)
{
}

public function handle(ServerRequestInterface $request, array $allowedMethods): ResponseInterface
{
if (empty($allowedMethods)) {
throw new InvalidArgumentException("Allowed methods can't be empty array.");
}

$status = $request->getMethod() === Method::OPTIONS ? Status::NO_CONTENT : Status::METHOD_NOT_ALLOWED;

return $this->responseFactory
->createResponse($status)
->withHeader(Header::ALLOW, implode(', ', $allowedMethods));
}
}
21 changes: 21 additions & 0 deletions src/MethodFailureHandlerInterface.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;

/**
* `MethodFailureHandlerInterface` produces a response with a list of the target resource's supported methods.
*/
interface MethodFailureHandlerInterface
{
/**
* Produces a response listing resource's allowed methods.
*
* @param string[] $allowedMethods a list of the HTTP methods supported by the request's resource
*/
public function handle(ServerRequestInterface $request, array $allowedMethods): ResponseInterface;
}
19 changes: 5 additions & 14 deletions src/Middleware/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
namespace Yiisoft\Router\Middleware;

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
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\MethodFailureHandlerInterface;
use Yiisoft\Router\UrlMatcherInterface;

final class Router implements MiddlewareInterface
Expand All @@ -23,10 +21,10 @@ final class Router implements MiddlewareInterface

public function __construct(
private readonly UrlMatcherInterface $matcher,
private readonly ResponseFactoryInterface $responseFactory,
MiddlewareFactory $middlewareFactory,
private readonly CurrentRoute $currentRoute,
?EventDispatcherInterface $eventDispatcher = null
private readonly ?MethodFailureHandlerInterface $methodFailureHandler,
?EventDispatcherInterface $eventDispatcher = null,
) {
$this->dispatcher = new MiddlewareDispatcher($middlewareFactory, $eventDispatcher);
}
Expand All @@ -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()) {
Expand Down
27 changes: 20 additions & 7 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']);
Expand All @@ -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';
}
}
Loading
Loading