Passed
Pull Request — master (#97)
by Sergei
03:07
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 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