Passed
Push — main ( 369dbb...9991ca )
by Daniel
02:32
created

LanguageNegotiator   A

Complexity

Total Complexity 10

Size/Duplication

Total Lines 114
Duplicated Lines 0 %

Test Coverage

Coverage 94.87%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 10
eloc 35
c 1
b 0
f 0
dl 0
loc 114
ccs 37
cts 39
cp 0.9487
rs 10

4 Methods

Rating   Name   Duplication   Size   Complexity  
A process() 0 10 1
A __construct() 0 6 1
A normalizeAcceptLanguageString() 0 20 4
A negotiate() 0 44 4
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);
0 ignored issues
show
Bug Best Practice introduced by
The expression return key($result) could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
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 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