Passed
Pull Request — master (#24)
by Dmitriy
02:33
created

UrlMatcher::loadDispatchData()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 14
c 0
b 0
f 0
nc 3
nop 0
dl 0
loc 26
rs 9.7998
ccs 0
cts 15
cp 0
crap 12
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\RouteParser\Std as RouteParser;
10
use FastRoute\DataGenerator\GroupCountBased as RouteGenerator;
11
use FastRoute\RouteCollector;
12
use Psr\Http\Message\ServerRequestInterface;
13
use Yiisoft\Http\Method;
14
use Yiisoft\Router\Route;
15
use Yiisoft\Router\MatchingResult;
16
use Yiisoft\Router\UrlMatcherInterface;
17
use Yiisoft\Router\RouteCollectionInterface;
18
19
use function array_merge;
20
use function array_reduce;
21
use function array_unique;
22
use function dirname;
23
use function file_exists;
24
use function file_put_contents;
25
use function is_array;
26
use function is_dir;
27
use function is_writable;
28
use function restore_error_handler;
29
use function set_error_handler;
30
use function sprintf;
31
use function var_export;
32
33
final class UrlMatcher implements UrlMatcherInterface
34
{
35
    /**
36
     * Template used when generating the cache file.
37
     */
38
    public const CACHE_TEMPLATE = <<< 'EOT'
39
<?php
40
return %s;
41
EOT;
42
43
    /**
44
     * @const string Configuration key used to enable/disable fastroute caching
45
     */
46
    public const CONFIG_CACHE_ENABLED = 'cache_enabled';
47
48
    /**
49
     * @const string Configuration key used to set the cache file path
50
     */
51
    public const CONFIG_CACHE_FILE = 'cache_file';
52
53
    /**
54
     * Cache generated route data?
55
     *
56
     * @var bool
57
     */
58
    private bool $cacheEnabled = false;
59
60
    /**
61
     * Cache file path relative to the project directory.
62
     *
63
     * @var string
64
     */
65
    private string $cacheFile = 'data/cache/fastroute.php.cache';
66
67
    /**
68
     * @var callable A factory callback that can return a dispatcher.
69
     */
70
    private $dispatcherCallback;
71
72
    /**
73
     * Cached data used by the dispatcher.
74
     *
75
     * @var array
76
     */
77
    private array $dispatchData = [];
78
79
    /**
80
     * True if cache is enabled and valid dispatch data has been loaded from
81
     * cache.
82
     *
83
     * @var bool
84
     */
85
    private bool $hasCache = false;
86
87
    /**
88
     * FastRoute collector
89
     *
90
     * @var RouteCollector
91
     */
92
    private RouteCollector $fastRouteCollector;
93
94
    /**
95
     * Route collection
96
     *
97
     * @var RouteCollectionInterface
98
     */
99
    private RouteCollectionInterface $routeCollection;
100
101
102
    /** @var Route|null */
103
    private ?Route $currentRoute = null;
104
105
    /**
106
     * Last matched request
107
     *
108
     * @var ServerRequestInterface|null
109
     */
110
    private ?ServerRequestInterface $request = null;
111
112
    /**
113
     * Constructor
114
     *
115
     * Accepts optionally a FastRoute RouteCollector and a callable factory
116
     * that can return a FastRoute dispatcher.
117
     *
118
     * If either is not provided defaults will be used:
119
     *
120
     * - A RouteCollector instance will be created composing a RouteParser and
121
     *   RouteGenerator.
122
     * - A callable that returns a GroupCountBased dispatcher will be created.
123
     *
124
     * @param null|RouteCollector $fastRouteCollector If not provided, a default
125
     *     implementation will be used.
126
     * @param null|callable $dispatcherFactory Callable that will return a
127
     *     FastRoute dispatcher.
128
     * @param array $config Array of custom configuration options.
129
     */
130 27
    public function __construct(
131
        RouteCollectionInterface $routeCollection,
132
        RouteCollector $fastRouteCollector = null,
133
        callable $dispatcherFactory = null,
134
        array $config = null
135
    ) {
136 27
        if (null === $fastRouteCollector) {
137 27
            $fastRouteCollector = $this->createRouteCollector();
138
        }
139 27
        $this->routeCollection = $routeCollection;
140 27
        $this->fastRouteCollector = $fastRouteCollector;
141 27
        $this->dispatcherCallback = $dispatcherFactory;
142
143 27
        $this->loadConfig($config);
144
    }
145
146
    /**
147
     * Load configuration parameters
148
     *
149
     * @param null|array $config Array of custom configuration options.
150
     */
151 27
    private function loadConfig(array $config = null): void
152
    {
153 27
        if (null === $config) {
154 27
            return;
155
        }
156
157
        if (isset($config[self::CONFIG_CACHE_ENABLED])) {
158
            $this->cacheEnabled = (bool)$config[self::CONFIG_CACHE_ENABLED];
159
        }
160
161
        if (isset($config[self::CONFIG_CACHE_FILE])) {
162
            $this->cacheFile = (string)$config[self::CONFIG_CACHE_FILE];
163
        }
164
165
        if ($this->cacheEnabled) {
166
            $this->loadDispatchData();
167
        }
168
    }
169
170 9
    public function match(ServerRequestInterface $request): MatchingResult
171
    {
172 9
        $this->request = $request;
173
174 9
        if (!$this->hasCache) {
175 9
            $this->injectRoutes();
176
        }
177
178 9
        $dispatchData = $this->getDispatchData();
179 9
        $path = rawurldecode($request->getUri()->getPath());
180 9
        $method = $request->getMethod();
181 9
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
182
183 9
        return $result[0] !== Dispatcher::FOUND
184
            ? $this->marshalFailedRoute($result)
185 9
            : $this->marshalMatchedRoute($result, $method);
186
    }
187
188
    /**
189
     * Returns the current Route object
190
     * @return Route|null current route
191
     */
192
    public function getCurrentRoute(): ?Route
193
    {
194
        return $this->currentRoute;
195
    }
196
197
    /**
198
     * Returns last matched Request
199
     * @return ServerRequestInterface|null current route
200
     */
201 15
    public function getLastMatchedRequest(): ?ServerRequestInterface
202
    {
203 15
        return $this->request;
204
    }
205
206
    /**
207
     * @return RouteCollectionInterface collection of routes
208
     */
209 26
    public function getRouteCollection(): RouteCollectionInterface
210
    {
211 26
        return $this->routeCollection;
212
    }
213
214
    /**
215
     * Retrieve the dispatcher instance.
216
     *
217
     * Uses the callable factory in $dispatcherCallback, passing it $data
218
     * (which should be derived from the router's getData() method); this
219
     * approach is done to allow testing against the dispatcher.
220
     *
221
     * @param array|object $data Data from RouteCollector::getData()
222
     * @return Dispatcher
223
     */
224 9
    private function getDispatcher($data): Dispatcher
225
    {
226 9
        if (!$this->dispatcherCallback) {
227 9
            $this->dispatcherCallback = $this->createDispatcherCallback();
228
        }
229
230 9
        $factory = $this->dispatcherCallback;
231
232 9
        return $factory($data);
233
    }
234
235
    /**
236
     * Create a default FastRoute Collector instance
237
     */
238 27
    private function createRouteCollector(): RouteCollector
239
    {
240 27
        return new RouteCollector(new RouteParser(), new RouteGenerator());
241
    }
242
243
    /**
244
     * Return a default implementation of a callback that can return a Dispatcher.
245
     */
246 9
    private function createDispatcherCallback(): callable
247
    {
248
        return static function ($data) {
249 9
            return new GroupCountBased($data);
250 9
        };
251
    }
252
253
    /**
254
     * Marshal a routing failure result.
255
     *
256
     * If the failure was due to the HTTP method, passes the allowed HTTP
257
     * methods to the factory.
258
     * @param array $result
259
     * @return MatchingResult
260
     */
261
    private function marshalFailedRoute(array $result): MatchingResult
262
    {
263
        $resultCode = $result[0];
264
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
265
            return MatchingResult::fromFailure($result[1]);
266
        }
267
268
        return MatchingResult::fromFailure(Method::ANY);
269
    }
270
271
    /**
272
     * Marshals a route result based on the results of matching and the current HTTP method.
273
     * @param array $result
274
     * @param string $method
275
     * @return MatchingResult
276
     */
277 9
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
278
    {
279 9
        [, $name, $parameters] = $result;
280
281 9
        $route = $this->routeCollection->getRoute($name);
282 9
        if (!in_array($method, $route->getMethods())) {
283
            $result[1] = $route->getPattern();
284
            return $this->marshalMethodNotAllowedResult($result);
285
        }
286
287 9
        $parameters = array_merge($route->getDefaults(), $parameters);
288 9
        $this->currentRoute = $route;
289
290 9
        return MatchingResult::fromSuccess($route, $parameters);
291
    }
292
293
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
294
    {
295
        $path = $result[1];
296
297
        $allowedMethods = array_unique(
298
            array_reduce(
299
                $this->routeCollection->getRoutes(),
300
                static function ($allowedMethods, Route $route) use ($path) {
301
                    if ($path !== $route->getPattern()) {
302
                        return $allowedMethods;
303
                    }
304
305
                    return array_merge($allowedMethods, $route->getMethods());
306
                },
307
                []
308
            )
309
        );
310
311
        return MatchingResult::fromFailure($allowedMethods);
312
    }
313
314
    /**
315
     * Inject routes into the underlying router
316
     */
317 9
    private function injectRoutes(): void
318
    {
319 9
        foreach ($this->routeCollection->getRoutes() as $index => $route) {
320
            /** @var Route $route */
321 9
            $this->fastRouteCollector->addRoute($route->getMethods(), $route->getPattern(), $route->getName());
322
        }
323
    }
324
325
    /**
326
     * Get the dispatch data either from cache or freshly generated by the
327
     * FastRoute data generator.
328
     *
329
     * If caching is enabled, store the freshly generated data to file.
330
     */
331 9
    private function getDispatchData(): array
332
    {
333 9
        if ($this->hasCache) {
334
            return $this->dispatchData;
335
        }
336
337 9
        $dispatchData = (array)$this->fastRouteCollector->getData();
338
339 9
        if ($this->cacheEnabled) {
340
            $this->cacheDispatchData($dispatchData);
341
        }
342
343 9
        return $dispatchData;
344
    }
345
346
    /**
347
     * Load dispatch data from cache
348
     * @throws \RuntimeException If the cache file contains invalid data
349
     */
350
    private function loadDispatchData(): void
351
    {
352
        set_error_handler(
353
            static function () {
354
            },
355
            E_WARNING
356
        ); // suppress php warnings
357
        $dispatchData = include $this->cacheFile;
358
        restore_error_handler();
359
360
        // Cache file does not exist
361
        if (false === $dispatchData) {
362
            return;
363
        }
364
365
        if (!is_array($dispatchData)) {
366
            throw new \RuntimeException(
367
                sprintf(
368
                    'Invalid cache file "%s"; cache file MUST return an array',
369
                    $this->cacheFile
370
                )
371
            );
372
        }
373
374
        $this->hasCache = true;
375
        $this->dispatchData = $dispatchData;
376
    }
377
378
    /**
379
     * Save dispatch data to cache
380
     * @param array $dispatchData
381
     * @return int|false bytes written to file or false if error
382
     * @throws \RuntimeException If the cache directory does not exist.
383
     * @throws \RuntimeException If the cache directory is not writable.
384
     * @throws \RuntimeException If the cache file exists but is not writable
385
     */
386
    private function cacheDispatchData(array $dispatchData)
387
    {
388
        $cacheDir = dirname($this->cacheFile);
389
390
        if (!is_dir($cacheDir)) {
391
            throw new \RuntimeException(
392
                sprintf(
393
                    'The cache directory "%s" does not exist',
394
                    $cacheDir
395
                )
396
            );
397
        }
398
399
        if (!is_writable($cacheDir)) {
400
            throw new \RuntimeException(
401
                sprintf(
402
                    'The cache directory "%s" is not writable',
403
                    $cacheDir
404
                )
405
            );
406
        }
407
408
        if (file_exists($this->cacheFile) && !is_writable($this->cacheFile)) {
409
            throw new \RuntimeException(
410
                sprintf(
411
                    'The cache file %s is not writable',
412
                    $this->cacheFile
413
                )
414
            );
415
        }
416
417
        return file_put_contents(
418
            $this->cacheFile,
419
            sprintf(self::CACHE_TEMPLATE, var_export($dispatchData, true)),
420
            LOCK_EX
421
        );
422
    }
423
}
424