Passed
Push — master ( edea3d...0ae37f )
by Alexander
01:22
created

FastRoute::loadConfig()   A

Complexity

Conditions 5
Paths 9

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 12.4085

Importance

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