Passed
Pull Request — master (#23)
by
unknown
12:28
created

FastRoute::generateAbsolute()   D

Complexity

Conditions 22
Paths 44

Size

Total Lines 31
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 22.0521

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 22
eloc 20
c 1
b 0
f 0
nc 44
nop 4
dl 0
loc 31
rs 4.1666
ccs 20
cts 21
cp 0.9524
crap 22.0521

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Router\FastRoute;
6
7
use FastRoute\Dispatcher;
8
use FastRoute\Dispatcher\GroupCountBased;
9
use FastRoute\RouteCollector;
10
use FastRoute\RouteParser;
11
use Psr\Http\Message\ServerRequestInterface;
12
use Yiisoft\Router\Group;
13
use Yiisoft\Router\MatchingResult;
14
use Yiisoft\Http\Method;
15
use Yiisoft\Router\Route;
16
use Yiisoft\Router\RouteNotFoundException;
17
use Yiisoft\Router\RouterInterface;
18
19
use function array_key_exists;
20
use function array_keys;
21
use function array_merge;
22
use function array_reduce;
23
use function array_unique;
24
use function dirname;
25
use function file_exists;
26
use function file_put_contents;
27
use function implode;
28
use function is_array;
29
use function is_dir;
30
use function is_string;
31
use function is_writable;
32
use function preg_match;
33
use function restore_error_handler;
34
use function set_error_handler;
35
use function sprintf;
36
use function var_export;
37
38
use const E_WARNING;
39
40
/**
41
 * Router implementation bridging nikic/fast-route.
42
 * Adapted from https://github.com/zendframework/zend-expressive-fastroute/
43
 */
44
class FastRoute extends Group implements RouterInterface
45
{
46
    /**
47
     * Template used when generating the cache file.
48
     */
49
    public const CACHE_TEMPLATE = <<< 'EOT'
50
<?php
51
return %s;
52
EOT;
53
54
    /**
55
     * @const string Configuration key used to enable/disable fastroute caching
56
     */
57
    public const CONFIG_CACHE_ENABLED = 'cache_enabled';
58
59
    /**
60
     * @const string Configuration key used to set the cache file path
61
     */
62
    public const CONFIG_CACHE_FILE = 'cache_file';
63
64
    /**
65
     * Cache generated route data?
66
     *
67
     * @var bool
68
     */
69
    private $cacheEnabled = false;
70
71
    /**
72
     * Cache file path relative to the project directory.
73
     *
74
     * @var string
75
     */
76
    private $cacheFile = 'data/cache/fastroute.php.cache';
77
78
    /**
79
     * @var callable A factory callback that can return a dispatcher.
80
     */
81
    private $dispatcherCallback;
82
83
    /**
84
     * Cached data used by the dispatcher.
85
     *
86
     * @var array
87
     */
88
    private $dispatchData = [];
89
90
    /**
91
     * True if cache is enabled and valid dispatch data has been loaded from
92
     * cache.
93
     *
94
     * @var bool
95
     */
96
    private $hasCache = false;
97
98
    /**
99
     * FastRoute router
100
     *
101
     * @var RouteCollector
102
     */
103
    private $router;
104
105
    /**
106
     * All attached routes as Route instances
107
     *
108
     * @var Route[]
109
     */
110
    private $routes = [];
111
112
    /**
113
     * @var RouteParser
114
     */
115
    private $routeParser;
116
117
    /** @var string */
118
    private $uriPrefix = '';
119
120
    /** @var Route|null */
121
    private ?Route $currentRoute = null;
122
123
    /**
124
     * Last matched request
125
     *
126
     * @var ServerRequestInterface|null
127
     */
128
    private ?ServerRequestInterface $request = null;
129
130
    /**
131
     * Constructor
132
     *
133
     * Accepts optionally a FastRoute RouteCollector and a callable factory
134
     * that can return a FastRoute dispatcher.
135
     *
136
     * If either is not provided defaults will be used:
137
     *
138
     * - A RouteCollector instance will be created composing a RouteParser and
139
     *   RouteGenerator.
140
     * - A callable that returns a GroupCountBased dispatcher will be created.
141
     *
142
     * @param null|RouteCollector $router If not provided, a default
143
     *     implementation will be used.
144
     * @param RouteParser $routeParser
145
     * @param null|callable $dispatcherFactory Callable that will return a
146
     *     FastRoute dispatcher.
147
     * @param array $config Array of custom configuration options.
148
     */
149 24
    public function __construct(
150
        RouteCollector $router,
151
        RouteParser $routeParser,
152
        callable $dispatcherFactory = null,
153
        array $config = null
154
    ) {
155 24
        $this->router = $router;
156 24
        $this->dispatcherCallback = $dispatcherFactory;
157 24
        $this->routeParser = $routeParser;
158
159 24
        $this->loadConfig($config);
160
    }
161
162
    /**
163
     * Load configuration parameters
164
     *
165
     * @param null|array $config Array of custom configuration options.
166
     */
167 24
    private function loadConfig(array $config = null): void
168
    {
169 24
        if (null === $config) {
170 24
            return;
171
        }
172
173
        if (isset($config[self::CONFIG_CACHE_ENABLED])) {
174
            $this->cacheEnabled = (bool)$config[self::CONFIG_CACHE_ENABLED];
175
        }
176
177
        if (isset($config[self::CONFIG_CACHE_FILE])) {
178
            $this->cacheFile = (string)$config[self::CONFIG_CACHE_FILE];
179
        }
180
181
        if ($this->cacheEnabled) {
182
            $this->loadDispatchData();
183
        }
184
    }
185
186 7
    public function match(ServerRequestInterface $request): MatchingResult
187
    {
188 7
        $this->request = $request;
189
        // Inject any pending route items
190 7
        $this->injectItems();
191
192 7
        $dispatchData = $this->getDispatchData();
193 7
        $path = rawurldecode($request->getUri()->getPath());
194 7
        $method = $request->getMethod();
195 7
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
196
197 7
        return $result[0] !== Dispatcher::FOUND
198
            ? $this->marshalFailedRoute($result)
199 7
            : $this->marshalMatchedRoute($result, $method);
200
    }
201
202 20
    public function getUriPrefix(): string
203
    {
204 20
        return $this->uriPrefix;
205
    }
206
207
    public function setUriPrefix(string $prefix): void
208
    {
209
        $this->uriPrefix = $prefix;
210
    }
211
212
    /**
213
     * Generate a URI based on a given route.
214
     *
215
     * Replacements in FastRoute are written as `{name}` or `{name:<pattern>}`;
216
     * this method uses `FastRoute\RouteParser\Std` to search for the best route
217
     * match based on the available substitutions and generates a uri.
218
     *
219
     * @param string $name Route name.
220
     * @param array $parameters Key/value option pairs to pass to the router for
221
     * purposes of generating a URI; takes precedence over options present
222
     * in route used to generate URI.
223
     *
224
     * @return string URI path generated.
225
     * @throws \RuntimeException if the route name is not known or a parameter value does not match its regex.
226
     */
227 23
    public function generate(string $name, array $parameters = []): string
228
    {
229
        // Inject any pending route items
230 23
        $this->injectItems();
231
232 23
        $route = $this->getRoute($name);
233
234 22
        $parsedRoutes = array_reverse($this->routeParser->parse($route->getPattern()));
235 22
        if ($parsedRoutes === []) {
236
            throw new RouteNotFoundException($name);
237
        }
238
239 22
        $missingParameters = [];
240
241
        // One route pattern can correspond to multiple routes if it has optional parts
242 22
        foreach ($parsedRoutes as $parsedRouteParts) {
243
            // Check if all parameters can be substituted
244 22
            $missingParameters = $this->missingParameters($parsedRouteParts, $parameters);
245
246
            // If not all parameters can be substituted, try the next route
247 22
            if (!empty($missingParameters)) {
248 3
                continue;
249
            }
250
251 20
            return $this->generatePath($parameters, $parsedRouteParts);
252
        }
253
254
        // No valid route was found: list minimal required parameters
255 2
        throw new \RuntimeException(sprintf(
256 2
            'Route `%s` expects at least parameter values for [%s], but received [%s]',
257 2
            $name,
258 2
            implode(',', $missingParameters),
259 2
            implode(',', array_keys($parameters))
260
        ));
261
    }
262
263
    /**
264
     * Generates absolute URL from named route and parameters
265
     *
266
     * @param string $name name of the route
267
     * @param array $parameters parameter-value set
268
     * @param string|null $scheme host scheme
269
     * @param string|null $host host for manual setup
270
     * @return string URL generated
271
     * @throws RouteNotFoundException in case there is no route with the name specified
272
     */
273 12
    public function generateAbsolute(string $name, array $parameters = [], string $scheme = null, string $host = null): string
274
    {
275 12
        $url = $this->generate($name, $parameters);
276 12
        $route = $this->getRoute($name);
277 12
        $uri = $this->request !== null ? $this->request->getUri() : null;
278 12
        $lastRequestScheme = $uri !== null ? $uri->getScheme() : null;
279
280 12
        if ($host !== null) {
281 5
            if ($scheme === null && (strpos($host, '://') !== false || strpos($host, '//') === 0)) {
282 3
                return rtrim($host, '/') . $url;
283 2
            } elseif ($scheme === '' && $host !== '' && !(strpos($host, '://') !== false || strpos($host, '//') === 0)) {
284 1
                $host = '//' . $host;
285
            }
286 2
            return $this->ensureScheme(rtrim($host, '/') . $url, $scheme ?? $lastRequestScheme);
287
        }
288
289 7
        if (($host = $route->getHost()) !== null) {
290 5
            if ($scheme === null && (strpos($host, '://') !== false || strpos($host, '//') === 0)) {
291 3
                return rtrim($host, '/') . $url;
292 2
            } elseif ($scheme === '' && $host !== '' && !(strpos($host, '://') !== false || strpos($host, '//') === 0)) {
293 1
                $host = '//' . $host;
294
            }
295 2
            return $this->ensureScheme(rtrim($host, '/') . $url, $scheme ?? $lastRequestScheme);
296
        }
297
298 2
        if ($uri !== null) {
299 2
            $port = $uri->getPort() === 80 || $uri->getPort() === null ? '' : ':' . $uri->getPort();
300 2
            return  $lastRequestScheme  . '://' . $uri->getHost() . $port . $url;
301
        }
302
303
        return $url;
304
    }
305
306
    /**
307
     * Normalize URL by ensuring that it use specified scheme.
308
     *
309
     * If URL is relative or scheme is null, normalization is skipped.
310
     *
311
     * @param string $url the URL to process
312
     * @param string|null $scheme the URI scheme used in URL (e.g. `http` or `https`). Use empty string to
313
     * create protocol-relative URL (e.g. `//example.com/path`)
314
     * @return string the processed URL
315
     */
316 4
    private function ensureScheme(string $url, ?string $scheme): string
317
    {
318 4
        if ($scheme === null || $this->isRelative($url)) {
319
            return $url;
320
        }
321
322 4
        if (strpos($url, '//') === 0) {
323
            // e.g. //example.com/path/to/resource
324 2
            return $scheme === '' ? $url : "$scheme:$url";
325
        }
326
327 4
        if (($pos = strpos($url, '://')) !== false) {
328 4
            if ($scheme === '') {
329 2
                $url = substr($url, $pos + 1);
330
            } else {
331 2
                $url = $scheme . substr($url, $pos);
332
            }
333
        }
334
335 4
        return $url;
336
    }
337
338
    /**
339
     * Returns a value indicating whether a URL is relative.
340
     * A relative URL does not have host info part.
341
     * @param string $url the URL to be checked
342
     * @return bool whether the URL is relative
343
     */
344 4
    private function isRelative(string $url): bool
345
    {
346 4
        return strncmp($url, '//', 2) && strpos($url, '://') === false;
347
    }
348
349
    /**
350
     * Returns the current Route object
351
     * @return Route|null current route
352
     */
353
    public function getCurrentRoute(): ?Route
354
    {
355
        return $this->currentRoute;
356
    }
357
358
    /**
359
     * Checks for any missing route parameters
360
     * @param array $parts
361
     * @param array $substitutions
362
     * @return array with minimum required parameters if any are missing or an empty array if none are missing
363
     */
364 22
    private function missingParameters(array $parts, array $substitutions): array
365
    {
366 22
        $missingParameters = [];
367
368
        // Gather required parameters
369 22
        foreach ($parts as $part) {
370 22
            if (is_string($part)) {
371 22
                continue;
372
            }
373
374 9
            $missingParameters[] = $part[0];
375
        }
376
377
        // Check if all parameters exist
378 22
        foreach ($missingParameters as $parameter) {
379 9
            if (!array_key_exists($parameter, $substitutions)) {
380
                // Return the parameters so they can be used in an
381
                // exception if needed
382 3
                return $missingParameters;
383
            }
384
        }
385
386
        // All required parameters are available
387 20
        return [];
388
    }
389
390
    /**
391
     * Retrieve the dispatcher instance.
392
     *
393
     * Uses the callable factory in $dispatcherCallback, passing it $data
394
     * (which should be derived from the router's getData() method); this
395
     * approach is done to allow testing against the dispatcher.
396
     *
397
     * @param array|object $data Data from RouteCollection::getData()
398
     * @return Dispatcher
399
     */
400 7
    private function getDispatcher($data): Dispatcher
401
    {
402 7
        if (!$this->dispatcherCallback) {
403
            $this->dispatcherCallback = $this->createDispatcherCallback();
404
        }
405
406 7
        $factory = $this->dispatcherCallback;
407
408 7
        return $factory($data);
409
    }
410
411
    /**
412
     * Return a default implementation of a callback that can return a Dispatcher.
413
     */
414
    private function createDispatcherCallback(): callable
415
    {
416
        return static function ($data) {
417
            return new GroupCountBased($data);
418
        };
419
    }
420
421
    /**
422
     * Marshal a routing failure result.
423
     *
424
     * If the failure was due to the HTTP method, passes the allowed HTTP
425
     * methods to the factory.
426
     * @param array $result
427
     * @return MatchingResult
428
     */
429
    private function marshalFailedRoute(array $result): MatchingResult
430
    {
431
        $resultCode = $result[0];
432
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
433
            return MatchingResult::fromFailure($result[1]);
434
        }
435
436
        return MatchingResult::fromFailure(Method::ANY);
437
    }
438
439
    /**
440
     * Marshals a route result based on the results of matching and the current HTTP method.
441
     * @param array $result
442
     * @param string $method
443
     * @return MatchingResult
444
     */
445 7
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
446
    {
447 7
        [, $path, $parameters] = $result;
448
449
        /* @var Route $route */
450 7
        $route = array_reduce(
451 7
            $this->routes,
452 7
            static function ($matched, Route $route) use ($path, $method) {
453 7
                if ($matched) {
454 1
                    return $matched;
455
                }
456
457 7
                if ($path !== $route->getPattern()) {
458
                    return $matched;
459
                }
460
461 7
                if (!in_array($method, $route->getMethods(), true)) {
462
                    return $matched;
463
                }
464
465 7
                return $route;
466 7
            },
467 7
            false
468
        );
469
470 7
        if (false === $route) {
0 ignored issues
show
introduced by
The condition false === $route is always false.
Loading history...
471
            return $this->marshalMethodNotAllowedResult($result);
472
        }
473
474 7
        $parameters = array_merge($route->getDefaults(), $parameters);
475 7
        $this->currentRoute = $route;
476
477 7
        return MatchingResult::fromSuccess($route, $parameters);
478
    }
479
480
    /**
481
     * Inject queued items into the underlying router
482
     */
483 24
    private function injectItems(): void
484
    {
485 24
        foreach ($this->items as $index => $item) {
486 24
            $this->injectItem($item);
487 24
            unset($this->items[$index]);
488
        }
489
    }
490
491
    /**
492
     * Inject an item into the underlying router
493
     * @param Route|Group $route
494
     */
495 24
    private function injectItem($route): void
496
    {
497 24
        if ($route instanceof Group) {
498 2
            $this->injectGroup($route);
499 2
            return;
500
        }
501
502
        // Filling the routes' hash-map is required by the `generateUri` method
503 22
        $this->routes[$route->getName()] = $route;
504
505
        // Skip feeding FastRoute collector if valid cached data was already loaded
506 22
        if ($this->hasCache) {
507
            return;
508
        }
509
510 22
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
511
    }
512
513
    /**
514
     * Inject a Group instance into the underlying router.
515
     */
516 2
    private function injectGroup(Group $group, RouteCollector $collector = null, string $prefix = ''): void
517
    {
518 2
        if ($collector === null) {
519 2
            $collector = $this->router;
520
        }
521
522 2
        $collector->addGroup(
523 2
            $group->getPrefix(),
524 2
            function (RouteCollector $r) use ($group, $prefix) {
525 2
                $prefix .= $group->getPrefix();
526 2
                foreach ($group->items as $index => $item) {
527 2
                    if ($item instanceof Group) {
528 1
                        $this->injectGroup($item, $r, $prefix);
529 1
                        continue;
530
                    }
531
532
                    /** @var Route $modifiedItem */
533 2
                    $modifiedItem = $item->pattern($prefix . $item->getPattern());
534
535 2
                    $groupMiddlewares = $group->getMiddlewares();
536
537 2
                    for (end($groupMiddlewares); key($groupMiddlewares) !== null; prev($groupMiddlewares)) {
538
                        $modifiedItem = $modifiedItem->addMiddleware(current($groupMiddlewares));
539
                    }
540
541
                    // Filling the routes' hash-map is required by the `generateUri` method
542 2
                    $this->routes[$modifiedItem->getName()] = $modifiedItem;
543
544
                    // Skip feeding FastRoute collector if valid cached data was already loaded
545 2
                    if ($this->hasCache) {
546
                        continue;
547
                    }
548
549 2
                    $r->addRoute($item->getMethods(), $item->getPattern(), $modifiedItem->getPattern());
550
                }
551 2
            }
552
        );
553
    }
554
555
    /**
556
     * Get the dispatch data either from cache or freshly generated by the
557
     * FastRoute data generator.
558
     *
559
     * If caching is enabled, store the freshly generated data to file.
560
     */
561 7
    private function getDispatchData(): array
562
    {
563 7
        if ($this->hasCache) {
564
            return $this->dispatchData;
565
        }
566
567 7
        $dispatchData = (array)$this->router->getData();
568
569 7
        if ($this->cacheEnabled) {
570
            $this->cacheDispatchData($dispatchData);
571
        }
572
573 7
        return $dispatchData;
574
    }
575
576
    /**
577
     * Load dispatch data from cache
578
     * @throws \RuntimeException If the cache file contains invalid data
579
     */
580
    private function loadDispatchData(): void
581
    {
582
        set_error_handler(
583
            static function () {
584
            },
585
            E_WARNING
586
        ); // suppress php warnings
587
        $dispatchData = include $this->cacheFile;
588
        restore_error_handler();
589
590
        // Cache file does not exist
591
        if (false === $dispatchData) {
592
            return;
593
        }
594
595
        if (!is_array($dispatchData)) {
596
            throw new \RuntimeException(
597
                sprintf(
598
                    'Invalid cache file "%s"; cache file MUST return an array',
599
                    $this->cacheFile
600
                )
601
            );
602
        }
603
604
        $this->hasCache = true;
605
        $this->dispatchData = $dispatchData;
606
    }
607
608
    /**
609
     * Save dispatch data to cache
610
     * @param array $dispatchData
611
     * @return int|false bytes written to file or false if error
612
     * @throws \RuntimeException If the cache directory does not exist.
613
     * @throws \RuntimeException If the cache directory is not writable.
614
     * @throws \RuntimeException If the cache file exists but is not writable
615
     */
616
    private function cacheDispatchData(array $dispatchData)
617
    {
618
        $cacheDir = dirname($this->cacheFile);
619
620
        if (!is_dir($cacheDir)) {
621
            throw new \RuntimeException(
622
                sprintf(
623
                    'The cache directory "%s" does not exist',
624
                    $cacheDir
625
                )
626
            );
627
        }
628
629
        if (!is_writable($cacheDir)) {
630
            throw new \RuntimeException(
631
                sprintf(
632
                    'The cache directory "%s" is not writable',
633
                    $cacheDir
634
                )
635
            );
636
        }
637
638
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
639
            throw new \RuntimeException(
640
                sprintf(
641
                    'The cache file %s is not writable',
642
                    $this->cacheFile
643
                )
644
            );
645
        }
646
647
        return file_put_contents(
648
            $this->cacheFile,
649
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
650
            LOCK_EX
651
        );
652
    }
653
654
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
655
    {
656
        $path = $result[1];
657
658
        $allowedMethods = array_unique(
659
            array_reduce(
660
                $this->routes,
661
                static function ($allowedMethods, Route $route) use ($path) {
662
                    if ($path !== $route->getPattern()) {
663
                        return $allowedMethods;
664
                    }
665
666
                    return array_merge($allowedMethods, $route->getMethods());
667
                },
668
                []
669
            )
670
        );
671
672
        return MatchingResult::fromFailure($allowedMethods);
673
    }
674
675
    /**
676
     * @param string $name
677
     * @return Route
678
     */
679 23
    private function getRoute(string $name): Route
680
    {
681 23
        if (!array_key_exists($name, $this->routes)) {
682 1
            throw new RouteNotFoundException($name);
683
        }
684
685 22
        return $this->routes[$name];
686
    }
687
688
    /**
689
     * @param array $parameters
690
     * @param array $parts
691
     * @return string
692
     */
693 20
    private function generatePath(array $parameters, array $parts): string
694
    {
695 20
        $notSubstitutedParams = $parameters;
696 20
        $path = $this->getUriPrefix();
697
698 20
        foreach ($parts as $part) {
699 20
            if (is_string($part)) {
700
                // Append the string
701 20
                $path .= $part;
702 20
                continue;
703
            }
704
705
            // Check substitute value with regex
706 6
            $pattern = str_replace('~', '\~', $part[1]);
707 6
            if (preg_match('~^' . $pattern . '$~', (string)$parameters[$part[0]]) === 0) {
708 1
                throw new \RuntimeException(
709 1
                    sprintf(
710 1
                        'Parameter value for [%s] did not match the regex `%s`',
711 1
                        $part[0],
712 1
                        $part[1]
713
                    )
714
                );
715
            }
716
717
            // Append the substituted value
718 5
            $path .= $parameters[$part[0]];
719 5
            unset($notSubstitutedParams[$part[0]]);
720
        }
721
722 19
        return $path . ($notSubstitutedParams !== [] ? '?' . http_build_query($notSubstitutedParams) : '');
723
    }
724
}
725