Passed
Push — master ( aac064...084c0d )
by Alexander
03:43 queued 47s
created

Locale::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 8
dl 0
loc 12
ccs 3
cts 3
cp 1
crap 1
rs 10

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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 42
    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 42
        $this->assertSupportedLocalesFormat($supportedLocales);
71 38
        $this->supportedLocales = $supportedLocales;
72
    }
73
74 35
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
75
    {
76 35
        if (empty($this->supportedLocales)) {
77 1
            return $handler->handle($request);
78
        }
79
80 34
        $uri = $request->getUri();
81 34
        $path = $uri->getPath();
82 34
        $query = $uri->getQuery();
83 34
        $locale = $this->getLocaleFromPath($path);
84
85 34
        if ($locale !== null) {
86 16
            if ($locale === $this->defaultLocale && $request->getMethod() === Method::GET) {
87 16
                return $this->saveLocale(
88 16
                    $locale,
89 16
                    $this->createRedirectResponse(substr($path, strlen($locale) + 1) ?: '/', $query)
90 16
                );
91
            }
92
        } else {
93
            /** @psalm-var array<string, string> $queryParameters */
94 18
            $queryParameters = $request->getQueryParams();
95 18
            $locale = $this->getLocaleFromQuery($queryParameters);
96
97 18
            if ($locale === null && $this->cookieDuration !== null) {
98
                /** @psalm-var array<string, string> $cookieParameters */
99 3
                $cookieParameters = $request->getCookieParams();
100 3
                $locale = $this->getLocaleFromCookies($cookieParameters);
101
            }
102
103 18
            if ($locale === null && $this->detectLocale) {
104 7
                $locale = $this->detectLocale($request);
105
            }
106
107 18
            if ($locale === null || $locale === $this->defaultLocale || $this->isRequestIgnored($request)) {
108 4
                $this->urlGenerator->setDefaultArgument($this->queryParameterName, null);
109 4
                $request = $request->withUri($uri->withPath('/' . $this->defaultLocale . $path));
110
111 4
                return $handler->handle($request);
112
            }
113
114 14
            if ($request->getMethod() === Method::GET) {
115 13
                return $this->createRedirectResponse('/' . $locale . $path, $query);
116
            }
117
        }
118
119 13
        $response = $handler->handle($request);
120
121
        /** @var string $locale */
122 13
        $this->eventDispatcher->dispatch(new SetLocaleEvent($this->supportedLocales[$locale]));
123 13
        $this->urlGenerator->setDefaultArgument($this->queryParameterName, $locale);
124
125 13
        return $this->saveLocale($locale, $response);
126
    }
127
128 17
    private function createRedirectResponse(string $path, string $query): ResponseInterface
129
    {
130 17
        return $this
131 17
            ->responseFactory
132 17
            ->createResponse(Status::FOUND)
133 17
            ->withHeader(
134 17
                Header::LOCATION,
135 17
                $this->getBaseUrl() . $path . ($query !== '' ? '?' . $query : '')
136 17
            );
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 16
            $matchedLocale = $matches[1];
150 16
            if (!isset($this->supportedLocales[$matchedLocale])) {
151 1
                $matchedLocale = $this->parseLocale($matchedLocale);
152
            }
153 16
            if (isset($this->supportedLocales[$matchedLocale])) {
154 16
                $this->logger->debug(sprintf("Locale '%s' found in URL.", $matchedLocale));
155 16
                return $matchedLocale;
156
            }
157
        }
158 18
        return null;
159
    }
160
161
    /**
162
     * @psalm-param array<string, string> $queryParameters
163
     */
164 18
    private function getLocaleFromQuery($queryParameters): ?string
165
    {
166 18
        if (!isset($queryParameters[$this->queryParameterName])) {
167 11
            return null;
168
        }
169
170 7
        $this->logger->debug(
171 7
            sprintf("Locale '%s' found in query string.", $queryParameters[$this->queryParameterName]),
172 7
        );
173
174 7
        return $this->parseLocale($queryParameters[$this->queryParameterName]);
175
    }
176
177
    /**
178
     * @psalm-param array<string, string> $cookieParameters
179
     */
180 3
    private function getLocaleFromCookies($cookieParameters): ?string
181
    {
182 3
        if (!isset($cookieParameters[$this->cookieName])) {
183 1
            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 17
    private function saveLocale(string $locale, ResponseInterface $response): ResponseInterface
205
    {
206 17
        if ($this->cookieDuration === null) {
207 12
            return $response;
208
        }
209
210 5
        $this->logger->debug('Saving found locale to cookies.');
211 5
        $cookie = new Cookie(name: $this->cookieName, value: $locale, secure: $this->secureCookie);
212 5
        $cookie = $cookie->withMaxAge($this->cookieDuration);
213
214 5
        return $cookie->addToResponse($response);
215
    }
216
217 15
    private function parseLocale(string $locale): string
218
    {
219 15
        foreach (self::LOCALE_SEPARATORS as $separator) {
220 15
            $separatorPosition = strpos($locale, $separator);
221 15
            if ($separatorPosition !== false) {
222 6
                return substr($locale, 0, $separatorPosition);
223
            }
224
        }
225
226 9
        return $locale;
227
    }
228
229 15
    private function isRequestIgnored(ServerRequestInterface $request): bool
230
    {
231 15
        foreach ($this->ignoredRequestUrlPatterns as $ignoredRequest) {
232 1
            if ((new WildcardPattern($ignoredRequest))->match($request->getUri()->getPath())) {
233 1
                return true;
234
            }
235
        }
236 14
        return false;
237
    }
238
239
    /**
240
     * @psalm-assert array<string, string> $supportedLocales
241
     *
242
     * @throws InvalidLocalesFormatException
243
     */
244 42
    private function assertSupportedLocalesFormat(array $supportedLocales): void
245
    {
246 42
        foreach ($supportedLocales as $code => $locale) {
247 41
            if (!is_string($code) || !is_string($locale)) {
248 4
                throw new InvalidLocalesFormatException();
249
            }
250
        }
251
    }
252
253 17
    private function getBaseUrl(): string
254
    {
255 17
        return rtrim($this->urlGenerator->getUriPrefix(), '/');
256
    }
257
258
    /**
259
     * Return new instance with supported locales specified.
260
     *
261
     * @param array $locales List of supported locales in key-value format such as `['ru' => 'ru_RU', 'uz' => 'uz_UZ']`.
262
     *
263
     * @throws InvalidLocalesFormatException
264
     */
265 1
    public function withSupportedLocales(array $locales): self
266
    {
267 1
        $this->assertSupportedLocalesFormat($locales);
268 1
        $new = clone $this;
269 1
        $new->supportedLocales = $locales;
270 1
        return $new;
271
    }
272
273
    /**
274
     * Return new instance with default locale specified.
275
     *
276
     * @param string $defaultLocale Default locale. It must be present as a key in {@see $supportedLocales}.
277
     */
278 8
    public function withDefaultLocale(string $defaultLocale): self
279
    {
280 8
        if (!array_key_exists($defaultLocale, $this->supportedLocales)) {
281 2
            throw new InvalidArgumentException('Default locale allows only keys from supported locales.');
282
        }
283
284 6
        $new = clone $this;
285 6
        $new->defaultLocale = $defaultLocale;
286 6
        return $new;
287
    }
288
289
    /**
290
     * Return new instance with the name of the query string parameter to look for locale.
291
     *
292
     * @param string $queryParameterName Name of the query string parameter.
293
     */
294 2
    public function withQueryParameterName(string $queryParameterName): self
295
    {
296 2
        $new = clone $this;
297 2
        $new->queryParameterName = $queryParameterName;
298 2
        return $new;
299
    }
300
301
    /**
302
     * Return new instance with the name of cookie parameter to store found locale. Effective only when
303
     * {@see $cookieDuration} isn't `null`.
304
     *
305
     * @param string $cookieName Name of cookie parameter.
306
     */
307 3
    public function withCookieName(string $cookieName): self
308
    {
309 3
        $new = clone $this;
310 3
        $new->cookieName = $cookieName;
311 3
        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 9
    public function withDetectLocale(bool $enabled): self
320
    {
321 9
        $new = clone $this;
322 9
        $new->detectLocale = $enabled;
323 9
        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 3
    public function withSecureCookie(bool $secure): self
344
    {
345 3
        $new = clone $this;
346 3
        $new->secureCookie = $secure;
347 3
        return $new;
348
    }
349
350
    /**
351
     * Return new instance with changed cookie duration.
352
     *
353
     * @param ?DateInterval $cookieDuration Locale cookie lifetime. When set to `null`, saving locale to cookies is
354
     * disabled completely.
355
     */
356 5
    public function withCookieDuration(?DateInterval $cookieDuration): self
357
    {
358 5
        $new = clone $this;
359 5
        $new->cookieDuration = $cookieDuration;
360 5
        return $new;
361
    }
362
}
363