Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
215 / 215
100.00% covered (success)
100.00%
26 / 26
CRAP
100.00% covered (success)
100.00%
1 / 1
RouteCollection
100.00% covered (success)
100.00%
215 / 215
100.00% covered (success)
100.00%
26 / 26
68
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 __get
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOrigin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRouteName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addRoute
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 notFound
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRouteNotFound
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeRoute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addSimple
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeRouteActionFromArray
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 patch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 group
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 namespace
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resource
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
8
 presenter
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
10
 count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 jsonSerialize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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 */
10namespace Framework\Routing;
11
12use BadMethodCallException;
13use Closure;
14use Error;
15use Framework\HTTP\Method;
16use InvalidArgumentException;
17use 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 */
29class 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}