Passed
Pull Request — master (#127)
by Rustam
02:35
created

UrlMatcher::match()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 7

Importance

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

296
        $this->cache->/** @scrutinizer ignore-call */ 
297
                      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...
297
    }
298
}
299