Skip to content

Commit 0b13a70

Browse files
committed
Request: added Helpers::parseQualityList() and rewrote detectLanguage() on top of it
parseQualityList() is a generic parser for HTTP quality-value headers (Accept, Accept-Language, Accept-Encoding, ...). detectLanguage() now matches case-insensitively and returns the language code in the caller's original casing.
1 parent f9eaeb1 commit 0b13a70

4 files changed

Lines changed: 88 additions & 17 deletions

File tree

src/Http/Helpers.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Nette;
1111
use Nette\Utils\DateTime;
12+
use function array_shift, arsort, explode, preg_match, strtolower, trim;
1213

1314

1415
/**
@@ -35,6 +36,39 @@ public static function formatDate(string|int|\DateTimeInterface $time): string
3536
}
3637

3738

39+
/**
40+
* Parses an HTTP quality-value list such as the Accept, Accept-Language or Accept-Encoding header
41+
* into tokens mapped to their q-factor, ordered by descending preference. Tokens are lowercased and
42+
* those explicitly rejected with q=0 are omitted.
43+
* @return array<string, float> e.g. ['cs-cz' => 1.0, 'en' => 0.8]
44+
*/
45+
public static function parseQualityList(string $header): array
46+
{
47+
$list = [];
48+
foreach (explode(',', $header) as $item) {
49+
$params = explode(';', $item);
50+
$token = strtolower(trim((string) array_shift($params)));
51+
if ($token === '') {
52+
continue;
53+
}
54+
55+
$q = 1.0;
56+
foreach ($params as $param) {
57+
if (preg_match('#^\s*q\s*=\s*([0-9.]+)#i', $param, $m)) {
58+
$q = min(1.0, (float) $m[1]); // q is capped at 1 per RFC 9110
59+
}
60+
}
61+
62+
if ($q > 0) {
63+
$list[$token] = max($list[$token] ?? 0.0, $q); // a repeated token keeps its highest q
64+
}
65+
}
66+
67+
arsort($list); // stable since PHP 8.0, so equal q keeps header order
68+
return $list;
69+
}
70+
71+
3872
/**
3973
* Checks whether an IP address falls within a CIDR block (e.g. '192.168.1.0/24').
4074
*/

src/Http/Request.php

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace Nette\Http;
99

1010
use Nette;
11-
use function array_change_key_case, base64_decode, count, explode, func_num_args, implode, in_array, preg_match, preg_match_all, rsort, strcasecmp, strtr;
11+
use function array_change_key_case, array_keys, base64_decode, count, explode, func_num_args, in_array, preg_match, str_starts_with, strcasecmp, strlen, strtolower, strtr, usort;
1212

1313

1414
/**
@@ -326,25 +326,18 @@ public function detectLanguage(array $langs): ?string
326326
return null;
327327
}
328328

329-
$s = strtolower($header); // case insensitive
330-
$s = strtr($s, '_', '-'); // cs_CZ means cs-CZ
331-
rsort($langs); // first more specific
332-
preg_match_all('#(' . implode('|', $langs) . ')(?:-[^\s,;=]+)?\s*(?:;\s*q=([0-9.]+))?#', $s, $matches);
329+
usort($langs, fn($a, $b) => strlen($b) <=> strlen($a)); // more specific first
330+
$accepted = Helpers::parseQualityList(strtr($header, '_', '-')); // cs_CZ means cs-CZ
333331

334-
if (!$matches[0]) {
335-
return null;
336-
}
337-
338-
$max = 0;
339-
$lang = null;
340-
foreach ($matches[1] as $key => $value) {
341-
$q = $matches[2][$key] === '' ? 1.0 : (float) $matches[2][$key];
342-
if ($q > $max) {
343-
$max = $q;
344-
$lang = $value;
332+
foreach (array_keys($accepted) as $token) {
333+
foreach ($langs as $lang) {
334+
$l = strtolower($lang);
335+
if ($token === '*' || $token === $l || str_starts_with($token, $l . '-')) {
336+
return $lang;
337+
}
345338
}
346339
}
347340

348-
return $lang;
341+
return null;
349342
}
350343
}

tests/Http/Helpers.phpt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,33 @@ test('IPv6 address matching', function () {
3838

3939

4040

41+
test('parseQualityList', function () {
42+
Assert::same([], Helpers::parseQualityList(''));
43+
Assert::same(['gzip' => 1.0], Helpers::parseQualityList('gzip'));
44+
45+
// ordered by descending q-factor, default q is 1.0
46+
Assert::same(
47+
['da' => 1.0, 'en-gb' => 0.8, 'en' => 0.7],
48+
Helpers::parseQualityList('da, en-gb;q=0.8, en;q=0.7'),
49+
);
50+
51+
// equal q keeps the header order (stable sort)
52+
Assert::same(['en' => 1.0, 'cs' => 1.0], Helpers::parseQualityList('en, cs'));
53+
54+
// tokens are lowercased and trimmed, q=0 is dropped
55+
Assert::same(
56+
['text/html' => 0.9, '*/*' => 0.8],
57+
Helpers::parseQualityList('TEXT/HTML ; q=0.9 , identity;q=0 , */* ;q=0.8'),
58+
);
59+
60+
// a repeated token keeps its highest q
61+
Assert::same(['de' => 0.9], Helpers::parseQualityList('de;q=0.9, de;q=0.1'));
62+
63+
// q is capped at 1
64+
Assert::same(['a' => 1.0, 'b' => 0.9], Helpers::parseQualityList('a;q=5, b;q=0.9'));
65+
});
66+
67+
4168
test('date formatting', function () {
4269
Assert::same('Tue, 15 Nov 1994 08:12:31 GMT', Helpers::formatDate('1994-11-15T08:12:31+0000'));
4370
Assert::same('Tue, 15 Nov 1994 08:12:31 GMT', Helpers::formatDate('1994-11-15T10:12:31+0200'));

tests/Http/Request.detectLanguage.phpt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ test('language with quality weights', function () {
2929
});
3030

3131

32+
test('case-insensitive matching, original case is returned', function () {
33+
$headers = ['Accept-Language' => 'cs_CZ, EN;q=0.8'];
34+
$request = new Http\Request(new Http\UrlScript, headers: $headers);
35+
36+
Assert::same('CS', $request->detectLanguage(['CS', 'En']));
37+
Assert::same('cs-cz', $request->detectLanguage(['en', 'cs-cz']));
38+
});
39+
40+
41+
test('wildcard matches any supported language', function () {
42+
$request = new Http\Request(new Http\UrlScript, headers: ['Accept-Language' => 'fr, *;q=0.1']);
43+
44+
Assert::same('en', $request->detectLanguage(['en', 'cs'])); // fr unsupported, * falls back
45+
Assert::null($request->detectLanguage([])); // nothing to fall back to
46+
});
47+
48+
3249
test('no Accept-Language header', function () {
3350
$headers = [];
3451
$request = new Http\Request(new Http\UrlScript, headers: $headers);

0 commit comments

Comments
 (0)