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

FastRoute::isRelative()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 3
rs 10
ccs 2
cts 2
cp 1
crap 2
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