Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
110 / 110
100.00% covered (success)
100.00%
44 / 44
CRAP
100.00% covered (success)
100.00%
1 / 1
Router
100.00% covered (success)
100.00%
110 / 110
100.00% covered (success)
100.00%
44 / 44
75
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 addRoute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addRoutes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addDispatchableParams
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 appendDispatchableParams
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDispatchableParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDispatchableParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeDispatchableParams
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRoutes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRouteMatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRouteParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRouteParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDispatchable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDispatchable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDispatchableClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCli
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHttp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 httpMatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 head
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 trace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 patch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addCustomMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addCustomMethods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasCustomMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMethodMismatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getAllowedMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 methodNotAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 acceptsHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 prepare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 route
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
21
 noRouteFound
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Router;
16
17use Closure;
18use ReflectionException;
19use Pop\App;
20use Pop\Utils\Arr;
21
22/**
23 * Pop router class
24 *
25 * @category   Pop
26 * @package    Pop\Router
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    5.0.0
31 */
32class Router
33{
34
35    /**
36     * Route match object
37     * @var ?Match\MatchInterface
38     */
39    protected ?Match\MatchInterface $routeMatch = null;
40
41    /**
42     * Dispatchable object
43     * @var mixed
44     */
45    protected mixed $dispatchable = null;
46
47    /**
48     * Action
49     * @var mixed
50     */
51    protected mixed $action = null;
52
53    /**
54     * Dispatchable class
55     * @var ?string
56     */
57    protected ?string $dispatchableClass = null;
58
59    /**
60     * Constructor
61     *
62     * Instantiate the router object
63     *
64     * @param  ?array               $routes
65     * @param  ?Match\AbstractMatch $match
66     */
67    public function __construct(?array $routes = null, ?Match\AbstractMatch $match = null)
68    {
69        if ($match !== null) {
70            $this->routeMatch = $match;
71        } else {
72            $this->routeMatch = ((stripos(php_sapi_name(), 'cli') !== false) &&
73                (stripos(php_sapi_name(), 'server') === false)) ?
74                new Match\Cli() : new Match\Http();
75        }
76
77        if ($routes !== null) {
78            $this->addRoutes($routes);
79        }
80    }
81
82    /**
83     * Add a route
84     *
85     * @param  string $route
86     * @param  mixed  $controller
87     * @return static
88     */
89    public function addRoute(string $route, mixed $controller): static
90    {
91        $this->routeMatch->addRoute($route, $controller);
92        return $this;
93    }
94
95    /**
96     * Add multiple controller routes
97     *
98     * @param  array $routes
99     * @return static
100     */
101    public function addRoutes(array $routes): static
102    {
103        $this->routeMatch->addRoutes($routes);
104        return $this;
105    }
106
107    /**
108     * Add a route name
109     *
110     * @param  string $routeName
111     * @return static
112     */
113    public function name(string $routeName): static
114    {
115        $this->routeMatch->name($routeName);
116        return $this;
117    }
118
119    /**
120     * Has a route name
121     *
122     * @param  string $routeName
123     * @return bool
124     */
125    public function hasName(string $routeName): bool
126    {
127        return $this->routeMatch->hasName($routeName);
128    }
129
130    /**
131     * Get URL for the named route
132     *
133     * @param  string $routeName
134     * @param  mixed  $params
135     * @param  bool   $fqdn
136     * @throws Exception
137     * @return string
138     */
139    public function getUrl(string $routeName, mixed $params = null, bool $fqdn = false): string
140    {
141        if (!$this->isHttp()) {
142            throw new Exception('Error: The route is not HTTP.');
143        }
144        return $this->routeMatch->getUrl($routeName, $params, $fqdn);
145    }
146
147    /**
148     * Add dispatchable params to be passed into a new dispatchable instance
149     *
150     * @param  string $dispatchable
151     * @param  mixed  $params
152     * @return static
153     */
154    public function addDispatchableParams(string $dispatchable, mixed $params): static
155    {
156        $this->routeMatch->addDispatchableParams($dispatchable, $params);
157        return $this;
158    }
159
160    /**
161     * Append dispatchable params to be passed into a new dispatchable instance
162     *
163     * @param  string $dispatchable
164     * @param  mixed  $params
165     * @return static
166     */
167    public function appendDispatchableParams(string $dispatchable, mixed $params): static
168    {
169        $this->routeMatch->appendDispatchableParams($dispatchable, $params);
170        return $this;
171    }
172
173    /**
174     * Get the params assigned to the dispatchable
175     *
176     * @param  string $dispatchable
177     * @return mixed
178     */
179    public function getDispatchableParams(string $dispatchable): mixed
180    {
181        return $this->routeMatch->getDispatchableParams($dispatchable);
182    }
183
184    /**
185     * Determine if the dispatchable has params
186     *
187     * @param  string $dispatchable
188     * @return bool
189     */
190    public function hasDispatchableParams(string $dispatchable): bool
191    {
192        return $this->routeMatch->hasDispatchableParams($dispatchable);
193    }
194
195    /**
196     * Remove dispatchable params
197     *
198     * @param  string $dispatchable
199     * @return static
200     */
201    public function removeDispatchableParams(string $dispatchable): static
202    {
203        $this->routeMatch->removeDispatchableParams($dispatchable);
204        return $this;
205    }
206
207    /**
208     * Get routes
209     *
210     * @return array
211     */
212    public function getRoutes(): array
213    {
214        return $this->routeMatch->getRoutes();
215    }
216
217    /**
218     * Get route match object
219     *
220     * @return Match\MatchInterface
221     */
222    public function getRouteMatch(): Match\MatchInterface
223    {
224        return $this->routeMatch;
225    }
226
227    /**
228     * Determine if there is a route match
229     *
230     * @return bool
231     */
232    public function hasRoute(): bool
233    {
234        return $this->routeMatch->hasRoute();
235    }
236
237    /**
238     * Get the params discovered from the route
239     *
240     * @return array
241     */
242    public function getRouteParams(): array
243    {
244        return $this->routeMatch->getRouteParams();
245    }
246
247    /**
248     * Determine if the route has params
249     *
250     * @return bool
251     */
252    public function hasRouteParams(): bool
253    {
254        return $this->routeMatch->hasRouteParams();
255    }
256
257    /**
258     * Get the current dispatchable object
259     *
260     * @return mixed
261     */
262    public function getDispatchable(): mixed
263    {
264        return $this->dispatchable;
265    }
266
267    /**
268     * Determine if the router has a dispatchable
269     *
270     * @return bool
271     */
272    public function hasDispatchable(): bool
273    {
274        return ($this->dispatchable !== null);
275    }
276
277    /**
278     * Get the action
279     *
280     * @return mixed
281     */
282    public function getAction(): mixed
283    {
284        return $this->action;
285    }
286
287    /**
288     * Determine if the router has an action
289     *
290     * @return bool
291     */
292    public function hasAction(): bool
293    {
294        return ($this->action !== null);
295    }
296
297    /**
298     * Get the current dispatchable class name
299     *
300     * @return string
301     */
302    public function getDispatchableClass(): string
303    {
304        return $this->dispatchableClass;
305    }
306
307    /**
308     * Determine if the route is CLI
309     *
310     * @return bool
311     */
312    public function isCli(): bool
313    {
314        return ($this->routeMatch instanceof Match\Cli);
315    }
316
317    /**
318     * Determine if the route is HTTP
319     *
320     * @phpstan-assert-if-true Match\Http $this->routeMatch
321     * @return bool
322     */
323    public function isHttp(): bool
324    {
325        return ($this->routeMatch instanceof Match\Http);
326    }
327
328    /**
329     * Get the active HTTP match object, guarding that the router is in HTTP mode
330     *
331     * @throws Exception
332     * @return Match\Http
333     */
334    protected function httpMatch(): Match\Http
335    {
336        if (!$this->isHttp()) {
337            throw new Exception('Error: The route is not HTTP.');
338        }
339        return $this->routeMatch;
340    }
341
342    /**
343     * Add a GET route
344     *
345     * @param  string $route
346     * @param  mixed  $controller
347     * @throws Exception
348     * @return static
349     */
350    public function get(string $route, mixed $controller): static
351    {
352        $this->httpMatch()->get($route, $controller);
353        return $this;
354    }
355
356    /**
357     * Add a HEAD route
358     *
359     * @param  string $route
360     * @param  mixed  $controller
361     * @throws Exception
362     * @return static
363     */
364    public function head(string $route, mixed $controller): static
365    {
366        $this->httpMatch()->head($route, $controller);
367        return $this;
368    }
369
370    /**
371     * Add a POST route
372     *
373     * @param  string $route
374     * @param  mixed  $controller
375     * @throws Exception
376     * @return static
377     */
378    public function post(string $route, mixed $controller): static
379    {
380        $this->httpMatch()->post($route, $controller);
381        return $this;
382    }
383
384    /**
385     * Add a PUT route
386     *
387     * @param  string $route
388     * @param  mixed  $controller
389     * @throws Exception
390     * @return static
391     */
392    public function put(string $route, mixed $controller): static
393    {
394        $this->httpMatch()->put($route, $controller);
395        return $this;
396    }
397
398    /**
399     * Add a DELETE route
400     *
401     * @param  string $route
402     * @param  mixed  $controller
403     * @throws Exception
404     * @return static
405     */
406    public function delete(string $route, mixed $controller): static
407    {
408        $this->httpMatch()->delete($route, $controller);
409        return $this;
410    }
411
412    /**
413     * Add a TRACE route
414     *
415     * @param  string $route
416     * @param  mixed  $controller
417     * @throws Exception
418     * @return static
419     */
420    public function trace(string $route, mixed $controller): static
421    {
422        $this->httpMatch()->trace($route, $controller);
423        return $this;
424    }
425
426    /**
427     * Add an OPTIONS route
428     *
429     * @param  string $route
430     * @param  mixed  $controller
431     * @throws Exception
432     * @return static
433     */
434    public function options(string $route, mixed $controller): static
435    {
436        $this->httpMatch()->options($route, $controller);
437        return $this;
438    }
439
440    /**
441     * Add a CONNECT route
442     *
443     * @param  string $route
444     * @param  mixed  $controller
445     * @throws Exception
446     * @return static
447     */
448    public function connect(string $route, mixed $controller): static
449    {
450        $this->httpMatch()->connect($route, $controller);
451        return $this;
452    }
453
454    /**
455     * Add a PATCH route
456     *
457     * @param  string $route
458     * @param  mixed  $controller
459     * @throws Exception
460     * @return static
461     */
462    public function patch(string $route, mixed $controller): static
463    {
464        $this->httpMatch()->patch($route, $controller);
465        return $this;
466    }
467
468    /**
469     * Add a custom HTTP method to the whitelist
470     *
471     * @param  string $method
472     * @throws Exception
473     * @return static
474     */
475    public function addCustomMethod(string $method): static
476    {
477        $this->httpMatch()->addCustomMethod($method);
478        return $this;
479    }
480
481    /**
482     * Add multiple custom HTTP methods to the whitelist
483     *
484     * @param  array $methods
485     * @throws Exception
486     * @return static
487     */
488    public function addCustomMethods(array $methods): static
489    {
490        $this->httpMatch()->addCustomMethods($methods);
491        return $this;
492    }
493
494    /**
495     * Determine if a custom HTTP method has been whitelisted
496     *
497     * @param  string $method
498     * @throws Exception
499     * @return bool
500     */
501    public function hasCustomMethod(string $method): bool
502    {
503        return $this->httpMatch()->hasCustomMethod($method);
504    }
505
506    /**
507     * Determine if the last route() call matched a path whose method was rejected
508     *
509     * @return bool
510     */
511    public function hasMethodMismatch(): bool
512    {
513        return $this->isHttp() && $this->routeMatch->hasMethodMismatch();
514    }
515
516    /**
517     * Get the methods accepted by at least one path-matching route from the last route() call
518     *
519     * @return array
520     */
521    public function getAllowedMethods(): array
522    {
523        return ($this->isHttp()) ? $this->routeMatch->getAllowedMethods() : [];
524    }
525
526    /**
527     * Send a 405 Method Not Allowed response
528     *
529     * @param  array $allowedMethods
530     * @param  bool  $exit
531     * @throws Exception
532     * @return void
533     */
534    public function methodNotAllowed(array $allowedMethods, bool $exit = true): void
535    {
536        $this->httpMatch()->methodNotAllowed($allowedMethods, $exit);
537    }
538
539    /**
540     * Determine if the inbound request has a real preference for an HTML response
541     *
542     * @throws Exception
543     * @return bool
544     */
545    public function acceptsHtml(): bool
546    {
547        return $this->httpMatch()->acceptsHtml();
548    }
549
550    /**
551     * Magic method to register a route for a whitelisted custom HTTP method
552     *
553     * Only forwards to the HTTP match object when $name is either a real
554     * method there or a whitelisted custom verb - not in HTTP mode at all, or
555     * in HTTP mode but neither of those, both mean $name isn't a real method
556     * on Router, so it's reported as such rather than misdiagnosed as an
557     * HTTP/CLI mode mismatch or an unregistered custom HTTP verb (the
558     * explicit HTTP-only proxy methods like get()/post()/addCustomMethod()
559     * are unaffected - they call httpMatch() directly and still correctly
560     * throw "not HTTP" when called against a CLI-mode router).
561     *
562     * @param  string $name
563     * @param  array  $arguments
564     * @throws Exception
565     * @return static
566     */
567    public function __call(string $name, array $arguments): static
568    {
569        if (!$this->isHttp() ||
570            (!method_exists($this->routeMatch, $name) && !$this->routeMatch->hasCustomMethod($name))) {
571            throw new Exception('Error: Call to undefined method ' . static::class . '::' . $name . '()');
572        }
573
574        $this->routeMatch->{$name}(...$arguments);
575        return $this;
576    }
577
578    /**
579     * Prepare routes
580     *
581     * @return static
582     */
583    public function prepare(): static
584    {
585        $this->routeMatch->prepare();
586        return $this;
587    }
588
589    /**
590     * Route to the correct controller
591     *
592     * @param  string|array|null $forceRoute
593     * @throws Exception|ReflectionException
594     * @return void
595     */
596    public function route(string|array|null $forceRoute = null): void
597    {
598        $this->dispatchable      = null;
599        $this->dispatchableClass = null;
600        $this->action            = null;
601
602        if ($this->routeMatch->match($forceRoute)) {
603            if ($this->routeMatch->hasDispatchable()) {
604                $dispatchable       = $this->routeMatch->getDispatchable();
605                $application        = App::get();
606                $middlewareDisabled = App::middlewareDisabled();
607
608                $routeConfig = $this->routeMatch->getRouteConfig();
609                if (!empty($routeConfig['middleware']) && ($middlewareDisabled != 'route') && ($middlewareDisabled != 'all') &&
610                    ($application !== null)) {
611                    $application->middleware->addItems(Arr::make($routeConfig['middleware']));
612                }
613
614                // If the dispatchable is a plain closure
615                if ($dispatchable instanceof Closure) {
616                    $this->dispatchableClass = 'Closure';
617                    $this->dispatchable      = $dispatchable;
618                // Else, if the dispatchable is a plain callable object
619                } else if (is_string($dispatchable) && !is_subclass_of($dispatchable, 'Pop\Dispatch\AbstractDispatcher', true)) {
620                    $this->dispatchableClass = 'Pop\Utils\CallableObject';
621                    $this->dispatchable      = $dispatchable;
622                // Else, if the dispatchable is a Dispatch\AbstractDispatcher subclass
623                } else if (class_exists($dispatchable) && is_subclass_of($dispatchable, 'Pop\Dispatch\AbstractDispatcher', true)) {
624                    $this->dispatchableClass = $dispatchable;
625                    $dispatchableParams      = null;
626
627                    if ($this->routeMatch->hasDispatchableParams($dispatchable)) {
628                        $dispatchableParams = $this->routeMatch->getDispatchableParams($dispatchable);
629                    } else if ($this->routeMatch->hasDispatchableParams('*')) {
630                        $dispatchableParams = $this->routeMatch->getDispatchableParams('*');
631                    }
632
633                    // Use user pre-defined dispatchable parameters
634                    if ($dispatchableParams !== null) {
635                        $this->dispatchable = (new \ReflectionClass($dispatchable))->newInstanceArgs($dispatchableParams);
636                    // Else, write in the dispatchable parameters
637                    } else {
638                        $constructor        = (new \ReflectionClass($dispatchable))->getConstructor();
639                        $firstParam         = $constructor?->getParameters()[0] ?? null;
640                        $firstParamType     = $firstParam?->getType();
641                        $acceptsApplication = ($application !== null) && ($firstParamType instanceof \ReflectionNamedType) &&
642                            !$firstParamType->isBuiltin() && is_a($application, $firstParamType->getName());
643
644                        $this->dispatchable = $acceptsApplication ? new $dispatchable($application) : new $dispatchable();
645                    }
646
647                    $action       = $this->routeMatch->getAction();
648                    $this->action = (($action === null) && ($this->routeMatch->isDynamicRoute())) ? 'index' : $action;
649                }
650            }
651        }
652    }
653
654    /**
655     * Method to process if a route was not found
656     *
657     * @param  bool $exit
658     * @return void
659     */
660    public function noRouteFound(bool $exit = true): void
661    {
662        $this->routeMatch->noRouteFound($exit);
663    }
664
665}