Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
259 / 259 |
|
100.00% |
49 / 49 |
CRAP | |
100.00% |
1 / 1 |
| Router | |
100.00% |
259 / 259 |
|
100.00% |
49 / 49 |
94 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| __get | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getResponse | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setLanguage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getLanguage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| getDefaultRouteActionMethod | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setDefaultRouteActionMethod | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getDefaultRouteNotFound | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
2 | |||
| setDefaultRouteNotFound | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getRouteNotFound | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| addPlaceholder | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getPlaceholders | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| replacePlaceholders | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| fillPlaceholders | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
6 | |||
| serve | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
| addServedCollection | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| addCollection | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getCollections | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedCollection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedCollection | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedRoute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedRoute | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedPath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedPath | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedPathArguments | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedPathArguments | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedUrl | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| getMatchedOrigin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedOrigin | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getMatchedOriginArguments | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setMatchedOriginArguments | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| match | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| makeMatchedRoute | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
| makePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAlternativeRoute | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| matchCollection | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| matchRoute | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| setAutoOptions | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| isAutoOptions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setAutoMethods | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| isAutoMethods | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRouteWithAllowHeader | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| getAllowedMethods | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
7 | |||
| getNamedRoute | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| hasNamedRoute | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| getRoutes | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| jsonSerialize | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| setDebugCollector | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getDebugCollector | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php declare(strict_types=1); |
| 2 | /* |
| 3 | * This file is part of Aplus Framework Routing Library. |
| 4 | * |
| 5 | * (c) Natan Felles <natanfelles@gmail.com> |
| 6 | * |
| 7 | * For the full copyright and license information, please view the LICENSE |
| 8 | * file that was distributed with this source code. |
| 9 | */ |
| 10 | namespace Framework\Routing; |
| 11 | |
| 12 | use Closure; |
| 13 | use Framework\HTTP\Method; |
| 14 | use Framework\HTTP\Request; |
| 15 | use Framework\HTTP\Response; |
| 16 | use Framework\HTTP\ResponseHeader; |
| 17 | use Framework\HTTP\Status; |
| 18 | use Framework\Language\Language; |
| 19 | use Framework\Routing\Debug\RoutingCollector; |
| 20 | use InvalidArgumentException; |
| 21 | use JetBrains\PhpStorm\Pure; |
| 22 | use OutOfBoundsException; |
| 23 | use RuntimeException; |
| 24 | |
| 25 | /** |
| 26 | * Class Router. |
| 27 | * |
| 28 | * @package routing |
| 29 | */ |
| 30 | class Router implements \JsonSerializable |
| 31 | { |
| 32 | protected string $defaultRouteActionMethod = 'index'; |
| 33 | protected Closure | string $defaultRouteNotFound; |
| 34 | /** |
| 35 | * @var array<string,string> |
| 36 | */ |
| 37 | protected static array $placeholders = [ |
| 38 | '{alpha}' => '([a-zA-Z]+)', |
| 39 | '{alphanum}' => '([a-zA-Z0-9]+)', |
| 40 | '{any}' => '(.*)', |
| 41 | '{hex}' => '([[:xdigit:]]+)', |
| 42 | '{int}' => '([0-9]{1,18}+)', |
| 43 | '{md5}' => '([a-f0-9]{32}+)', |
| 44 | '{num}' => '([0-9]+)', |
| 45 | '{port}' => '([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])', |
| 46 | '{scheme}' => '(https?)', |
| 47 | '{segment}' => '([^/]+)', |
| 48 | '{slug}' => '([a-z0-9_-]+)', |
| 49 | '{subdomain}' => '([^.]+)', |
| 50 | //'{subdomain}' => '([A-Za-z0-9](?:[a-zA-Z0-9\-]{0,61}[A-Za-z0-9])?)', |
| 51 | '{title}' => '([a-zA-Z0-9_-]+)', |
| 52 | '{uuid}' => '([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}+)', |
| 53 | ]; |
| 54 | /** |
| 55 | * @var array<int,RouteCollection> |
| 56 | */ |
| 57 | protected array $collections = []; |
| 58 | protected ?RouteCollection $matchedCollection = null; |
| 59 | protected ?Route $matchedRoute = null; |
| 60 | protected ?string $matchedOrigin = null; |
| 61 | /** |
| 62 | * @var array<int,string> |
| 63 | */ |
| 64 | protected array $matchedOriginArguments = []; |
| 65 | protected ?string $matchedPath = null; |
| 66 | /** |
| 67 | * @var array<int,string> |
| 68 | */ |
| 69 | protected array $matchedPathArguments = []; |
| 70 | protected bool $autoOptions = false; |
| 71 | protected bool $autoMethods = false; |
| 72 | protected Response $response; |
| 73 | protected Language $language; |
| 74 | protected RoutingCollector $debugCollector; |
| 75 | |
| 76 | /** |
| 77 | * Router constructor. |
| 78 | * |
| 79 | * @param Response $response |
| 80 | * @param Language|null $language |
| 81 | */ |
| 82 | public function __construct(Response $response, Language $language = null) |
| 83 | { |
| 84 | $this->response = $response; |
| 85 | if ($language) { |
| 86 | $this->setLanguage($language); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | public function __get(string $property) : mixed |
| 91 | { |
| 92 | if (\property_exists($this, $property)) { |
| 93 | return $this->{$property} ?? null; |
| 94 | } |
| 95 | throw new OutOfBoundsException( |
| 96 | 'Property not exists: ' . static::class . '::$' . $property |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Gets the HTTP Response instance. |
| 102 | * |
| 103 | * @return Response |
| 104 | */ |
| 105 | #[Pure] |
| 106 | public function getResponse() : Response |
| 107 | { |
| 108 | return $this->response; |
| 109 | } |
| 110 | |
| 111 | public function setLanguage(Language $language = null) : static |
| 112 | { |
| 113 | $this->language = $language ?? new Language(); |
| 114 | $this->language->addDirectory(__DIR__ . '/Languages'); |
| 115 | return $this; |
| 116 | } |
| 117 | |
| 118 | public function getLanguage() : Language |
| 119 | { |
| 120 | if ( ! isset($this->language)) { |
| 121 | $this->setLanguage(); |
| 122 | } |
| 123 | return $this->language; |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Gets the default route action method. |
| 128 | * |
| 129 | * Normally, it is "index". |
| 130 | * |
| 131 | * @see Router::setDefaultRouteActionMethod() |
| 132 | * |
| 133 | * @return string |
| 134 | */ |
| 135 | #[Pure] |
| 136 | public function getDefaultRouteActionMethod() : string |
| 137 | { |
| 138 | return $this->defaultRouteActionMethod; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Set the class method name to be called when a Route action is set without |
| 143 | * a method. |
| 144 | * |
| 145 | * @param string $action |
| 146 | * |
| 147 | * @return static |
| 148 | */ |
| 149 | public function setDefaultRouteActionMethod(string $action) : static |
| 150 | { |
| 151 | $this->defaultRouteActionMethod = $action; |
| 152 | return $this; |
| 153 | } |
| 154 | |
| 155 | protected function getDefaultRouteNotFound() : Route |
| 156 | { |
| 157 | return (new Route( |
| 158 | $this, |
| 159 | $this->getMatchedOrigin(), |
| 160 | $this->getMatchedPath(), |
| 161 | $this->defaultRouteNotFound ?? function () { |
| 162 | $this->response->setStatus(Status::NOT_FOUND); |
| 163 | if ($this->response->getRequest()->isJson()) { |
| 164 | return $this->response->setJson([ |
| 165 | 'status' => [ |
| 166 | 'code' => Status::NOT_FOUND, |
| 167 | 'reason' => Status::getReason(Status::NOT_FOUND), |
| 168 | ], |
| 169 | ]); |
| 170 | } |
| 171 | $language = $this->getLanguage(); |
| 172 | $lang = $language->getCurrentLocale(); |
| 173 | $dir = $language->getCurrentLocaleDirection(); |
| 174 | $title = $language->render('routing', 'error404'); |
| 175 | $message = $language->render('routing', 'pageNotFound'); |
| 176 | return $this->response->setBody( |
| 177 | <<<HTML |
| 178 | <!doctype html> |
| 179 | <html lang="{$lang}" dir="{$dir}"> |
| 180 | <head> |
| 181 | <meta charset="utf-8"> |
| 182 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 183 | <title>{$title}</title> |
| 184 | <style> |
| 185 | body { |
| 186 | background: #fff; |
| 187 | color: #000; |
| 188 | font-family: Arial, Helvetica, sans-serif; |
| 189 | font-size: 1.2rem; |
| 190 | line-height: 1.5rem; |
| 191 | margin: 1rem; |
| 192 | } |
| 193 | </style> |
| 194 | </head> |
| 195 | <body> |
| 196 | <h1>{$title}</h1> |
| 197 | <p>{$message}</p> |
| 198 | </body> |
| 199 | </html> |
| 200 | |
| 201 | HTML |
| 202 | ); |
| 203 | } |
| 204 | ))->setName('not-found'); |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Sets the Default Route Not Found action. |
| 209 | * |
| 210 | * @param Closure|string $action the function to run when no Route path is found |
| 211 | * |
| 212 | * @return static |
| 213 | */ |
| 214 | public function setDefaultRouteNotFound(Closure | string $action) : static |
| 215 | { |
| 216 | $this->defaultRouteNotFound = $action; |
| 217 | return $this; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Gets the Route Not Found. |
| 222 | * |
| 223 | * Must be called after {@see Router::match()} and will return the Route |
| 224 | * Not Found from the matched collection or the Default Route Not Found |
| 225 | * from the router. |
| 226 | * |
| 227 | * @see RouteCollection::notFound() |
| 228 | * @see Router::setDefaultRouteNotFound() |
| 229 | * |
| 230 | * @return Route |
| 231 | */ |
| 232 | public function getRouteNotFound() : Route |
| 233 | { |
| 234 | // @phpstan-ignore-next-line |
| 235 | return $this->getMatchedCollection()?->getRouteNotFound() |
| 236 | ?? $this->getDefaultRouteNotFound(); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Adds Router placeholders. |
| 241 | * |
| 242 | * @param array<string,string>|string $placeholder |
| 243 | * @param string|null $pattern |
| 244 | * |
| 245 | * @return static |
| 246 | */ |
| 247 | public function addPlaceholder(array | string $placeholder, string $pattern = null) : static |
| 248 | { |
| 249 | if (\is_array($placeholder)) { |
| 250 | foreach ($placeholder as $key => $value) { |
| 251 | static::$placeholders['{' . $key . '}'] = $value; |
| 252 | } |
| 253 | return $this; |
| 254 | } |
| 255 | static::$placeholders['{' . $placeholder . '}'] = $pattern; |
| 256 | return $this; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * Gets all Router placeholders. |
| 261 | * |
| 262 | * @return array<string,string> |
| 263 | */ |
| 264 | #[Pure] |
| 265 | public function getPlaceholders() : array |
| 266 | { |
| 267 | return static::$placeholders; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Replaces string placeholders with patterns or patterns with placeholders. |
| 272 | * |
| 273 | * @param string $string The string with placeholders or patterns |
| 274 | * @param bool $flip Set true to replace patterns with placeholders |
| 275 | * |
| 276 | * @return string |
| 277 | */ |
| 278 | #[Pure] |
| 279 | public function replacePlaceholders( |
| 280 | string $string, |
| 281 | bool $flip = false |
| 282 | ) : string { |
| 283 | $placeholders = $this->getPlaceholders(); |
| 284 | if ($flip) { |
| 285 | $placeholders = \array_flip($placeholders); |
| 286 | } |
| 287 | return \strtr($string, $placeholders); |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Fills argument values into a string with placeholders. |
| 292 | * |
| 293 | * @param string $string The input string |
| 294 | * @param string ...$arguments Values to fill the string placeholders |
| 295 | * |
| 296 | * @throws InvalidArgumentException if param not required, empty or invalid |
| 297 | * @throws RuntimeException if a pattern position is not found |
| 298 | * |
| 299 | * @return string The string with argument values in place of placeholders |
| 300 | */ |
| 301 | public function fillPlaceholders(string $string, string ...$arguments) : string |
| 302 | { |
| 303 | $string = $this->replacePlaceholders($string); |
| 304 | \preg_match_all('#\(([^)]+)\)#', $string, $matches); |
| 305 | if (empty($matches[0])) { |
| 306 | if ($arguments) { |
| 307 | throw new InvalidArgumentException( |
| 308 | 'String has no placeholders. Arguments not required' |
| 309 | ); |
| 310 | } |
| 311 | return $string; |
| 312 | } |
| 313 | foreach ($matches[0] as $index => $pattern) { |
| 314 | if ( ! isset($arguments[$index])) { |
| 315 | throw new InvalidArgumentException("Placeholder argument is not set: {$index}"); |
| 316 | } |
| 317 | if ( ! \preg_match('#' . $pattern . '#', $arguments[$index])) { |
| 318 | throw new InvalidArgumentException("Placeholder argument is invalid: {$index}"); |
| 319 | } |
| 320 | $string = \substr_replace( |
| 321 | $string, |
| 322 | $arguments[$index], |
| 323 | \strpos($string, $pattern), // @phpstan-ignore-line |
| 324 | \strlen($pattern) |
| 325 | ); |
| 326 | } |
| 327 | return $string; |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * Serves a RouteCollection to a specific Origin. |
| 332 | * |
| 333 | * @param string|null $origin URL Origin. A string in the following format: |
| 334 | * `{scheme}://{hostname}[:{port}]`. Null to auto-detect. |
| 335 | * @param callable $callable A function receiving an instance of RouteCollection |
| 336 | * as the first parameter |
| 337 | * @param string|null $collectionName The RouteCollection name |
| 338 | * |
| 339 | * @return static |
| 340 | */ |
| 341 | public function serve(?string $origin, callable $callable, string $collectionName = null) : static |
| 342 | { |
| 343 | if (isset($this->debugCollector)) { |
| 344 | $start = \microtime(true); |
| 345 | $this->addServedCollection($origin, $callable, $collectionName); |
| 346 | $end = \microtime(true); |
| 347 | $this->debugCollector->addData([ |
| 348 | 'type' => 'serve', |
| 349 | 'start' => $start, |
| 350 | 'end' => $end, |
| 351 | 'collectionId' => \spl_object_id( |
| 352 | $this->collections[\array_key_last($this->collections)] |
| 353 | ), |
| 354 | ]); |
| 355 | return $this; |
| 356 | } |
| 357 | return $this->addServedCollection($origin, $callable, $collectionName); |
| 358 | } |
| 359 | |
| 360 | protected function addServedCollection( |
| 361 | ?string $origin, |
| 362 | callable $callable, |
| 363 | string $collectionName = null |
| 364 | ) : static { |
| 365 | if ($origin === null) { |
| 366 | $origin = $this->response->getRequest()->getUrl()->getOrigin(); |
| 367 | } |
| 368 | $collection = new RouteCollection($this, $origin, $collectionName); |
| 369 | $callable($collection); |
| 370 | $this->addCollection($collection); |
| 371 | return $this; |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * @param RouteCollection $collection |
| 376 | * |
| 377 | * @return static |
| 378 | */ |
| 379 | protected function addCollection(RouteCollection $collection) : static |
| 380 | { |
| 381 | $this->collections[] = $collection; |
| 382 | return $this; |
| 383 | } |
| 384 | |
| 385 | /** |
| 386 | * Gets all Route Collections. |
| 387 | * |
| 388 | * @return array<int,RouteCollection> |
| 389 | */ |
| 390 | #[Pure] |
| 391 | public function getCollections() : array |
| 392 | { |
| 393 | return $this->collections; |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Gets the matched Route Collection. |
| 398 | * |
| 399 | * Note: Will return null if no URL Origin was matched in a Route Collection |
| 400 | * |
| 401 | * @return RouteCollection|null |
| 402 | */ |
| 403 | #[Pure] |
| 404 | public function getMatchedCollection() : ?RouteCollection |
| 405 | { |
| 406 | return $this->matchedCollection; |
| 407 | } |
| 408 | |
| 409 | protected function setMatchedCollection(RouteCollection $matchedCollection) : static |
| 410 | { |
| 411 | $this->matchedCollection = $matchedCollection; |
| 412 | return $this; |
| 413 | } |
| 414 | |
| 415 | /** |
| 416 | * Gets the matched Route. |
| 417 | * |
| 418 | * @return Route|null |
| 419 | */ |
| 420 | #[Pure] |
| 421 | public function getMatchedRoute() : ?Route |
| 422 | { |
| 423 | return $this->matchedRoute; |
| 424 | } |
| 425 | |
| 426 | /** |
| 427 | * @param Route $route |
| 428 | * |
| 429 | * @return static |
| 430 | */ |
| 431 | protected function setMatchedRoute(Route $route) : static |
| 432 | { |
| 433 | $this->matchedRoute = $route; |
| 434 | return $this; |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * Gets the matched URL Path. |
| 439 | * |
| 440 | * @return string|null |
| 441 | */ |
| 442 | #[Pure] |
| 443 | public function getMatchedPath() : ?string |
| 444 | { |
| 445 | return $this->matchedPath; |
| 446 | } |
| 447 | |
| 448 | /** |
| 449 | * @param string $path |
| 450 | * |
| 451 | * @return static |
| 452 | */ |
| 453 | protected function setMatchedPath(string $path) : static |
| 454 | { |
| 455 | $this->matchedPath = $path; |
| 456 | return $this; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Gets the matched URL Path arguments. |
| 461 | * |
| 462 | * @return array<int,string> |
| 463 | */ |
| 464 | #[Pure] |
| 465 | public function getMatchedPathArguments() : array |
| 466 | { |
| 467 | return $this->matchedPathArguments; |
| 468 | } |
| 469 | |
| 470 | /** |
| 471 | * @param array<int,string> $arguments |
| 472 | * |
| 473 | * @return static |
| 474 | */ |
| 475 | protected function setMatchedPathArguments(array $arguments) : static |
| 476 | { |
| 477 | $this->matchedPathArguments = $arguments; |
| 478 | return $this; |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * Gets the matched URL. |
| 483 | * |
| 484 | * Note: This method does not return the URL query. If it is needed, get |
| 485 | * with {@see Request::getUrl()}. |
| 486 | * |
| 487 | * @return string|null |
| 488 | */ |
| 489 | #[Pure] |
| 490 | public function getMatchedUrl() : ?string |
| 491 | { |
| 492 | return $this->getMatchedOrigin() |
| 493 | ? $this->getMatchedOrigin() . $this->getMatchedPath() |
| 494 | : null; |
| 495 | } |
| 496 | |
| 497 | /** |
| 498 | * Gets the matched URL Origin. |
| 499 | * |
| 500 | * @return string|null |
| 501 | */ |
| 502 | #[Pure] |
| 503 | public function getMatchedOrigin() : ?string |
| 504 | { |
| 505 | return $this->matchedOrigin; |
| 506 | } |
| 507 | |
| 508 | /** |
| 509 | * @param string $origin |
| 510 | * |
| 511 | * @return static |
| 512 | */ |
| 513 | protected function setMatchedOrigin(string $origin) : static |
| 514 | { |
| 515 | $this->matchedOrigin = $origin; |
| 516 | return $this; |
| 517 | } |
| 518 | |
| 519 | /** |
| 520 | * Gets the matched URL Origin arguments. |
| 521 | * |
| 522 | * @return array<int,string> |
| 523 | */ |
| 524 | #[Pure] |
| 525 | public function getMatchedOriginArguments() : array |
| 526 | { |
| 527 | return $this->matchedOriginArguments; |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * @param array<int,string> $arguments |
| 532 | * |
| 533 | * @return static |
| 534 | */ |
| 535 | protected function setMatchedOriginArguments(array $arguments) : static |
| 536 | { |
| 537 | $this->matchedOriginArguments = $arguments; |
| 538 | return $this; |
| 539 | } |
| 540 | |
| 541 | /** |
| 542 | * Match HTTP Method and URL against RouteCollections to process the request. |
| 543 | * |
| 544 | * @see Router::serve() |
| 545 | * |
| 546 | * @return Route Always returns a Route, even if it is the Route Not Found |
| 547 | */ |
| 548 | public function match() : Route |
| 549 | { |
| 550 | if (isset($this->debugCollector)) { |
| 551 | $start = \microtime(true); |
| 552 | $route = $this->makeMatchedRoute(); |
| 553 | $end = \microtime(true); |
| 554 | $this->debugCollector->addData([ |
| 555 | 'type' => 'match', |
| 556 | 'start' => $start, |
| 557 | 'end' => $end, |
| 558 | ]); |
| 559 | return $route; |
| 560 | } |
| 561 | return $this->makeMatchedRoute(); |
| 562 | } |
| 563 | |
| 564 | protected function makeMatchedRoute() : Route |
| 565 | { |
| 566 | $method = $this->response->getRequest()->getMethod(); |
| 567 | if ($method === 'HEAD') { |
| 568 | $method = 'GET'; |
| 569 | } |
| 570 | $url = $this->response->getRequest()->getUrl(); |
| 571 | $path = $this->makePath($url->getPath()); |
| 572 | $this->setMatchedPath($path); |
| 573 | $this->setMatchedOrigin($url->getOrigin()); |
| 574 | $this->matchedCollection = $this->matchCollection($url->getOrigin()); |
| 575 | if ( ! $this->matchedCollection) { |
| 576 | return $this->matchedRoute = $this->getDefaultRouteNotFound(); |
| 577 | } |
| 578 | return $this->matchedRoute = $this->matchRoute( |
| 579 | $method, |
| 580 | $this->matchedCollection, |
| 581 | $path |
| 582 | ) ?? $this->getAlternativeRoute($method, $this->matchedCollection); |
| 583 | } |
| 584 | |
| 585 | /** |
| 586 | * Creates a path without a trailing slash to be able to match both with and |
| 587 | * without a slash at the end. |
| 588 | * |
| 589 | * @since 3.4.3 |
| 590 | * |
| 591 | * @param string $path |
| 592 | * |
| 593 | * @return string |
| 594 | */ |
| 595 | protected function makePath(string $path) : string |
| 596 | { |
| 597 | return '/' . \trim($path, '/'); |
| 598 | } |
| 599 | |
| 600 | protected function getAlternativeRoute(string $method, RouteCollection $collection) : Route |
| 601 | { |
| 602 | if ($method === 'OPTIONS' && $this->isAutoOptions()) { |
| 603 | $route = $this->getRouteWithAllowHeader($collection, Status::OK); |
| 604 | } elseif ($this->isAutoMethods()) { |
| 605 | $route = $this->getRouteWithAllowHeader( |
| 606 | $collection, |
| 607 | Status::METHOD_NOT_ALLOWED |
| 608 | ); |
| 609 | } |
| 610 | if ( ! isset($route)) { |
| 611 | // @phpstan-ignore-next-line |
| 612 | $route = $collection->getRouteNotFound() ?? $this->getDefaultRouteNotFound(); |
| 613 | } |
| 614 | return $route; |
| 615 | } |
| 616 | |
| 617 | protected function matchCollection(string $origin) : ?RouteCollection |
| 618 | { |
| 619 | foreach ($this->getCollections() as $collection) { |
| 620 | $pattern = $this->replacePlaceholders($collection->origin); |
| 621 | $matched = \preg_match( |
| 622 | '#^' . $pattern . '$#', |
| 623 | $origin, |
| 624 | $matches |
| 625 | ); |
| 626 | if ($matched) { |
| 627 | $this->setMatchedOrigin($matches[0]); |
| 628 | unset($matches[0]); |
| 629 | $this->setMatchedOriginArguments(\array_values($matches)); |
| 630 | return $collection; |
| 631 | } |
| 632 | } |
| 633 | return null; |
| 634 | } |
| 635 | |
| 636 | protected function matchRoute( |
| 637 | string $method, |
| 638 | RouteCollection $collection, |
| 639 | string $path |
| 640 | ) : ?Route { |
| 641 | $routes = $collection->routes; |
| 642 | if (empty($routes[$method])) { |
| 643 | return null; |
| 644 | } |
| 645 | foreach ($routes[$method] as $route) { |
| 646 | $pattern = $this->replacePlaceholders($route->getPath()); |
| 647 | $matched = \preg_match( |
| 648 | '#^' . $pattern . '$#', |
| 649 | $path, |
| 650 | $matches |
| 651 | ); |
| 652 | if ($matched) { |
| 653 | unset($matches[0]); |
| 654 | $this->setMatchedPathArguments(\array_values($matches)); |
| 655 | $route->setActionArguments($this->getMatchedPathArguments()); |
| 656 | return $route; |
| 657 | } |
| 658 | } |
| 659 | return null; |
| 660 | } |
| 661 | |
| 662 | /** |
| 663 | * Enable/disable the feature of auto-detect and show HTTP allowed methods |
| 664 | * via the Allow header when the Request has the OPTIONS method. |
| 665 | * |
| 666 | * @param bool $enabled true to enable, false to disable |
| 667 | * |
| 668 | * @see Method::OPTIONS |
| 669 | * @see ResponseHeader::ALLOW |
| 670 | * |
| 671 | * @return static |
| 672 | */ |
| 673 | public function setAutoOptions(bool $enabled = true) : static |
| 674 | { |
| 675 | $this->autoOptions = $enabled; |
| 676 | return $this; |
| 677 | } |
| 678 | |
| 679 | /** |
| 680 | * Tells if auto options is enabled. |
| 681 | * |
| 682 | * @see Router::setAutoOptions() |
| 683 | * |
| 684 | * @return bool |
| 685 | */ |
| 686 | #[Pure] |
| 687 | public function isAutoOptions() : bool |
| 688 | { |
| 689 | return $this->autoOptions; |
| 690 | } |
| 691 | |
| 692 | /** |
| 693 | * Enable/disable the feature of auto-detect and show HTTP allowed methods |
| 694 | * via the Allow header when a route with the requested method does not exist. |
| 695 | * |
| 696 | * A response with code 405 "Method Not Allowed" will trigger. |
| 697 | * |
| 698 | * @param bool $enabled true to enable, false to disable |
| 699 | * |
| 700 | * @see Status::METHOD_NOT_ALLOWED |
| 701 | * @see ResponseHeader::ALLOW |
| 702 | * |
| 703 | * @return static |
| 704 | */ |
| 705 | public function setAutoMethods(bool $enabled = true) : static |
| 706 | { |
| 707 | $this->autoMethods = $enabled; |
| 708 | return $this; |
| 709 | } |
| 710 | |
| 711 | /** |
| 712 | * Tells if auto methods is enabled. |
| 713 | * |
| 714 | * @see Router::setAutoMethods() |
| 715 | * |
| 716 | * @return bool |
| 717 | */ |
| 718 | #[Pure] |
| 719 | public function isAutoMethods() : bool |
| 720 | { |
| 721 | return $this->autoMethods; |
| 722 | } |
| 723 | |
| 724 | protected function getRouteWithAllowHeader(RouteCollection $collection, int $code) : ?Route |
| 725 | { |
| 726 | $allowed = $this->getAllowedMethods($collection); |
| 727 | $response = $this->response; |
| 728 | return empty($allowed) |
| 729 | ? null |
| 730 | : (new Route( |
| 731 | $this, |
| 732 | $this->getMatchedOrigin(), |
| 733 | $this->getMatchedPath(), |
| 734 | static function () use ($allowed, $code, $response) : void { |
| 735 | $response->setStatus($code); |
| 736 | $response->setHeader('Allow', \implode(', ', $allowed)); |
| 737 | } |
| 738 | ))->setName('auto-allow-' . $code); |
| 739 | } |
| 740 | |
| 741 | /** |
| 742 | * @param RouteCollection $collection |
| 743 | * |
| 744 | * @return array<int,string> |
| 745 | */ |
| 746 | protected function getAllowedMethods(RouteCollection $collection) : array |
| 747 | { |
| 748 | $allowed = []; |
| 749 | foreach ($collection->routes as $method => $routes) { |
| 750 | foreach ($routes as $route) { |
| 751 | $pattern = $this->replacePlaceholders($route->getPath()); |
| 752 | $matched = \preg_match( |
| 753 | '#^' . $pattern . '$#', |
| 754 | $this->getMatchedPath() |
| 755 | ); |
| 756 | if ($matched) { |
| 757 | $allowed[] = $method; |
| 758 | continue 2; |
| 759 | } |
| 760 | } |
| 761 | } |
| 762 | if ($allowed) { |
| 763 | if (\in_array('GET', $allowed, true)) { |
| 764 | $allowed[] = 'HEAD'; |
| 765 | } |
| 766 | if ($this->isAutoOptions()) { |
| 767 | $allowed[] = 'OPTIONS'; |
| 768 | } |
| 769 | $allowed = \array_unique($allowed); |
| 770 | \sort($allowed); |
| 771 | } |
| 772 | return $allowed; |
| 773 | } |
| 774 | |
| 775 | /** |
| 776 | * Gets a named route. |
| 777 | * |
| 778 | * @param string $name |
| 779 | * |
| 780 | * @throws RuntimeException if named route not found |
| 781 | * |
| 782 | * @return Route |
| 783 | */ |
| 784 | public function getNamedRoute(string $name) : Route |
| 785 | { |
| 786 | foreach ($this->getCollections() as $collection) { |
| 787 | foreach ($collection->routes as $routes) { |
| 788 | foreach ($routes as $route) { |
| 789 | if ($route->getName() === $name) { |
| 790 | return $route; |
| 791 | } |
| 792 | } |
| 793 | } |
| 794 | } |
| 795 | throw new RuntimeException('Named route not found: ' . $name); |
| 796 | } |
| 797 | |
| 798 | /** |
| 799 | * Tells if it has a named route. |
| 800 | * |
| 801 | * @param string $name |
| 802 | * |
| 803 | * @return bool |
| 804 | */ |
| 805 | #[Pure] |
| 806 | public function hasNamedRoute( |
| 807 | string $name |
| 808 | ) : bool { |
| 809 | foreach ($this->getCollections() as $collection) { |
| 810 | foreach ($collection->routes as $routes) { |
| 811 | foreach ($routes as $route) { |
| 812 | if ($route->getName() === $name) { |
| 813 | return true; |
| 814 | } |
| 815 | } |
| 816 | } |
| 817 | } |
| 818 | return false; |
| 819 | } |
| 820 | |
| 821 | /** |
| 822 | * Gets all routes, except the not found. |
| 823 | * |
| 824 | * @return array<string,Route[]> The HTTP Methods as keys and its Routes as |
| 825 | * values |
| 826 | */ |
| 827 | #[Pure] |
| 828 | public function getRoutes() : array |
| 829 | { |
| 830 | $result = []; |
| 831 | foreach ($this->getCollections() as $collection) { |
| 832 | foreach ($collection->routes as $method => $routes) { |
| 833 | foreach ($routes as $route) { |
| 834 | $result[$method][] = $route; |
| 835 | } |
| 836 | } |
| 837 | } |
| 838 | return $result; |
| 839 | } |
| 840 | |
| 841 | /** |
| 842 | * @return array<string,mixed> |
| 843 | */ |
| 844 | public function jsonSerialize() : array |
| 845 | { |
| 846 | return [ |
| 847 | 'matched' => $this->getMatchedRoute(), |
| 848 | 'collections' => $this->getCollections(), |
| 849 | 'isAutoMethods' => $this->isAutoMethods(), |
| 850 | 'isAutoOptions' => $this->isAutoOptions(), |
| 851 | 'placeholders' => $this->getPlaceholders(), |
| 852 | ]; |
| 853 | } |
| 854 | |
| 855 | public function setDebugCollector(RoutingCollector $debugCollector) : static |
| 856 | { |
| 857 | $this->debugCollector = $debugCollector; |
| 858 | $this->debugCollector->setRouter($this); |
| 859 | return $this; |
| 860 | } |
| 861 | |
| 862 | public function getDebugCollector() : ?RoutingCollector |
| 863 | { |
| 864 | return $this->debugCollector ?? null; |
| 865 | } |
| 866 | } |