Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.78% covered (success)
95.78%
159 / 166
70.00% covered (success)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Http
95.78% covered (success)
95.78%
159 / 166
70.00% covered (success)
70.00%
7 / 10
71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 getBasePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 match
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 hasRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 noRouteFound
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 flattenRoutes
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
11.47
 getRouteRegex
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
7
 parseRouteParams
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
12.02
 getUrl
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
19
1<?php
2/**
3 * Pop PHP Framework (https://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@noladev.com>
7 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
8 * @license    https://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Router\Match;
15
16/**
17 * Pop router HTTP match class
18 *
19 * @category   Pop
20 * @package    Pop\Router
21 * @author     Nick Sagona, III <dev@noladev.com>
22 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
23 * @license    https://www.popphp.org/license     New BSD License
24 * @version    4.3.5
25 */
26class Http extends AbstractMatch
27{
28
29    /**
30     * Base path
31     * @var ?string
32     */
33    protected ?string $basePath = null;
34
35    /**
36     * Constructor
37     *
38     * Instantiate the HTTP match object
39     */
40    public function __construct()
41    {
42        $basePath       = str_replace([realpath($_SERVER['DOCUMENT_ROOT']), '\\'], ['', '/'], realpath(getcwd()));
43        $this->basePath = !empty($basePath) ? $basePath : '';
44        $trailingSlash  = null;
45
46        $path = ($this->basePath != '') ?
47            substr($_SERVER['REQUEST_URI'], strlen($this->basePath)) : $_SERVER['REQUEST_URI'];
48
49        // Trim query string, if present
50        if (strpos($path, '?')) {
51            $path = substr($path, 0, strpos($path, '?'));
52        }
53
54        // Trim trailing slash, if present
55        if (str_ends_with($path, '/')) {
56            $path          = substr($path, 0, -1);
57            $trailingSlash = '/';
58        }
59
60        if ($path == '') {
61            $this->segments    = ['index'];
62            $this->routeString = '/';
63        } else {
64            $this->segments    = explode('/', substr($path, 1));
65            $this->routeString = '/' . implode('/', $this->segments) . $trailingSlash;
66        }
67    }
68
69    /**
70     * Get the base path
71     *
72     * @return string
73     */
74    public function getBasePath(): string
75    {
76        return $this->basePath;
77    }
78
79    /**
80     * Prepare the routes
81     *
82     * @return static
83     */
84    public function prepare(): static
85    {
86        $this->flattenRoutes($this->routes);
87        return $this;
88    }
89
90    /**
91     * Match the route
92     *
93     * @param  mixed $forceRoute
94     * @return bool
95     */
96    public function match(mixed $forceRoute = null): bool
97    {
98        if (count($this->preparedRoutes) == 0) {
99            $this->prepare();
100        }
101
102        $routeToMatch = $forceRoute ?? $this->routeString;
103        $directMatch  = null;
104
105        if (array_key_exists($routeToMatch, $this->routes)) {
106            $directMatch = $routeToMatch;
107        } else if (array_key_exists($routeToMatch . '/', $this->routes)) {
108            $directMatch = $routeToMatch . '/';
109        } else if (array_key_exists($routeToMatch . '[/]', $this->routes)) {
110            $directMatch = $routeToMatch . '[/]';
111        }
112
113        if ($directMatch !== null) {
114            foreach ($this->preparedRoutes as $regex => $controller) {
115                if ($directMatch == $controller['route']) {
116                    $this->route = $regex;
117                    break;
118                }
119            }
120        } else {
121            foreach ($this->preparedRoutes as $regex => $controller) {
122                if (preg_match($regex, $routeToMatch) != 0) {
123                    $this->route = $regex;
124                    break;
125                }
126            }
127        }
128
129        $this->parseRouteParams();
130
131        return $this->hasRoute();
132    }
133
134    /**
135     * Determine if the route has been matched
136     *
137     * @return bool
138     */
139    public function hasRoute(): bool
140    {
141        return ($this->route !== null) || ($this->dynamicRoute !== null) || ($this->defaultRoute !== null);
142    }
143
144    /**
145     * Method to process if a route was not found
146     *
147     * @param  bool $exit
148     * @return void
149     */
150    public function noRouteFound(bool $exit = true): void
151    {
152        if (!headers_sent()) {
153            header('HTTP/1.1 404 Not Found');
154        }
155        echo '<!DOCTYPE html>' . PHP_EOL;
156        echo '<html>' . PHP_EOL;
157        echo '    <head>' . PHP_EOL;
158        echo '        <title>Page Not Found</title>' . PHP_EOL;
159        echo '    </head>' . PHP_EOL;
160        echo '<body>' . PHP_EOL;
161        echo '    <h1>Page Not Found</h1>' . PHP_EOL;
162        echo '</body>' . PHP_EOL;
163        echo '</html>'. PHP_EOL;
164
165        if ($exit) {
166            exit();
167        }
168    }
169
170    /**
171     * Flatten the nested routes
172     *
173     * @param  array|string $route
174     * @param  mixed        $controller
175     * @return void
176     */
177    protected function flattenRoutes(array|string $route, mixed $controller = null): void
178    {
179        if (is_array($route)) {
180            foreach ($route as $r => $c) {
181                $this->flattenRoutes($r, $c);
182            }
183        } else if ($controller !== null) {
184            if (!isset($controller['controller'])) {
185                foreach ($controller as $r => $c) {
186                    $this->flattenRoutes($route . $r, $c);
187                }
188            } else {
189                $routeRegex = $this->getRouteRegex($route);
190                $this->preparedRoutes[$routeRegex['regex']] = array_merge($controller, [
191                    'route'  => $route,
192                    'params' => $routeRegex['params'],
193                ]);
194                if (isset($controller['default']) && ($controller['default'])) {
195                    if (isset($controller['action'])) {
196                        unset($controller['action']);
197                    }
198                    $this->defaultRoute['*'] = $controller;
199                }
200            }
201        }
202    }
203
204    /**
205     * Get the REGEX pattern for the route string
206     *
207     * @param  string $route
208     * @return array
209     */
210    protected function getRouteRegex(string $route): array
211    {
212        $required   = [];
213        $optional   = [];
214        $params     = [];
215        $offsets    = [];
216        $paramArray = false;
217
218        if (str_contains($route, '*')) {
219            $paramArray = true;
220            $route      = str_replace('*', '', $route);
221        }
222
223        preg_match_all('/\[\/\:[^\[]+\]/', $route, $optional, PREG_OFFSET_CAPTURE);
224        preg_match_all('/(?<!\[)\/\:+\w*/', $route, $required, PREG_OFFSET_CAPTURE);
225
226        foreach ($required[0] as $req) {
227            $name      = substr($req[0], (strpos($req[0], ':') + 1));
228            $route     = str_replace($req[0], '/.[a-zA-Z0-9_\.\-\p{L}]*', $route);
229            $offsets[] = $req[1];
230            $params[]  = [
231                'param'    => $req[0],
232                'name'     => $name,
233                'offset'   => $req[1],
234                'required' => true,
235                'array'    => false
236            ];
237        }
238
239        foreach ($optional[0] as $opt) {
240            $name      = substr($opt[0], (strpos($opt[0], ':') + 1), -1);
241            $route     = str_replace($opt[0], '(|/[a-zA-Z0-9_\.\-\p{L}]*)', $route);
242            $offsets[] = $opt[1];
243            $params[]  = [
244                'param'    => $opt[0],
245                'name'     => $name,
246                'offset'   => $opt[1],
247                'required' => false,
248                'array'    => false
249            ];
250        }
251
252        $route = '^' . str_replace('/', '\/', $route) . '$';
253        if (str_ends_with($route, '[\/]$')) {
254            $route = str_replace('[\/]$', '(|\/)$', $route);
255        }
256
257        array_multisort($offsets, SORT_ASC, $params);
258
259        if (($paramArray) && (count($params) > 0)) {
260            $params[(count($params) - 1)]['array'] = true;
261            $route = str_replace('$', '.*', $route);
262        }
263
264        return [
265            'regex'  => '/' . $route . '/u',
266            'params' => $params
267        ];
268    }
269
270    /**
271     * Parse route dispatch parameters
272     *
273     * @return void
274     */
275    protected function parseRouteParams(): void
276    {
277        if (isset($this->preparedRoutes[$this->route]['params']) &&
278            (count($this->preparedRoutes[$this->route]['params']) > 0)) {
279            $offset = 0;
280            foreach ($this->preparedRoutes[$this->route]['params'] as $i => $param) {
281                $value = substr($this->routeString, ($param['offset'] + $offset + 1));
282                if ($param['array']) {
283                    if (!$value) {
284                        $value = [];
285                    } else {
286                        $value = (str_contains($value, '/')) ? explode('/', $value) : [$value];
287                    }
288                } else {
289                    if (str_contains($value, '/')) {
290                        $value   = substr($value, 0, strpos($value, '/'));
291                        $offset += strlen($value) - strlen($param['param']) + 1;
292                    } else {
293                        $offset += strlen($value) - strlen($param['param']) + 1;
294                    }
295                }
296                if ($value != '') {
297                    $this->routeParams[$param['name']] = $value;
298                }
299            }
300        } else if (($this->dynamicRoute !== null) && (count($this->segments) >= 3)) {
301            $this->routeParams = (str_contains($this->dynamicRoute, '/:param*')) ?
302                [array_slice($this->segments, 2)] : array_slice($this->segments, 2);
303        }
304    }
305
306    /**
307     * Get URL for the named route
308     *
309     * @param  string $routeName
310     * @param  mixed  $params
311     * @param  bool   $fqdn
312     * @return string
313     */
314    public function getUrl(string $routeName, mixed $params = null, bool $fqdn = false): string
315    {
316        $url     = '';
317        $baseUrl = '';
318
319        if ($fqdn) {
320            $baseUrl .= (isset($_SERVER['SERVER_PORT']) && ($_SERVER['SERVER_PORT'] == 443)) ? 'https://' : 'http://';
321            if (isset($_SERVER['HTTP_HOST'])) {
322                $baseUrl .= $_SERVER['HTTP_HOST'];
323            }
324        }
325
326        $baseUrl .= $this->basePath;
327
328        if (isset($this->routeNames[$routeName]) && isset($this->routes[$this->routeNames[$routeName]])) {
329            $route         = $this->routeNames[$routeName];
330            $preparedRoute = null;
331
332            if (count($this->preparedRoutes) == 0) {
333                $this->prepare();
334            }
335
336            foreach ($this->preparedRoutes as $prepRoute) {
337                if ($prepRoute['route'] == $route) {
338                    $preparedRoute = $prepRoute;
339                    break;
340                }
341            }
342
343            if (!empty($params) && !empty($preparedRoute['params'])) {
344                foreach ($preparedRoute['params'] as $param) {
345                    $paramName    = $param['name'];
346                    $paramString  = null;
347                    $paramUrlName = null;
348
349                    if (is_object($params) && isset($params->{$paramName})) {
350                        if (is_array($params->{$paramName})) {
351                            $paramString  = implode('/', $params->{$paramName});
352                            $paramUrlName = $param['param'] . '*';
353                        } else {
354                            $paramString  = $params->{$paramName};
355                            $paramUrlName = $param['param'];
356                        }
357                    } else if (is_array($params) && isset($params[$paramName])) {
358                        if (is_array($params[$paramName])) {
359                            $paramString  = implode('/', $params[$paramName]);
360                            $paramUrlName = $param['param'] . '*';
361                        } else {
362                            $paramString  = $params[$paramName];
363                            $paramUrlName = $param['param'];
364                        }
365                    }
366
367                    $route = $baseUrl . str_replace($paramUrlName, '/' . $paramString, $route);
368                }
369            }
370
371            $url = $route;
372        }
373
374        return $url;
375    }
376
377}