Passed
Pull Request — master (#26)
by Dmitriy
03:42
created

UrlMatcher::createRouteCollector()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
ccs 2
cts 2
cp 1
crap 1
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 Psr\SimpleCache\CacheInterface;
14
use Yiisoft\Http\Method;
15
use Yiisoft\Router\Route;
16
use Yiisoft\Router\MatchingResult;
17
use Yiisoft\Router\UrlMatcherInterface;
18
use Yiisoft\Router\RouteCollectionInterface;
19
20
use function array_merge;
21
use function array_reduce;
22
use function array_unique;
23
use function dirname;
24
use function file_exists;
25
use function file_put_contents;
26
use function is_array;
27
use function is_dir;
28
use function is_writable;
29
use function restore_error_handler;
30
use function set_error_handler;
31
use function sprintf;
32
use function var_export;
33
34
final class UrlMatcher implements UrlMatcherInterface
35
{
36
    /**
37
     * @const string Configuration key used to set the cache file path
38
     */
39
    public const CONFIG_CACHE_KEY = 'cache_key';
40
41
    /**
42
     * @const string Configuration key used to set the cache file path
43
     */
44
    private string $cacheKey = 'routes-cache';
45
46
    /**
47
     * Cache generated route data?
48
     *
49
     * @var bool
50
     */
51
    private bool $cacheEnabled = false;
52
53
    /**
54
     * @var callable A factory callback that can return a dispatcher.
55
     */
56
    private $dispatcherCallback;
57
58
    /**
59
     * Cached data used by the dispatcher.
60
     *
61
     * @var array
62
     */
63
    private array $dispatchData = [];
64
65
    /**
66
     * True if cache is enabled and valid dispatch data has been loaded from
67
     * cache.
68
     *
69
     * @var bool
70
     */
71
    private bool $hasCache = false;
72
    private ?CacheInterface $cache = null;
73
74
    private RouteCollector $fastRouteCollector;
75
    private RouteCollectionInterface $routeCollection;
76
    private ?Route $currentRoute = null;
77
    private bool $hasInjectedRoutes = false;
78
79
    /**
80
     * Last matched request
81
     *
82
     * @var ServerRequestInterface|null
83
     */
84
    private ?ServerRequestInterface $request = null;
85
86
    /**
87
     * Constructor
88
     *
89
     * Accepts optionally a FastRoute RouteCollector and a callable factory
90
     * that can return a FastRoute dispatcher.
91
     *
92
     * If either is not provided defaults will be used:
93
     *
94
     * - A RouteCollector instance will be created composing a RouteParser and
95
     *   RouteGenerator.
96
     * - A callable that returns a GroupCountBased dispatcher will be created.
97
     *
98
     * @param null|RouteCollector $fastRouteCollector If not provided, a default
99
     *     implementation will be used.
100
     * @param null|callable $dispatcherFactory Callable that will return a
101
     *     FastRoute dispatcher.
102
     * @param array $config Array of custom configuration options.
103
     */
104 41
    public function __construct(
105
        RouteCollectionInterface $routeCollection,
106
        CacheInterface $cache = null,
107
        array $config = null,
108
        RouteCollector $fastRouteCollector = null,
109
        callable $dispatcherFactory = null
110
    ) {
111 41
        if (null === $fastRouteCollector) {
112 41
            $fastRouteCollector = $this->createRouteCollector();
113
        }
114 41
        $this->routeCollection = $routeCollection;
115 41
        $this->fastRouteCollector = $fastRouteCollector;
116 41
        $this->dispatcherCallback = $dispatcherFactory;
117 41
        $this->loadConfig($config);
118 41
        $this->cache = $cache;
119 41
        $this->cacheEnabled = $cache === null ? false : true;
120 41
        $this->loadDispatchData();
121
    }
122
123 22
    public function match(ServerRequestInterface $request): MatchingResult
124
    {
125 22
        $this->request = $request;
126
127 22
        if (!$this->hasCache && !$this->hasInjectedRoutes) {
128 21
            $this->injectRoutes();
129
        }
130
131 22
        $dispatchData = $this->getDispatchData();
132 22
        $path = rawurldecode($request->getUri()->getPath());
133 22
        $method = $request->getMethod();
134 22
        $result = $this->getDispatcher($dispatchData)->dispatch($method, $path);
135
136 22
        return $result[0] !== Dispatcher::FOUND
137 2
            ? $this->marshalFailedRoute($result)
138 22
            : $this->marshalMatchedRoute($result, $method);
139
    }
140
141
    /**
142
     * Returns the current Route object
143
     * @return Route|null current route
144
     */
145 1
    public function getCurrentRoute(): ?Route
146
    {
147 1
        return $this->currentRoute;
148
    }
149
150
    /**
151
     * Returns last matched Request
152
     * @return ServerRequestInterface|null current route
153
     */
154 16
    public function getLastMatchedRequest(): ?ServerRequestInterface
155
    {
156 16
        return $this->request;
157
    }
158
159
    /**
160
     * @return RouteCollectionInterface collection of routes
161
     */
162 27
    public function getRouteCollection(): RouteCollectionInterface
163
    {
164 27
        return $this->routeCollection;
165
    }
166
167
    /**
168
     * Load configuration parameters
169
     *
170
     * @param null|array $config Array of custom configuration options.
171
     */
172 41
    private function loadConfig(array $config = null): void
173
    {
174 41
        if (null === $config) {
175 41
            return;
176
        }
177
178
        if (isset($config[self::CONFIG_CACHE_KEY])) {
179
            $this->cacheKey = (bool)$config[self::CONFIG_CACHE_KEY];
180
        }
181
    }
182
183
    /**
184
     * Retrieve the dispatcher instance.
185
     *
186
     * Uses the callable factory in $dispatcherCallback, passing it $data
187
     * (which should be derived from the router's getData() method); this
188
     * approach is done to allow testing against the dispatcher.
189
     *
190
     * @param array|object $data Data from RouteCollector::getData()
191
     * @return Dispatcher
192
     */
193 22
    private function getDispatcher($data): Dispatcher
194
    {
195 22
        if (!$this->dispatcherCallback) {
196 22
            $this->dispatcherCallback = $this->createDispatcherCallback();
197
        }
198
199 22
        $factory = $this->dispatcherCallback;
200
201 22
        return $factory($data);
202
    }
203
204
    /**
205
     * Create a default FastRoute Collector instance
206
     */
207 41
    private function createRouteCollector(): RouteCollector
208
    {
209 41
        return new RouteCollector(new RouteParser(), new RouteGenerator());
210
    }
211
212
    /**
213
     * Return a default implementation of a callback that can return a Dispatcher.
214
     */
215 22
    private function createDispatcherCallback(): callable
216
    {
217
        return static function ($data) {
218 22
            return new GroupCountBased($data);
219 22
        };
220
    }
221
222
    /**
223
     * Marshal a routing failure result.
224
     *
225
     * If the failure was due to the HTTP method, passes the allowed HTTP
226
     * methods to the factory.
227
     * @param array $result
228
     * @return MatchingResult
229
     */
230 2
    private function marshalFailedRoute(array $result): MatchingResult
231
    {
232 2
        $resultCode = $result[0];
233 2
        if ($resultCode === Dispatcher::METHOD_NOT_ALLOWED) {
234 1
            return MatchingResult::fromFailure($result[1]);
235
        }
236
237 1
        return MatchingResult::fromFailure(Method::ANY);
238
    }
239
240
    /**
241
     * Marshals a route result based on the results of matching and the current HTTP method.
242
     * @param array $result
243
     * @param string $method
244
     * @return MatchingResult
245
     */
246 20
    private function marshalMatchedRoute(array $result, string $method): MatchingResult
247
    {
248 20
        [, $name, $parameters] = $result;
249
250 20
        $route = $this->routeCollection->getRoute($name);
251 20
        if (!in_array($method, $route->getMethods(), true)) {
252 1
            $result[1] = $route->getPattern();
253 1
            return $this->marshalMethodNotAllowedResult($result);
254
        }
255
256 19
        $parameters = array_merge($route->getDefaults(), $parameters);
257 19
        $this->currentRoute = $route;
258
259 19
        return MatchingResult::fromSuccess($route, $parameters);
260
    }
261
262 1
    private function marshalMethodNotAllowedResult(array $result): MatchingResult
263
    {
264 1
        $path = $result[1];
265
266 1
        $allowedMethods = array_unique(
267 1
            array_reduce(
268 1
                $this->routeCollection->getRoutes(),
269
                static function ($allowedMethods, Route $route) use ($path) {
270 1
                    if ($path !== $route->getPattern()) {
271 1
                        return $allowedMethods;
272
                    }
273
274 1
                    return array_merge($allowedMethods, $route->getMethods());
275 1
                },
276 1
                []
277
            )
278
        );
279
280 1
        return MatchingResult::fromFailure($allowedMethods);
281
    }
282
283
    /**
284
     * Inject routes into the underlying router
285
     */
286 21
    private function injectRoutes(): void
287
    {
288 21
        foreach ($this->routeCollection->getRoutes() as $index => $route) {
289
            /** @var Route $route */
290 21
            $this->fastRouteCollector->addRoute($route->getMethods(), $route->getPattern(), $route->getName());
291
        }
292 21
        $this->hasInjectedRoutes = true;
293
    }
294
295
    /**
296
     * Get the dispatch data either from cache or freshly generated by the
297
     * FastRoute data generator.
298
     *
299
     * If caching is enabled, store the freshly generated data to file.
300
     */
301 22
    private function getDispatchData(): array
302
    {
303 22
        if ($this->hasCache) {
304 1
            return $this->dispatchData;
305
        }
306
307 21
        $dispatchData = (array)$this->fastRouteCollector->getData();
308
309 21
        if ($this->cacheEnabled) {
310
            $this->cacheDispatchData($dispatchData);
311
        }
312
313 21
        return $dispatchData;
314
    }
315
316
    /**
317
     * Load dispatch data from cache
318
     * @throws \RuntimeException If the cache file contains invalid data
319
     * @throws \Psr\SimpleCache\InvalidArgumentException
320
     */
321 41
    private function loadDispatchData(): void
322
    {
323 41
        if ($this->cacheEnabled && $this->cache->has($this->cacheKey)) {
0 ignored issues
show
Bug introduced by
The method has() does not exist on null. ( Ignorable by Annotation )

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

323
        if ($this->cacheEnabled && $this->cache->/** @scrutinizer ignore-call */ has($this->cacheKey)) {

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...
324 1
            $dispatchData = $this->cache->get($this->cacheKey);
325
326 1
            $this->hasCache = true;
327 1
            $this->dispatchData = $dispatchData;
328 1
            return;
329
        }
330
331 40
        $this->hasCache = false;
332
    }
333
334
    /**
335
     * Save dispatch data to cache
336
     * @param array $dispatchData
337
     * @return int|false bytes written to file or false if error
338
     * @throws \RuntimeException If the cache directory does not exist.
339
     * @throws \RuntimeException If the cache directory is not writable.
340
     * @throws \RuntimeException If the cache file exists but is not writable
341
     * @throws \Psr\SimpleCache\InvalidArgumentException
342
     */
343
    private function cacheDispatchData(array $dispatchData): void
344
    {
345
        $this->cache->set($this->cacheKey, $dispatchData);
346
    }
347
}
348