Skip to content
Closed
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 1.1.1 under development

- no changes in this release.
- New #16: Add `ContentNegotiatorMiddleware` (@vjik)

## 1.1.0 June 09, 2025

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ HTTP features:

- [`ContentLengthMiddleware`](docs/guide/en/content-length-middleware.md) — manages the `Content-Length` header in
the response;
- [`ContentNegotiatorMiddleware`](docs/guide/en/content-negotiator-middleware.md) — performs content negotiation by
delegating request handling to specific middlewares based on the `Accept` header;
- [`CorsAllowAllMiddleware`](docs/guide/en/cors-allow-all-middleware.md) — adds
[CORS](https://developer.mozilla.org/docs/Web/HTTP/Guides/CORS) headers allowing any request origins in later
requests;
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0"
"psr/http-server-middleware": "^1.0",
"yiisoft/http": "^1.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.92",
Expand Down
1 change: 1 addition & 0 deletions docs/guide/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The package provides a collection of [PSR-15](https://www.php-fig.org/psr/psr-15
HTTP features. All middleware implements independent functionality and doesn't interact with each other in any way.

- [`ContentLengthMiddleware`](content-length-middleware.md)
- [`ContentNegotiatorMiddleware`](content-negotiator-middleware.md)
- [`CorsAllowAllMiddleware`](cors-allow-all-middleware.md)
- [`ForceSecureConnectionMiddleware`](force-secure-connection-middleware.md)
- [`HeadRequestMiddleware`](head-request-middleware.md)
Expand Down
105 changes: 105 additions & 0 deletions docs/guide/en/content-negotiator-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# `ContentNegotiatorMiddleware`

A middleware that performs [content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation)
by delegating request handling to specific middlewares based on the `Accept` header. This allows different request
processing pipelines based on the client's preferred content type.

The middleware is useful when you need to handle different content types (JSON, XML, HTML, etc.) with different
processing logic or formatters.

General usage:

```php
use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware;

$middleware = new ContentNegotiatorMiddleware([
'application/json' => $jsonMiddleware,
'application/xml' => $xmlMiddleware,
'text/html' => $htmlMiddleware,
]);
```

## How it works

1. The middleware examines the `Accept` header from the incoming request and sorts the accepted types by their quality
values (q-parameter), with higher quality values taking precedence.
2. It iterates through the sorted accept types (highest quality first).
3. For each accept type, it checks all configured middlewares to find a matching content type using substring matching.
4. When it finds a match, it delegates the request to the corresponding middleware.
5. If no match is found and a fallback is configured (middleware or request handler), the request is delegated to it.
6. If no match is found and no fallback is configured, the request is passed to the next handler
in the pipeline without any special processing.

## Constructor parameters

### `$middlewares` (required)

Type: `array<string, MiddlewareInterface>`

A map of content types to middleware instances. The array key is the content type string (e.g., `'application/json'`),
and the value is an instance of `Psr\Http\Server\MiddlewareInterface`.

Example:

```php
use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware;

$middleware = new ContentNegotiatorMiddleware([
'application/json' => new JsonResponseMiddleware(),
'application/xml' => new XmlResponseMiddleware(),
]);

// Request with Accept: application/json -> JsonResponseMiddleware will be used
// Request with Accept: application/xml -> XmlResponseMiddleware will be used
// Request with Accept: text/html -> passed to next handler (no match)
```

### `$fallback` (optional)

Type: `MiddlewareInterface|RequestHandlerInterface|null`

Default: `null`

A middleware or request handler to use when no content type matches the client's `Accept` header.
If it is a `MiddlewareInterface`, the request is passed through it together with the next handler in the pipeline.
If it is a `RequestHandlerInterface`, the request is passed directly to it.
If `null`, the request is passed to the next handler in the pipeline.

Example:

```php
use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware;

$middleware = new ContentNegotiatorMiddleware(
middlewares: [
'application/json' => new JsonResponseMiddleware(),
'application/xml' => new XmlResponseMiddleware(),
],
fallback: new HtmlResponseMiddleware(),
);

// Request with Accept: application/json -> JsonResponseMiddleware will be used
// Request with Accept: text/html -> HtmlResponseMiddleware will be used (fallback)
// Request with Accept: */* -> HtmlResponseMiddleware will be used (fallback)
```

## Handling multiple accept values

When a client sends multiple content types in the `Accept` header (e.g., `Accept: text/html, application/json;q=0.9`),
the middleware sorts them by quality values and processes them in order of preference. Higher quality values are
processed first.

```php
use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware;

$middleware = new ContentNegotiatorMiddleware([
'application/json' => new JsonFormatterMiddleware(),
'application/xml' => new XmlFormatterMiddleware(),
]);

// Request with Accept: application/json;q=0.5, application/xml;q=0.9
// Will use XmlFormatterMiddleware (xml has higher quality value)

// Request with Accept: application/json, application/xml
// Will use JsonFormatterMiddleware (both have default q=1.0, first in Accept header wins)
```
90 changes: 90 additions & 0 deletions src/ContentNegotiatorMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use Yiisoft\Http\HeaderValueHelper;

use function gettype;
use function is_string;
use function sprintf;
use function str_contains;

/**
* Content negotiation by delegating request handling to specific middlewares based on the `Accept` header.
*
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
*/
final class ContentNegotiatorMiddleware implements MiddlewareInterface
{
/**
* @param array $middlewares The array key is the content type, and the value is an instance of
* {@see MiddlewareInterface}.
* @param MiddlewareInterface|RequestHandlerInterface|null $fallback The middleware or request handler to use when
* no content type matches. If `null`, the request is passed to the next handler.
*
* @psalm-param array<string, MiddlewareInterface> $middlewares
*/
public function __construct(
private readonly array $middlewares,
private readonly MiddlewareInterface|RequestHandlerInterface|null $fallback = null,
) {
$this->checkMiddlewares($this->middlewares);
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$accepted = HeaderValueHelper::getSortedAcceptTypes(
$request->getHeader('Accept'),
);

foreach ($accepted as $accept) {
foreach ($this->middlewares as $contentType => $middleware) {
if (str_contains($accept, $contentType)) {
return $middleware->process($request, $handler);
}
}
}

return match (true) {
$this->fallback instanceof MiddlewareInterface => $this->fallback->process($request, $handler),
$this->fallback instanceof RequestHandlerInterface => $this->fallback->handle($request),
default => $handler->handle($request),
};
}

/**
* Checks the content middlewares.
*
* @param array $middlewares The content middlewares to check.
*/
private function checkMiddlewares(array $middlewares): void
{
foreach ($middlewares as $contentType => $middleware) {
if (!is_string($contentType)) {
throw new RuntimeException(
sprintf(
'Invalid middleware content type. A string is expected, "%s" is received.',
gettype($contentType),
),
);
}

if (!($middleware instanceof MiddlewareInterface)) {
throw new RuntimeException(
sprintf(
'Invalid middleware. A "%s" instance is expected, "%s" is received.',
MiddlewareInterface::class,
get_debug_type($middleware),
),
);
}
}
}
}
28 changes: 28 additions & 0 deletions src/NotAcceptableRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware;

use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\Http\Status;

/**
* Returns a 406 Not Acceptable response.
*
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
*/
final class NotAcceptableRequestHandler implements RequestHandlerInterface
{
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
) {}

public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->responseFactory->createResponse(Status::NOT_ACCEPTABLE);
}
}
Loading
Loading