Passed
Pull Request — master (#13)
by Rustam
01:14
created

FastRoute::injectItems()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 5
ccs 4
cts 4
cp 1
crap 2
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 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 2
            $missingParameters[] = $part[0];
253
        }
254
255
        // Check if all parameters exist
256 4
        foreach ($missingParameters as $param) {
257 2
            if (!isset($substitutions[$param])) {
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
        $options = $route->getParameters();
353
        if (!empty($options['defaults'])) {
354
            $parameters = array_merge($options['defaults'], $parameters);
355
        }
356
357
        return MatchingResult::fromSuccess($route, $parameters);
358
    }
359
360
    /**
361
     * Inject queued items into the underlying router
362
     */
363 5
    private function injectItems(): void
364
    {
365 5
        foreach ($this->items as $index => $item) {
366 5
            $this->injectItem($item);
367 5
            unset($this->items[$index]);
368
        }
369
    }
370
371
    /**
372
     * Inject an item into the underlying router
373
     * @param Route|Group $route
374
     */
375 5
    private function injectItem($route): void
376
    {
377 5
        if ($route instanceof Group) {
378 1
            $this->injectGroup($route);
379 1
            return;
380
        }
381
382
        // Filling the routes' hash-map is required by the `generateUri` method
383 5
        $this->routes[$route->getName()] = $route;
384
385
        // Skip feeding FastRoute collector if valid cached data was already loaded
386 5
        if ($this->hasCache) {
387
            return;
388
        }
389
390 5
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
391
    }
392
393
    /**
394
     * Inject a Group instance into the underlying router.
395
     */
396 1
    private function injectGroup(Group $group, RouteCollector $collector = null): void
397
    {
398 1
        if ($collector === null) {
399 1
            $collector = $this->router;
400
        }
401 1
        $collector->addGroup(
402 1
            $group->getPrefix(),
403
            function (RouteCollector $r) use ($group) {
404 1
                foreach ($group->items as $index => $item) {
405 1
                    if ($item instanceof Group) {
406
                        $this->injectGroup($item, $r);
407
                        return;
408
                    }
409
410 1
                    $groupMiddlewares = $group->getMiddlewares();
411
412 1
                    for (end($groupMiddlewares); key($groupMiddlewares) !== null; prev($groupMiddlewares)) {
413
                        $item = $item->prepend(current($groupMiddlewares));
414
                    }
415
416
                    // Filling the routes' hash-map is required by the `generateUri` method
417 1
                    $this->routes[$item->getName()] = $item;
418
419
                    // Skip feeding FastRoute collector if valid cached data was already loaded
420 1
                    if ($this->hasCache) {
421
                        return;
422
                    }
423
424 1
                    $r->addRoute($item->getMethods(), $item->getPattern(), $item->getPattern());
425
                }
426 1
            }
427
        );
428
    }
429
430
    /**
431
     * Get the dispatch data either from cache or freshly generated by the
432
     * FastRoute data generator.
433
     *
434
     * If caching is enabled, store the freshly generated data to file.
435
     */
436
    private function getDispatchData(): array
437
    {
438
        if ($this->hasCache) {
439
            return $this->dispatchData;
440
        }
441
442
        $dispatchData = (array)$this->router->getData();
443
444
        if ($this->cacheEnabled) {
445
            $this->cacheDispatchData($dispatchData);
446
        }
447
448
        return $dispatchData;
449
    }
450
451
    /**
452
     * Load dispatch data from cache
453
     * @throws \RuntimeException If the cache file contains invalid data
454
     */
455
    private function loadDispatchData(): void
456
    {
457
        set_error_handler(
458
            static function () {
459
            },
460
            E_WARNING
461
        ); // suppress php warnings
462
        $dispatchData = include $this->cacheFile;
463
        restore_error_handler();
464
465
        // Cache file does not exist
466
        if (false === $dispatchData) {
467
            return;
468
        }
469
470
        if (!is_array($dispatchData)) {
471
            throw new \RuntimeException(
472
                sprintf(
473
                    'Invalid cache file "%s"; cache file MUST return an array',
474
                    $this->cacheFile
475
                )
476
            );
477
        }
478
479
        $this->hasCache = true;
480
        $this->dispatchData = $dispatchData;
481
    }
482
483
    /**
484
     * Save dispatch data to cache
485
     * @param array $dispatchData
486
     * @return int|false bytes written to file or false if error
487
     * @throws \RuntimeException If the cache directory does not exist.
488
     * @throws \RuntimeException If the cache directory is not writable.
489
     * @throws \RuntimeException If the cache file exists but is not writable
490
     */
491
    private function cacheDispatchData(array $dispatchData)
492
    {
493
        $cacheDir = dirname($this->cacheFile);
494
495
        if (!is_dir($cacheDir)) {
496
            throw new \RuntimeException(
497
                sprintf(
498
                    'The cache directory "%s" does not exist',
499
                    $cacheDir
500
                )
501
            );
502
        }
503
504
        if (!is_writable($cacheDir)) {
505
            throw new \RuntimeException(
506
                sprintf(
507
                    'The cache directory "%s" is not writable',
508
                    $cacheDir
509
                )
510
            );
511
        }
512
513
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
514
            throw new \RuntimeException(
515
                sprintf(
516
                    'The cache file %s is not writable',
517
                    $this->cacheFile
518
                )
519
            );
520
        }
521
522
        return file_put_contents(
523
            $this->cacheFile,
524
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
525
            LOCK_EX
526
        );
527
    }
528
529
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
530
    {
531
        $path = $result[1];
532
533
        $allowedMethods = array_unique(
534
            array_reduce(
535
                $this->routes,
536
                static function ($allowedMethods, Route $route) use ($path) {
537
                    if ($path !== $route->getPattern()) {
538
                        return $allowedMethods;
539
                    }
540
541
                    return array_merge($allowedMethods, $route->getMethods());
542
                },
543
                []
544
            )
545
        );
546
547
        return MatchingResult::fromFailure($allowedMethods);
548
    }
549
550
    /**
551
     * @param string $name
552
     * @return Route
553
     */
554 5
    private function getRoute(string $name): Route
555
    {
556 5
        if (!array_key_exists($name, $this->routes)) {
557 1
            throw new RouteNotFoundException($name);
558
        }
559
560 4
        return $this->routes[$name];
561
    }
562
563
    /**
564
     * @param string $name
565
     * @param array $parameters
566
     * @param array $parts
567
     */
568 4
    private function checkUrlParameters(string $name, array $parameters, array $parts): void
569
    {
570
        // Check if all parameters can be substituted
571 4
        $missingParameters = $this->missingParameters($parts, $parameters);
572
573
        // If not all parameters can be substituted, try the next route
574 4
        if (!empty($missingParameters)) {
575 1
            throw new  \RuntimeException(
576 1
                sprintf(
577 1
                    'Route `%s` expects at least parameter values for [%s], but received [%s]',
578 1
                    $name,
579 1
                    implode(',', $missingParameters),
580 1
                    implode(',', array_keys($parameters))
581
                )
582
            );
583
        }
584
    }
585
586
    /**
587
     * @param array $parameters
588
     * @param array $parts
589
     * @return string
590
     */
591 3
    private function generatePath(array $parameters, array $parts): string
592
    {
593 3
        $path = $this->getUriPrefix();
594 3
        foreach ($parts as $part) {
595 3
            if (is_string($part)) {
596
                // Append the string
597 3
                $path .= $part;
598 3
                continue;
599
            }
600
601
            // Check substitute value with regex
602 1
            $pattern = str_replace('~', '\~', $part[1]);
603 1
            if (preg_match('~^' . $pattern . '$~', (string)$parameters[$part[0]]) === 0) {
604
                throw new \RuntimeException(
605
                    sprintf(
606
                        'Parameter value for [%s] did not match the regex `%s`',
607
                        $part[0],
608
                        $part[1]
609
                    )
610
                );
611
            }
612
613
            // Append the substituted value
614 1
            $path .= $parameters[$part[0]];
615
        }
616
617 3
        return $path;
618
    }
619
}
620