Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
215 / 215 |
|
100.00% |
26 / 26 |
CRAP | |
100.00% |
1 / 1 |
RouteCollection | |
100.00% |
215 / 215 |
|
100.00% |
26 / 26 |
68 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
__call | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
__get | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
6 | |||
__isset | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setOrigin | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getRouteName | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
addRoute | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
notFound | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRouteNotFound | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
add | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
makeRoute | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
addSimple | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
makeRouteActionFromArray | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
get | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
post | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
put | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
patch | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
delete | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
options | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
redirect | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
group | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
namespace | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
resource | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
8 | |||
presenter | |
100.00% |
54 / 54 |
|
100.00% |
1 / 1 |
10 | |||
count | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
jsonSerialize | |
100.00% |
5 / 5 |
|
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 BadMethodCallException; |
13 | use Closure; |
14 | use Error; |
15 | use Framework\HTTP\Method; |
16 | use InvalidArgumentException; |
17 | use LogicException; |
18 | |
19 | /** |
20 | * Class RouteCollection. |
21 | * |
22 | * @property-read string|null $name |
23 | * @property-read string $origin |
24 | * @property-read Router $router |
25 | * @property-read array<string, Route[]> $routes |
26 | * |
27 | * @package routing |
28 | */ |
29 | class RouteCollection implements \Countable, \JsonSerializable |
30 | { |
31 | protected Router $router; |
32 | protected string $origin; |
33 | protected ?string $name; |
34 | /** |
35 | * Array of HTTP Methods as keys and array of Routes as values. |
36 | * |
37 | * @var array<string, Route[]> |
38 | */ |
39 | protected array $routes = []; |
40 | /** |
41 | * The Error 404 page action. |
42 | */ |
43 | protected Closure | string $notFoundAction; |
44 | |
45 | /** |
46 | * RouteCollection constructor. |
47 | * |
48 | * @param Router $router A Router instance |
49 | * @param string $origin URL Origin. A string in the following format: |
50 | * `{scheme}://{hostname}[:{port}]` |
51 | * @param string|null $name The collection name |
52 | */ |
53 | public function __construct(Router $router, string $origin, string $name = null) |
54 | { |
55 | $this->router = $router; |
56 | $this->setOrigin($origin); |
57 | $this->name = $name; |
58 | } |
59 | |
60 | /** |
61 | * @param string $method |
62 | * @param array<int,mixed> $arguments |
63 | * |
64 | * @throws BadMethodCallException for method not allowed or method not found |
65 | * |
66 | * @return Route|null |
67 | */ |
68 | public function __call(string $method, array $arguments) |
69 | { |
70 | if ($method === 'getRouteNotFound') { |
71 | return $this->getRouteNotFound(); |
72 | } |
73 | $class = static::class; |
74 | if (\method_exists($this, $method)) { |
75 | throw new BadMethodCallException( |
76 | "Method not allowed: {$class}::{$method}" |
77 | ); |
78 | } |
79 | throw new BadMethodCallException("Method not found: {$class}::{$method}"); |
80 | } |
81 | |
82 | /** |
83 | * @param string $property |
84 | * |
85 | * @throws Error if cannot access property |
86 | * |
87 | * @return mixed |
88 | */ |
89 | public function __get(string $property) : mixed |
90 | { |
91 | if ($property === 'name') { |
92 | return $this->name; |
93 | } |
94 | if ($property === 'notFoundAction') { |
95 | return $this->notFoundAction; |
96 | } |
97 | if ($property === 'origin') { |
98 | return $this->origin; |
99 | } |
100 | if ($property === 'router') { |
101 | return $this->router; |
102 | } |
103 | if ($property === 'routes') { |
104 | return $this->routes; |
105 | } |
106 | throw new Error( |
107 | 'Cannot access property ' . static::class . '::$' . $property |
108 | ); |
109 | } |
110 | |
111 | public function __isset(string $property) : bool |
112 | { |
113 | return isset($this->{$property}); |
114 | } |
115 | |
116 | /** |
117 | * @param string $origin |
118 | * |
119 | * @return static |
120 | */ |
121 | protected function setOrigin(string $origin) : static |
122 | { |
123 | $this->origin = \ltrim($origin, '/'); |
124 | return $this; |
125 | } |
126 | |
127 | /** |
128 | * Get a Route name. |
129 | * |
130 | * @param string $name The current Route name |
131 | * |
132 | * @return string The Route name prefixed with the collection name and a |
133 | * dot if it is set |
134 | */ |
135 | protected function getRouteName(string $name) : string |
136 | { |
137 | if (isset($this->name)) { |
138 | $name = $this->name . '.' . $name; |
139 | } |
140 | return $name; |
141 | } |
142 | |
143 | /** |
144 | * @param string $httpMethod |
145 | * @param Route $route |
146 | * |
147 | * @throws InvalidArgumentException for invalid method |
148 | * |
149 | * @return static |
150 | */ |
151 | protected function addRoute(string $httpMethod, Route $route) : static |
152 | { |
153 | $method = \strtoupper($httpMethod); |
154 | if ( ! \in_array($method, [ |
155 | 'DELETE', |
156 | 'GET', |
157 | 'OPTIONS', |
158 | 'PATCH', |
159 | 'POST', |
160 | 'PUT', |
161 | ], true)) { |
162 | throw new InvalidArgumentException('Invalid method: ' . $httpMethod); |
163 | } |
164 | $this->routes[$method][] = $route; |
165 | return $this; |
166 | } |
167 | |
168 | /** |
169 | * Sets the Route Not Found action for this collection. |
170 | * |
171 | * @param Closure|string $action the Route function to run when no Route |
172 | * path is found for this collection |
173 | */ |
174 | public function notFound(Closure | string $action) : void |
175 | { |
176 | $this->notFoundAction = $action; |
177 | } |
178 | |
179 | /** |
180 | * Gets the Route Not Found for this collection. |
181 | * |
182 | * @see RouteCollection::notFound() |
183 | * |
184 | * @return Route|null The Route containing the Not Found Action or null if |
185 | * the Action was not set |
186 | */ |
187 | protected function getRouteNotFound() : ?Route |
188 | { |
189 | if (isset($this->notFoundAction)) { |
190 | $this->router->getResponse()->setStatus(404); |
191 | return (new Route( |
192 | $this->router, |
193 | $this->router->getMatchedOrigin(), |
194 | $this->router->getMatchedPath(), |
195 | $this->notFoundAction |
196 | ))->setName( |
197 | $this->getRouteName('collection-not-found') |
198 | ); |
199 | } |
200 | return null; |
201 | } |
202 | |
203 | /** |
204 | * Adds a Route to match many HTTP Methods. |
205 | * |
206 | * @param array<int,string> $httpMethods The HTTP Methods |
207 | * @param string $path The URL path |
208 | * @param array<int,string>|Closure|string $action The Route action |
209 | * @param string|null $name The Route name |
210 | * |
211 | * @see Method::DELETE |
212 | * @see Method::GET |
213 | * @see Method::OPTIONS |
214 | * @see Method::PATCH |
215 | * @see Method::POST |
216 | * @see Method::PUT |
217 | * |
218 | * @return Route |
219 | */ |
220 | public function add( |
221 | array $httpMethods, |
222 | string $path, |
223 | array | Closure | string $action, |
224 | string $name = null |
225 | ) : Route { |
226 | $route = $this->makeRoute($path, $action, $name); |
227 | foreach ($httpMethods as $method) { |
228 | $this->addRoute($method, $route); |
229 | } |
230 | return $route; |
231 | } |
232 | |
233 | /** |
234 | * @param string $path |
235 | * @param array<int,string>|Closure|string $action |
236 | * @param string|null $name |
237 | * |
238 | * @return Route |
239 | */ |
240 | protected function makeRoute( |
241 | string $path, |
242 | array | Closure | string $action, |
243 | string $name = null |
244 | ) : Route { |
245 | if (\is_array($action)) { |
246 | $action = $this->makeRouteActionFromArray($action); |
247 | } |
248 | $route = new Route($this->router, $this->origin, $path, $action); |
249 | if ($name !== null) { |
250 | $route->setName($this->getRouteName($name)); |
251 | } |
252 | return $route; |
253 | } |
254 | |
255 | /** |
256 | * @param string $method |
257 | * @param string $path |
258 | * @param array<int,string>|Closure|string $action |
259 | * @param string|null $name |
260 | * |
261 | * @return Route |
262 | */ |
263 | protected function addSimple( |
264 | string $method, |
265 | string $path, |
266 | array | Closure | string $action, |
267 | string $name = null |
268 | ) : Route { |
269 | return $this->routes[$method][] = $this->makeRoute($path, $action, $name); |
270 | } |
271 | |
272 | /** |
273 | * @param array<int,string> $action |
274 | * |
275 | * @return string |
276 | */ |
277 | protected function makeRouteActionFromArray(array $action) : string |
278 | { |
279 | if (empty($action[0])) { |
280 | throw new LogicException( |
281 | 'When adding a route action as array, the index 0 must be a FQCN' |
282 | ); |
283 | } |
284 | if ( ! isset($action[1])) { |
285 | $action[1] = $this->router->getDefaultRouteActionMethod(); |
286 | } |
287 | if ( ! isset($action[2])) { |
288 | $action[2] = '*'; |
289 | } |
290 | if ($action[2] !== '') { |
291 | $action[2] = '/' . $action[2]; |
292 | } |
293 | return $action[0] . '::' . $action[1] . $action[2]; |
294 | } |
295 | |
296 | /** |
297 | * Adds a Route to match the HTTP GET Method. |
298 | * |
299 | * @param string $path The URL path |
300 | * @param array<int,string>|Closure|string $action The Route action |
301 | * @param string|null $name The Route name |
302 | * |
303 | * @see Method::GET |
304 | * |
305 | * @return Route The Route added to the collection |
306 | */ |
307 | public function get( |
308 | string $path, |
309 | array | Closure | string $action, |
310 | string $name = null |
311 | ) : Route { |
312 | return $this->addSimple('GET', $path, $action, $name); |
313 | } |
314 | |
315 | /** |
316 | * Adds a Route to match the HTTP POST Method. |
317 | * |
318 | * @param string $path The URL path |
319 | * @param array<int,string>|Closure|string $action The Route action |
320 | * @param string|null $name The Route name |
321 | * |
322 | * @see Method::POST |
323 | * |
324 | * @return Route The Route added to the collection |
325 | */ |
326 | public function post( |
327 | string $path, |
328 | array | Closure | string $action, |
329 | string $name = null |
330 | ) : Route { |
331 | return $this->addSimple('POST', $path, $action, $name); |
332 | } |
333 | |
334 | /** |
335 | * Adds a Route to match the HTTP PUT Method. |
336 | * |
337 | * @param string $path The URL path |
338 | * @param array<int,string>|Closure|string $action The Route action |
339 | * @param string|null $name The Route name |
340 | * |
341 | * @see Method::PUT |
342 | * |
343 | * @return Route The Route added to the collection |
344 | */ |
345 | public function put( |
346 | string $path, |
347 | array | Closure | string $action, |
348 | string $name = null |
349 | ) : Route { |
350 | return $this->addSimple('PUT', $path, $action, $name); |
351 | } |
352 | |
353 | /** |
354 | * Adds a Route to match the HTTP PATCH Method. |
355 | * |
356 | * @param string $path The URL path |
357 | * @param array<int,string>|Closure|string $action The Route action |
358 | * @param string|null $name The Route name |
359 | * |
360 | * @see Method::PATCH |
361 | * |
362 | * @return Route The Route added to the collection |
363 | */ |
364 | public function patch( |
365 | string $path, |
366 | array | Closure | string $action, |
367 | string $name = null |
368 | ) : Route { |
369 | return $this->addSimple('PATCH', $path, $action, $name); |
370 | } |
371 | |
372 | /** |
373 | * Adds a Route to match the HTTP DELETE Method. |
374 | * |
375 | * @param string $path The URL path |
376 | * @param array<int,string>|Closure|string $action The Route action |
377 | * @param string|null $name The Route name |
378 | * |
379 | * @see Method::DELETE |
380 | * |
381 | * @return Route The Route added to the collection |
382 | */ |
383 | public function delete( |
384 | string $path, |
385 | array | Closure | string $action, |
386 | string $name = null |
387 | ) : Route { |
388 | return $this->addSimple('DELETE', $path, $action, $name); |
389 | } |
390 | |
391 | /** |
392 | * Adds a Route to match the HTTP OPTIONS Method. |
393 | * |
394 | * @param string $path The URL path |
395 | * @param array<int,string>|Closure|string $action The Route action |
396 | * @param string|null $name The Route name |
397 | * |
398 | * @see Method::OPTIONS |
399 | * |
400 | * @return Route The Route added to the collection |
401 | */ |
402 | public function options( |
403 | string $path, |
404 | array | Closure | string $action, |
405 | string $name = null |
406 | ) : Route { |
407 | return $this->addSimple('OPTIONS', $path, $action, $name); |
408 | } |
409 | |
410 | /** |
411 | * Adds a GET Route to match a path and automatically redirects to a URL. |
412 | * |
413 | * @param string $path The URL path |
414 | * @param string $location The URL to redirect |
415 | * @param int|null $code The status code of the response |
416 | * |
417 | * @return Route The Route added to the collection |
418 | */ |
419 | public function redirect(string $path, string $location, int $code = null) : Route |
420 | { |
421 | $response = $this->router->getResponse(); |
422 | return $this->addSimple( |
423 | 'GET', |
424 | $path, |
425 | static function () use ($response, $location, $code) : void { |
426 | $response->redirect($location, [], $code); |
427 | } |
428 | ); |
429 | } |
430 | |
431 | /** |
432 | * Groups many Routes into a URL path. |
433 | * |
434 | * @param string $basePath The URL path to group in |
435 | * @param array<array<mixed|Route>|Route> $routes The Routes to be grouped |
436 | * @param array<string,mixed> $options Custom options passed to the Routes |
437 | * |
438 | * @return array<array<mixed|Route>|Route> The same $routes with updated paths and options |
439 | */ |
440 | public function group(string $basePath, array $routes, array $options = []) : array |
441 | { |
442 | $basePath = \rtrim($basePath, '/'); |
443 | foreach ($routes as $route) { |
444 | if (\is_array($route)) { |
445 | $this->group($basePath, $route, $options); |
446 | continue; |
447 | } |
448 | $route->setPath($basePath . $route->getPath()); |
449 | if ($options) { |
450 | $specificOptions = $options; |
451 | if ($route->getOptions()) { |
452 | $specificOptions = \array_replace_recursive($options, $route->getOptions()); |
453 | } |
454 | $route->setOptions($specificOptions); |
455 | } |
456 | } |
457 | return $routes; |
458 | } |
459 | |
460 | /** |
461 | * Updates Routes actions, which are strings, prepending a namespace. |
462 | * |
463 | * @param string $namespace The namespace |
464 | * @param array<array<mixed|Route>|Route> $routes The Routes |
465 | * |
466 | * @return array<array<mixed|Route>|Route> The same $routes with updated actions |
467 | */ |
468 | public function namespace(string $namespace, array $routes) : array |
469 | { |
470 | $namespace = \trim($namespace, '\\'); |
471 | foreach ($routes as $route) { |
472 | if (\is_array($route)) { |
473 | $this->namespace($namespace, $route); |
474 | continue; |
475 | } |
476 | if (\is_string($route->getAction())) { |
477 | $route->setAction($namespace . '\\' . $route->getAction()); |
478 | } |
479 | } |
480 | return $routes; |
481 | } |
482 | |
483 | /** |
484 | * Adds many Routes that can be used as a REST Resource. |
485 | * |
486 | * @param string $path The URL path |
487 | * @param string $class The name of the class where the resource will point |
488 | * @param string $baseName The base name used as a Route name prefix |
489 | * @param array<int,string> $except Actions not added. Allowed values are: |
490 | * index, create, show, update, replace and delete |
491 | * @param string $placeholder The placeholder. Normally it matches an id, a number |
492 | * |
493 | * @see ResourceInterface |
494 | * @see Router::$placeholders |
495 | * |
496 | * @return array<int,Route> The Routes added to the collection |
497 | */ |
498 | public function resource( |
499 | string $path, |
500 | string $class, |
501 | string $baseName, |
502 | array $except = [], |
503 | string $placeholder = '{int}' |
504 | ) : array { |
505 | $path = \rtrim($path, '/') . '/'; |
506 | $class .= '::'; |
507 | if ($except) { |
508 | $except = \array_flip($except); |
509 | } |
510 | $routes = []; |
511 | if ( ! isset($except['index'])) { |
512 | $routes[] = $this->get( |
513 | $path, |
514 | $class . 'index/*', |
515 | $baseName . '.index' |
516 | ); |
517 | } |
518 | if ( ! isset($except['create'])) { |
519 | $routes[] = $this->post( |
520 | $path, |
521 | $class . 'create/*', |
522 | $baseName . '.create' |
523 | ); |
524 | } |
525 | if ( ! isset($except['show'])) { |
526 | $routes[] = $this->get( |
527 | $path . $placeholder, |
528 | $class . 'show/*', |
529 | $baseName . '.show' |
530 | ); |
531 | } |
532 | if ( ! isset($except['update'])) { |
533 | $routes[] = $this->patch( |
534 | $path . $placeholder, |
535 | $class . 'update/*', |
536 | $baseName . '.update' |
537 | ); |
538 | } |
539 | if ( ! isset($except['replace'])) { |
540 | $routes[] = $this->put( |
541 | $path . $placeholder, |
542 | $class . 'replace/*', |
543 | $baseName . '.replace' |
544 | ); |
545 | } |
546 | if ( ! isset($except['delete'])) { |
547 | $routes[] = $this->delete( |
548 | $path . $placeholder, |
549 | $class . 'delete/*', |
550 | $baseName . '.delete' |
551 | ); |
552 | } |
553 | return $routes; |
554 | } |
555 | |
556 | /** |
557 | * Adds many Routes that can be used by a User Interface. |
558 | * |
559 | * @param string $path The URL path |
560 | * @param string $class The name of the class where the resource will point |
561 | * @param string $baseName The base name used as a Route name prefix |
562 | * @param array<int,string> $except Actions not added. Allowed values are: |
563 | * index, new, create, show, edit, update, remove and delete |
564 | * @param string $placeholder The placeholder. Normally it matches an id, a number |
565 | * |
566 | * @see PresenterInterface |
567 | * @see Router::$placeholders |
568 | * |
569 | * @return array<int,Route> The Routes added to the collection |
570 | */ |
571 | public function presenter( |
572 | string $path, |
573 | string $class, |
574 | string $baseName, |
575 | array $except = [], |
576 | string $placeholder = '{int}' |
577 | ) : array { |
578 | $path = \rtrim($path, '/') . '/'; |
579 | $class .= '::'; |
580 | if ($except) { |
581 | $except = \array_flip($except); |
582 | } |
583 | $routes = []; |
584 | if ( ! isset($except['index'])) { |
585 | $routes[] = $this->get( |
586 | $path, |
587 | $class . 'index/*', |
588 | $baseName . '.index' |
589 | ); |
590 | } |
591 | if ( ! isset($except['new'])) { |
592 | $routes[] = $this->get( |
593 | $path . 'new', |
594 | $class . 'new/*', |
595 | $baseName . '.new' |
596 | ); |
597 | } |
598 | if ( ! isset($except['create'])) { |
599 | $routes[] = $this->post( |
600 | $path, |
601 | $class . 'create/*', |
602 | $baseName . '.create' |
603 | ); |
604 | } |
605 | if ( ! isset($except['show'])) { |
606 | $routes[] = $this->get( |
607 | $path . $placeholder, |
608 | $class . 'show/*', |
609 | $baseName . '.show' |
610 | ); |
611 | } |
612 | if ( ! isset($except['edit'])) { |
613 | $routes[] = $this->get( |
614 | $path . $placeholder . '/edit', |
615 | $class . 'edit/*', |
616 | $baseName . '.edit' |
617 | ); |
618 | } |
619 | if ( ! isset($except['update'])) { |
620 | $routes[] = $this->post( |
621 | $path . $placeholder . '/update', |
622 | $class . 'update/*', |
623 | $baseName . '.update' |
624 | ); |
625 | } |
626 | if ( ! isset($except['remove'])) { |
627 | $routes[] = $this->get( |
628 | $path . $placeholder . '/remove', |
629 | $class . 'remove/*', |
630 | $baseName . '.remove' |
631 | ); |
632 | } |
633 | if ( ! isset($except['delete'])) { |
634 | $routes[] = $this->post( |
635 | $path . $placeholder . '/delete', |
636 | $class . 'delete/*', |
637 | $baseName . '.delete' |
638 | ); |
639 | } |
640 | return $routes; |
641 | } |
642 | |
643 | /** |
644 | * Count routes in the collection. |
645 | * |
646 | * @return int |
647 | */ |
648 | public function count() : int |
649 | { |
650 | $count = isset($this->notFoundAction) ? 1 : 0; |
651 | foreach ($this->routes as $routes) { |
652 | $count += \count($routes); |
653 | } |
654 | return $count; |
655 | } |
656 | |
657 | /** |
658 | * @return array<string,mixed> |
659 | */ |
660 | public function jsonSerialize() : array |
661 | { |
662 | return [ |
663 | 'origin' => $this->origin, |
664 | 'routes' => $this->routes, |
665 | 'hasNotFound' => isset($this->notFoundAction), |
666 | ]; |
667 | } |
668 | } |