Passed
Push — master ( bc8f5e...d557db )
by
unknown
02:49
created

Locale::withCookieDuration()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 3
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 5
ccs 4
cts 4
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Middleware;
6
7
use DateInterval;
8
use InvalidArgumentException;
9
use Psr\Http\Message\ResponseFactoryInterface;
10
use Psr\Http\Message\ResponseInterface;
11
use Psr\Http\Message\ServerRequestInterface;
12
use Psr\Http\Server\MiddlewareInterface;
13
use Psr\Http\Server\RequestHandlerInterface;
14
use Psr\Log\LoggerInterface;
15
use Yiisoft\Cookies\Cookie;
16
use Yiisoft\Http\Header;
17
use Yiisoft\Http\Method;
18
use Yiisoft\Http\Status;
19
use Yiisoft\Router\UrlGeneratorInterface;
20
use Yiisoft\Session\SessionInterface;
21
use Yiisoft\Strings\WildcardPattern;
22
use Yiisoft\Translator\TranslatorInterface;
23
use Yiisoft\Yii\Middleware\Exception\InvalidLocalesFormatException;
24
25
use function array_key_exists;
26
27
/**
28
 * Locale middleware supports locale-based routing and configures translator and URL generator.
29
 * You should place it before `Route` middleware in the middleware list.
30
 */
31
final class Locale implements MiddlewareInterface
32
{
33
    private const DEFAULT_LOCALE = 'en';
34
    private const DEFAULT_LOCALE_NAME = '_language';
35
    private const LOCALE_SEPARATORS = ['-', '_'];
36
37
    private bool $saveLocale = true;
38
    private bool $detectLocale = false;
39
    private string $defaultLocale = self::DEFAULT_LOCALE;
40
    private string $queryParameterName = self::DEFAULT_LOCALE_NAME;
41
    private string $sessionName = self::DEFAULT_LOCALE_NAME;
42
    private string $cookieName = self::DEFAULT_LOCALE_NAME;
43
    /**
44
     * @psalm-var array<string, string>
45
     */
46
    private array $supportedLocales;
47
48
    /**
49
     * @param TranslatorInterface $translator Translator instance to set locale for.
50
     * @param UrlGeneratorInterface $urlGenerator URL generator instance to set locale for.
51
     * @param SessionInterface $session Session instance to save locale to.
52
     * @param LoggerInterface $logger Logger instance to write debug logs to.
53
     * @param ResponseFactoryInterface $responseFactory Response factory used to create redirect responses.
54
     * @param array $supportedLocales List of supported locales in key-value format such as `['ru' => 'ru_RU', 'uz' => 'uz_UZ']`.
55
     * @param string[] $ignoredRequestUrlPatterns {@see WildcardPattern Patterns} for ignoring requests with URLs matching.
56
     * @param ?DateInterval $cookieDuration Locale cookie lifetime. Effective only when {@see $saveLocale} is set to
57
     * `true`. `null` disables saving locale to cookies completely.
58
     * @param bool $secureCookie Whether middleware should flag locale cookie as "secure". Effective only when
59
     * {@see $saveLocale} is set to `true` and {@see $cookieDuration} is not `null`.
60
     */
61 42
    public function __construct(
62
        private TranslatorInterface $translator,
63
        private UrlGeneratorInterface $urlGenerator,
64
        private SessionInterface $session,
65
        private LoggerInterface $logger,
66
        private ResponseFactoryInterface $responseFactory,
67
        array $supportedLocales = [],
68
        private array $ignoredRequestUrlPatterns = [],
69
        private bool $secureCookie = false,
70
        private ?DateInterval $cookieDuration = null,
71
    ) {
72 42
        $this->assertSupportedLocalesFormat($supportedLocales);
73 38
        $this->supportedLocales = $supportedLocales;
74
    }
75
76 35
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
77
    {
78 35
        if (empty($this->supportedLocales)) {
79 1
            return $handler->handle($request);
80
        }
81
82 34
        $uri = $request->getUri();
83 34
        $path = $uri->getPath();
84 34
        $query = $uri->getQuery();
85 34
        $newPath = null;
86 34
        $locale = $this->getLocaleFromPath($path);
87
88 34
        if ($locale !== null) {
89 18
            if ($request->getMethod() === Method::GET) {
90 18
                $newPath = substr($path, strlen($locale) + 1) ?: '/';
91
            }
92
        } else {
93
            /** @psalm-var array<string, string> $queryParameters */
94 16
            $queryParameters = $request->getQueryParams();
95 16
            $locale = $this->getLocaleFromQuery($queryParameters);
96
97 16
            if ($locale === null) {
98
                /** @psalm-var array<string, string> $cookieParameters */
99 10
                $cookieParameters = $request->getCookieParams();
100 10
                $locale = $this->getLocaleFromCookies($cookieParameters);
101
            }
102
103 16
            if ($locale === null && $this->detectLocale) {
104 7
                $locale = $this->detectLocale($request);
105
            }
106
107 16
            if ($locale === null || $locale === $this->defaultLocale || $this->isRequestIgnored($request)) {
108 3
                $this->urlGenerator->setDefaultArgument($this->queryParameterName, null);
109 3
                $request = $request->withUri($uri->withPath('/' . $this->defaultLocale . $path));
110
111 3
                return $handler->handle($request);
112
            }
113
114 13
            if ($request->getMethod() === Method::GET) {
115 12
                $newPath = '/' . $locale . $path;
116
            }
117
        }
118
119 31
        $response = $handler->handle($request);
120 31
        if ($newPath !== null) {
121 29
            $location = $this->getBaseUrl() . $newPath . ($query !== '' ? '?' . $query : '');
122 29
            $response = $this
123 29
                ->responseFactory
124 29
                ->createResponse(Status::FOUND)
125 29
                ->withHeader(Header::LOCATION, $location);
126
        }
127
128
        /** @var string $locale */
129 31
        $this->translator->setLocale($this->supportedLocales[$locale]);
130 31
        $this->urlGenerator->setDefaultArgument($this->queryParameterName, $locale);
131
132 31
        if ($this->saveLocale) {
133 30
            $response = $this->saveLocale($locale, $response);
134
        }
135
136 31
        return $response;
137
    }
138
139 34
    private function getLocaleFromPath(string $path): ?string
140
    {
141 34
        $parts = [];
142 34
        foreach ($this->supportedLocales as $code => $locale) {
143 34
            $parts[] = $code;
144 34
            $parts[] = $locale;
145
        }
146
147 34
        $pattern = implode('|', $parts);
148 34
        if (preg_match("#^/($pattern)\b(/?)#i", $path, $matches)) {
149 18
            $matchedLocale = $matches[1];
150 18
            if (!isset($this->supportedLocales[$matchedLocale])) {
151 1
                $matchedLocale = $this->parseLocale($matchedLocale);
152
            }
153 18
            if (isset($this->supportedLocales[$matchedLocale])) {
154 18
                $this->logger->debug(sprintf("Locale '%s' found in URL.", $matchedLocale));
155 18
                return $matchedLocale;
156
            }
157
        }
158 16
        return null;
159
    }
160
161
    /**
162
     * @psalm-param array<string, string> $queryParameters
163
     */
164 16
    private function getLocaleFromQuery($queryParameters): ?string
165
    {
166 16
        if (!isset($queryParameters[$this->queryParameterName])) {
167 10
            return null;
168
        }
169
170 6
        $this->logger->debug(
171 6
            sprintf("Locale '%s' found in query string.", $queryParameters[$this->queryParameterName]),
172 6
        );
173
174 6
        return $this->parseLocale($queryParameters[$this->queryParameterName]);
175
    }
176
177
    /**
178
     * @psalm-param array<string, string> $cookieParameters
179
     */
180 10
    private function getLocaleFromCookies($cookieParameters): ?string
181
    {
182 10
        if (!isset($cookieParameters[$this->cookieName])) {
183 8
            return null;
184
        }
185
186 2
        $this->logger->debug(sprintf("Locale '%s' found in cookies.", $cookieParameters[$this->cookieName]));
187
188 2
        return $this->parseLocale($cookieParameters[$this->cookieName]);
189
    }
190
191 7
    private function detectLocale(ServerRequestInterface $request): ?string
192
    {
193 7
        foreach ($request->getHeader(Header::ACCEPT_LANGUAGE) as $language) {
194 6
            if (!isset($this->supportedLocales[$language])) {
195 5
                $language = $this->parseLocale($language);
196
            }
197 6
            if (isset($this->supportedLocales[$language])) {
198 6
                return $language;
199
            }
200
        }
201 1
        return null;
202
    }
203
204 30
    private function saveLocale(string $locale, ResponseInterface $response): ResponseInterface
205
    {
206 30
        $this->logger->debug('Saving found locale to session.');
207 30
        $this->session->set($this->sessionName, $locale);
208
209 30
        if ($this->cookieDuration === null) {
210 26
            return $response;
211
        }
212
213 4
        $this->logger->debug('Saving found locale to cookies.');
214 4
        $cookie = new Cookie(name: $this->cookieName, value: $locale, secure: $this->secureCookie);
215 4
        $cookie = $cookie->withMaxAge($this->cookieDuration);
216
217 4
        return $cookie->addToResponse($response);
218
    }
219
220 14
    private function parseLocale(string $locale): string
221
    {
222 14
        foreach (self::LOCALE_SEPARATORS as $separator) {
223 14
            $separatorPosition = strpos($locale, $separator);
224 14
            if ($separatorPosition !== false) {
225 6
                return substr($locale, 0, $separatorPosition);
226
            }
227
        }
228
229 8
        return $locale;
230
    }
231
232 14
    private function isRequestIgnored(ServerRequestInterface $request): bool
233
    {
234 14
        foreach ($this->ignoredRequestUrlPatterns as $ignoredRequest) {
235 1
            if ((new WildcardPattern($ignoredRequest))->match($request->getUri()->getPath())) {
236 1
                return true;
237
            }
238
        }
239 13
        return false;
240
    }
241
242
    /**
243
     * @psalm-assert array<string, string> $supportedLocales
244
     *
245
     * @throws InvalidLocalesFormatException
246
     */
247 42
    private function assertSupportedLocalesFormat(array $supportedLocales): void
248
    {
249 42
        foreach ($supportedLocales as $code => $locale) {
250 41
            if (!is_string($code) || !is_string($locale)) {
251 4
                throw new InvalidLocalesFormatException();
252
            }
253
        }
254
    }
255
256 29
    private function getBaseUrl(): string
257
    {
258 29
        return rtrim($this->urlGenerator->getUriPrefix(), '/');
259
    }
260
261
    /**
262
     * Return new instance with supported locales specified.
263
     *
264
     * @param array $locales List of supported locales in key-value format such as `['ru' => 'ru_RU', 'uz' => 'uz_UZ']`.
265
     *
266
     * @throws InvalidLocalesFormatException
267
     */
268 1
    public function withSupportedLocales(array $locales): self
269
    {
270 1
        $this->assertSupportedLocalesFormat($locales);
271 1
        $new = clone $this;
272 1
        $new->supportedLocales = $locales;
273 1
        return $new;
274
    }
275
276
    /**
277
     * Return new instance with default locale specified.
278
     *
279
     * @param string $defaultLocale Default locale. It must be present as a key in {@see $supportedLocales}.
280
     */
281 8
    public function withDefaultLocale(string $defaultLocale): self
282
    {
283 8
        if (!array_key_exists($defaultLocale, $this->supportedLocales)) {
284 2
            throw new InvalidArgumentException('Default locale allows only keys from supported locales.');
285
        }
286
287 6
        $new = clone $this;
288 6
        $new->defaultLocale = $defaultLocale;
289 6
        return $new;
290
    }
291
292
    /**
293
     * Return new instance with the name of the query string parameter to look for locale.
294
     *
295
     * @param string $queryParameterName Name of the query string parameter.
296
     */
297 2
    public function withQueryParameterName(string $queryParameterName): self
298
    {
299 2
        $new = clone $this;
300 2
        $new->queryParameterName = $queryParameterName;
301 2
        return $new;
302
    }
303
304
    /**
305
     * Return new instance with the name of session parameter to store found locale.
306
     *
307
     * @param string $sessionName Name of session parameter.
308
     */
309 3
    public function withSessionName(string $sessionName): self
310
    {
311 3
        $new = clone $this;
312 3
        $new->sessionName = $sessionName;
313 3
        return $new;
314
    }
315
316
    /**
317
     * Return new instance with the name of cookie parameter to store found locale. Effective only when
318
     * {@see $saveLocale} is set to `true` and {@see $cookieDuration} is not `null`.
319
     *
320
     * @param string $sessionName Name of cookie parameter.
321
     */
322 3
    public function withCookieName(string $sessionName): self
323
    {
324 3
        $new = clone $this;
325 3
        $new->cookieName = $sessionName;
326 3
        return $new;
327
    }
328
329
    /**
330
     * Return new instance with enabled or disabled saving of locale. Locale is saved to session and optionally - to
331
     * cookies (when {@see $cookieDuration} is not `null`).
332
     *
333
     * @param bool $enabled Whether middleware should save locale.
334
     */
335 2
    public function withSaveLocale(bool $enabled): self
336
    {
337 2
        $new = clone $this;
338 2
        $new->saveLocale = $enabled;
339 2
        return $new;
340
    }
341
342
    /**
343
     * Return new instance with enabled or disabled detection of locale based on `Accept-Language` header.
344
     *
345
     * @param bool $enabled Whether middleware should detect locale.
346
     */
347 9
    public function withDetectLocale(bool $enabled): self
348
    {
349 9
        $new = clone $this;
350 9
        $new->detectLocale = $enabled;
351 9
        return $new;
352
    }
353
354
    /**
355
     * Return new instance with {@see WildcardPattern patterns} for ignoring requests with URLs matching.
356
     *
357
     * @param string[] $patterns Patterns.
358
     */
359 2
    public function withIgnoredRequestUrlPatterns(array $patterns): self
360
    {
361 2
        $new = clone $this;
362 2
        $new->ignoredRequestUrlPatterns = $patterns;
363 2
        return $new;
364
    }
365
366
    /**
367
     * Return new instance with enabled or disabled secure cookies.
368
     *
369
     * @param bool $secure Whether middleware should flag locale cookie as "secure."
370
     */
371 3
    public function withSecureCookie(bool $secure): self
372
    {
373 3
        $new = clone $this;
374 3
        $new->secureCookie = $secure;
375 3
        return $new;
376
    }
377
378
    /**
379
     * Return new instance with changed cookie duration.
380
     *
381
     * @param ?DateInterval $cookieDuration Locale cookie lifetime. When set to `null`, saving locale to cookies is
382
     * disabled completely.
383
     */
384 5
    public function withCookieDuration(?DateInterval $cookieDuration): self
385
    {
386 5
        $new = clone $this;
387 5
        $new->cookieDuration = $cookieDuration;
388 5
        return $new;
389
    }
390
}
391