Passed
Pull Request — master (#23)
by
unknown
01:22
created

FastRoute::generateAbsolute()   C

Complexity

Conditions 15
Paths 32

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 15.0667

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 15
eloc 14
c 2
b 0
f 0
nc 32
nop 4
dl 0
loc 22
ccs 14
cts 15
cp 0.9333
crap 15.0667
rs 5.9166

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 26
    public function __construct(
150
        RouteCollector $router,
151
        RouteParser $routeParser,
152
        callable $dispatcherFactory = null,
153
        array $config = null
154
    ) {
155 26
        $this->router = $router;
156 26
        $this->dispatcherCallback = $dispatcherFactory;
157 26
        $this->routeParser = $routeParser;
158
159 26
        $this->loadConfig($config);
160
    }
161
162
    /**
163
     * Load configuration parameters
164
     *
165
     * @param null|array $config Array of custom configuration options.
166
     */
167 26
    private function loadConfig(array $config = null): void
168
    {
169 26
        if (null === $config) {
170 26
            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 9
    public function match(ServerRequestInterface $request): MatchingResult
187
    {
188 9
        $this->request = $request;
189
        // Inject any pending route items
190 9
        $this->injectItems();
191
192 9
        $dispatchData = $this->getDispatchData();
193 9
        $path = rawurldecode($request->getUri()->getPath());
194 9
        $method = $request->getMethod();
195 9
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
196
197 9
        return $result[0] !== Dispatcher::FOUND
198
            ? $this->marshalFailedRoute($result)
199 9
            : $this->marshalMatchedRoute($result, $method);
200
    }
201
202 22
    public function getUriPrefix(): string
203
    {
204 22
        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 25
    public function generate(string $name, array $parameters = []): string
228
    {
229
        // Inject any pending route items
230 25
        $this->injectItems();
231
232 25
        $route = $this->getRoute($name);
233
234 24
        $parsedRoutes = array_reverse($this->routeParser->parse($route->getPattern()));
235 24
        if ($parsedRoutes === []) {
236
            throw new RouteNotFoundException($name);
237
        }
238
239 24
        $missingParameters = [];
240
241
        // One route pattern can correspond to multiple routes if it has optional parts
242 24
        foreach ($parsedRoutes as $parsedRouteParts) {
243
            // Check if all parameters can be substituted
244 24
            $missingParameters = $this->missingParameters($parsedRouteParts, $parameters);
245
246
            // If not all parameters can be substituted, try the next route
247 24
            if (!empty($missingParameters)) {
248 3
                continue;
249
            }
250
251 22
            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 14
    public function generateAbsolute(string $name, array $parameters = [], string $scheme = null, string $host = null): string
274
    {
275 14
        $url = $this->generate($name, $parameters);
276 14
        $route = $this->getRoute($name);
277 14
        $uri = $this->request !== null ? $this->request->getUri() : null;
278 14
        $lastRequestScheme = $uri !== null ? $uri->getScheme() : null;
279
280 14
        if ($host !== null || ($host = $route->getHost()) !== null) {
281 10
            if ($scheme === null && (strpos($host, '://') !== false || strpos($host, '//') === 0)) {
282 6
                return rtrim($host, '/') . $url;
283 4
            } elseif ($scheme === '' && $host !== '' && !(strpos($host, '://') !== false || strpos($host, '//') === 0)) {
284 2
                $host = '//' . $host;
285
            }
286 4
            return $this->ensureScheme(rtrim($host, '/') . $url, $scheme ?? $lastRequestScheme);
287
        }
288
289 4
        if ($uri !== null) {
290 4
            $port = $uri->getPort() === 80 || $uri->getPort() === null ? '' : ':' . $uri->getPort();
291 4
            return  $this->ensureScheme('://' . $uri->getHost() . $port . $url, $scheme ?? $lastRequestScheme);
292
        }
293
294
        return $url;
295
    }
296
297
    /**
298
     * Normalize URL by ensuring that it use specified scheme.
299
     *
300
     * If URL is relative or scheme is null, normalization is skipped.
301
     *
302
     * @param string $url the URL to process
303
     * @param string|null $scheme the URI scheme used in URL (e.g. `http` or `https`). Use empty string to
304
     * create protocol-relative URL (e.g. `//example.com/path`)
305
     * @return string the processed URL
306
     */
307 8
    private function ensureScheme(string $url, ?string $scheme): string
308
    {
309 8
        if ($scheme === null || $this->isRelative($url)) {
310
            return $url;
311
        }
312
313 8
        if (strpos($url, '//') === 0) {
314
            // e.g. //example.com/path/to/resource
315 2
            return $scheme === '' ? $url : "$scheme:$url";
316
        }
317
318 8
        if (($pos = strpos($url, '://')) !== false) {
319 8
            if ($scheme === '') {
320 3
                $url = substr($url, $pos + 1);
321
            } else {
322 5
                $url = $scheme . substr($url, $pos);
323
            }
324
        }
325
326 8
        return $url;
327
    }
328
329
    /**
330
     * Returns a value indicating whether a URL is relative.
331
     * A relative URL does not have host info part.
332
     * @param string $url the URL to be checked
333
     * @return bool whether the URL is relative
334
     */
335 8
    private function isRelative(string $url): bool
336
    {
337 8
        return strncmp($url, '//', 2) && strpos($url, '://') === false;
338
    }
339
340
    /**
341
     * Returns the current Route object
342
     * @return Route|null current route
343
     */
344
    public function getCurrentRoute(): ?Route
345
    {
346
        return $this->currentRoute;
347
    }
348
349
    /**
350
     * Checks for any missing route parameters
351
     * @param array $parts
352
     * @param array $substitutions
353
     * @return array with minimum required parameters if any are missing or an empty array if none are missing
354
     */
355 24
    private function missingParameters(array $parts, array $substitutions): array
356
    {
357 24
        $missingParameters = [];
358
359
        // Gather required parameters
360 24
        foreach ($parts as $part) {
361 24
            if (is_string($part)) {
362 24
                continue;
363
            }
364
365 9
            $missingParameters[] = $part[0];
366
        }
367
368
        // Check if all parameters exist
369 24
        foreach ($missingParameters as $parameter) {
370 9
            if (!array_key_exists($parameter, $substitutions)) {
371
                // Return the parameters so they can be used in an
372
                // exception if needed
373 3
                return $missingParameters;
374
            }
375
        }
376
377
        // All required parameters are available
378 22
        return [];
379
    }
380
381
    /**
382
     * Retrieve the dispatcher instance.
383
     *
384
     * Uses the callable factory in $dispatcherCallback, passing it $data
385
     * (which should be derived from the router's getData() method); this
386
     * approach is done to allow testing against the dispatcher.
387
     *
388
     * @param array|object $data Data from RouteCollection::getData()
389
     * @return Dispatcher
390
     */
391 9
    private function getDispatcher($data): Dispatcher
392
    {
393 9
        if (!$this->dispatcherCallback) {
394
            $this->dispatcherCallback = $this->createDispatcherCallback();
395
        }
396
397 9
        $factory = $this->dispatcherCallback;
398
399 9
        return $factory($data);
400
    }
401
402
    /**
403
     * Return a default implementation of a callback that can return a Dispatcher.
404
     */
405
    private function createDispatcherCallback(): callable
406
    {
407
        return static function ($data) {
408
            return new GroupCountBased($data);
409
        };
410
    }
411
412
    /**
413
     * Marshal a routing failure result.
414
     *
415
     * If the failure was due to the HTTP method, passes the allowed HTTP
416
     * methods to the factory.
417
     * @param array $result
418
     * @return MatchingResult
419
     */
420
    private function marshalFailedRoute(array $result): MatchingResult
421
    {
422
        $resultCode = $result[0];
423
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
424
            return MatchingResult::fromFailure($result[1]);
425
        }
426
427
        return MatchingResult::fromFailure(Method::ANY);
428
    }
429
430
    /**
431
     * Marshals a route result based on the results of matching and the current HTTP method.
432
     * @param array $result
433
     * @param string $method
434
     * @return MatchingResult
435
     */
436 9
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
437
    {
438 9
        [, $path, $parameters] = $result;
439
440
        /* @var Route $route */
441 9
        $route = array_reduce(
442 9
            $this->routes,
443 9
            static function ($matched, Route $route) use ($path, $method) {
444 9
                if ($matched) {
445 1
                    return $matched;
446
                }
447
448 9
                if ($path !== $route->getPattern()) {
449
                    return $matched;
450
                }
451
452 9
                if (!in_array($method, $route->getMethods(), true)) {
453
                    return $matched;
454
                }
455
456 9
                return $route;
457 9
            },
458 9
            false
459
        );
460
461 9
        if (false === $route) {
0 ignored issues
show
introduced by
The condition false === $route is always false.
Loading history...
462
            return $this->marshalMethodNotAllowedResult($result);
463
        }
464
465 9
        $parameters = array_merge($route->getDefaults(), $parameters);
466 9
        $this->currentRoute = $route;
467
468 9
        return MatchingResult::fromSuccess($route, $parameters);
469
    }
470
471
    /**
472
     * Inject queued items into the underlying router
473
     */
474 26
    private function injectItems(): void
475
    {
476 26
        foreach ($this->items as $index => $item) {
477 26
            $this->injectItem($item);
478 26
            unset($this->items[$index]);
479
        }
480
    }
481
482
    /**
483
     * Inject an item into the underlying router
484
     * @param Route|Group $route
485
     */
486 26
    private function injectItem($route): void
487
    {
488 26
        if ($route instanceof Group) {
489 2
            $this->injectGroup($route);
490 2
            return;
491
        }
492
493
        // Filling the routes' hash-map is required by the `generateUri` method
494 24
        $this->routes[$route->getName()] = $route;
495
496
        // Skip feeding FastRoute collector if valid cached data was already loaded
497 24
        if ($this->hasCache) {
498
            return;
499
        }
500
501 24
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
502
    }
503
504
    /**
505
     * Inject a Group instance into the underlying router.
506
     */
507 2
    private function injectGroup(Group $group, RouteCollector $collector = null, string $prefix = ''): void
508
    {
509 2
        if ($collector === null) {
510 2
            $collector = $this->router;
511
        }
512
513 2
        $collector->addGroup(
514 2
            $group->getPrefix(),
515 2
            function (RouteCollector $r) use ($group, $prefix) {
516 2
                $prefix .= $group->getPrefix();
517 2
                foreach ($group->items as $index => $item) {
518 2
                    if ($item instanceof Group) {
519 1
                        $this->injectGroup($item, $r, $prefix);
520 1
                        continue;
521
                    }
522
523
                    /** @var Route $modifiedItem */
524 2
                    $modifiedItem = $item->pattern($prefix . $item->getPattern());
525
526 2
                    $groupMiddlewares = $group->getMiddlewares();
527
528 2
                    for (end($groupMiddlewares); key($groupMiddlewares) !== null; prev($groupMiddlewares)) {
529
                        $modifiedItem = $modifiedItem->addMiddleware(current($groupMiddlewares));
530
                    }
531
532
                    // Filling the routes' hash-map is required by the `generateUri` method
533 2
                    $this->routes[$modifiedItem->getName()] = $modifiedItem;
534
535
                    // Skip feeding FastRoute collector if valid cached data was already loaded
536 2
                    if ($this->hasCache) {
537
                        continue;
538
                    }
539
540 2
                    $r->addRoute($item->getMethods(), $item->getPattern(), $modifiedItem->getPattern());
541
                }
542 2
            }
543
        );
544
    }
545
546
    /**
547
     * Get the dispatch data either from cache or freshly generated by the
548
     * FastRoute data generator.
549
     *
550
     * If caching is enabled, store the freshly generated data to file.
551
     */
552 9
    private function getDispatchData(): array
553
    {
554 9
        if ($this->hasCache) {
555
            return $this->dispatchData;
556
        }
557
558 9
        $dispatchData = (array)$this->router->getData();
559
560 9
        if ($this->cacheEnabled) {
561
            $this->cacheDispatchData($dispatchData);
562
        }
563
564 9
        return $dispatchData;
565
    }
566
567
    /**
568
     * Load dispatch data from cache
569
     * @throws \RuntimeException If the cache file contains invalid data
570
     */
571
    private function loadDispatchData(): void
572
    {
573
        set_error_handler(
574
            static function () {
575
            },
576
            E_WARNING
577
        ); // suppress php warnings
578
        $dispatchData = include $this->cacheFile;
579
        restore_error_handler();
580
581
        // Cache file does not exist
582
        if (false === $dispatchData) {
583
            return;
584
        }
585
586
        if (!is_array($dispatchData)) {
587
            throw new \RuntimeException(
588
                sprintf(
589
                    'Invalid cache file "%s"; cache file MUST return an array',
590
                    $this->cacheFile
591
                )
592
            );
593
        }
594
595
        $this->hasCache = true;
596
        $this->dispatchData = $dispatchData;
597
    }
598
599
    /**
600
     * Save dispatch data to cache
601
     * @param array $dispatchData
602
     * @return int|false bytes written to file or false if error
603
     * @throws \RuntimeException If the cache directory does not exist.
604
     * @throws \RuntimeException If the cache directory is not writable.
605
     * @throws \RuntimeException If the cache file exists but is not writable
606
     */
607
    private function cacheDispatchData(array $dispatchData)
608
    {
609
        $cacheDir = dirname($this->cacheFile);
610
611
        if (!is_dir($cacheDir)) {
612
            throw new \RuntimeException(
613
                sprintf(
614
                    'The cache directory "%s" does not exist',
615
                    $cacheDir
616
                )
617
            );
618
        }
619
620
        if (!is_writable($cacheDir)) {
621
            throw new \RuntimeException(
622
                sprintf(
623
                    'The cache directory "%s" is not writable',
624
                    $cacheDir
625
                )
626
            );
627
        }
628
629
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
630
            throw new \RuntimeException(
631
                sprintf(
632
                    'The cache file %s is not writable',
633
                    $this->cacheFile
634
                )
635
            );
636
        }
637
638
        return file_put_contents(
639
            $this->cacheFile,
640
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
641
            LOCK_EX
642
        );
643
    }
644
645
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
646
    {
647
        $path = $result[1];
648
649
        $allowedMethods = array_unique(
650
            array_reduce(
651
                $this->routes,
652
                static function ($allowedMethods, Route $route) use ($path) {
653
                    if ($path !== $route->getPattern()) {
654
                        return $allowedMethods;
655
                    }
656
657
                    return array_merge($allowedMethods, $route->getMethods());
658
                },
659
                []
660
            )
661
        );
662
663
        return MatchingResult::fromFailure($allowedMethods);
664
    }
665
666
    /**
667
     * @param string $name
668
     * @return Route
669
     */
670 25
    private function getRoute(string $name): Route
671
    {
672 25
        if (!array_key_exists($name, $this->routes)) {
673 1
            throw new RouteNotFoundException($name);
674
        }
675
676 24
        return $this->routes[$name];
677
    }
678
679
    /**
680
     * @param array $parameters
681
     * @param array $parts
682
     * @return string
683
     */
684 22
    private function generatePath(array $parameters, array $parts): string
685
    {
686 22
        $notSubstitutedParams = $parameters;
687 22
        $path = $this->getUriPrefix();
688
689 22
        foreach ($parts as $part) {
690 22
            if (is_string($part)) {
691
                // Append the string
692 22
                $path .= $part;
693 22
                continue;
694
            }
695
696
            // Check substitute value with regex
697 6
            $pattern = str_replace('~', '\~', $part[1]);
698 6
            if (preg_match('~^' . $pattern . '$~', (string)$parameters[$part[0]]) === 0) {
699 1
                throw new \RuntimeException(
700 1
                    sprintf(
701 1
                        'Parameter value for [%s] did not match the regex `%s`',
702 1
                        $part[0],
703 1
                        $part[1]
704
                    )
705
                );
706
            }
707
708
            // Append the substituted value
709 5
            $path .= $parameters[$part[0]];
710 5
            unset($notSubstitutedParams[$part[0]]);
711
        }
712
713 21
        return $path . ($notSubstitutedParams !== [] ? '?' . http_build_query($notSubstitutedParams) : '');
714
    }
715
}
716