Passed
Pull Request — master (#24)
by Dmitriy
01:34
created

UrlMatcher::cacheDispatchData()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 42
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

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