Skip to content

Commit cc96fe3

Browse files
authored
feat: Strict locale negotiation (codeigniter4#9360)
* feat: Add parameter for locale comparison * feat: Update language negotiator * tests: Update `NegotiateTest` * docs: Update userguide * fix: Rename feature option * feat: Add `getBestLocaleMatch()` * tests: Update `Negotiate` tests * docs: Update docs * fix: Rename feature option to back * docs: Update the description for Negotiation * fix: Update with the renamed option * fix: Delete checking *.* * fix: Move code to file
1 parent 4764fe3 commit cc96fe3

File tree

8 files changed

+167
-5
lines changed

8 files changed

+167
-5
lines changed

app/Config/Feature.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,12 @@ class Feature extends BaseConfig
2626
* If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.)
2727
*/
2828
public bool $limitZeroAsAll = true;
29+
30+
/**
31+
* Use strict location negotiation.
32+
*
33+
* By default, the locale is selected based on a loose comparison of the language code (ISO 639-1)
34+
* Enabling strict comparison will also consider the region code (ISO 3166-1 alpha-2).
35+
*/
36+
public bool $strictLocaleNegotiation = false;
2937
}

system/HTTP/Negotiate.php

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\HTTP;
1515

1616
use CodeIgniter\HTTP\Exceptions\HTTPException;
17+
use Config\Feature;
1718

1819
/**
1920
* Class Negotiate
@@ -122,11 +123,15 @@ public function encoding(array $supported = []): string
122123
* types the application says it supports, and the types requested
123124
* by the client.
124125
*
125-
* If no match is found, the first, highest-ranking client requested
126+
* If strict locale negotiation is disabled and no match is found, the first, highest-ranking client requested
126127
* type is returned.
127128
*/
128129
public function language(array $supported): string
129130
{
131+
if (config(Feature::class)->strictLocaleNegotiation) {
132+
return $this->getBestLocaleMatch($supported, $this->request->getHeaderLine('accept-language'));
133+
}
134+
130135
return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'), false, false, true);
131136
}
132137

@@ -189,6 +194,69 @@ protected function getBestMatch(
189194
return $strictMatch ? '' : $supported[0];
190195
}
191196

197+
/**
198+
* Try to find the best matching locale. It supports strict locale comparison.
199+
*
200+
* If Config\App::$supportedLocales have "en-US" and "en-GB" locales, they can be recognized
201+
* as two different locales. This method checks first for the strict match, then fallback
202+
* to the most general locale (in this case "en") ISO 639-1 and finally to the locale variant
203+
* "en-*" (ISO 639-1 plus "wildcard" for ISO 3166-1 alpha-2).
204+
*
205+
* If nothing from above is matched, then it returns the first option from the $supportedLocales array.
206+
*
207+
* @param list<string> $supportedLocales App-supported values
208+
* @param ?string $header Compatible 'Accept-Language' header string
209+
*/
210+
protected function getBestLocaleMatch(array $supportedLocales, ?string $header): string
211+
{
212+
if ($supportedLocales === []) {
213+
throw HTTPException::forEmptySupportedNegotiations();
214+
}
215+
216+
if ($header === null || $header === '') {
217+
return $supportedLocales[0];
218+
}
219+
220+
$acceptable = $this->parseHeader($header);
221+
$fallbackLocales = [];
222+
223+
foreach ($acceptable as $accept) {
224+
// if acceptable quality is zero, skip it.
225+
if ($accept['q'] === 0.0) {
226+
continue;
227+
}
228+
229+
// if acceptable value is "anything", return the first available
230+
if ($accept['value'] === '*') {
231+
return $supportedLocales[0];
232+
}
233+
234+
// look for exact match
235+
if (in_array($accept['value'], $supportedLocales, true)) {
236+
return $accept['value'];
237+
}
238+
239+
// set a fallback locale
240+
$fallbackLocales[] = strtok($accept['value'], '-');
241+
}
242+
243+
foreach ($fallbackLocales as $fallbackLocale) {
244+
// look for exact match
245+
if (in_array($fallbackLocale, $supportedLocales, true)) {
246+
return $fallbackLocale;
247+
}
248+
249+
// look for regional locale match
250+
foreach ($supportedLocales as $locale) {
251+
if (str_starts_with($locale, $fallbackLocale . '-')) {
252+
return $locale;
253+
}
254+
}
255+
}
256+
257+
return $supportedLocales[0];
258+
}
259+
192260
/**
193261
* Parses an Accept* header into it's multiple values.
194262
*

tests/system/HTTP/NegotiateTest.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use CodeIgniter\HTTP\Exceptions\HTTPException;
1717
use CodeIgniter\Test\CIUnitTestCase;
1818
use Config\App;
19+
use Config\Feature;
1920
use PHPUnit\Framework\Attributes\Group;
2021

2122
/**
@@ -111,11 +112,23 @@ public function testNegotiatesEncodingBasics(): void
111112

112113
public function testAcceptLanguageBasics(): void
113114
{
114-
$this->request->setHeader('Accept-Language', 'da, en-gb;q=0.8, en;q=0.7');
115+
$this->request->setHeader('Accept-Language', 'da, en-gb, en-us;q=0.8, en;q=0.7');
115116

116117
$this->assertSame('da', $this->negotiate->language(['da', 'en']));
117118
$this->assertSame('en-gb', $this->negotiate->language(['en-gb', 'en']));
118119
$this->assertSame('en', $this->negotiate->language(['en']));
120+
121+
// Will find the first locale instead of "en-gb"
122+
$this->assertSame('en-us', $this->negotiate->language(['en-us', 'en-gb', 'en']));
123+
$this->assertSame('en', $this->negotiate->language(['en', 'en-us', 'en-gb']));
124+
125+
config(Feature::class)->strictLocaleNegotiation = true;
126+
127+
$this->assertSame('da', $this->negotiate->language(['da', 'en']));
128+
$this->assertSame('en-gb', $this->negotiate->language(['en-gb', 'en']));
129+
$this->assertSame('en', $this->negotiate->language(['en']));
130+
$this->assertSame('en-gb', $this->negotiate->language(['en-us', 'en-gb', 'en']));
131+
$this->assertSame('en-gb', $this->negotiate->language(['en', 'en-us', 'en-gb']));
119132
}
120133

121134
/**
@@ -125,7 +138,19 @@ public function testAcceptLanguageMatchesBroadly(): void
125138
{
126139
$this->request->setHeader('Accept-Language', 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7');
127140

128-
$this->assertSame('fr', $this->negotiate->language(['fr', 'en']));
141+
$this->assertSame('fr', $this->negotiate->language(['fr', 'fr-FR', 'en']));
142+
$this->assertSame('fr-FR', $this->negotiate->language(['fr-FR', 'fr', 'en']));
143+
$this->assertSame('fr-BE', $this->negotiate->language(['fr-BE', 'fr', 'en']));
144+
$this->assertSame('en', $this->negotiate->language(['en', 'en-US']));
145+
$this->assertSame('fr-BE', $this->negotiate->language(['ru', 'en-GB', 'fr-BE']));
146+
147+
config(Feature::class)->strictLocaleNegotiation = true;
148+
149+
$this->assertSame('fr-FR', $this->negotiate->language(['fr', 'fr-FR', 'en']));
150+
$this->assertSame('fr-FR', $this->negotiate->language(['fr-FR', 'fr', 'en']));
151+
$this->assertSame('fr', $this->negotiate->language(['fr-BE', 'fr', 'en']));
152+
$this->assertSame('en-US', $this->negotiate->language(['en', 'en-US']));
153+
$this->assertSame('fr-BE', $this->negotiate->language(['ru', 'en-GB', 'fr-BE']));
129154
}
130155

131156
public function testBestMatchEmpty(): void

user_guide_src/source/changelogs/v4.6.0.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ Routing
239239

240240
- Now you can specify multiple hostnames when restricting routes.
241241

242+
Negotiator
243+
==========
244+
245+
- Added a feature flag ``Feature::$strictLocaleNegotiation`` to enable strict locale comparision.
246+
Previously, response with language headers ``Accept-language: en-US,en-GB;q=0.9`` returned the first allowed language ``en`` could instead of the exact language ``en-US`` or ``en-GB``.
247+
Set the value to ``true`` to enable comparison not only by language code ('en' - ISO 639-1) but also by regional code ('en-US' - ISO 639-1 plus ISO 3166-1 alpha).
248+
242249
Testing
243250
=======
244251

user_guide_src/source/incoming/content_negotiation.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,49 @@ and German you would do something like:
102102
In this example, 'en' would be returned as the current language. If no match is found, it will return the first element
103103
in the ``$supported`` array, so that should always be the preferred language.
104104

105+
Strict Locale Negotiation
106+
-------------------------
107+
108+
.. versionadded:: 4.6.0
109+
110+
By default, locale is determined on a lossy comparison basis. So only the first part of the locale string is taken
111+
into account (language). This is usually sufficient. But sometimes we want to be able to distinguish between regional versions such as
112+
``en-US`` and ``en-GB`` to serve different content.
113+
114+
For such cases, we have introduced a new setting that can be enabled via ``Config\Feature::$strictLocaleNegotiation``. This will ensure
115+
that the strict comparison will be made in the first place.
116+
117+
.. note::
118+
119+
CodeIgniter comes with translations only for primary language tags ('en', 'fr', etc.). So if you enable this feature and your
120+
settings in ``Config\App::$supportedLocales`` include regional language tags ('en-US', 'fr-FR', etc.), then keep in mind that
121+
if you have your own translation files, you **must also change** the folder names for CodeIgniter's translation files to match
122+
what you put in the ``$supportedLocales`` array.
123+
124+
Now let's consider the below example. The browser's preferred language will be set as this::
125+
126+
GET /foo HTTP/1.1
127+
Accept-Language: fr; q=1.0, en-GB; q=0.5
128+
129+
In this example, the browser would prefer French, with a second choice of English (United Kingdom). Your website on another hand
130+
supports German and English (United States):
131+
132+
.. literalinclude:: content_negotiation/008.php
133+
134+
In this example, 'en-US' would be returned as the current language. If no match is found, it will return the first element
135+
in the ``$supported`` array. Here is how exactly the locale selection process works.
136+
137+
Even though the 'fr' is preferred by the browser it is not in our ``$supported`` array. The same problem occurs with 'en-GB', but here
138+
we will be able to search for variants. First, we will fallback to the most general locale (in this case 'en') which again is not in our
139+
array. Then we will search for the regional locale 'en-'. And that's when our value from the ``$supported`` array will be matched.
140+
We will return 'en-US'.
141+
142+
So the process of selecting a locale is as follows:
143+
144+
#. strict match ('en-GB') - ISO 639-1 plus ISO 3166-1 alpha-2
145+
#. general locale match ('en') - ISO 639-1
146+
#. regional locale match ('en-') - ISO 639-1 plus "wildcard" for ISO 3166-1 alpha-2
147+
105148
Encoding
106149
========
107150

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
$supported = [
4+
'de',
5+
'en-US',
6+
];
7+
8+
$lang = $request->negotiate('language', $supported);
9+
// or
10+
$lang = $negotiate->language($supported);

user_guide_src/source/installation/upgrade_458.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ All Changes
5252
This is a list of all files in the **project space** that received changes;
5353
many will be simple comments or formatting that have no effect on the runtime:
5454

55-
- @TODO
55+
- @TODO

user_guide_src/source/installation/upgrade_460.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ Config
211211

212212
- app/Config/Feature.php
213213
- ``Config\Feature::$autoRoutesImproved`` has been changed to ``true``.
214+
- ``Config\Feature::$strictLocaleNegotiation`` has been added.
214215
- app/Config/Routing.php
215216
- ``Config\Routing::$translateUriToCamelCase`` has been changed to ``true``.
216217

@@ -220,4 +221,4 @@ All Changes
220221
This is a list of all files in the **project space** that received changes;
221222
many will be simple comments or formatting that have no effect on the runtime:
222223

223-
- @TODO
224+
- app/Config/Feature.php

0 commit comments

Comments
 (0)