Skip to content

Commit d3ad402

Browse files
Merge branch '7.4' into 8.0
* 7.4: [Translation][Lokalise] fix "Project too big for sync export" [DependencyInjection] Fix lazy proxy creation for interfaces aliased to final classes [HttpKernel] Fix StreamedResponse with chunks support in HttpKernelBrowser [HttpFoundation] Fix AcceptHeader overwrites items with different parameters [JsonStreamer] Rebuild cache on class update [Routing] Fix default value not taken if usigng name:entity.attribute [Mime] Remove unused variable in Email::prepareParts [DependencyInjection] Fix merging explicit tags and #[AsTaggeditem]
2 parents 86b949a + cdc7f21 commit d3ad402

File tree

3 files changed

+475
-15
lines changed

3 files changed

+475
-15
lines changed

AcceptHeader.php

Lines changed: 170 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class_exists(AcceptHeaderItem::class);
2525
class AcceptHeader
2626
{
2727
/**
28-
* @var AcceptHeaderItem[]
28+
* @var array<string, AcceptHeaderItem>
2929
*/
3030
private array $items = [];
3131

@@ -73,15 +73,35 @@ public function __toString(): string
7373
*/
7474
public function has(string $value): bool
7575
{
76-
return isset($this->items[$value]);
76+
$canonicalKey = $this->getCanonicalKey(AcceptHeaderItem::fromString($value));
77+
78+
return isset($this->items[$canonicalKey]);
7779
}
7880

7981
/**
8082
* Returns given value's item, if exists.
8183
*/
8284
public function get(string $value): ?AcceptHeaderItem
8385
{
84-
return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null;
86+
$queryItem = AcceptHeaderItem::fromString($value.';q=1');
87+
$canonicalKey = $this->getCanonicalKey($queryItem);
88+
89+
if (isset($this->items[$canonicalKey])) {
90+
return $this->items[$canonicalKey];
91+
}
92+
93+
// Collect and filter matching candidates
94+
if (!$candidates = array_filter($this->items, fn (AcceptHeaderItem $item) => $this->matches($item, $queryItem))) {
95+
return null;
96+
}
97+
98+
usort($candidates, fn ($a, $b) =>
99+
$this->getSpecificity($b, $queryItem) <=> $this->getSpecificity($a, $queryItem) // Descending specificity
100+
?: $b->getQuality() <=> $a->getQuality() // Descending quality
101+
?: $a->getIndex() <=> $b->getIndex() // Ascending index (stability)
102+
);
103+
104+
return reset($candidates);
85105
}
86106

87107
/**
@@ -91,7 +111,7 @@ public function get(string $value): ?AcceptHeaderItem
91111
*/
92112
public function add(AcceptHeaderItem $item): static
93113
{
94-
$this->items[$item->getValue()] = $item;
114+
$this->items[$this->getCanonicalKey($item)] = $item;
95115
$this->sorted = false;
96116

97117
return $this;
@@ -114,7 +134,7 @@ public function all(): array
114134
*/
115135
public function filter(string $pattern): self
116136
{
117-
return new self(array_filter($this->items, fn (AcceptHeaderItem $item) => preg_match($pattern, $item->getValue())));
137+
return new self(array_filter($this->items, static fn ($item) => preg_match($pattern, $item->getValue())));
118138
}
119139

120140
/**
@@ -133,18 +153,154 @@ public function first(): ?AcceptHeaderItem
133153
private function sort(): void
134154
{
135155
if (!$this->sorted) {
136-
uasort($this->items, function (AcceptHeaderItem $a, AcceptHeaderItem $b) {
137-
$qA = $a->getQuality();
138-
$qB = $b->getQuality();
156+
uasort($this->items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex());
157+
158+
$this->sorted = true;
159+
}
160+
}
139161

140-
if ($qA === $qB) {
141-
return $a->getIndex() > $b->getIndex() ? 1 : -1;
142-
}
162+
/**
163+
* Generates the canonical key for storing/retrieving an item.
164+
*/
165+
private function getCanonicalKey(AcceptHeaderItem $item): string
166+
{
167+
$parts = [];
143168

144-
return $qA > $qB ? -1 : 1;
145-
});
169+
// Normalize and sort attributes for consistent key generation
170+
$attributes = $this->getMediaParams($item);
171+
ksort($attributes);
146172

147-
$this->sorted = true;
173+
foreach ($attributes as $name => $value) {
174+
if (null === $value) {
175+
$parts[] = $name; // Flag parameter (e.g., "flowed")
176+
continue;
177+
}
178+
179+
// Quote values containing spaces, commas, semicolons, or equals per RFC 9110
180+
// This handles cases like 'format="value with space"' or similar.
181+
$quotedValue = \is_string($value) && preg_match('/[\s;,=]/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value;
182+
183+
$parts[] = $name.'='.$quotedValue;
148184
}
185+
186+
return $item->getValue().($parts ? ';'.implode(';', $parts) : '');
187+
}
188+
189+
/**
190+
* Checks if a given header item (range) matches a queried item (value).
191+
*
192+
* @param AcceptHeaderItem $rangeItem The item from the Accept header (e.g., text/*;format=flowed)
193+
* @param AcceptHeaderItem $queryItem The item being queried (e.g., text/plain;format=flowed;charset=utf-8)
194+
*/
195+
private function matches(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
196+
{
197+
$rangeValue = strtolower($rangeItem->getValue());
198+
$queryValue = strtolower($queryItem->getValue());
199+
200+
// Handle universal wildcard ranges
201+
if ('*' === $rangeValue || '*/*' === $rangeValue) {
202+
return $this->rangeParametersMatch($rangeItem, $queryItem);
203+
}
204+
205+
// Queries for '*' only match wildcard ranges (handled above)
206+
if ('*' === $queryValue) {
207+
return false;
208+
}
209+
210+
// Ensure media vs. non-media consistency
211+
$isQueryMedia = str_contains($queryValue, '/');
212+
$isRangeMedia = str_contains($rangeValue, '/');
213+
214+
if ($isQueryMedia !== $isRangeMedia) {
215+
return false;
216+
}
217+
218+
// Non-media: exact match only (wildcards handled above)
219+
if (!$isQueryMedia) {
220+
return $rangeValue === $queryValue && $this->rangeParametersMatch($rangeItem, $queryItem);
221+
}
222+
223+
// Media type: type/subtype with wildcards
224+
[$queryType, $querySubtype] = explode('/', $queryValue, 2);
225+
[$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
226+
227+
if ('*' !== $rangeType && $rangeType !== $queryType) {
228+
return false;
229+
}
230+
231+
if ('*' !== $rangeSubtype && $rangeSubtype !== $querySubtype) {
232+
return false;
233+
}
234+
235+
// Parameters must match
236+
return $this->rangeParametersMatch($rangeItem, $queryItem);
237+
}
238+
239+
/**
240+
* Checks if the parameters of a range item are satisfied by the query item.
241+
*
242+
* Parameters are case-insensitive; range params must be a subset of query params.
243+
*/
244+
private function rangeParametersMatch(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool
245+
{
246+
$queryAttributes = $this->getMediaParams($queryItem);
247+
$rangeAttributes = $this->getMediaParams($rangeItem);
248+
249+
foreach ($rangeAttributes as $name => $rangeValue) {
250+
if (!\array_key_exists($name, $queryAttributes)) {
251+
return false; // Missing required param
252+
}
253+
254+
$queryValue = $queryAttributes[$name];
255+
256+
if (null === $rangeValue) {
257+
return null === $queryValue; // Both flags or neither
258+
}
259+
260+
if (null === $queryValue || strtolower($queryValue) !== strtolower($rangeValue)) {
261+
return false;
262+
}
263+
}
264+
265+
return true;
266+
}
267+
268+
/**
269+
* Calculates a specificity score for sorting: media precision + param count.
270+
*/
271+
private function getSpecificity(AcceptHeaderItem $item, AcceptHeaderItem $queryItem): int
272+
{
273+
$rangeValue = strtolower($item->getValue());
274+
$queryValue = strtolower($queryItem->getValue());
275+
276+
$paramCount = \count($this->getMediaParams($item));
277+
278+
$isQueryMedia = str_contains($queryValue, '/');
279+
$isRangeMedia = str_contains($rangeValue, '/');
280+
281+
if (!$isQueryMedia && !$isRangeMedia) {
282+
return ('*' !== $rangeValue ? 2000 : 1000) + $paramCount;
283+
}
284+
285+
[$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*'];
286+
287+
$specificity = match (true) {
288+
'*' !== $rangeSubtype => 3000, // Exact subtype (text/plain)
289+
'*' !== $rangeType => 2000, // Type wildcard (text/*)
290+
default => 1000, // Full wildcard (*/* or *)
291+
};
292+
293+
return $specificity + $paramCount;
294+
}
295+
296+
/**
297+
* Returns normalized attributes: keys lowercased, excluding 'q'.
298+
*/
299+
private function getMediaParams(AcceptHeaderItem $item): array
300+
{
301+
$attributes = array_change_key_case($item->getAttributes(), \CASE_LOWER);
302+
unset($attributes['q']);
303+
304+
return $attributes;
149305
}
150306
}

Tests/AcceptHeaderTest.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,16 @@ public static function provideSortingData()
9393
'quality has priority' => ['*;q=0.3,ISO-8859-1,utf-8;q=0.7', ['ISO-8859-1', 'utf-8', '*']],
9494
'order matters when q is equal' => ['*;q=0.3,ISO-8859-1;q=0.7,utf-8;q=0.7', ['ISO-8859-1', 'utf-8', '*']],
9595
'order matters when q is equal2' => ['*;q=0.3,utf-8;q=0.7,ISO-8859-1;q=0.7', ['utf-8', 'ISO-8859-1', '*']],
96+
'additional attributes like "format" should be handled according RFC 9110' => ['text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5', ['text/plain;format=flowed', 'text/plain', '*/*', 'text/plain;format=fixed', 'text/*']],
97+
'additional attributes like "format" should be handled according obsoleted RFC 7231 as well' => ['text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5', ['text/html;level=1', 'text/html', '*/*', 'text/html;level=2', 'text/*']],
9698
];
9799
}
98100

99101
#[DataProvider('provideDefaultValueData')]
100102
public function testDefaultValue($acceptHeader, $value, $expectedQuality)
101103
{
102104
$header = AcceptHeader::fromString($acceptHeader);
103-
$this->assertSame($expectedQuality, $header->get($value)->getQuality());
105+
$this->assertSame($expectedQuality, $header->get($value)?->getQuality());
104106
}
105107

106108
public static function provideDefaultValueData()
@@ -119,5 +121,50 @@ public static function provideDefaultValueData()
119121
yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', '*', 0.3];
120122
yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', 'utf-8', 0.7];
121123
yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', 'SHIFT_JIS', 0.3];
124+
yield 'additional attributes like "format" should be handled according RFC 9110' => ['text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5', 'text/plain;format=flowed', 1.0];
125+
yield 'additional attributes like "format" should be handled according obsoleted RFC 7231 as well' => ['text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5', 'text/html;level=1', 1.0];
126+
127+
// Edge cases for case-insensitivity
128+
yield 'case-insensitive param names' => ['text/plain;format=flowed;q=0.8, text/plain;Format=fixed', 'text/plain;format=fixed', 1.0];
129+
yield 'case-insensitive charset' => ['text/plain;Charset=utf-8', 'text/plain;charset=utf-8', 1.0];
130+
131+
// Quoted values and specials
132+
yield 'quoted value with space' => ['text/plain;param="value with space"', 'text/plain;param="value with space"', 1.0];
133+
yield 'quoted value with backslash' => ['text/plain;param="value\\with\\backslash"', 'text/plain;param="value\\with\\backslash"', 1.0];
134+
yield 'mismatched quoted' => ['text/plain;param="value with space"', 'text/plain;param=value with space', 1.0];
135+
136+
// Flag params or empty
137+
yield 'flag param' => ['text/plain;flowed;q=0.9', 'text/plain;flowed', 0.9];
138+
yield 'empty param value' => ['text/plain;param=', 'text/plain;param=""', 1.0];
139+
yield 'missing required flag' => ['text/plain;flowed', 'text/plain', null];
140+
141+
// Extra params in query
142+
yield 'extra param in query' => ['text/plain;format=flowed', 'text/plain;format=flowed;charset=utf-8', 1.0];
143+
yield 'missing required param in query' => ['text/plain;format=flowed', 'text/plain;charset=utf-8', null];
144+
yield 'wildcard with param' => ['text/*;format=flowed', 'text/plain;format=flowed', 1.0];
145+
yield 'wildcard missing param' => ['text/*;format=flowed', 'text/plain', null];
146+
147+
// Wildcards and specificity
148+
yield 'specificity priority' => ['*/*;q=0.1, text/*;format=flowed;q=0.5, text/plain;q=0.8', 'text/plain;format=flowed', 0.8];
149+
yield 'wildcard with param match' => ['*/*;param=test', 'text/plain;param=test', 1.0];
150+
yield 'wildcard with param no match' => ['*/*;param=test', 'text/plain', null];
151+
152+
// Non-media types
153+
yield 'charset wildcard' => ['utf-8;q=0.9, *;q=0.5', 'iso-8859-1', 0.5];
154+
yield 'language star' => ['*;q=0.5', 'en-US', 0.5];
155+
yield 'non-media */*' => ['*/*;q=0.5', 'utf-8', 0.5];
156+
157+
// Ties and duplicates
158+
yield 'duplicate params tie on index' => ['text/plain;format=flowed;q=0.8, text/plain;format=flowed;q=0.8', 'text/plain;format=flowed', 0.8];
159+
yield 'param count tie' => ['text/plain;q=0.5, text/plain;format=flowed;q=0.5', 'text/plain;format=flowed;extra=foo', 0.5];
160+
161+
// Invalid/malformed
162+
yield 'non-media invalid' => ['text', 'text', 1.0];
163+
yield 'invalid subtype' => ['text/', 'text/plain', null];
164+
yield 'empty header' => ['', 'text/plain', null];
165+
166+
// Mixed case types
167+
yield 'mixed case type' => ['Text/Plain;Format=flowed', 'text/plain;format=flowed', 1.0];
168+
yield 'mixed case charset' => ['UTF-8;q=0.9', 'utf-8', 0.9];
122169
}
123170
}

0 commit comments

Comments
 (0)