From 92b4b6ecae2126a5a355356f353e457727c43e2d Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 16 Jan 2026 11:09:22 +0300 Subject: [PATCH 1/6] Add `ContentNegotiatorMiddleware` --- CHANGELOG.md | 2 +- README.md | 2 + composer.json | 3 +- docs/guide/en/README.md | 1 + .../guide/en/content-negotiator-middleware.md | 112 ++++++++++++++++++ src/ContentNegotiatorMiddleware.php | 88 ++++++++++++++ tests/ContentNegotiatorMiddlewareTest.php | 73 ++++++++++++ tests/Support/MiddlewareStub.php | 22 ++++ 8 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 docs/guide/en/content-negotiator-middleware.md create mode 100644 src/ContentNegotiatorMiddleware.php create mode 100644 tests/ContentNegotiatorMiddlewareTest.php create mode 100644 tests/Support/MiddlewareStub.php 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..d8e3589 --- /dev/null +++ b/docs/guide/en/content-negotiator-middleware.md @@ -0,0 +1,112 @@ +# `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, 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 JsonFormatterMiddleware(), + 'application/xml' => new XmlFormatterMiddleware(), + 'text/html' => new HtmlFormatterMiddleware(), +]); +``` + +## Usage examples + +### Basic content negotiation + +```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) +``` + +### 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 +// Request with Accept: text/html, application/json;q=0.9 +// Will use HtmlFormatterMiddleware (text/html has default q=1.0, higher than json's q=0.9) + +// 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) +``` + +### Content type with parameters + +The middleware uses substring matching, so it works with content types that have additional parameters: + +```php +// Request with Accept: application/json;charset=UTF-8 +// Will match 'application/json' and use JsonResponseMiddleware +``` + +## Validation + +The middleware validates the constructor parameters and throws a `RuntimeException` if: + +- A content type key is not a string +- A middleware value is not an instance of `MiddlewareInterface` + +```php +// This will throw RuntimeException +new ContentNegotiatorMiddleware([ + 123 => $middleware, // Invalid: key must be a string +]); + +// This will also throw RuntimeException +new ContentNegotiatorMiddleware([ + 'application/json' => 'invalid', // Invalid: value must be MiddlewareInterface +]); +``` diff --git a/src/ContentNegotiatorMiddleware.php b/src/ContentNegotiatorMiddleware.php new file mode 100644 index 0000000..df5d523 --- /dev/null +++ b/src/ContentNegotiatorMiddleware.php @@ -0,0 +1,88 @@ + + */ + private array $middlewares; + + /** + * @param array $middlewares The array key is the content type, and the value is an instance of + * {@see MiddlewareInterface}. + * + * @psalm-param array $middlewares + */ + public function __construct(array $middlewares) + { + $this->checkMiddlewares($middlewares); + $this->middlewares = $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 $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/tests/ContentNegotiatorMiddlewareTest.php b/tests/ContentNegotiatorMiddlewareTest.php new file mode 100644 index 0000000..d2d721f --- /dev/null +++ b/tests/ContentNegotiatorMiddlewareTest.php @@ -0,0 +1,73 @@ +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 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); + } +} From 8c3d1112296ada4404f3d90048f7c35dd76ea580 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 16 Jan 2026 11:12:21 +0300 Subject: [PATCH 2/6] fix --- .../guide/en/content-negotiator-middleware.md | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/docs/guide/en/content-negotiator-middleware.md b/docs/guide/en/content-negotiator-middleware.md index d8e3589..72f399c 100644 --- a/docs/guide/en/content-negotiator-middleware.md +++ b/docs/guide/en/content-negotiator-middleware.md @@ -69,44 +69,13 @@ $middleware = new ContentNegotiatorMiddleware([ ### 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. +the middleware sorts them by quality values and processes them in order of preference. Higher quality values are +processed first. ```php -// Request with Accept: text/html, application/json;q=0.9 -// Will use HtmlFormatterMiddleware (text/html has default q=1.0, higher than json's q=0.9) - // 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) ``` - -### Content type with parameters - -The middleware uses substring matching, so it works with content types that have additional parameters: - -```php -// Request with Accept: application/json;charset=UTF-8 -// Will match 'application/json' and use JsonResponseMiddleware -``` - -## Validation - -The middleware validates the constructor parameters and throws a `RuntimeException` if: - -- A content type key is not a string -- A middleware value is not an instance of `MiddlewareInterface` - -```php -// This will throw RuntimeException -new ContentNegotiatorMiddleware([ - 123 => $middleware, // Invalid: key must be a string -]); - -// This will also throw RuntimeException -new ContentNegotiatorMiddleware([ - 'application/json' => 'invalid', // Invalid: value must be MiddlewareInterface -]); -``` From 37e143a153310d4faa9a8d5ef48bcdb2c89533d9 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 4 Feb 2026 17:18:31 +0300 Subject: [PATCH 3/6] improve --- .../guide/en/content-negotiator-middleware.md | 48 ++++++++++++++----- src/ContentNegotiatorMiddleware.php | 20 ++++---- tests/ContentNegotiatorMiddlewareTest.php | 42 ++++++++++++++++ 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/docs/guide/en/content-negotiator-middleware.md b/docs/guide/en/content-negotiator-middleware.md index 72f399c..e0578fe 100644 --- a/docs/guide/en/content-negotiator-middleware.md +++ b/docs/guide/en/content-negotiator-middleware.md @@ -26,7 +26,9 @@ $middleware = new ContentNegotiatorMiddleware([ 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, the request is passed to the next handler in the pipeline without any special processing. +5. If no match is found and a fallback middleware is configured, the request is delegated to the fallback middleware. +6. If no match is found and no fallback middleware is configured, the request is passed to the next handler + in the pipeline without any special processing. ## Constructor parameters @@ -43,36 +45,56 @@ Example: use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware; $middleware = new ContentNegotiatorMiddleware([ - 'application/json' => new JsonFormatterMiddleware(), - 'application/xml' => new XmlFormatterMiddleware(), - 'text/html' => new HtmlFormatterMiddleware(), + '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) ``` -## Usage examples +### `$fallbackMiddleware` (optional) + +Type: `MiddlewareInterface|null` + +Default: `null` -### Basic content negotiation +A middleware to use when no content type matches the client's `Accept` header. If `null`, the request is passed +to the next handler in the pipeline. + +Example: ```php use Yiisoft\HttpMiddleware\ContentNegotiatorMiddleware; -$middleware = new ContentNegotiatorMiddleware([ - 'application/json' => new JsonResponseMiddleware(), - 'application/xml' => new XmlResponseMiddleware(), -]); +$middleware = new ContentNegotiatorMiddleware( + middlewares: [ + 'application/json' => new JsonResponseMiddleware(), + 'application/xml' => new XmlResponseMiddleware(), + ], + fallbackMiddleware: new HtmlResponseMiddleware(), +); // 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) +// Request with Accept: text/html -> HtmlResponseMiddleware will be used (fallback) +// Request with Accept: */* -> HtmlResponseMiddleware will be used (fallback) ``` -### Handling multiple accept values +## 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) diff --git a/src/ContentNegotiatorMiddleware.php b/src/ContentNegotiatorMiddleware.php index df5d523..38bf801 100644 --- a/src/ContentNegotiatorMiddleware.php +++ b/src/ContentNegotiatorMiddleware.php @@ -23,21 +23,19 @@ */ final class ContentNegotiatorMiddleware implements MiddlewareInterface { - /** - * @psalm-var array - */ - private array $middlewares; - /** * @param array $middlewares The array key is the content type, and the value is an instance of * {@see MiddlewareInterface}. + * @param MiddlewareInterface|null $fallbackMiddleware The middleware to use when no content type matches. + * If `null`, the request is passed to the next handler. * * @psalm-param array $middlewares */ - public function __construct(array $middlewares) - { - $this->checkMiddlewares($middlewares); - $this->middlewares = $middlewares; + public function __construct( + private readonly array $middlewares, + private readonly ?MiddlewareInterface $fallbackMiddleware = null, + ) { + $this->checkMiddlewares($this->middlewares); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -54,6 +52,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } } + if ($this->fallbackMiddleware !== null) { + return $this->fallbackMiddleware->process($request, $handler); + } + return $handler->handle($request); } diff --git a/tests/ContentNegotiatorMiddlewareTest.php b/tests/ContentNegotiatorMiddlewareTest.php index d2d721f..7cc68cd 100644 --- a/tests/ContentNegotiatorMiddlewareTest.php +++ b/tests/ContentNegotiatorMiddlewareTest.php @@ -49,6 +49,48 @@ public function testBase(string $expectedBody, ?string $accept): void 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 testInvalidContentTypeThrowsException(): void { $middleware = new MiddlewareStub(); From 21fe378dbd2989ad77c7d7387c7cfe68a6a6546d Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 4 Feb 2026 22:13:50 +0300 Subject: [PATCH 4/6] add request handler as fallback --- .../guide/en/content-negotiator-middleware.md | 16 +++---- src/ContentNegotiatorMiddleware.php | 16 +++---- tests/ContentNegotiatorMiddlewareTest.php | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/guide/en/content-negotiator-middleware.md b/docs/guide/en/content-negotiator-middleware.md index e0578fe..f8bf1e5 100644 --- a/docs/guide/en/content-negotiator-middleware.md +++ b/docs/guide/en/content-negotiator-middleware.md @@ -26,8 +26,8 @@ $middleware = new ContentNegotiatorMiddleware([ 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 middleware is configured, the request is delegated to the fallback middleware. -6. If no match is found and no fallback middleware is configured, the request is passed to the next handler +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 @@ -54,14 +54,16 @@ $middleware = new ContentNegotiatorMiddleware([ // Request with Accept: text/html -> passed to next handler (no match) ``` -### `$fallbackMiddleware` (optional) +### `$fallback` (optional) -Type: `MiddlewareInterface|null` +Type: `MiddlewareInterface|RequestHandlerInterface|null` Default: `null` -A middleware to use when no content type matches the client's `Accept` header. If `null`, the request is passed -to the next handler in the pipeline. +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: @@ -73,7 +75,7 @@ $middleware = new ContentNegotiatorMiddleware( 'application/json' => new JsonResponseMiddleware(), 'application/xml' => new XmlResponseMiddleware(), ], - fallbackMiddleware: new HtmlResponseMiddleware(), + fallback: new HtmlResponseMiddleware(), ); // Request with Accept: application/json -> JsonResponseMiddleware will be used diff --git a/src/ContentNegotiatorMiddleware.php b/src/ContentNegotiatorMiddleware.php index 38bf801..cb54b99 100644 --- a/src/ContentNegotiatorMiddleware.php +++ b/src/ContentNegotiatorMiddleware.php @@ -26,14 +26,14 @@ 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|null $fallbackMiddleware The middleware to use when no content type matches. - * If `null`, the request is passed to the next handler. + * @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 $middlewares */ public function __construct( private readonly array $middlewares, - private readonly ?MiddlewareInterface $fallbackMiddleware = null, + private readonly MiddlewareInterface|RequestHandlerInterface|null $fallback = null, ) { $this->checkMiddlewares($this->middlewares); } @@ -52,11 +52,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } } - if ($this->fallbackMiddleware !== null) { - return $this->fallbackMiddleware->process($request, $handler); - } - - return $handler->handle($request); + return match (true) { + $this->fallback instanceof MiddlewareInterface => $this->fallback->process($request, $handler), + $this->fallback instanceof RequestHandlerInterface => $this->fallback->handle($request), + default => $handler->handle($request), + }; } /** diff --git a/tests/ContentNegotiatorMiddlewareTest.php b/tests/ContentNegotiatorMiddlewareTest.php index 7cc68cd..4af4973 100644 --- a/tests/ContentNegotiatorMiddlewareTest.php +++ b/tests/ContentNegotiatorMiddlewareTest.php @@ -91,6 +91,48 @@ public function testFallbackMiddlewareNotUsedWhenMatch(): void 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(); From 45c36ffefa8126b53323c01c92230c70c10aefe7 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 4 Feb 2026 22:29:41 +0300 Subject: [PATCH 5/6] Add `NotAcceptableRequestHandler` to handle 406 responses --- src/NotAcceptableRequestHandler.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/NotAcceptableRequestHandler.php diff --git a/src/NotAcceptableRequestHandler.php b/src/NotAcceptableRequestHandler.php new file mode 100644 index 0000000..1f4bf0e --- /dev/null +++ b/src/NotAcceptableRequestHandler.php @@ -0,0 +1,29 @@ +responseFactory->createResponse(Status::NOT_ACCEPTABLE); + } +} From 312bf8ddd315cf7a7785c0273b513141b8c7df56 Mon Sep 17 00:00:00 2001 From: vjik <525501+vjik@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:30:21 +0000 Subject: [PATCH 6/6] Apply PHP CS Fixer and Rector changes (CI) --- src/NotAcceptableRequestHandler.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NotAcceptableRequestHandler.php b/src/NotAcceptableRequestHandler.php index 1f4bf0e..55411b8 100644 --- a/src/NotAcceptableRequestHandler.php +++ b/src/NotAcceptableRequestHandler.php @@ -19,8 +19,7 @@ final class NotAcceptableRequestHandler implements RequestHandlerInterface { public function __construct( private readonly ResponseFactoryInterface $responseFactory, - ) { - } + ) {} public function handle(ServerRequestInterface $request): ResponseInterface {