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

UrlGenerator::ensureScheme()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 7.049

Importance

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

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

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