UrlMatcher   A
last analyzed

Complexity

Total Complexity 29

Size/Duplication

Total Lines 262
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
wmc 29
eloc 79
c 9
b 0
f 0
dl 0
loc 262
ccs 83
cts 83
cp 1
rs 10

12 Methods

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

290
        $this->cache->/** @scrutinizer ignore-call */ 
291
                      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...
291
    }
292
}
293