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
|
11 |
|
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
|
11 |
|
} |
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
|
11 |
|
public function negotiate( |
47
|
|
|
?array $request = null |
48
|
|
|
): string { |
49
|
|
|
// return fallback if both, headerLine and serverRequest is not set |
50
|
11 |
|
$requestHeaders = $request ?? $this->serverRequest ?? null; |
51
|
|
|
|
52
|
11 |
|
if ($requestHeaders === null) { |
53
|
|
|
return $this->fallbackLanguage; |
54
|
|
|
} |
55
|
|
|
|
56
|
11 |
|
$headerValue = $this->normalizeAcceptLanguageString($requestHeaders); |
57
|
|
|
|
58
|
|
|
// return the fallback if the header is not usable |
59
|
11 |
|
if ($headerValue === null) { |
60
|
6 |
|
return $this->fallbackLanguage; |
61
|
|
|
} |
62
|
|
|
|
63
|
|
|
// split up the header to determine a list of all accepted languages |
64
|
5 |
|
$acceptedLanguages = array_reduce( |
65
|
5 |
|
explode(',', $headerValue), |
66
|
5 |
|
static function (array $result, string $line): array { |
67
|
5 |
|
[$language, $priority] = array_merge(explode(';q=', $line), [1]); |
68
|
5 |
|
$result[trim((string) $language)] = (float) $priority; |
69
|
5 |
|
return $result; |
70
|
5 |
|
}, |
71
|
5 |
|
[] |
72
|
|
|
); |
73
|
|
|
|
74
|
|
|
// sort by value which is actually the language priority defined within the client |
75
|
5 |
|
arsort($acceptedLanguages); |
76
|
|
|
|
77
|
|
|
// determine the intersection between supported and available languages |
78
|
5 |
|
$result = array_intersect_key( |
79
|
5 |
|
$acceptedLanguages, |
80
|
5 |
|
array_flip($this->supportedLanguages), |
81
|
|
|
); |
82
|
|
|
|
83
|
|
|
// use the set fallback language if negotiation fails |
84
|
5 |
|
if ($result === []) { |
85
|
|
|
return $this->fallbackLanguage; |
86
|
|
|
} |
87
|
|
|
|
88
|
|
|
// returns the first element (the one having the highest priority) |
89
|
5 |
|
return key($result); |
|
|
|
|
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
|
11 |
|
private function normalizeAcceptLanguageString(array $headers): ?string { |
111
|
11 |
|
$headerValue = null; |
112
|
|
|
|
113
|
|
|
// convert all keys to lowercase - just in case |
114
|
11 |
|
$headers = array_change_key_case($headers); |
115
|
|
|
|
116
|
|
|
// lookup all accepted header values |
117
|
11 |
|
foreach (self::ACCEPT_LANGUAGE_HEADERS as $headerName) { |
118
|
11 |
|
$headerValue = $headers[$headerName] ?? null; |
119
|
11 |
|
if ($headerValue !== null) { |
120
|
7 |
|
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
|
11 |
|
if (!is_string($headerValue)) { |
126
|
6 |
|
return null; |
127
|
|
|
} |
128
|
|
|
|
129
|
5 |
|
return strtolower($headerValue); |
130
|
|
|
} |
131
|
|
|
} |
132
|
|
|
|