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

Locale::createRedirectResponse()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 1
nop 2
dl 0
loc 8
ccs 7
cts 7
cp 1
crap 2
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\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