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

Locale::withSaveLocale()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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