Skip to content
6 changes: 6 additions & 0 deletions app/Config/Feature.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ class Feature extends BaseConfig
* If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.)
*/
public bool $limitZeroAsAll = true;

/**
* Set `false` to use strict localization comparison (with territory en-*) instead of an abbreviated value.
* Set `true`, so territory was cut off (en-* as en) before localization comparing.
*/
public bool $looseLocaleNegotiation = true;
}
65 changes: 63 additions & 2 deletions system/HTTP/Negotiate.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace CodeIgniter\HTTP;

use CodeIgniter\HTTP\Exceptions\HTTPException;
use Config\Feature;

/**
* Class Negotiate
Expand Down Expand Up @@ -122,12 +123,16 @@ public function encoding(array $supported = []): string
* types the application says it supports, and the types requested
* by the client.
*
* If no match is found, the first, highest-ranking client requested
* If loose locale negotiation is enabled and no match is found, the first, highest-ranking client requested
* type is returned.
*/
public function language(array $supported): string
{
return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'), false, false, true);
if (config(Feature::class)->looseLocaleNegotiation) {
return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-language'), false, false, true);
}

return $this->getBestLocaleMatch($supported, $this->request->getHeaderLine('accept-language'));
}

// --------------------------------------------------------------------
Expand Down Expand Up @@ -189,6 +194,62 @@ protected function getBestMatch(
return $strictMatch ? '' : $supported[0];
}

/**
* Strict locale search, including territories (en-*)
*
* @param list<string> $supported App-supported values
* @param ?string $header Compatible 'Accept-Language' header string
*/
protected function getBestLocaleMatch(array $supported, ?string $header): string
{
if ($supported === []) {
throw HTTPException::forEmptySupportedNegotiations();
}

if ($header === null || $header === '') {
return $supported[0];
}

$acceptable = $this->parseHeader($header);
$fallbackLocales = [];

foreach ($acceptable as $accept) {
// if acceptable quality is zero, skip it.
if ($accept['q'] === 0.0) {
continue;
}

// if acceptable value is "anything", return the first available
if ($accept['value'] === '*' || $accept['value'] === '*/*') {
return $supported[0];
}

// look for exact match
if (in_array($accept['value'], $supported, true)) {
return $accept['value'];
}

// set a fallback locale
$fallbackLocales[] = strtok($accept['value'], '-');
}

foreach ($fallbackLocales as $fallbackLocale) {
// look for exact match
if (in_array($fallbackLocale, $supported, true)) {
return $fallbackLocale;
}

// look for locale variants match
foreach ($supported as $locale) {
if (str_starts_with($locale, $fallbackLocale . '-')) {
return $locale;
}
}
}

return $supported[0];
}

/**
* Parses an Accept* header into it's multiple values.
*
Expand Down
29 changes: 27 additions & 2 deletions tests/system/HTTP/NegotiateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use CodeIgniter\HTTP\Exceptions\HTTPException;
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Feature;
use PHPUnit\Framework\Attributes\Group;

/**
Expand Down Expand Up @@ -111,11 +112,23 @@ public function testNegotiatesEncodingBasics(): void

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

$this->assertSame('da', $this->negotiate->language(['da', 'en']));
$this->assertSame('en-gb', $this->negotiate->language(['en-gb', 'en']));
$this->assertSame('en', $this->negotiate->language(['en']));

// Will find the first locale instead of "en-gb"
$this->assertSame('en-us', $this->negotiate->language(['en-us', 'en-gb', 'en']));
$this->assertSame('en', $this->negotiate->language(['en', 'en-us', 'en-gb']));

config(Feature::class)->looseLocaleNegotiation = false;

$this->assertSame('da', $this->negotiate->language(['da', 'en']));
$this->assertSame('en-gb', $this->negotiate->language(['en-gb', 'en']));
$this->assertSame('en', $this->negotiate->language(['en']));
$this->assertSame('en-gb', $this->negotiate->language(['en-us', 'en-gb', 'en']));
$this->assertSame('en-gb', $this->negotiate->language(['en', 'en-us', 'en-gb']));
}

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

$this->assertSame('fr', $this->negotiate->language(['fr', 'en']));
$this->assertSame('fr', $this->negotiate->language(['fr', 'fr-FR', 'en']));
$this->assertSame('fr-FR', $this->negotiate->language(['fr-FR', 'fr', 'en']));
$this->assertSame('fr-BE', $this->negotiate->language(['fr-BE', 'fr', 'en']));
$this->assertSame('en', $this->negotiate->language(['en', 'en-US']));
$this->assertSame('fr-BE', $this->negotiate->language(['ru', 'en-GB', 'fr-BE']));

config(Feature::class)->looseLocaleNegotiation = false;

$this->assertSame('fr-FR', $this->negotiate->language(['fr', 'fr-FR', 'en']));
$this->assertSame('fr-FR', $this->negotiate->language(['fr-FR', 'fr', 'en']));
$this->assertSame('fr', $this->negotiate->language(['fr-BE', 'fr', 'en']));
$this->assertSame('en-US', $this->negotiate->language(['en', 'en-US']));
$this->assertSame('fr-BE', $this->negotiate->language(['ru', 'en-GB', 'fr-BE']));
}

public function testBestMatchEmpty(): void
Expand Down
7 changes: 7 additions & 0 deletions user_guide_src/source/changelogs/v4.6.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ Routing

- Now you can specify multiple hostnames when restricting routes.

Negotiator
==========

- Added a feature flag ``Feature::$looseLocaleNegotiation`` fix simple locale comparison.
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``.
Set the value to ``false`` to be able to get ``en-*``

Testing
=======

Expand Down
5 changes: 5 additions & 0 deletions user_guide_src/source/incoming/content_negotiation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ and German you would do something like:
In this example, 'en' would be returned as the current language. If no match is found, it will return the first element
in the ``$supported`` array, so that should always be the preferred language.

.. versionadded:: 4.6.0

Disabling the ``Config\Feature::$looseLocaleNegotiation`` value allows you to strictly search for the requested language from the specified territory (``en-*``).
In the case of a non-strict search, the language may be limited only by the country ``en``. Don't forget to create files for the ``en-*`` locale if you need a translation.

Encoding
========

Expand Down
2 changes: 1 addition & 1 deletion user_guide_src/source/installation/upgrade_458.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ All Changes
This is a list of all files in the **project space** that received changes;
many will be simple comments or formatting that have no effect on the runtime:

- @TODO
- @TODO
3 changes: 2 additions & 1 deletion user_guide_src/source/installation/upgrade_460.rst
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,14 @@ Config

- app/Config/Feature.php
- ``Config\Feature::$autoRoutesImproved`` has been changed to ``true``.
- ``Config\Feature::$looseLocaleNegotiation`` has been added.
- app/Config/Routing.php
- ``Config\Routing::$translateUriToCamelCase`` has been changed to ``true``.

All Changes
===========

This is a list of all files in the **project space** that received changes;
many will be simple comments or formatting that have no effect on the runtime:

- app/Config/Feature.php
- @TODO
Loading