Skip to content

Commit e7c631d

Browse files
committed
Issue #411: Updated ContentNegotiationMiddleware logic
Signed-off-by: alexmerlin <[email protected]>
1 parent 668a665 commit e7c631d

File tree

6 files changed

+276
-137
lines changed

6 files changed

+276
-137
lines changed

config/autoload/content-negotiation.global.php

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,24 @@
22

33
declare(strict_types=1);
44

5+
use Api\App\Middleware\ContentNegotiationMiddleware;
6+
57
return [
68
'content-negotiation' => [
7-
'default' => [ // default to any route if not configured above
8-
'Accept' => [ // the Accept is what format the server can send back
9+
// default to any route if not configured below
10+
ContentNegotiationMiddleware::DEFAULT_HEADERS => [
11+
// the Accept is what format the server can send back
12+
'Accept' => [
913
'application/json',
1014
'application/hal+json',
1115
],
12-
'Content-Type' => [ // the Content-Type is what format the server can process
16+
// the Content-Type is what format the server can process
17+
'Content-Type' => [
1318
'application/json',
1419
'application/hal+json',
1520
],
1621
],
17-
'your.route.name' => [
18-
'Accept' => [],
19-
'Content-Type' => [],
20-
],
21-
'user::create-account-avatar' => [
22+
'user::create-account-avatar' => [
2223
'Accept' => [
2324
'application/json',
2425
'application/hal+json',
@@ -27,7 +28,7 @@
2728
'multipart/form-data',
2829
],
2930
],
30-
'user::create-user-avatar' => [
31+
'user::create-user-avatar' => [
3132
'Accept' => [
3233
'application/json',
3334
'application/hal+json',
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Api\App\Exception;
6+
7+
use Exception;
8+
use Fig\Http\Message\StatusCodeInterface;
9+
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
10+
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
11+
12+
class NotAcceptableException extends Exception implements ProblemDetailsExceptionInterface
13+
{
14+
use CommonProblemDetailsExceptionTrait;
15+
16+
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
17+
{
18+
$exception = new self();
19+
20+
$exception->type = $type;
21+
$exception->detail = $detail;
22+
$exception->status = StatusCodeInterface::STATUS_NOT_ACCEPTABLE;
23+
$exception->title = $title;
24+
$exception->additional = $additional;
25+
26+
return $exception;
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Api\App\Exception;
6+
7+
use Exception;
8+
use Fig\Http\Message\StatusCodeInterface;
9+
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
10+
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
11+
12+
class UnsupportedMediaTypeException extends Exception implements ProblemDetailsExceptionInterface
13+
{
14+
use CommonProblemDetailsExceptionTrait;
15+
16+
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
17+
{
18+
$exception = new self();
19+
20+
$exception->type = $type;
21+
$exception->detail = $detail;
22+
$exception->status = StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE;
23+
$exception->title = $title;
24+
$exception->additional = $additional;
25+
26+
return $exception;
27+
}
28+
}

src/App/src/Middleware/ContentNegotiationMiddleware.php

Lines changed: 155 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace Api\App\Middleware;
66

7+
use Api\App\Exception\NotAcceptableException;
8+
use Api\App\Exception\UnsupportedMediaTypeException;
9+
use Core\App\Message;
710
use Dot\DependencyInjection\Attribute\Inject;
811
use Fig\Http\Message\StatusCodeInterface;
912
use Laminas\Diactoros\Response\JsonResponse;
@@ -13,26 +16,35 @@
1316
use Psr\Http\Server\MiddlewareInterface;
1417
use Psr\Http\Server\RequestHandlerInterface;
1518

16-
use function array_filter;
17-
use function array_intersect;
18-
use function array_map;
19+
use function count;
1920
use function explode;
2021
use function in_array;
2122
use function is_array;
23+
use function preg_match;
2224
use function str_contains;
23-
use function strtok;
25+
use function str_ends_with;
26+
use function str_starts_with;
27+
use function strpos;
28+
use function substr;
2429
use function trim;
30+
use function usort;
2531

26-
readonly class ContentNegotiationMiddleware implements MiddlewareInterface
32+
class ContentNegotiationMiddleware implements MiddlewareInterface
2733
{
34+
public const DEFAULT_HEADERS = 'default';
35+
2836
#[Inject(
2937
'config.content-negotiation',
3038
)]
3139
public function __construct(
32-
private array $config,
40+
private readonly array $config,
3341
) {
3442
}
3543

44+
/**
45+
* @throws NotAcceptableException
46+
* @throws UnsupportedMediaTypeException
47+
*/
3648
public function process(
3749
ServerRequestInterface $request,
3850
RequestHandlerInterface $handler
@@ -44,92 +56,180 @@ public function process(
4456

4557
$routeName = (string) $routeResult->getMatchedRouteName();
4658

47-
$accept = $this->formatAcceptRequest($request->getHeaderLine('Accept'));
48-
if (! $this->checkAccept($routeName, $accept)) {
49-
return $this->notAcceptableResponse('Not Acceptable');
59+
// Parse Accept header including quality values
60+
$acceptedTypes = $this->parseAcceptHeader($request->getHeaderLine('Accept'));
61+
if (count($acceptedTypes) === 0) {
62+
// If no Accept header is provided, assume a wildcard
63+
$acceptedTypes = [['mediaType' => '*/*', 'quality' => 1.0]];
5064
}
5165

52-
$contentType = $request->getHeaderLine('Content-Type');
53-
if (! $this->checkContentType($routeName, $contentType)) {
54-
return $this->unsupportedMediaTypeResponse('Unsupported Media Type');
66+
$supportedTypes = $this->getConfiguredTypes($routeName, 'Accept');
67+
if (! $this->isAcceptable($acceptedTypes, $supportedTypes)) {
68+
throw NotAcceptableException::create(Message::notAcceptable($supportedTypes));
5569
}
5670

57-
$response = $handler->handle($request);
58-
59-
$responseContentType = $response->getHeaderLine('Content-Type');
71+
$contentTypeHeader = $request->getHeaderLine('Content-Type');
72+
if (! empty($contentTypeHeader)) {
73+
$contentType = $this->parseContentTypeHeader($contentTypeHeader);
74+
$acceptableContentTypes = $this->getConfiguredTypes($routeName, 'Content-Type');
75+
if (! $this->isContentTypeSupported($contentType, $acceptableContentTypes)) {
76+
throw UnsupportedMediaTypeException::create(Message::unsupportedMediaType($acceptableContentTypes));
77+
}
78+
}
6079

61-
if (! $this->validateResponseContentType($responseContentType, $accept)) {
62-
return $this->notAcceptableResponse('Unable to resolve Accept header to a representation');
80+
$response = $handler->handle($request);
81+
if (! $this->isResponseContentTypeValid($response->getHeaderLine('Content-Type'), $acceptedTypes)) {
82+
throw NotAcceptableException::create('Unable to provide content in any of the accepted formats.');
6383
}
6484

6585
return $response;
6686
}
6787

68-
public function formatAcceptRequest(string $accept): array
88+
private function parseAcceptHeader(string $header): array
6989
{
70-
$accept = array_map(
71-
fn ($item): string => trim((string) strtok($item, ';')),
72-
explode(',', $accept)
73-
);
90+
if (empty($header)) {
91+
return [];
92+
}
7493

75-
return array_filter($accept);
94+
$types = [];
95+
$parts = explode(',', $header);
96+
97+
foreach ($parts as $part) {
98+
$part = trim($part);
99+
$quality = 1.0;
100+
$mediaType = $part;
101+
if (str_contains($part, ';')) {
102+
[$mediaType, $parameters] = explode(';', $part, 2);
103+
104+
$mediaType = trim($mediaType);
105+
106+
// Extract quality value if present
107+
if (preg_match('/q=([0-9]*\.?[0-9]+)/', $parameters, $matches)) {
108+
$quality = (float) $matches[1];
109+
}
110+
}
111+
112+
// Skip empty media types
113+
if (empty($mediaType)) {
114+
continue;
115+
}
116+
117+
$types[] = [
118+
'mediaType' => $mediaType,
119+
'quality' => $quality,
120+
];
121+
}
122+
123+
// Sort by quality in descending order
124+
usort($types, fn ($a, $b) => $b['quality'] <=> $a['quality']);
125+
126+
return $types;
76127
}
77128

78-
public function checkAccept(string $routeName, array $accept): bool
129+
private function parseContentTypeHeader(string $header): array
79130
{
80-
if (in_array('*/*', $accept, true)) {
81-
return true;
131+
if (empty($header)) {
132+
return [];
82133
}
83134

84-
$acceptList = $this->config['default']['Accept'] ?? [];
85-
if (! empty($this->config[$routeName]['Accept'])) {
86-
$acceptList = $this->config[$routeName]['Accept'] ?? [];
87-
}
135+
$parts = explode(';', $header);
88136

89-
if (is_array($acceptList)) {
90-
return ! empty(array_intersect($accept, $acceptList));
91-
} else {
92-
return in_array($acceptList, $accept, true);
137+
$params = [];
138+
for ($i = 1; $i < count($parts); $i++) {
139+
$paramParts = explode('=', $parts[$i], 2);
140+
if (count($paramParts) === 2) {
141+
$params[trim($paramParts[0])] = trim($paramParts[1], ' "\'');
142+
}
93143
}
144+
145+
return [
146+
'mediaType' => trim($parts[0]),
147+
'parameters' => $params,
148+
];
94149
}
95150

96-
public function checkContentType(string $routeName, string $contentType): bool
151+
private function getConfiguredTypes(string $routeName, string $headerType): array
97152
{
98-
if (empty($contentType)) {
99-
return true;
153+
$types = $this->config[self::DEFAULT_HEADERS][$headerType] ?? [];
154+
if (! empty($this->config[$routeName][$headerType])) {
155+
$types = $this->config[$routeName][$headerType];
100156
}
101157

102-
$contentType = explode(';', $contentType);
158+
return is_array($types) ? $types : [$types];
159+
}
103160

104-
$acceptList = $this->config['default']['Content-Type'] ?? [];
105-
if (! empty($this->config[$routeName]['Content-Type'])) {
106-
$acceptList = $this->config[$routeName]['Content-Type'] ?? [];
161+
private function isAcceptable(array $acceptedTypes, array $supportedTypes): bool
162+
{
163+
foreach ($acceptedTypes as $accept) {
164+
// Wildcard accept
165+
if ($accept['mediaType'] === '*/*') {
166+
return true;
167+
}
168+
169+
// Type a wildcard like image/*
170+
if (str_ends_with($accept['mediaType'], '/*')) {
171+
$prefix = substr($accept['mediaType'], 0, strpos($accept['mediaType'], '/*'));
172+
foreach ($supportedTypes as $supported) {
173+
if (str_starts_with($supported, $prefix . '/')) {
174+
return true;
175+
}
176+
}
177+
}
178+
179+
// Direct match
180+
if (in_array($accept['mediaType'], $supportedTypes, true)) {
181+
return true;
182+
}
107183
}
108184

109-
if (is_array($acceptList)) {
110-
return ! empty(array_intersect($contentType, $acceptList));
111-
} else {
112-
return in_array($acceptList, $contentType, true);
113-
}
185+
return false;
114186
}
115187

116-
public function validateResponseContentType(?string $contentType, array $accept): bool
188+
private function isContentTypeSupported(array $contentType, array $supportedTypes): bool
117189
{
118-
if (in_array('*/*', $accept, true)) {
190+
if (empty($contentType)) {
119191
return true;
120192
}
121193

122-
if (null === $contentType) {
123-
return false;
124-
}
194+
return in_array($contentType['mediaType'], $supportedTypes, true);
195+
}
125196

126-
$accept = array_map(fn (string $item): string => str_contains($item, 'json') ? 'json' : $item, $accept);
197+
private function isResponseContentTypeValid(?string $responseType, array $acceptedTypes): bool
198+
{
199+
if (empty($responseType)) {
200+
return true;
201+
}
127202

128-
if (str_contains($contentType, 'json')) {
129-
$contentType = 'json';
203+
// Parse response content type to handle parameters
204+
$parts = explode(';', $responseType);
205+
$mediaType = trim($parts[0]);
206+
207+
// Check for wildcard accept
208+
foreach ($acceptedTypes as $accept) {
209+
if ($accept['mediaType'] === '*/*') {
210+
return true;
211+
}
212+
213+
// Type a wildcard like image/*
214+
if (str_ends_with($accept['mediaType'], '/*')) {
215+
$prefix = substr($accept['mediaType'], 0, strpos($accept['mediaType'], '/*'));
216+
if (str_starts_with($mediaType, $prefix . '/')) {
217+
return true;
218+
}
219+
}
220+
221+
// Handle +json suffix matching
222+
if (str_ends_with($mediaType, '+json') && str_ends_with($accept['mediaType'], '+json')) {
223+
return true;
224+
}
225+
226+
// Direct match
227+
if ($mediaType === $accept['mediaType']) {
228+
return true;
229+
}
130230
}
131231

132-
return in_array($contentType, $accept, true);
232+
return false;
133233
}
134234

135235
public function notAcceptableResponse(string $message): ResponseInterface

0 commit comments

Comments
 (0)