diff --git a/CHANGELOG.md b/CHANGELOG.md index b786f22..b157827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1a3c9e9..a8f75c9 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/composer.json b/composer.json index 4ce40c4..ae40b59 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index bf7cddb..a2a7cea 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -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) diff --git a/docs/guide/en/content-negotiator-middleware.md b/docs/guide/en/content-negotiator-middleware.md new file mode 100644 index 0000000..f8bf1e5 --- /dev/null +++ b/docs/guide/en/content-negotiator-middleware.md @@ -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` + +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) +``` diff --git a/src/ContentNegotiatorMiddleware.php b/src/ContentNegotiatorMiddleware.php new file mode 100644 index 0000000..cb54b99 --- /dev/null +++ b/src/ContentNegotiatorMiddleware.php @@ -0,0 +1,90 @@ + $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), + ), + ); + } + } + } +} diff --git a/src/NotAcceptableRequestHandler.php b/src/NotAcceptableRequestHandler.php new file mode 100644 index 0000000..55411b8 --- /dev/null +++ b/src/NotAcceptableRequestHandler.php @@ -0,0 +1,28 @@ +responseFactory->createResponse(Status::NOT_ACCEPTABLE); + } +} diff --git a/tests/ContentNegotiatorMiddlewareTest.php b/tests/ContentNegotiatorMiddlewareTest.php new file mode 100644 index 0000000..4af4973 --- /dev/null +++ b/tests/ContentNegotiatorMiddlewareTest.php @@ -0,0 +1,157 @@ +createServerRequest('GET', '/'); + if ($accept !== null) { + $request = $request->withHeader('Accept', $accept); + } + + $streamFactory = new StreamFactory(); + $handler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('base'))); + $jsonMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('json'))); + $xmlMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('xml'))); + + $middleware = new ContentNegotiatorMiddleware([ + 'application/json' => $jsonMiddleware, + 'application/xml' => $xmlMiddleware, + ]); + + $response = $middleware->process($request, $handler); + + assertSame($expectedBody, (string) $response->getBody()); + } + + public function testFallbackMiddlewareUsedWhenNoMatch(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('GET', '/') + ->withHeader('Accept', 'text/html'); + + $streamFactory = new StreamFactory(); + $handler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('base'))); + $jsonMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('json'))); + $fallbackMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('fallback'))); + + $middleware = new ContentNegotiatorMiddleware( + ['application/json' => $jsonMiddleware], + $fallbackMiddleware, + ); + + $response = $middleware->process($request, $handler); + + assertSame('fallback', (string) $response->getBody()); + } + + public function testFallbackMiddlewareNotUsedWhenMatch(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $streamFactory = new StreamFactory(); + $handler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('base'))); + $jsonMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('json'))); + $fallbackMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('fallback'))); + + $middleware = new ContentNegotiatorMiddleware( + ['application/json' => $jsonMiddleware], + $fallbackMiddleware, + ); + + $response = $middleware->process($request, $handler); + + assertSame('json', (string) $response->getBody()); + } + + public function testFallbackRequestHandlerUsedWhenNoMatch(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('GET', '/') + ->withHeader('Accept', 'text/html'); + + $streamFactory = new StreamFactory(); + $handler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('base'))); + $jsonMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('json'))); + $fallbackHandler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('fallback'))); + + $middleware = new ContentNegotiatorMiddleware( + ['application/json' => $jsonMiddleware], + $fallbackHandler, + ); + + $response = $middleware->process($request, $handler); + + assertSame('fallback', (string) $response->getBody()); + } + + public function testFallbackRequestHandlerNotUsedWhenMatch(): void + { + $request = (new ServerRequestFactory()) + ->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $streamFactory = new StreamFactory(); + $handler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('base'))); + $jsonMiddleware = new MiddlewareStub(new Response(body: $streamFactory->createStream('json'))); + $fallbackHandler = new FakeRequestHandler(new Response(body: $streamFactory->createStream('fallback'))); + + $middleware = new ContentNegotiatorMiddleware( + ['application/json' => $jsonMiddleware], + $fallbackHandler, + ); + + $response = $middleware->process($request, $handler); + + assertSame('json', (string) $response->getBody()); + } + + public function testInvalidContentTypeThrowsException(): void + { + $middleware = new MiddlewareStub(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid middleware content type. A string is expected, "integer" is received.'); + new ContentNegotiatorMiddleware([ + 123 => $middleware, + ]); + } + + public function testInvalidMiddlewareThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Invalid middleware. A "Psr\Http\Server\MiddlewareInterface" instance is expected, "string" is received.', + ); + new ContentNegotiatorMiddleware([ + 'application/json' => 'invalid', + ]); + } +} diff --git a/tests/Support/MiddlewareStub.php b/tests/Support/MiddlewareStub.php new file mode 100644 index 0000000..860f581 --- /dev/null +++ b/tests/Support/MiddlewareStub.php @@ -0,0 +1,22 @@ +response ?? $handler->handle($request); + } +}