yiisoft /
router-fastroute
| 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
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); |
|
| 291 | } |
||
| 292 | } |
||
| 293 |