Passed
Push — main ( 3e73b5...8dceb6 )
by Daniel
02:42
created

LanguageNegotiator::negotiate()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 44
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 19
c 1
b 0
f 0
dl 0
loc 44
ccs 22
cts 22
cp 1
rs 9.6333
cc 4
nc 4
nop 1
crap 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 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
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 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