Passed
Pull Request — master (#82)
by
unknown
02:27
created

Locale   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 375
Duplicated Lines 0 %

Test Coverage

Coverage 99.32%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 134
c 2
b 0
f 0
dl 0
loc 375
ccs 145
cts 146
cp 0.9932
rs 6
wmc 55

22 Methods

Rating   Name   Duplication   Size   Complexity  
A getLocaleFromQuery() 0 11 2
A isRequestIgnored() 0 8 3
A withDetectLocale() 0 5 1
A withCookieName() 0 5 1
A saveLocale() 0 16 3
A withSupportedLocales() 0 6 1
A __construct() 0 13 1
A withQueryParameterName() 0 5 1
A withDefaultLocale() 0 9 2
A parseLocale() 0 10 3
A withSaveLocale() 0 5 1
A getLocaleFromCookies() 0 9 2
A withIgnoredRequestUrlPatterns() 0 5 1
A withSession() 0 5 1
A detectLocale() 0 11 4
C process() 0 61 15
A withSessionName() 0 5 1
A getBaseUrl() 0 3 1
A withSecureCookie() 0 5 1
A withCookieDuration() 0 5 1
A getLocaleFromPath() 0 20 5
A assertSupportedLocalesFormat() 0 5 4

How to fix   Complexity   

Complex Class

Complex classes like Locale often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Locale, and based on these observations, apply Extract Interface, too.

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