Passed
Pull Request — master (#15)
by Alexander
13:04
created

FastRoute   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 571
Duplicated Lines 0 %

Test Coverage

Coverage 40%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 182
c 5
b 0
f 0
dl 0
loc 571
ccs 76
cts 190
cp 0.4
rs 4.08
wmc 59

21 Methods

Rating   Name   Duplication   Size   Complexity  
A setUriPrefix() 0 3 1
A getUriPrefix() 0 3 1
A loadConfig() 0 16 5
A marshalFailedRoute() 0 8 2
A generate() 0 18 2
A createDispatcherCallback() 0 4 1
A __construct() 0 11 1
A missingParameters() 0 24 5
A getDispatcher() 0 9 2
A match() 0 13 2
A cacheDispatchData() 0 35 5
A getDispatchData() 0 13 3
A generatePath() 0 27 4
A getRoute() 0 7 2
A marshalMatchedRoute() 0 32 5
A injectItems() 0 5 2
A injectItem() 0 16 3
A loadDispatchData() 0 26 3
A marshalMethodNotAllowedResult() 0 19 2
A injectGroup() 0 30 6
A checkUrlParameters() 0 13 2

How to fix   Complexity   

Complex Class

Complex classes like FastRoute often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FastRoute, and based on these observations, apply Extract Interface, too.

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