Passed
Pull Request — master (#15)
by Alexander
02:00
created

FastRoute::getUriPrefix()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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