Passed
Push — master ( aa7f32...153751 )
by Alexander
34:54 queued 19:53
created

FastRoute::marshalMethodNotAllowedResult()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 2
b 0
f 0
nc 1
nop 1
dl 0
loc 19
rs 9.9666
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 Yiisoft\Router\Group;
13
use Yiisoft\Router\MatchingResult;
14
use Yiisoft\Router\Method;
15
use Yiisoft\Router\Route;
16
use Yiisoft\Router\RouteNotFoundException;
17
use Yiisoft\Router\RouterInterface;
18
19
use function array_key_exists;
20
use function array_keys;
21
use function array_merge;
22
use function array_reduce;
23
use function array_unique;
24
use function dirname;
25
use function file_exists;
26
use function file_put_contents;
27
use function implode;
28
use function is_array;
29
use function is_dir;
30
use function is_string;
31
use function is_writable;
32
use function preg_match;
33
use function restore_error_handler;
34
use function set_error_handler;
35
use function sprintf;
36
use function var_export;
37
38
use const E_WARNING;
39
40
/**
41
 * Router implementation bridging nikic/fast-route.
42
 * Adapted from https://github.com/zendframework/zend-expressive-fastroute/
43
 */
44
class FastRoute implements RouterInterface
45
{
46
    /**
47
     * Template used when generating the cache file.
48
     */
49
    public const CACHE_TEMPLATE = <<< 'EOT'
50
<?php
51
return %s;
52
EOT;
53
54
    /**
55
     * @const string Configuration key used to enable/disable fastroute caching
56
     */
57
    public const CONFIG_CACHE_ENABLED = 'cache_enabled';
58
59
    /**
60
     * @const string Configuration key used to set the cache file path
61
     */
62
    public const CONFIG_CACHE_FILE = 'cache_file';
63
64
    /**
65
     * Cache generated route data?
66
     *
67
     * @var bool
68
     */
69
    private $cacheEnabled = false;
70
71
    /**
72
     * Cache file path relative to the project directory.
73
     *
74
     * @var string
75
     */
76
    private $cacheFile = 'data/cache/fastroute.php.cache';
77
78
    /**
79
     * @var callable A factory callback that can return a dispatcher.
80
     */
81
    private $dispatcherCallback;
82
83
    /**
84
     * Cached data used by the dispatcher.
85
     *
86
     * @var array
87
     */
88
    private $dispatchData = [];
89
90
    /**
91
     * True if cache is enabled and valid dispatch data has been loaded from
92
     * cache.
93
     *
94
     * @var bool
95
     */
96
    private $hasCache = false;
97
98
    /**
99
     * FastRoute router
100
     *
101
     * @var RouteCollector
102
     */
103
    private $router;
104
105
    /**
106
     * All attached routes as Route instances
107
     *
108
     * @var Route[]
109
     */
110
    private $routes = [];
111
112
    /**
113
     * Routes to inject into the underlying RouteCollector.
114
     *
115
     * @var Route[]
116
     */
117
    private $routesToInject = [];
118
    /**
119
     * @var RouteParser
120
     */
121
    private $routeParser;
122
123
    /**
124
     * Groups to inject into the underlying RouteCollector.
125
     *
126
     * @var Group[]
127
     */
128
    private $groupsToInject = [];
129
130
    /**
131
     * Constructor
132
     *
133
     * Accepts optionally a FastRoute RouteCollector and a callable factory
134
     * that can return a FastRoute dispatcher.
135
     *
136
     * If either is not provided defaults will be used:
137
     *
138
     * - A RouteCollector instance will be created composing a RouteParser and
139
     *   RouteGenerator.
140
     * - A callable that returns a GroupCountBased dispatcher will be created.
141
     *
142
     * @param null|RouteCollector $router If not provided, a default
143
     *     implementation will be used.
144
     * @param RouteParser $routeParser
145
     * @param null|callable $dispatcherFactory Callable that will return a
146
     *     FastRoute dispatcher.
147
     * @param array $config Array of custom configuration options.
148
     */
149
    public function __construct(
150
        RouteCollector $router,
151
        RouteParser $routeParser,
152
        callable $dispatcherFactory = null,
153
        array $config = null
154
    ) {
155
        $this->router = $router;
156
        $this->dispatcherCallback = $dispatcherFactory;
157
        $this->routeParser = $routeParser;
158
159
        $this->loadConfig($config);
160
    }
161
162
    /**
163
     * Load configuration parameters
164
     *
165
     * @param null|array $config Array of custom configuration options.
166
     */
167
    private function loadConfig(array $config = null): void
168
    {
169
        if (null === $config) {
170
            return;
171
        }
172
173
        if (isset($config[self::CONFIG_CACHE_ENABLED])) {
174
            $this->cacheEnabled = (bool)$config[self::CONFIG_CACHE_ENABLED];
175
        }
176
177
        if (isset($config[self::CONFIG_CACHE_FILE])) {
178
            $this->cacheFile = (string)$config[self::CONFIG_CACHE_FILE];
179
        }
180
181
        if ($this->cacheEnabled) {
182
            $this->loadDispatchData();
183
        }
184
    }
185
186
    /**
187
     * Add a route to the collection.
188
     *
189
     * Uses the HTTP methods associated (creating sane defaults for an empty
190
     * list or Route::HTTP_METHOD_ANY) and the path, and uses the path as
191
     * the name (to allow later lookup of the middleware).
192
     * @param Route $route
193
     */
194
    public function addRoute(Route $route): void
195
    {
196
        $this->routesToInject[] = $route;
197
    }
198
199
    public function addGroup(Group $group): void
200
    {
201
        $this->groupsToInject[] = $group;
202
    }
203
204
    public function match(ServerRequestInterface $request): MatchingResult
205
    {
206
        // Inject any pending routes
207
        $this->injectRoutes();
208
209
        // Inject any pending groups
210
        $this->injectGroups();
211
212
        $dispatchData = $this->getDispatchData();
213
        $path = rawurldecode($request->getUri()->getPath());
214
        $method = $request->getMethod();
215
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
216
217
        return $result[0] !== Dispatcher::FOUND
218
            ? $this->marshalFailedRoute($result)
219
            : $this->marshalMatchedRoute($result, $method);
220
    }
221
222
    /**
223
     * Generate a URI based on a given route.
224
     *
225
     * Replacements in FastRoute are written as `{name}` or `{name:<pattern>}`;
226
     * this method uses `FastRoute\RouteParser\Std` to search for the best route
227
     * match based on the available substitutions and generates a uri.
228
     *
229
     * @param string $name Route name.
230
     * @param array $parameters Key/value option pairs to pass to the router for
231
     * purposes of generating a URI; takes precedence over options present
232
     * in route used to generate URI.
233
     *
234
     * @return string URI path generated.
235
     * @throws \RuntimeException if the route name is not known or a parameter value does not match its regex.
236
     */
237
    public function generate(string $name, array $parameters = []): string
238
    {
239
        // Inject any pending routes
240
        $this->injectRoutes();
241
242
        $route = $this->getRoute($name);
243
        $parameters = array_merge($route->getDefaults(), $parameters);
244
245
        $parsedRoutes = $this->routeParser->parse($route->getPattern());
246
247
        if (count($parsedRoutes) === 0) {
248
            throw new RouteNotFoundException();
249
        }
250
        $parts = reset($parsedRoutes);
251
252
        $this->checkUrlParameters($name, $parameters, $parts);
253
254
        return $this->generatePath($parameters, $parts);
255
    }
256
257
    /**
258
     * Checks for any missing route parameters
259
     * @param array $parts
260
     * @param array $substitutions
261
     * @return array with minimum required parameters if any are missing or an empty array if none are missing
262
     */
263
    private function missingParameters(array $parts, array $substitutions): array
264
    {
265
        $missingParameters = [];
266
267
        // Gather required parameters
268
        foreach ($parts as $part) {
269
            if (is_string($part)) {
270
                continue;
271
            }
272
273
            $missingParameters[] = $part[0];
274
        }
275
276
        // Check if all parameters exist
277
        foreach ($missingParameters as $param) {
278
            if (!isset($substitutions[$param])) {
279
                // Return the parameters so they can be used in an
280
                // exception if needed
281
                return $missingParameters;
282
            }
283
        }
284
285
        // All required parameters are available
286
        return [];
287
    }
288
289
    /**
290
     * Retrieve the dispatcher instance.
291
     *
292
     * Uses the callable factory in $dispatcherCallback, passing it $data
293
     * (which should be derived from the router's getData() method); this
294
     * approach is done to allow testing against the dispatcher.
295
     *
296
     * @param array|object $data Data from RouteCollection::getData()
297
     * @return Dispatcher
298
     */
299
    private function getDispatcher($data): Dispatcher
300
    {
301
        if (!$this->dispatcherCallback) {
302
            $this->dispatcherCallback = $this->createDispatcherCallback();
303
        }
304
305
        $factory = $this->dispatcherCallback;
306
307
        return $factory($data);
308
    }
309
310
    /**
311
     * Return a default implementation of a callback that can return a Dispatcher.
312
     */
313
    private function createDispatcherCallback(): callable
314
    {
315
        return static function ($data) {
316
            return new GroupCountBased($data);
317
        };
318
    }
319
320
    /**
321
     * Marshal a routing failure result.
322
     *
323
     * If the failure was due to the HTTP method, passes the allowed HTTP
324
     * methods to the factory.
325
     * @param array $result
326
     * @return MatchingResult
327
     */
328
    private function marshalFailedRoute(array $result): MatchingResult
329
    {
330
        $resultCode = $result[0];
331
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
332
            return MatchingResult::fromFailure($result[1]);
333
        }
334
335
        return MatchingResult::fromFailure(Method::ANY);
336
    }
337
338
    /**
339
     * Marshals a route result based on the results of matching and the current HTTP method.
340
     * @param array $result
341
     * @param string $method
342
     * @return MatchingResult
343
     */
344
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
345
    {
346
        [, $path, $parameters] = $result;
347
348
        /* @var Route $route */
349
        $route = array_reduce(
350
            $this->routes,
351
            function ($matched, Route $route) use ($path, $method) {
352
                if ($matched) {
353
                    return $matched;
354
                }
355
356
                if ($path !== $route->getPattern()) {
357
                    return $matched;
358
                }
359
360
                if (!in_array($method, $route->getMethods(), true)) {
361
                    return $matched;
362
                }
363
364
                return $route;
365
            },
366
            false
367
        );
368
369
        if (false === $route) {
0 ignored issues
show
introduced by
The condition false === $route is always false.
Loading history...
370
            return $this->marshalMethodNotAllowedResult($result);
371
        }
372
373
        $options = $route->getParameters();
374
        if (!empty($options['defaults'])) {
375
            $parameters = array_merge($options['defaults'], $parameters);
376
        }
377
378
        return MatchingResult::fromSuccess($route, $parameters);
379
    }
380
381
    /**
382
     * Inject queued Route instances into the underlying router.
383
     */
384
    private function injectRoutes(): void
385
    {
386
        foreach ($this->routesToInject as $index => $route) {
387
            $this->injectRoute($route);
388
            unset($this->routesToInject[$index]);
389
        }
390
    }
391
392
    /**
393
     * Inject a Route instance into the underlying router.
394
     * @param Route $route
395
     */
396
    private function injectRoute(Route $route): void
397
    {
398
        // Filling the routes' hash-map is required by the `generateUri` method
399
        $this->routes[$route->getName()] = $route;
400
401
        // Skip feeding FastRoute collector if valid cached data was already loaded
402
        if ($this->hasCache) {
403
            return;
404
        }
405
406
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
407
    }
408
409
    /**
410
     * Inject queued Group instances into the underlying router.
411
     */
412
    private function injectGroups(): void
413
    {
414
        foreach ($this->groupsToInject as $index => $group) {
415
            $this->injectGroup($group);
416
            unset($this->groupsToInject[$index]);
417
        }
418
    }
419
420
    /**
421
     * Inject a Group instance into the underlying router.
422
     */
423
    private function injectGroup(Group $group): void
424
    {
425
        $this->router->addGroup(
426
            $group->getPrefix(),
427
            static function (RouteCollector $r) use ($group) {
428
                $groupMiddleware = $group->getMiddleware();
429
                foreach ($group->getRoutes() as $route) {
430
                    $routeMiddleware = $route->getMiddleware();
0 ignored issues
show
Bug introduced by
The method getMiddleware() does not exist on Yiisoft\Router\Route. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

430
                    /** @scrutinizer ignore-call */ 
431
                    $routeMiddleware = $route->getMiddleware();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
431
                    if ($groupMiddleware !== null) {
432
                        $routeMiddleware = $groupMiddleware->withRouteMiddleware($routeMiddleware);
433
                    }
434
435
                    $r->addRoute(
436
                        $route->getMethods(),
437
                        $route->getPattern(),
438
                        $routeMiddleware
439
                    );
440
                }
441
            }
442
        );
443
    }
444
445
    /**
446
     * Get the dispatch data either from cache or freshly generated by the
447
     * FastRoute data generator.
448
     *
449
     * If caching is enabled, store the freshly generated data to file.
450
     */
451
    private function getDispatchData(): array
452
    {
453
        if ($this->hasCache) {
454
            return $this->dispatchData;
455
        }
456
457
        $dispatchData = (array)$this->router->getData();
458
459
        if ($this->cacheEnabled) {
460
            $this->cacheDispatchData($dispatchData);
461
        }
462
463
        return $dispatchData;
464
    }
465
466
    /**
467
     * Load dispatch data from cache
468
     * @throws \RuntimeException If the cache file contains invalid data
469
     */
470
    private function loadDispatchData(): void
471
    {
472
        set_error_handler(
473
            function () {
474
            },
475
            E_WARNING
476
        ); // suppress php warnings
477
        $dispatchData = include $this->cacheFile;
478
        restore_error_handler();
479
480
        // Cache file does not exist
481
        if (false === $dispatchData) {
482
            return;
483
        }
484
485
        if (!is_array($dispatchData)) {
486
            throw new \RuntimeException(
487
                sprintf(
488
                    'Invalid cache file "%s"; cache file MUST return an array',
489
                    $this->cacheFile
490
                )
491
            );
492
        }
493
494
        $this->hasCache = true;
495
        $this->dispatchData = $dispatchData;
496
    }
497
498
    /**
499
     * Save dispatch data to cache
500
     * @param array $dispatchData
501
     * @return int|false bytes written to file or false if error
502
     * @throws \RuntimeException If the cache directory does not exist.
503
     * @throws \RuntimeException If the cache directory is not writable.
504
     * @throws \RuntimeException If the cache file exists but is not writable
505
     */
506
    private function cacheDispatchData(array $dispatchData)
507
    {
508
        $cacheDir = dirname($this->cacheFile);
509
510
        if (!is_dir($cacheDir)) {
511
            throw new \RuntimeException(
512
                sprintf(
513
                    'The cache directory "%s" does not exist',
514
                    $cacheDir
515
                )
516
            );
517
        }
518
519
        if (!is_writable($cacheDir)) {
520
            throw new \RuntimeException(
521
                sprintf(
522
                    'The cache directory "%s" is not writable',
523
                    $cacheDir
524
                )
525
            );
526
        }
527
528
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
529
            throw new \RuntimeException(
530
                sprintf(
531
                    'The cache file %s is not writable',
532
                    $this->cacheFile
533
                )
534
            );
535
        }
536
537
        return file_put_contents(
538
            $this->cacheFile,
539
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
540
            LOCK_EX
541
        );
542
    }
543
544
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
545
    {
546
        $path = $result[1];
547
548
        $allowedMethods = array_unique(
549
            array_reduce(
550
                $this->routes,
551
                static function ($allowedMethods, Route $route) use ($path) {
552
                    if ($path !== $route->getPattern()) {
553
                        return $allowedMethods;
554
                    }
555
556
                    return array_merge($allowedMethods, $route->getMethods());
557
                },
558
                []
559
            )
560
        );
561
562
        return MatchingResult::fromFailure($allowedMethods);
563
    }
564
565
    /**
566
     * @param string $name
567
     * @return Route
568
     */
569
    private function getRoute(string $name): Route
570
    {
571
        if (!array_key_exists($name, $this->routes)) {
572
            throw new RouteNotFoundException($name);
573
        }
574
575
        return $this->routes[$name];
576
    }
577
578
    /**
579
     * @param string $name
580
     * @param array $parameters
581
     * @param array $parts
582
     */
583
    private function checkUrlParameters(string $name, array $parameters, array $parts): void
584
    {
585
        // Check if all parameters can be substituted
586
        $missingParameters = $this->missingParameters($parts, $parameters);
587
588
        // If not all parameters can be substituted, try the next route
589
        if (!empty($missingParameters)) {
590
            throw new  \RuntimeException(
591
                sprintf(
592
                    'Route `%s` expects at least parameter values for [%s], but received [%s]',
593
                    $name,
594
                    implode(',', $missingParameters),
595
                    implode(',', array_keys($parameters))
596
                )
597
            );
598
        }
599
    }
600
601
    /**
602
     * @param array $parameters
603
     * @param array $parts
604
     * @return string
605
     */
606
    private function generatePath(array $parameters, array $parts): string
607
    {
608
        $path = '';
609
        foreach ($parts as $part) {
610
            if (is_string($part)) {
611
                // Append the string
612
                $path .= $part;
613
                continue;
614
            }
615
616
            // Check substitute value with regex
617
            if (!preg_match('~^' . $part[1] . '$~', (string)$parameters[$part[0]])) {
618
                throw new \RuntimeException(
619
                    sprintf(
620
                        'Parameter value for [%s] did not match the regex `%s`',
621
                        $part[0],
622
                        $part[1]
623
                    )
624
                );
625
            }
626
627
            // Append the substituted value
628
            $path .= $parameters[$part[0]];
629
        }
630
631
        return $path;
632
    }
633
}
634