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 | } |