Passed
Pull Request — master (#19)
by
unknown
01:30
created

FastRoute::getDispatchData()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3.2098

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 2
b 0
f 0
nc 3
nop 0
dl 0
loc 13
ccs 5
cts 7
cp 0.7143
crap 3.2098
rs 10
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
     * Constructor
125
     *
126
     * Accepts optionally a FastRoute RouteCollector and a callable factory
127
     * that can return a FastRoute dispatcher.
128
     *
129
     * If either is not provided defaults will be used:
130
     *
131
     * - A RouteCollector instance will be created composing a RouteParser and
132
     *   RouteGenerator.
133
     * - A callable that returns a GroupCountBased dispatcher will be created.
134
     *
135
     * @param null|RouteCollector $router If not provided, a default
136
     *     implementation will be used.
137
     * @param RouteParser $routeParser
138
     * @param null|callable $dispatcherFactory Callable that will return a
139
     *     FastRoute dispatcher.
140
     * @param array $config Array of custom configuration options.
141
     */
142 11
    public function __construct(
143
        RouteCollector $router,
144
        RouteParser $routeParser,
145
        callable $dispatcherFactory = null,
146
        array $config = null
147
    ) {
148 11
        $this->router = $router;
149 11
        $this->dispatcherCallback = $dispatcherFactory;
150 11
        $this->routeParser = $routeParser;
151
152 11
        $this->loadConfig($config);
153
    }
154
155
    /**
156
     * Load configuration parameters
157
     *
158
     * @param null|array $config Array of custom configuration options.
159
     */
160 11
    private function loadConfig(array $config = null): void
161
    {
162 11
        if (null === $config) {
163 11
            return;
164
        }
165
166
        if (isset($config[self::CONFIG_CACHE_ENABLED])) {
167
            $this->cacheEnabled = (bool)$config[self::CONFIG_CACHE_ENABLED];
168
        }
169
170
        if (isset($config[self::CONFIG_CACHE_FILE])) {
171
            $this->cacheFile = (string)$config[self::CONFIG_CACHE_FILE];
172
        }
173
174
        if ($this->cacheEnabled) {
175
            $this->loadDispatchData();
176
        }
177
    }
178
179 1
    public function match(ServerRequestInterface $request): MatchingResult
180
    {
181
        // Inject any pending route items
182 1
        $this->injectItems();
183
184 1
        $dispatchData = $this->getDispatchData();
185 1
        $path = rawurldecode($request->getUri()->getPath());
186 1
        $method = $request->getMethod();
187 1
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
188
189 1
        return $result[0] !== Dispatcher::FOUND
190
            ? $this->marshalFailedRoute($result)
191 1
            : $this->marshalMatchedRoute($result, $method);
192
    }
193
194 7
    public function getUriPrefix(): string
195
    {
196 7
        return $this->uriPrefix;
197
    }
198
199
    public function setUriPrefix(string $prefix): void
200
    {
201
        $this->uriPrefix = $prefix;
202
    }
203
204
    /**
205
     * Generate a URI based on a given route.
206
     *
207
     * Replacements in FastRoute are written as `{name}` or `{name:<pattern>}`;
208
     * this method uses `FastRoute\RouteParser\Std` to search for the best route
209
     * match based on the available substitutions and generates a uri.
210
     *
211
     * @param string $name Route name.
212
     * @param array $parameters Key/value option pairs to pass to the router for
213
     * purposes of generating a URI; takes precedence over options present
214
     * in route used to generate URI.
215
     *
216
     * @return string URI path generated.
217
     * @throws \RuntimeException if the route name is not known or a parameter value does not match its regex.
218
     */
219 10
    public function generate(string $name, array $parameters = []): string
220
    {
221
        // Inject any pending route items
222 10
        $this->injectItems();
223
224 10
        $route = $this->getRoute($name);
225
226 9
        $parsedRoutes = array_reverse($this->routeParser->parse($route->getPattern()));
227 9
        if ($parsedRoutes === []) {
228
            throw new RouteNotFoundException($name);
229
        }
230
231 9
        $missingParameters = [];
232
233
        // One route pattern can correspond to multiple routes if it has optional parts
234 9
        foreach ($parsedRoutes as $parsedRouteParts) {
235
            // Check if all parameters can be substituted
236 9
            $missingParameters = $this->missingParameters($parsedRouteParts, $parameters);
237
238
            // If not all parameters can be substituted, try the next route
239 9
            if (!empty($missingParameters)) {
240 3
                continue;
241
            }
242
243 7
            return $this->generatePath($parameters, $parsedRouteParts);
244
        }
245
246
        // No valid route was found: list minimal required parameters
247 2
        throw new \RuntimeException(sprintf(
248 2
            'Route `%s` expects at least parameter values for [%s], but received [%s]',
249 2
            $name,
250 2
            implode(',', $missingParameters),
251 2
            implode(',', array_keys($parameters))
252
        ));
253
    }
254
255
    /**
256
     * Returns the current Route object
257
     * @return Route|null current route
258
     */
259
    public function getCurrentRoute(): ?Route
260
    {
261
        return $this->currentRoute;
262
    }
263
264
    /**
265
     * Checks for any missing route parameters
266
     * @param array $parts
267
     * @param array $substitutions
268
     * @return array with minimum required parameters if any are missing or an empty array if none are missing
269
     */
270 9
    private function missingParameters(array $parts, array $substitutions): array
271
    {
272 9
        $missingParameters = [];
273
274
        // Gather required parameters
275 9
        foreach ($parts as $part) {
276 9
            if (is_string($part)) {
277 9
                continue;
278
            }
279
280 8
            $missingParameters[] = $part[0];
281
        }
282
283
        // Check if all parameters exist
284 9
        foreach ($missingParameters as $parameter) {
285 8
            if (!array_key_exists($parameter, $substitutions)) {
286
                // Return the parameters so they can be used in an
287
                // exception if needed
288 3
                return $missingParameters;
289
            }
290
        }
291
292
        // All required parameters are available
293 7
        return [];
294
    }
295
296
    /**
297
     * Retrieve the dispatcher instance.
298
     *
299
     * Uses the callable factory in $dispatcherCallback, passing it $data
300
     * (which should be derived from the router's getData() method); this
301
     * approach is done to allow testing against the dispatcher.
302
     *
303
     * @param array|object $data Data from RouteCollection::getData()
304
     * @return Dispatcher
305
     */
306 1
    private function getDispatcher($data): Dispatcher
307
    {
308 1
        if (!$this->dispatcherCallback) {
309
            $this->dispatcherCallback = $this->createDispatcherCallback();
310
        }
311
312 1
        $factory = $this->dispatcherCallback;
313
314 1
        return $factory($data);
315
    }
316
317
    /**
318
     * Return a default implementation of a callback that can return a Dispatcher.
319
     */
320
    private function createDispatcherCallback(): callable
321
    {
322
        return static function ($data) {
323
            return new GroupCountBased($data);
324
        };
325
    }
326
327
    /**
328
     * Marshal a routing failure result.
329
     *
330
     * If the failure was due to the HTTP method, passes the allowed HTTP
331
     * methods to the factory.
332
     * @param array $result
333
     * @return MatchingResult
334
     */
335
    private function marshalFailedRoute(array $result): MatchingResult
336
    {
337
        $resultCode = $result[0];
338
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
339
            return MatchingResult::fromFailure($result[1]);
340
        }
341
342
        return MatchingResult::fromFailure(Method::ANY);
343
    }
344
345
    /**
346
     * Marshals a route result based on the results of matching and the current HTTP method.
347
     * @param array $result
348
     * @param string $method
349
     * @return MatchingResult
350
     */
351 1
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
352
    {
353 1
        [, $path, $parameters] = $result;
354
355
        /* @var Route $route */
356 1
        $route = array_reduce(
357 1
            $this->routes,
358
            static function ($matched, Route $route) use ($path, $method) {
359 1
                if ($matched) {
360
                    return $matched;
361
                }
362
363 1
                if ($path !== $route->getPattern()) {
364
                    return $matched;
365
                }
366
367 1
                if (!in_array($method, $route->getMethods(), true)) {
368
                    return $matched;
369
                }
370
371 1
                return $route;
372 1
            },
373 1
            false
374
        );
375
376 1
        if (false === $route) {
0 ignored issues
show
introduced by
The condition false === $route is always false.
Loading history...
377
            return $this->marshalMethodNotAllowedResult($result);
378
        }
379
380 1
        $parameters = array_merge($route->getDefaults(), $parameters);
381 1
        $this->currentRoute = $route;
382
383 1
        return MatchingResult::fromSuccess($route, $parameters);
384
    }
385
386
    /**
387
     * Inject queued items into the underlying router
388
     */
389 11
    private function injectItems(): void
390
    {
391 11
        foreach ($this->items as $index => $item) {
392 11
            $this->injectItem($item);
393 11
            unset($this->items[$index]);
394
        }
395
    }
396
397
    /**
398
     * Inject an item into the underlying router
399
     * @param Route|Group $route
400
     */
401 11
    private function injectItem($route): void
402
    {
403 11
        if ($route instanceof Group) {
404 2
            $this->injectGroup($route);
405 2
            return;
406
        }
407
408
        // Filling the routes' hash-map is required by the `generateUri` method
409 9
        $this->routes[$route->getName()] = $route;
410
411
        // Skip feeding FastRoute collector if valid cached data was already loaded
412 9
        if ($this->hasCache) {
413
            return;
414
        }
415
416 9
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
417
    }
418
419
    /**
420
     * Inject a Group instance into the underlying router.
421
     */
422 2
    private function injectGroup(Group $group, RouteCollector $collector = null, string $prefix = ''): void
423
    {
424 2
        if ($collector === null) {
425 2
            $collector = $this->router;
426
        }
427
428 2
        $collector->addGroup(
429 2
            $group->getPrefix(),
430
            function (RouteCollector $r) use ($group, $prefix) {
431 2
                foreach ($group->items as $index => $item) {
432 2
                    if ($item instanceof Group) {
433 1
                        $prefix .= $group->getPrefix();
434 1
                        $this->injectGroup($item, $r, $prefix);
435 1
                        continue;
436
                    }
437
438
                    /** @var Route $modifiedItem */
439 2
                    $modifiedItem = $item->pattern($prefix . $group->getPrefix() . $item->getPattern());
440
441 2
                    $groupMiddlewares = $group->getMiddlewares();
442
443 2
                    for (end($groupMiddlewares); key($groupMiddlewares) !== null; prev($groupMiddlewares)) {
444
                        $modifiedItem = $modifiedItem->addMiddleware(current($groupMiddlewares));
445
                    }
446
447
                    // Filling the routes' hash-map is required by the `generateUri` method
448 2
                    $this->routes[$modifiedItem->getName()] = $modifiedItem;
449
450
                    // Skip feeding FastRoute collector if valid cached data was already loaded
451 2
                    if ($this->hasCache) {
452
                        continue;
453
                    }
454
455 2
                    $r->addRoute($item->getMethods(), $item->getPattern(), $modifiedItem->getPattern());
456
                }
457 2
            }
458
        );
459
    }
460
461
    /**
462
     * Get the dispatch data either from cache or freshly generated by the
463
     * FastRoute data generator.
464
     *
465
     * If caching is enabled, store the freshly generated data to file.
466
     */
467 1
    private function getDispatchData(): array
468
    {
469 1
        if ($this->hasCache) {
470
            return $this->dispatchData;
471
        }
472
473 1
        $dispatchData = (array)$this->router->getData();
474
475 1
        if ($this->cacheEnabled) {
476
            $this->cacheDispatchData($dispatchData);
477
        }
478
479 1
        return $dispatchData;
480
    }
481
482
    /**
483
     * Load dispatch data from cache
484
     * @throws \RuntimeException If the cache file contains invalid data
485
     */
486
    private function loadDispatchData(): void
487
    {
488
        set_error_handler(
489
            static function () {
490
            },
491
            E_WARNING
492
        ); // suppress php warnings
493
        $dispatchData = include $this->cacheFile;
494
        restore_error_handler();
495
496
        // Cache file does not exist
497
        if (false === $dispatchData) {
498
            return;
499
        }
500
501
        if (!is_array($dispatchData)) {
502
            throw new \RuntimeException(
503
                sprintf(
504
                    'Invalid cache file "%s"; cache file MUST return an array',
505
                    $this->cacheFile
506
                )
507
            );
508
        }
509
510
        $this->hasCache = true;
511
        $this->dispatchData = $dispatchData;
512
    }
513
514
    /**
515
     * Save dispatch data to cache
516
     * @param array $dispatchData
517
     * @return int|false bytes written to file or false if error
518
     * @throws \RuntimeException If the cache directory does not exist.
519
     * @throws \RuntimeException If the cache directory is not writable.
520
     * @throws \RuntimeException If the cache file exists but is not writable
521
     */
522
    private function cacheDispatchData(array $dispatchData)
523
    {
524
        $cacheDir = dirname($this->cacheFile);
525
526
        if (!is_dir($cacheDir)) {
527
            throw new \RuntimeException(
528
                sprintf(
529
                    'The cache directory "%s" does not exist',
530
                    $cacheDir
531
                )
532
            );
533
        }
534
535
        if (!is_writable($cacheDir)) {
536
            throw new \RuntimeException(
537
                sprintf(
538
                    'The cache directory "%s" is not writable',
539
                    $cacheDir
540
                )
541
            );
542
        }
543
544
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
545
            throw new \RuntimeException(
546
                sprintf(
547
                    'The cache file %s is not writable',
548
                    $this->cacheFile
549
                )
550
            );
551
        }
552
553
        return file_put_contents(
554
            $this->cacheFile,
555
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
556
            LOCK_EX
557
        );
558
    }
559
560
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
561
    {
562
        $path = $result[1];
563
564
        $allowedMethods = array_unique(
565
            array_reduce(
566
                $this->routes,
567
                static function ($allowedMethods, Route $route) use ($path) {
568
                    if ($path !== $route->getPattern()) {
569
                        return $allowedMethods;
570
                    }
571
572
                    return array_merge($allowedMethods, $route->getMethods());
573
                },
574
                []
575
            )
576
        );
577
578
        return MatchingResult::fromFailure($allowedMethods);
579
    }
580
581
    /**
582
     * @param string $name
583
     * @return Route
584
     */
585 10
    private function getRoute(string $name): Route
586
    {
587 10
        if (!array_key_exists($name, $this->routes)) {
588 1
            throw new RouteNotFoundException($name);
589
        }
590
591 9
        return $this->routes[$name];
592
    }
593
594
    /**
595
     * @param array $parameters
596
     * @param array $parts
597
     * @return string
598
     */
599 7
    private function generatePath(array $parameters, array $parts): string
600
    {
601 7
        $path = $this->getUriPrefix();
602 7
        foreach ($parts as $part) {
603 7
            if (is_string($part)) {
604
                // Append the string
605 7
                $path .= $part;
606 7
                continue;
607
            }
608
609
            // Check substitute value with regex
610 5
            $pattern = str_replace('~', '\~', $part[1]);
611 5
            if (preg_match('~^' . $pattern . '$~', (string)$parameters[$part[0]]) === 0) {
612 1
                throw new \RuntimeException(
613 1
                    sprintf(
614 1
                        'Parameter value for [%s] did not match the regex `%s`',
615 1
                        $part[0],
616 1
                        $part[1]
617
                    )
618
                );
619
            }
620
621
            // Append the substituted value
622 4
            $path .= $parameters[$part[0]];
623
        }
624
625 6
        return $path;
626
    }
627
}
628