Passed
Pull Request — master (#79)
by Rustam
04:17 queued 01:59
created

UrlMatcher   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 282
Duplicated Lines 0 %

Test Coverage

Coverage 97.78%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 80
c 5
b 0
f 0
dl 0
loc 282
ccs 88
cts 90
cp 0.9778
rs 10
wmc 29

13 Methods

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

307
        $this->cache->/** @scrutinizer ignore-call */ 
308
                      set($this->cacheKey, $dispatchData);

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...
308 2
    }
309
}
310