| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace Usox\LanguageNegotiator; |
||
| 6 | |||
| 7 | use Psr\Http\Message\ResponseInterface; |
||
| 8 | use Psr\Http\Message\ServerRequestInterface; |
||
| 9 | use Psr\Http\Server\RequestHandlerInterface; |
||
| 10 | |||
| 11 | /** |
||
| 12 | * Negotiate the http client language |
||
| 13 | * |
||
| 14 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language |
||
| 15 | */ |
||
| 16 | final class LanguageNegotiator implements LanguageNegotiatorInterface |
||
| 17 | { |
||
| 18 | /** @var string Public name of the psr server request attribute */ |
||
| 19 | public const REQUEST_ATTRIBUTE_NAME = 'negotiated-request-language'; |
||
| 20 | |||
| 21 | /** @var array<string> List of allowed accept-language header names */ |
||
| 22 | private const ACCEPT_LANGUAGE_HEADERS = ['accept-language', 'http_accept_language']; |
||
| 23 | |||
| 24 | /** |
||
| 25 | * @param array<string> $supportedLanguages List of language codes you application supports |
||
| 26 | * @param string $fallbackLanguage language code of the fallback language if negotiation fails |
||
| 27 | * @param null|array<string, mixed> $serverRequest Optional server request input ($_SERVER) |
||
| 28 | * @param string $attributeName Optional alternate name of the server request attribute for the negotiated language |
||
| 29 | */ |
||
| 30 | 14 | public function __construct( |
|
| 31 | private array $supportedLanguages, |
||
| 32 | private string $fallbackLanguage = 'en', |
||
| 33 | private ?array $serverRequest = null, |
||
| 34 | private string $attributeName = self::REQUEST_ATTRIBUTE_NAME |
||
| 35 | ) { |
||
| 36 | 14 | } |
|
| 37 | |||
| 38 | /** |
||
| 39 | * Negotiates the client language |
||
| 40 | * If the serverRequest was set by constructor, the parameter can be omitted |
||
| 41 | * |
||
| 42 | * @param array<string, mixed>|null $request Optional dict of server request variables; overwrites serverRequest defined in constructor |
||
| 43 | * |
||
| 44 | * @return string language code of the negotiated client language |
||
| 45 | */ |
||
| 46 | 14 | public function negotiate( |
|
| 47 | ?array $request = null |
||
| 48 | ): string { |
||
| 49 | // return fallback if both, headerLine and serverRequest is not set |
||
| 50 | 14 | $requestHeaders = $request ?? $this->serverRequest ?? null; |
|
| 51 | |||
| 52 | 14 | if ($requestHeaders === null) { |
|
| 53 | 1 | return $this->fallbackLanguage; |
|
| 54 | } |
||
| 55 | |||
| 56 | 13 | $headerValue = $this->normalizeAcceptLanguageString($requestHeaders); |
|
| 57 | |||
| 58 | // return the fallback if the header is not usable |
||
| 59 | 13 | if ($headerValue === null) { |
|
| 60 | 6 | return $this->fallbackLanguage; |
|
| 61 | } |
||
| 62 | |||
| 63 | // split up the header to determine a list of all accepted languages |
||
| 64 | 7 | $acceptedLanguages = array_reduce( |
|
| 65 | 7 | explode(',', $headerValue), |
|
| 66 | 7 | static function (array $result, string $line): array { |
|
| 67 | 7 | [$language, $priority] = array_merge(explode(';q=', $line), [1]); |
|
| 68 | 7 | $result[trim((string) $language)] = (float) $priority; |
|
| 69 | 7 | return $result; |
|
| 70 | 7 | }, |
|
| 71 | 7 | [] |
|
| 72 | ); |
||
| 73 | |||
| 74 | // sort by value which is actually the language priority defined within the client |
||
| 75 | 7 | arsort($acceptedLanguages); |
|
| 76 | |||
| 77 | // determine the intersection between supported and available languages |
||
| 78 | 7 | $result = array_intersect_key( |
|
| 79 | 7 | $acceptedLanguages, |
|
| 80 | 7 | array_flip($this->supportedLanguages), |
|
| 81 | ); |
||
| 82 | |||
| 83 | // use the set fallback language if negotiation fails |
||
| 84 | 7 | if ($result === []) { |
|
| 85 | 2 | return $this->fallbackLanguage; |
|
| 86 | } |
||
| 87 | |||
| 88 | // returns the first element (the one having the highest priority) |
||
| 89 | 5 | return key($result); |
|
|
0 ignored issues
–
show
Bug
Best Practice
introduced
by
Loading history...
|
|||
| 90 | } |
||
| 91 | |||
| 92 | /** |
||
| 93 | * Enriches the ServerRequest with the negotiated client language |
||
| 94 | */ |
||
| 95 | 1 | public function process( |
|
| 96 | ServerRequestInterface $request, |
||
| 97 | RequestHandlerInterface $handler |
||
| 98 | ): ResponseInterface { |
||
| 99 | 1 | $request = $request->withAttribute( |
|
| 100 | 1 | $this->attributeName, |
|
| 101 | 1 | $this->negotiate($request->getHeaders()) |
|
| 102 | ); |
||
| 103 | |||
| 104 | 1 | return $handler->handle($request); |
|
| 105 | } |
||
| 106 | |||
| 107 | /** |
||
| 108 | * @param array<string, mixed> $headers |
||
| 109 | */ |
||
| 110 | 13 | private function normalizeAcceptLanguageString(array $headers): ?string { |
|
| 111 | 13 | $headerValue = null; |
|
| 112 | |||
| 113 | // convert all keys to lowercase - just in case |
||
| 114 | 13 | $headers = array_change_key_case($headers); |
|
| 115 | |||
| 116 | // lookup all accepted header values |
||
| 117 | 13 | foreach (self::ACCEPT_LANGUAGE_HEADERS as $headerName) { |
|
| 118 | 13 | $headerValue = $headers[$headerName] ?? null; |
|
| 119 | 13 | if ($headerValue !== null) { |
|
| 120 | 9 | break; |
|
| 121 | } |
||
| 122 | } |
||
| 123 | |||
| 124 | // maybe something goes wrong and we end up with some other scalar values in here - jest ensure, it's a string |
||
| 125 | 13 | if (!is_string($headerValue)) { |
|
| 126 | 6 | return null; |
|
| 127 | } |
||
| 128 | |||
| 129 | 7 | return strtolower($headerValue); |
|
| 130 | } |
||
| 131 | } |
||
| 132 |