Passed
Pull Request — master (#107)
by Rustam
14:13 queued 11:55
created

UrlGenerator::getUriPrefix()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
ccs 2
cts 2
cp 1
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Router\FastRoute;
6
7
use FastRoute\RouteParser;
8
use InvalidArgumentException;
9
use Psr\Http\Message\UriInterface;
10
use RuntimeException;
11
use Stringable;
12
use Yiisoft\Router\RouteCollectionInterface;
13
use Yiisoft\Router\RouteNotFoundException;
14
use Yiisoft\Router\CurrentRoute;
15
use Yiisoft\Router\UrlGeneratorInterface;
16
17
use function array_key_exists;
18
use function array_keys;
19
use function implode;
20
use function is_string;
21
use function preg_match;
22
23
final class UrlGenerator implements UrlGeneratorInterface
24
{
25
    private string $uriPrefix = '';
26
27
    /**
28
     * @psalm-var array<string,string>
29
     */
30
    private array $defaultArguments = [];
31
    private bool $encodeRaw = true;
32
    private RouteCollectionInterface $routeCollection;
33
    private ?CurrentRoute $currentRoute;
34
    private RouteParser $routeParser;
35
36 47
    public function __construct(
37
        RouteCollectionInterface $routeCollection,
38
        CurrentRoute $currentRoute = null,
39
        RouteParser $parser = null
40
    ) {
41 47
        $this->currentRoute = $currentRoute;
42 47
        $this->routeCollection = $routeCollection;
43 47
        $this->routeParser = $parser ?? new RouteParser\Std();
44
    }
45
46
    /**
47
     * {@inheritDoc}
48
     *
49
     * Replacements in FastRoute are written as `{name}` or `{name:<pattern>}`;
50
     * this method uses {@see RouteParser\Std} to search for the best route
51
     * match based on the available substitutions and generates a URI.
52
     *
53
     * @throws RuntimeException If parameter value does not match its regex.
54
     */
55 43
    public function generate(string $name, array $arguments = [], array $queryParameters = []): string
56
    {
57 43
        $arguments = array_map('\strval', array_merge($this->defaultArguments, $arguments));
58 43
        $route = $this->routeCollection->getRoute($name);
59
        /** @psalm-var list<list<string|list<string>>> $parsedRoutes */
60 42
        $parsedRoutes = array_reverse($this->routeParser->parse($route->getData('pattern')));
61 42
        if ($parsedRoutes === []) {
62 1
            throw new RouteNotFoundException($name);
63
        }
64
65 41
        $missingArguments = [];
66
67
        // One route pattern can correspond to multiple routes if it has optional parts.
68 41
        foreach ($parsedRoutes as $parsedRouteParts) {
69
            // Check if all arguments can be substituted
70 41
            $missingArguments = $this->missingArguments($parsedRouteParts, $arguments);
71
72
            // If not all arguments can be substituted, try the next route.
73 41
            if (!empty($missingArguments)) {
74 3
                continue;
75
            }
76
77 39
            return $this->generatePath($arguments, $queryParameters, $parsedRouteParts);
78
        }
79
80
        // No valid route was found: list minimal required parameters.
81 2
        throw new RuntimeException(
82 2
            sprintf(
83
                'Route `%s` expects at least argument values for [%s], but received [%s]',
84
                $name,
85 2
                implode(',', $missingArguments),
86 2
                implode(',', array_keys($arguments))
87
            )
88
        );
89
    }
90
91 18
    public function generateAbsolute(
92
        string $name,
93
        array $arguments = [],
94
        array $queryParameters = [],
95
        string $scheme = null,
96
        string $host = null
97
    ): string {
98 18
        $arguments = array_map('\strval', $arguments);
99
100 18
        $url = $this->generate($name, $arguments, $queryParameters);
101 18
        $route = $this->routeCollection->getRoute($name);
102 18
        $uri = $this->currentRoute && $this->currentRoute->getUri() !== null ? $this->currentRoute->getUri() : null;
103 18
        $lastRequestScheme = $uri !== null ? $uri->getScheme() : null;
104
105 18
        if ($host !== null || ($host = $route->getData('host')) !== null) {
106 11
            if ($scheme === null && !$this->isRelative($host)) {
107 7
                return rtrim($host, '/') . $url;
108
            }
109
110 5
            if ((empty($scheme) || $lastRequestScheme === null) && $host !== '' && $this->isRelative($host)) {
111 3
                $host = '//' . $host;
112
            }
113
114 5
            return $this->ensureScheme(rtrim($host, '/') . $url, $scheme ?? $lastRequestScheme);
115
        }
116
117 7
        return $uri === null ? $url : $this->generateAbsoluteFromLastMatchedRequest($url, $uri, $scheme);
118
    }
119
120
    /**
121
     * {@inheritdoc}
122
     */
123 8
    public function generateFromCurrent(array $replacedArguments, string $fallbackRouteName = null): string
124
    {
125 8
        if ($this->currentRoute === null || $this->currentRoute->getName() === null) {
126 6
            if ($fallbackRouteName !== null) {
127 3
                return $this->generate($fallbackRouteName, $replacedArguments);
128
            }
129
130 3
            if ($this->currentRoute !== null && $this->currentRoute->getUri() !== null) {
131 1
                return $this->currentRoute
132 1
                    ->getUri()
133 1
                    ->getPath();
134
            }
135
136 2
            throw new RuntimeException('Current route is not detected.');
137
        }
138
139 2
        $queryParameters = [];
140 2
        if ($this->currentRoute->getUri() !== null) {
141 2
            parse_str($this->currentRoute->getUri()->getQuery(), $queryParameters);
142
        }
143
144
        /** @psalm-suppress PossiblyNullArgument Checked route name on null above. */
145 2
        return $this->generate(
146 2
            $this->currentRoute->getName(),
147 2
            array_merge($this->currentRoute->getArguments(), $replacedArguments),
148
            $queryParameters,
149
        );
150
    }
151
152
    /**
153
     * @psalm-param null|object|scalar $value
154
     */
155 12
    public function setDefaultArgument(string $name, $value): void
156
    {
157 12
        if (!is_scalar($value) && !$value instanceof Stringable && $value !== null) {
158
            throw new InvalidArgumentException('Default should be either scalar value or an instance of \Stringable.');
159
        }
160 12
        $this->defaultArguments[$name] = (string) $value;
161
    }
162
163 6
    private function generateAbsoluteFromLastMatchedRequest(string $url, UriInterface $uri, ?string $scheme): string
164
    {
165 6
        $port = '';
166 6
        $uriPort = $uri->getPort();
167 6
        if ($uriPort !== 80 && $uriPort !== null) {
168 1
            $port = ':' . $uriPort;
169
        }
170
171 6
        return $this->ensureScheme('://' . $uri->getHost() . $port . $url, $scheme ?? $uri->getScheme());
172
    }
173
174
    /**
175
     * Normalize URL by ensuring that it use specified scheme.
176
     *
177
     * If URL is relative or scheme is null, normalization is skipped.
178
     *
179
     * @param string $url The URL to process.
180
     * @param string|null $scheme The URI scheme used in URL (e.g. `http` or `https`). Use empty string to
181
     * create protocol-relative URL (e.g. `//example.com/path`).
182
     *
183
     * @return string The processed URL.
184
     */
185 11
    private function ensureScheme(string $url, ?string $scheme): string
186
    {
187 11
        if ($scheme === null || $this->isRelative($url)) {
188 1
            return $url;
189
        }
190
191 11
        if (strpos($url, '//') === 0) {
192
            // e.g. //example.com/path/to/resource
193 3
            return $scheme === '' ? $url : "$scheme:$url";
194
        }
195
196 10
        if (($pos = strpos($url, '://')) !== false) {
197 10
            if ($scheme === '') {
198 3
                $url = substr($url, $pos + 1);
199
            } else {
200 7
                $url = $scheme . substr($url, $pos);
201
            }
202
        }
203
204 10
        return $url;
205
    }
206
207
    /**
208
     * Returns a value indicating whether a URL is relative.
209
     * A relative URL does not have host info part.
210
     *
211
     * @param string $url The URL to be checked.
212
     *
213
     * @return bool Whether the URL is relative.
214
     */
215 17
    private function isRelative(string $url): bool
216
    {
217 17
        return strncmp($url, '//', 2) && strpos($url, '://') === false;
218
    }
219
220 40
    public function getUriPrefix(): string
221
    {
222 40
        return $this->uriPrefix;
223
    }
224
225 1
    public function setEncodeRaw(bool $encodeRaw): void
226
    {
227 1
        $this->encodeRaw = $encodeRaw;
228
    }
229
230 3
    public function setUriPrefix(string $name): void
231
    {
232 3
        $this->uriPrefix = $name;
233
    }
234
235
    /**
236
     * Checks for any missing route parameters.
237
     *
238
     * @param array $parts
239
     * @param array $substitutions
240
     *
241
     * @return string[] Either an array containing missing required parameters or an empty array if none are missing.
242
     *
243
     * @psalm-param list<string|list<string>> $parts
244
     */
245 41
    private function missingArguments(array $parts, array $substitutions): array
246
    {
247 41
        $missingArguments = [];
248
249
        // Gather required arguments.
250 41
        foreach ($parts as $part) {
251 41
            if (is_string($part)) {
252 41
                continue;
253
            }
254
255 22
            $missingArguments[] = $part[0];
256
        }
257
258
        // Check if all arguments exist.
259 41
        foreach ($missingArguments as $argument) {
260 22
            if (!array_key_exists($argument, $substitutions)) {
261
                // Return the arguments, so they can be used in an
262
                // exception if needed.
263 3
                return $missingArguments;
264
            }
265
        }
266
267
        // All required arguments are availgit logable.
268 39
        return [];
269
    }
270
271
    /**
272
     * @psalm-param array<string,string> $arguments
273
     * @psalm-param list<string|list<string>> $parts
274
     */
275 39
    private function generatePath(array $arguments, array $queryParameters, array $parts): string
276
    {
277 39
        $notSubstitutedArguments = $arguments;
278 39
        $path = $this->getUriPrefix();
279
280 39
        foreach ($parts as $part) {
281 39
            if (is_string($part)) {
282
                // Append the string.
283 39
                $path .= $part;
284 39
                continue;
285
            }
286
287 19
            if ($arguments[$part[0]] !== '') {
288
                // Check substitute value with regex.
289 19
                $pattern = str_replace('~', '\~', $part[1]);
290 19
                if (preg_match('~^' . $pattern . '$~', $arguments[$part[0]]) === 0) {
291 1
                    throw new RuntimeException(
292 1
                        sprintf(
293
                            'Argument value for [%s] did not match the regex `%s`',
294 1
                            $part[0],
295 1
                            $part[1]
296
                        )
297
                    );
298
                }
299
300
                // Append the substituted value.
301 18
                $path .= $this->encodeRaw
302 18
                    ? rawurlencode($arguments[$part[0]])
303 1
                    : urlencode($arguments[$part[0]]);
304
            }
305 18
            unset($notSubstitutedArguments[$part[0]]);
306
        }
307
308 38
        $path = str_replace('//', '/', $path);
309
310
        return $path . (
311 38
            $notSubstitutedArguments !== [] || $queryParameters !== [] ?
312 6
                '?' . http_build_query(array_merge($notSubstitutedArguments, $queryParameters))
313 38
                : ''
314
        );
315
    }
316
}
317