Passed
Pull Request — master (#63)
by
unknown
02:34
created

Locale::assertLocalesFormat()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4

Importance

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