Passed
Pull Request — master (#25)
by Dmitriy
10:35
created

UrlMatcher::getDispatchData()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3.2098

Importance

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