Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Strict locale negotiation #9360

Merged
merged 13 commits into from
Jan 5, 2025
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.
paulbalandan marked this conversation as resolved.
Show resolved Hide resolved
* Set `true`, so territory was cut off (en-* as en) before localization comparing.
paulbalandan marked this conversation as resolved.
Show resolved Hide resolved
*/
public bool $looseLocaleNegotiation = true;
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
}
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
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
* 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) {
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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
*/
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
protected function getBestLocaleMatch(array $supported, ?string $header): string
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
{
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'] === '*/*') {
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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.
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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-*``
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved

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.

neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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
paulbalandan marked this conversation as resolved.
Show resolved Hide resolved
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.
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
- app/Config/Routing.php
- ``Config\Routing::$translateUriToCamelCase`` has been changed to ``true``.

neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
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
neznaika0 marked this conversation as resolved.
Show resolved Hide resolved
- @TODO
Loading