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
![]() |
|||
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 |