Passed
Pull Request — master (#24)
by
unknown
01:47
created

UrlGenerator::generate()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 33
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4.0039

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 16
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 33
rs 9.7333
ccs 15
cts 16
cp 0.9375
crap 4.0039
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Router\FastRoute;
6
7
use Nyholm\Psr7\Uri;
8
use Yiisoft\Router\Group;
9
use Yiisoft\Router\Route;
10
use Yiisoft\Router\RouteCollectorInterface;
11
use Yiisoft\Router\RouteNotFoundException;
12
use Yiisoft\Router\UrlMatcherInterface;
13
use Yiisoft\Router\UrlGeneratorInterface;
14
use FastRoute\RouteParser;
15
16
use function array_key_exists;
17
use function array_keys;
18
use function implode;
19
use function is_string;
20
use function preg_match;
21
22
final class UrlGenerator implements UrlGeneratorInterface
23
{
24
    /** @var string */
25
    private string $uriPrefix = '';
26
27
    /**
28
     * All attached routes as Route instances
29
     *
30
     * @var Route[]
31
     */
32
    private array $routes = [];
33
34
    /**
35
     * @var UrlMatcherInterface $matcher
36
     */
37
    private UrlMatcherInterface $matcher;
38
39
    /**
40
     * @var RouteCollectorInterface $collector
41
     */
42
    private RouteCollectorInterface $collector;
43
44
    /**
45
     * @var RouteParser
46
     */
47
    private RouteParser $routeParser;
48
49
    /**
50
     * Constructor
51
     *
52
     * @param UrlMatcherInterface $matcher url matcher
53
     * @param RouteCollectorInterface $collector route collector
54
     */
55 26
    public function __construct(
56
        UrlMatcherInterface $matcher,
57
        RouteCollectorInterface $collector,
58
        RouteParser $parser = null
59
    ) {
60 26
        $this->matcher = $matcher;
61 26
        $this->collector = $collector;
62 26
        $this->routeParser = $parser ?? new RouteParser\Std();
63
    }
64
65
    /**
66
     * Generate a URI based on a given route.
67
     *
68
     * Replacements in FastRoute are written as `{name}` or `{name:<pattern>}`;
69
     * this method uses `FastRoute\RouteParser\Std` to search for the best route
70
     * match based on the available substitutions and generates a uri.
71
     *
72
     * @param string $name Route name.
73
     * @param array $parameters Key/value option pairs to pass to the router for
74
     * purposes of generating a URI; takes precedence over options present
75
     * in route used to generate URI.
76
     *
77
     * @return string URI path generated.
78
     * @throws \RuntimeException if the route name is not known or a parameter value does not match its regex.
79
     */
80 26
    public function generate(string $name, array $parameters = []): string
81
    {
82
        // Inject any pending route items
83 26
        $this->injectItems();
84
85 26
        $route = $this->getRoute($name);
86
87 25
        $parsedRoutes = array_reverse($this->routeParser->parse($route->getPattern()));
88 25
        if ($parsedRoutes === []) {
89
            throw new RouteNotFoundException($name);
90
        }
91
92 25
        $missingParameters = [];
93
94
        // One route pattern can correspond to multiple routes if it has optional parts
95 25
        foreach ($parsedRoutes as $parsedRouteParts) {
96
            // Check if all parameters can be substituted
97 25
            $missingParameters = $this->missingParameters($parsedRouteParts, $parameters);
98
99
            // If not all parameters can be substituted, try the next route
100 25
            if (!empty($missingParameters)) {
101 3
                continue;
102
            }
103
104 23
            return $this->generatePath($parameters, $parsedRouteParts);
105
        }
106
107
        // No valid route was found: list minimal required parameters
108 2
        throw new \RuntimeException(sprintf(
109 2
            'Route `%s` expects at least parameter values for [%s], but received [%s]',
110
            $name,
111 2
            implode(',', $missingParameters),
112 2
            implode(',', array_keys($parameters))
113
        ));
114
    }
115
116
    /**
117
     * Generates absolute URL from named route and parameters
118
     *
119
     * @param string $name name of the route
120
     * @param array $parameters parameter-value set
121
     * @param string|null $scheme host scheme
122
     * @param string|null $host host for manual setup
123
     * @return string URL generated
124
     * @throws RouteNotFoundException in case there is no route with the name specified
125
     */
126 15
    public function generateAbsolute(string $name, array $parameters = [], string $scheme = null, string $host = null): string
127
    {
128 15
        $url = $this->generate($name, $parameters);
129 15
        $route = $this->getRoute($name);
130
        /** @var Uri $uri */
131 15
        $uri = $this->matcher->getLastMatchedRequest() !== null ? $this->matcher->getLastMatchedRequest()->getUri() : null;
0 ignored issues
show
Bug introduced by
The method getLastMatchedRequest() does not exist on Yiisoft\Router\UrlMatcherInterface. It seems like you code against a sub-type of Yiisoft\Router\UrlMatcherInterface such as Yiisoft\Router\FastRoute\FastRoute. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

131
        $uri = $this->matcher->/** @scrutinizer ignore-call */ getLastMatchedRequest() !== null ? $this->matcher->getLastMatchedRequest()->getUri() : null;
Loading history...
132 15
        $lastRequestScheme = $uri !== null ? $uri->getScheme() : null;
133
134 15
        if ($host !== null || ($host = $route->getHost()) !== null) {
135 10
            if ($scheme === null && !$this->isRelative($host)) {
136 6
                return rtrim($host, '/') . $url;
137
            }
138
139 4
            if ($scheme === '' && $host !== '' && $this->isRelative($host)) {
140 2
                $host = '//' . $host;
141
            }
142
143 4
            return $this->ensureScheme(rtrim($host, '/') . $url, $scheme ?? $lastRequestScheme);
144
        }
145
146 5
        return $uri === null ? $url : $this->generateAbsoluteFromLastMatchedRequest($url, $uri, $scheme);
147
    }
148
149 4
    private function generateAbsoluteFromLastMatchedRequest(string $url, Uri $uri, ?string $scheme)
150
    {
151 4
        $port = $uri->getPort() === 80 || $uri->getPort() === null ? '' : ':' . $uri->getPort();
152 4
        return  $this->ensureScheme('://' . $uri->getHost() . $port . $url, $scheme ?? $uri->getScheme());
153
    }
154
155
    /**
156
     * Normalize URL by ensuring that it use specified scheme.
157
     *
158
     * If URL is relative or scheme is null, normalization is skipped.
159
     *
160
     * @param string $url the URL to process
161
     * @param string|null $scheme the URI scheme used in URL (e.g. `http` or `https`). Use empty string to
162
     * create protocol-relative URL (e.g. `//example.com/path`)
163
     * @return string the processed URL
164
     */
165 8
    private function ensureScheme(string $url, ?string $scheme): string
166
    {
167 8
        if ($scheme === null || $this->isRelative($url)) {
168
            return $url;
169
        }
170
171 8
        if (strpos($url, '//') === 0) {
172
            // e.g. //example.com/path/to/resource
173 2
            return $scheme === '' ? $url : "$scheme:$url";
174
        }
175
176 8
        if (($pos = strpos($url, '://')) !== false) {
177 8
            if ($scheme === '') {
178 3
                $url = substr($url, $pos + 1);
179
            } else {
180 5
                $url = $scheme . substr($url, $pos);
181
            }
182
        }
183
184 8
        return $url;
185
    }
186
187
    /**
188
     * Returns a value indicating whether a URL is relative.
189
     * A relative URL does not have host info part.
190
     * @param string $url the URL to be checked
191
     * @return bool whether the URL is relative
192
     */
193 14
    private function isRelative(string $url): bool
194
    {
195 14
        return strncmp($url, '//', 2) && strpos($url, '://') === false;
196
    }
197
198
199 23
    public function getUriPrefix(): string
200
    {
201 23
        return $this->uriPrefix;
202
    }
203
204
    public function setUriPrefix(string $prefix): void
205
    {
206
        $this->uriPrefix = $prefix;
207
    }
208
209
    /**
210
     * Checks for any missing route parameters
211
     * @param array $parts
212
     * @param array $substitutions
213
     * @return array with minimum required parameters if any are missing or an empty array if none are missing
214
     */
215 25
    private function missingParameters(array $parts, array $substitutions): array
216
    {
217 25
        $missingParameters = [];
218
219
        // Gather required parameters
220 25
        foreach ($parts as $part) {
221 25
            if (is_string($part)) {
222 25
                continue;
223
            }
224
225 9
            $missingParameters[] = $part[0];
226
        }
227
228
        // Check if all parameters exist
229 25
        foreach ($missingParameters as $parameter) {
230 9
            if (!array_key_exists($parameter, $substitutions)) {
231
                // Return the parameters so they can be used in an
232
                // exception if needed
233 3
                return $missingParameters;
234
            }
235
        }
236
237
        // All required parameters are available
238 23
        return [];
239
    }
240
241
    /**
242
     * @param string $name
243
     * @return Route
244
     */
245 26
    private function getRoute(string $name): Route
246
    {
247 26
        if (!array_key_exists($name, $this->routes)) {
248 1
            throw new RouteNotFoundException($name);
249
        }
250
251 25
        return $this->routes[$name];
252
    }
253
254
    /**
255
     * @param array $parameters
256
     * @param array $parts
257
     * @return string
258
     */
259 23
    private function generatePath(array $parameters, array $parts): string
260
    {
261 23
        $notSubstitutedParams = $parameters;
262 23
        $path = $this->getUriPrefix();
263
264 23
        foreach ($parts as $part) {
265 23
            if (is_string($part)) {
266
                // Append the string
267 23
                $path .= $part;
268 23
                continue;
269
            }
270
271
            // Check substitute value with regex
272 6
            $pattern = str_replace('~', '\~', $part[1]);
273 6
            if (preg_match('~^' . $pattern . '$~', (string)$parameters[$part[0]]) === 0) {
274 1
                throw new \RuntimeException(
275 1
                    sprintf(
276 1
                        'Parameter value for [%s] did not match the regex `%s`',
277 1
                        $part[0],
278 1
                        $part[1]
279
                    )
280
                );
281
            }
282
283
            // Append the substituted value
284 5
            $path .= $parameters[$part[0]];
285 5
            unset($notSubstitutedParams[$part[0]]);
286
        }
287
288 22
        return $path . ($notSubstitutedParams !== [] ? '?' . http_build_query($notSubstitutedParams) : '');
289
    }
290
291
    /**
292
     * Inject queued items into the underlying router
293
     */
294 26
    private function injectItems(): void
295
    {
296 26
        if ($this->routes === []) {
297 26
            $items = $this->collector->getItems();
0 ignored issues
show
Bug introduced by
The method getItems() does not exist on Yiisoft\Router\RouteCollectorInterface. It seems like you code against a sub-type of said class. However, the method does not exist in Yiisoft\Router\RouterInterface. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

297
            /** @scrutinizer ignore-call */ 
298
            $items = $this->collector->getItems();
Loading history...
298 26
            foreach ($items as $index => $item) {
299 26
                $this->injectItem($item);
300
            }
301
        }
302
    }
303
304
    /**
305
     * Inject an item into the underlying router
306
     * @param Route|Group $route
307
     */
308 26
    private function injectItem($route): void
309
    {
310 26
        if ($route instanceof Group) {
311 2
            $this->injectGroup($route);
312 2
            return;
313
        }
314
315 24
        $this->routes[$route->getName()] = $route;
316
    }
317
318
    /**
319
     * Inject a Group instance into the underlying router.
320
     */
321 2
    private function injectGroup(Group $group, string $prefix = ''): void
322
    {
323 2
        $prefix .= $group->getPrefix();
324
        /** @var $items Group[]|Route[]*/
325 2
        $items = $group->getItems();
326 2
        foreach ($items as $index => $item) {
327 2
            if ($item instanceof Group) {
328 1
                $this->injectGroup($item, $prefix);
329 1
                continue;
330
            }
331
332
            /** @var Route $modifiedItem */
333 2
            $modifiedItem = $item->pattern($prefix . $item->getPattern());
334 2
            $this->routes[$modifiedItem->getName()] = $modifiedItem;
335
        }
336
    }
337
}
338