Test Failed
Pull Request — master (#97)
by Alexander
07:44 queued 04:53
created

Locale::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

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