Passed
Pull Request — master (#97)
by Sergei
03:07
created

Locale   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 324
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 117
dl 0
loc 324
ccs 130
cts 130
cp 1
rs 7.92
c 3
b 0
f 0
wmc 51

20 Methods

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

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