Passed
Pull Request — master (#24)
by
unknown
01:33
created

FastRoute::generatePath()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 30
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 16
nc 7
nop 2
dl 0
loc 30
rs 9.4222
c 2
b 0
f 0
ccs 17
cts 17
cp 1
crap 5
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 Psr\Http\Message\ServerRequestInterface;
11
use Yiisoft\Router\Group;
12
use Yiisoft\Router\MatchingResult;
13
use Yiisoft\Http\Method;
14
use Yiisoft\Router\Route;
15
use Yiisoft\Router\RouterInterface;
16
17
use function array_merge;
18
use function array_reduce;
19
use function array_unique;
20
use function dirname;
21
use function file_exists;
22
use function file_put_contents;
23
use function is_array;
24
use function is_dir;
25
use function is_writable;
26
use function restore_error_handler;
27
use function set_error_handler;
28
use function sprintf;
29
use function var_export;
30
31
use const E_WARNING;
32
33
/**
34
 * Router implementation bridging nikic/fast-route.
35
 * Adapted from https://github.com/zendframework/zend-expressive-fastroute/
36
 */
37
final class FastRoute extends Group implements RouterInterface
38
{
39
    /**
40
     * Template used when generating the cache file.
41
     */
42
    public const CACHE_TEMPLATE = <<< 'EOT'
43
<?php
44
return %s;
45
EOT;
46
47
    /**
48
     * @const string Configuration key used to enable/disable fastroute caching
49
     */
50
    public const CONFIG_CACHE_ENABLED = 'cache_enabled';
51
52
    /**
53
     * @const string Configuration key used to set the cache file path
54
     */
55
    public const CONFIG_CACHE_FILE = 'cache_file';
56
57
    /**
58
     * Cache generated route data?
59
     *
60
     * @var bool
61
     */
62
    private bool $cacheEnabled = false;
63
64
    /**
65
     * Cache file path relative to the project directory.
66
     *
67
     * @var string
68
     */
69
    private string $cacheFile = 'data/cache/fastroute.php.cache';
70
71
    /**
72
     * @var callable A factory callback that can return a dispatcher.
73
     */
74
    private $dispatcherCallback;
75
76
    /**
77
     * Cached data used by the dispatcher.
78
     *
79
     * @var array
80
     */
81
    private array $dispatchData = [];
82
83
    /**
84
     * True if cache is enabled and valid dispatch data has been loaded from
85
     * cache.
86
     *
87
     * @var bool
88
     */
89
    private bool $hasCache = false;
90
91
    /**
92
     * FastRoute router
93
     *
94
     * @var RouteCollector
95
     */
96
    private RouteCollector $router;
97
98
    /**
99
     * All attached routes as Route instances
100
     *
101
     * @var Route[]
102
     */
103
    private array $routes = [];
104
105
    /** @var Route|null */
106
    private ?Route $currentRoute = null;
107
108
    /**
109
     * Last matched request
110
     *
111
     * @var ServerRequestInterface|null
112
     */
113
    private ?ServerRequestInterface $request = null;
114
115
    /**
116
     * Constructor
117
     *
118
     * Accepts optionally a FastRoute RouteCollector and a callable factory
119
     * that can return a FastRoute dispatcher.
120
     *
121
     * If either is not provided defaults will be used:
122
     *
123
     * - A RouteCollector instance will be created composing a RouteParser and
124
     *   RouteGenerator.
125
     * - A callable that returns a GroupCountBased dispatcher will be created.
126
     *
127
     * @param null|RouteCollector $router If not provided, a default
128
     *     implementation will be used.
129
     * @param null|callable $dispatcherFactory Callable that will return a
130
     *     FastRoute dispatcher.
131
     * @param array $config Array of custom configuration options.
132
     */
133 27
    public function __construct(
134
        RouteCollector $router,
135
        callable $dispatcherFactory = null,
136
        array $config = null
137
    ) {
138 27
        $this->router = $router;
139 27
        $this->dispatcherCallback = $dispatcherFactory;
140
141 27
        $this->loadConfig($config);
142
    }
143
144
    /**
145
     * Load configuration parameters
146
     *
147
     * @param null|array $config Array of custom configuration options.
148
     */
149 27
    private function loadConfig(array $config = null): void
150
    {
151 27
        if (null === $config) {
152 27
            return;
153
        }
154
155
        if (isset($config[self::CONFIG_CACHE_ENABLED])) {
156
            $this->cacheEnabled = (bool)$config[self::CONFIG_CACHE_ENABLED];
157
        }
158
159
        if (isset($config[self::CONFIG_CACHE_FILE])) {
160
            $this->cacheFile = (string)$config[self::CONFIG_CACHE_FILE];
161
        }
162
163
        if ($this->cacheEnabled) {
164
            $this->loadDispatchData();
165
        }
166
    }
167
168 9
    public function match(ServerRequestInterface $request): MatchingResult
169
    {
170 9
        $this->request = $request;
171
        // Inject any pending route items
172 9
        $this->injectItems();
173
174 9
        $dispatchData = $this->getDispatchData();
175 9
        $path = rawurldecode($request->getUri()->getPath());
176 9
        $method = $request->getMethod();
177 9
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
178
179 9
        return $result[0] !== Dispatcher::FOUND
180
            ? $this->marshalFailedRoute($result)
181 9
            : $this->marshalMatchedRoute($result, $method);
182
    }
183
184
    public function generate(string $name, array $parameters = []): string
185
    {
186
        // TODO: Implement generate() method.
187
    }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
188
189
    public function generateAbsolute(string $name, array $parameters = [], string $scheme = null, string $host = null): string
190
    {
191
        // TODO: Implement generateAbsolute() method.
192
    }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
193
194
    public function getUriPrefix(): string
195
    {
196
        // TODO: Implement getUriPrefix() method.
197
    }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
198
199
    public function setUriPrefix(string $name): void
200
    {
201
        // TODO: Implement setUriPrefix() method.
202
    }
203
204
    /**
205
     * Returns the current Route object
206
     * @return Route|null current route
207
     */
208
    public function getCurrentRoute(): ?Route
209
    {
210
        return $this->currentRoute;
211
    }
212
213
    /**
214
     * Returns last matched Request
215
     * @return ServerRequestInterface|null current route
216
     */
217 15
    public function getLastMatchedRequest(): ?ServerRequestInterface
218
    {
219 15
        return $this->request;
220
    }
221
222
223
    /**
224
     * Retrieve the dispatcher instance.
225
     *
226
     * Uses the callable factory in $dispatcherCallback, passing it $data
227
     * (which should be derived from the router's getData() method); this
228
     * approach is done to allow testing against the dispatcher.
229
     *
230
     * @param array|object $data Data from RouteCollection::getData()
231
     * @return Dispatcher
232
     */
233 9
    private function getDispatcher($data): Dispatcher
234
    {
235 9
        if (!$this->dispatcherCallback) {
236
            $this->dispatcherCallback = $this->createDispatcherCallback();
237
        }
238
239 9
        $factory = $this->dispatcherCallback;
240
241 9
        return $factory($data);
242
    }
243
244
    /**
245
     * Return a default implementation of a callback that can return a Dispatcher.
246
     */
247
    private function createDispatcherCallback(): callable
248
    {
249
        return static function ($data) {
250
            return new GroupCountBased($data);
251
        };
252
    }
253
254
    /**
255
     * Marshal a routing failure result.
256
     *
257
     * If the failure was due to the HTTP method, passes the allowed HTTP
258
     * methods to the factory.
259
     * @param array $result
260
     * @return MatchingResult
261
     */
262
    private function marshalFailedRoute(array $result): MatchingResult
263
    {
264
        $resultCode = $result[0];
265
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
266
            return MatchingResult::fromFailure($result[1]);
267
        }
268
269
        return MatchingResult::fromFailure(Method::ANY);
270
    }
271
272
    /**
273
     * Marshals a route result based on the results of matching and the current HTTP method.
274
     * @param array $result
275
     * @param string $method
276
     * @return MatchingResult
277
     */
278 9
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
279
    {
280 9
        [, $path, $parameters] = $result;
281
282
        /* @var Route $route */
283 9
        $route = array_reduce(
284 9
            $this->routes,
285
            static function ($matched, Route $route) use ($path, $method) {
286 9
                if ($matched) {
287 1
                    return $matched;
288
                }
289
290 9
                if ($path !== $route->getPattern()) {
291
                    return $matched;
292
                }
293
294 9
                if (!in_array($method, $route->getMethods(), true)) {
295
                    return $matched;
296
                }
297
298 9
                return $route;
299 9
            },
300 9
            false
301
        );
302
303 9
        if (false === $route) {
0 ignored issues
show
introduced by
The condition false === $route is always false.
Loading history...
304
            return $this->marshalMethodNotAllowedResult($result);
305
        }
306
307 9
        $parameters = array_merge($route->getDefaults(), $parameters);
308 9
        $this->currentRoute = $route;
309
310 9
        return MatchingResult::fromSuccess($route, $parameters);
311
    }
312
313
    /**
314
     * Inject queued items into the underlying router
315
     */
316 9
    private function injectItems(): void
317
    {
318 9
        if ($this->routes === []) {
319 9
            foreach ($this->items as $index => $item) {
320 9
                $this->injectItem($item);
321
            }
322
        }
323
    }
324
325
    /**
326
     * Inject an item into the underlying router
327
     * @param Route|Group $route
328
     */
329 9
    private function injectItem($route): void
330
    {
331 9
        if ($route instanceof Group) {
332
            $this->injectGroup($route);
333
            return;
334
        }
335
336
        // Filling the routes' hash-map is required by the `generateUri` method
337 9
        $this->routes[$route->getName()] = $route;
338
339
        // Skip feeding FastRoute collector if valid cached data was already loaded
340 9
        if ($this->hasCache) {
341
            return;
342
        }
343
344 9
        $this->router->addRoute($route->getMethods(), $route->getPattern(), $route->getPattern());
345
    }
346
347
    /**
348
     * Inject a Group instance into the underlying router.
349
     */
350
    private function injectGroup(Group $group, RouteCollector $collector = null, string $prefix = ''): void
351
    {
352
        if ($collector === null) {
353
            $collector = $this->router;
354
        }
355
356
        $collector->addGroup(
357
            $group->getPrefix(),
358
            function (RouteCollector $r) use ($group, $prefix) {
359
                $prefix .= $group->getPrefix();
360
                foreach ($group->items as $index => $item) {
361
                    if ($item instanceof Group) {
362
                        $this->injectGroup($item, $r, $prefix);
363
                        continue;
364
                    }
365
366
                    /** @var Route $modifiedItem */
367
                    $modifiedItem = $item->pattern($prefix . $item->getPattern());
368
369
                    $groupMiddlewares = $group->getMiddlewares();
370
371
                    for (end($groupMiddlewares); key($groupMiddlewares) !== null; prev($groupMiddlewares)) {
372
                        $modifiedItem = $modifiedItem->addMiddleware(current($groupMiddlewares));
373
                    }
374
375
                    // Filling the routes' hash-map is required by the `generateUri` method
376
                    $this->routes[$modifiedItem->getName()] = $modifiedItem;
377
378
                    // Skip feeding FastRoute collector if valid cached data was already loaded
379
                    if ($this->hasCache) {
380
                        continue;
381
                    }
382
383
                    $r->addRoute($item->getMethods(), $item->getPattern(), $modifiedItem->getPattern());
384
                }
385
            }
386
        );
387
    }
388
389
    /**
390
     * Get the dispatch data either from cache or freshly generated by the
391
     * FastRoute data generator.
392
     *
393
     * If caching is enabled, store the freshly generated data to file.
394
     */
395 9
    private function getDispatchData(): array
396
    {
397 9
        if ($this->hasCache) {
398
            return $this->dispatchData;
399
        }
400
401 9
        $dispatchData = (array)$this->router->getData();
402
403 9
        if ($this->cacheEnabled) {
404
            $this->cacheDispatchData($dispatchData);
405
        }
406
407 9
        return $dispatchData;
408
    }
409
410
    /**
411
     * Load dispatch data from cache
412
     * @throws \RuntimeException If the cache file contains invalid data
413
     */
414
    private function loadDispatchData(): void
415
    {
416
        set_error_handler(
417
            static function () {
418
            },
419
            E_WARNING
420
        ); // suppress php warnings
421
        $dispatchData = include $this->cacheFile;
422
        restore_error_handler();
423
424
        // Cache file does not exist
425
        if (false === $dispatchData) {
426
            return;
427
        }
428
429
        if (!is_array($dispatchData)) {
430
            throw new \RuntimeException(
431
                sprintf(
432
                    'Invalid cache file "%s"; cache file MUST return an array',
433
                    $this->cacheFile
434
                )
435
            );
436
        }
437
438
        $this->hasCache = true;
439
        $this->dispatchData = $dispatchData;
440
    }
441
442
    /**
443
     * Save dispatch data to cache
444
     * @param array $dispatchData
445
     * @return int|false bytes written to file or false if error
446
     * @throws \RuntimeException If the cache directory does not exist.
447
     * @throws \RuntimeException If the cache directory is not writable.
448
     * @throws \RuntimeException If the cache file exists but is not writable
449
     */
450
    private function cacheDispatchData(array $dispatchData)
451
    {
452
        $cacheDir = dirname($this->cacheFile);
453
454
        if (!is_dir($cacheDir)) {
455
            throw new \RuntimeException(
456
                sprintf(
457
                    'The cache directory "%s" does not exist',
458
                    $cacheDir
459
                )
460
            );
461
        }
462
463
        if (!is_writable($cacheDir)) {
464
            throw new \RuntimeException(
465
                sprintf(
466
                    'The cache directory "%s" is not writable',
467
                    $cacheDir
468
                )
469
            );
470
        }
471
472
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
473
            throw new \RuntimeException(
474
                sprintf(
475
                    'The cache file %s is not writable',
476
                    $this->cacheFile
477
                )
478
            );
479
        }
480
481
        return file_put_contents(
482
            $this->cacheFile,
483
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
484
            LOCK_EX
485
        );
486
    }
487
488
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
489
    {
490
        $path = $result[1];
491
492
        $allowedMethods = array_unique(
493
            array_reduce(
494
                $this->routes,
495
                static function ($allowedMethods, Route $route) use ($path) {
496
                    if ($path !== $route->getPattern()) {
497
                        return $allowedMethods;
498
                    }
499
500
                    return array_merge($allowedMethods, $route->getMethods());
501
                },
502
                []
503
            )
504
        );
505
506
        return MatchingResult::fromFailure($allowedMethods);
507
    }
508
}
509