Lines 67.70% 65 / 96
Methods 35.29% 6 / 17
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 registerRoute n/a 0 / 0 n/a 0 / 0 0
 loadRoutes n/a 0 / 0 n/a 0 / 0 0
 setDefaultController 0.00% 0 / 1 0.00% 0 / 1 2
 resolveController 0.00% 0 / 1 0.00% 0 / 1 2
 group 0.00% 0 / 17 0.00% 0 / 1 6
 parseRouteParameter 100.00% 3 / 3 100.00% 1 / 1 2
 disable 100.00% 4 / 4 100.00% 1 / 1 3
 isDisabled 100.00% 1 / 1 100.00% 1 / 1 2
 getUriAsArray 68.75% 11 / 16 0.00% 0 / 1 7.10
 analyseRouteAndLookup 100.00% 8 / 8 100.00% 1 / 1 5
 matchRoute 100.00% 24 / 24 100.00% 1 / 1 6
 setActiveMiddleware 0.00% 0 / 1 0.00% 0 / 1 2
 setPrefix 0.00% 0 / 1 0.00% 0 / 1 2
 getPrefix 0.00% 0 / 1 0.00% 0 / 1 2
 getMiddleware 0.00% 0 / 1 0.00% 0 / 1 2
 getFullPath 0.00% 0 / 1 0.00% 0 / 1 6
 getFullClassName 0.00% 0 / 1 0.00% 0 / 1 6
 reset 100.00% 7 / 7 100.00% 1 / 1 1
 getRoutes 87.50% 7 / 8 0.00% 0 / 1 5.05
11abstract class Router
12{
13    // HTTP Method Constants
14    public static string $ROUTE_POST = "POST";
15    public static string $ROUTE_GET = "GET";
16    public static string $ROUTE_PATCH = "PATCH";
17    public static string $ROUTE_DELETE = "DELETE";
18    public static string $ROUTE_CLI = "CLI";
19
20    // Route storage
21    protected array $routes = [];
22    protected array $groupStack = [];
23
24    // Current route group attributes
25    protected array $middleware = [];
26    protected ?string $prefix = null;
27    protected ?string $namespace = null;
28    protected ?string $defaultController = null;
29    protected array $disabled = ["*"=>false];
30
31    /**
32     * Register a new route with the router
33     */
34    abstract public function registerRoute(string $uri, string $type, string $method, ?string $controller = null, array $middleware = []);
35
36    /**
37     * Load routes from a file
38     */
39    abstract public function loadRoutes(string $file, ?string $prefix = null);
40
41    /**
42     * Set the default controller for the current group
43     */
44    public function setDefaultController(?string $controller): void
45    {
46        $this->defaultController = $controller;
47    }
48
49    /**
50     * Get the appropriate controller for a route
51     */
52    protected function resolveController(?string $routeController): ?string
53    {
54        return $routeController ?? $this->defaultController;
55    }
56
57    /**
58     * Start a new route group with shared attributes
59     */
60    public function group(array $attributes, callable $callback): void
61    {
62        $this->groupStack[] = [
63            'middleware' => $this->middleware,
64            'prefix' => $this->prefix,
65            'namespace' => $this->namespace,
66            'defaultController' => $this->defaultController
67        ];
68
69        // Merge new group attributes
70        $this->middleware = array_merge($this->middleware, $attributes['middleware'] ?? []);
71        // Append the new prefix onto the existing one, preserving the
72        // accumulation behaviour for nested groups (e.g. /api + /v1 = /api/v1).
73        // When no prefix is provided, keep the current value (which may be
74        // null) rather than converting it to an empty string.
75        if (array_key_exists('prefix', $attributes)) {
76            $this->prefix = ($this->prefix ?? '') . $attributes['prefix'];
77        }
78        // else: $this->prefix unchanged (preserves null distinct from '')
79        $this->namespace = $attributes['namespace'] ?? $this->namespace;
80        $this->defaultController = $attributes['defaultController'] ?? $this->defaultController;
81
82        // Execute the group's route definitions
83        $callback($this);
84
85        // Restore previous group attributes
86        $previous = array_pop($this->groupStack);
87        $this->middleware = $previous['middleware'];
88        $this->prefix = $previous['prefix'];
89        $this->namespace = $previous['namespace'];
90        $this->defaultController = $previous['defaultController'];
91    }
92
93    /**
94     * Check if a route segment is a parameter and extract its name
95     * Returns [bool $isParameter, string $paramName]
96     */
97    protected function parseRouteParameter(string $segment): array
98    {
99        if (preg_match('/^\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/', $segment, $matches)) {
100            return [true, $matches[1]];
101        }
102        return [false, ''];
103    }
104
105    /**
106     * Allows for all routes '*' or specific routes to be disabled.
107     * Returns void
108     */
109    public function disable(string|array $route): void
110    {
111        if (is_array($route)) {
112            foreach ($route as $r) {
113                $this->disabled[$r] = true;
114            }
115        } else {
116            $this->disabled[$route] = true;
117        }
118    }
119
120    /**
121     * Checks if a specific route is disabled, returns true or false.
122     */
123    protected function isDisabled(string $uri): bool
124    {
125        return ($this->disabled["*"] ?? false) || ($this->disabled[$uri] ?? false);
126    }
127
128    /**
129     * Convert a URI to an array of segments, removing empty segments
130     */
131    /**
132     * Modify getUriAsArray for more flexible CLI argument parsing
133     */
134    public function getUriAsArray(?string $url = null, string $separator = "/"): array
135    {
136        // If no URL provided, check for CLI arguments
137        if ($url === null) {
138            // Check if running in CLI mode
139            if (php_sapi_name() === 'cli') {
140                // Use global $_SERVER['argv'] for CLI arguments
141                $url = implode(' ', array_slice($_SERVER['argv'], 1));
142            } else {
143                $url = $_SERVER["REQUEST_URI"] ?? '';
144            }
145        }
146
147        // Remove any protocol, domain, or query string
148        $url = parse_url($url, PHP_URL_PATH);
149        if ($url === false || $url === null) {
150            $url = '';
151        }
152
153        // Decode URL to handle special characters
154        $url = urldecode($url);
155
156        // Remove query string if present (must use !== false - strpos returns 0
157        // when '?' is the first character, which is falsy)
158        $pos = strpos($url, "?");
159        if ($pos !== false) {
160            $url = substr($url, 0, $pos);
161        }
162
163        // Normalize URL for CLI
164        $url = preg_replace('/\s+/', $separator, trim($url));
165
166        // Normalize slashes and trim
167        $url = trim($url, '/');
168
169        // Split and filter empty segments
170        return array_values(array_filter(explode($separator, $url), function($segment) {
171            return $segment !== '';
172        }));
173    }
174    /**
175     * Find and analyze a matching route for the current request
176     */
177    public function analyseRouteAndLookup(array $route, string $method): array
178    {
179        if (!isset($this->routes[$method])) {
180            throw new HttpException(HttpStatus::NOT_FOUND);
181        }
182
183        foreach ($this->routes[$method] as $key => $routeDefinition) {
184            if ($match = $this->matchRoute($key, $route, '/', $routeDefinition, $method)) {
185                if ($this->isDisabled($key)) {
186                    throw new HttpException(HttpStatus::FORBIDDEN);
187                }
188                return $match;
189            }
190        }
191
192
193        throw new HttpException(HttpStatus::NOT_FOUND);
194    }
195
196    /**
197     * Check if a route matches the current URI and extract parameters
198     */
199    protected function matchRoute(string $routePath, array $uri, string $separator, array $route, string $method): ?array
200    {
201        // Normalize route path
202        $routePath = trim($routePath, '/');
203        $routeSegments = $this->getUriAsArray($routePath, $separator);
204
205        if (count($routeSegments) !== count($uri)) {
206            return null;
207        }
208
209        $variables = [];
210        $matches = true;
211
212        for ($i = 0; $i < count($routeSegments); $i++) {
213            [$isParameter, $paramName] = $this->parseRouteParameter($routeSegments[$i]);
214
215            if (!$isParameter) {
216                if ($routeSegments[$i] !== $uri[$i]) {
217                    $matches = false;
218                    break;
219                }
220            } else {
221                $variables[$paramName] = $uri[$i];
222            }
223        }
224
225        if (!$matches) {
226            return null;
227        }
228
229        Log::channel('lucent.routing')->info("[Router] Found route : " . $routePath."\n    Http Method:".$method."\n    Http Controller: {$route["controller"]}@{$route["method"]}");
230
231        return [
232            "route" => $routePath,
233            "outcome" => true,
234            "controller" => $route["controller"],
235            "method" => $route["method"],
236            "variables" => $variables,
237            "middleware" => $route["middleware"] ?? []
238        ];
239    }
240
241    /**
242     * Set active middleware for the current route group
243     */
244    public function setActiveMiddleware(array $middleware): void
245    {
246        $this->middleware = $middleware;
247    }
248
249    /**
250     * Set the current route group prefix
251     */
252    public function setPrefix(?string $prefix): void
253    {
254        $this->prefix = $prefix;
255    }
256
257    /**
258     * Get the current route group prefix
259     */
260    public function getPrefix(): ?string
261    {
262        return $this->prefix;
263    }
264
265    /**
266     * Get the current route group middleware
267     */
268    public function getMiddleware(): array
269    {
270        return $this->middleware;
271    }
272
273    /**
274     * Get the full path for a route, including prefix
275     */
276    protected function getFullPath(string $path): string
277    {
278        return $this->prefix ? rtrim($this->prefix, '/') . '/' . ltrim($path, '/') : $path;
279    }
280
281    /**
282     * Get the full class name for a controller, including namespace
283     */
284    protected function getFullClassName(string $controller): string
285    {
286        return $this->namespace ? rtrim($this->namespace, '\\') . '\\' . $controller : $controller;
287    }
288
289    public function reset(): void
290    {
291        $this->routes = [];
292        $this->groupStack = [];
293        $this->middleware = [];
294        $this->prefix = null;
295        $this->namespace = null;
296        $this->defaultController = null;
297        $this->disabled = ["*" => false];
298    }
299
300    public function getRoutes(bool $activeOnly = true): array
301    {
302        if ($activeOnly) {
303            $active = [];
304            foreach ($this->routes as $method => $routes) {
305                foreach ($routes as $uri => $route) {
306                    if (!$this->isDisabled($uri)) {
307                        $active[$method][$uri] = $route;
308                    }
309                }
310            }
311            return $active;
312        }
313
314        return $this->routes;
315    }
316}