Passed
Pull Request — master (#5)
by
unknown
22:45 queued 07:44
created

FastRoute   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 524
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 54
eloc 152
c 4
b 0
f 0
dl 0
loc 524
rs 6.4799

21 Methods

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