Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
116 / 116
91.85% covered (success)
91.85%
124 / 135
20.91% covered (danger)
20.91%
46 / 220
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
100.00% covered (success)
100.00%
116 / 116
91.85% covered (success)
91.85%
124 / 135
20.91% covered (danger)
20.91%
46 / 220
100.00% covered (success)
100.00%
16 / 16
1607.51
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 addRoute
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
10 / 10
27.78% covered (danger)
27.78%
5 / 18
100.00% covered (success)
100.00%
1 / 1
14.42
 group
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addStatic
100.00% covered (success)
100.00%
15 / 15
90.91% covered (success)
90.91%
10 / 11
62.50% covered (warning)
62.50%
5 / 8
100.00% covered (success)
100.00%
1 / 1
4.84
 asset
100.00% covered (success)
100.00%
10 / 10
91.67% covered (success)
91.67%
11 / 12
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
7.19
 url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 match
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
23 / 23
2.48% covered (danger)
2.48%
3 / 121
100.00% covered (success)
100.00%
1 / 1
102.74
 applyGlobalPrefix
100.00% covered (success)
100.00%
3 / 3
83.33% covered (warning)
83.33%
5 / 6
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 prependHost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 queryString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeQuery
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
10 / 10
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
5.93
 queryValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getCacheBuster
100.00% covered (success)
100.00%
9 / 9
84.62% covered (warning)
84.62%
11 / 13
30.77% covered (danger)
30.77%
4 / 13
100.00% covered (success)
100.00%
1 / 1
13.30
 splitStaticPath
100.00% covered (success)
100.00%
4 / 4
77.78% covered (warning)
77.78%
7 / 9
33.33% covered (danger)
33.33%
2 / 6
100.00% covered (success)
100.00%
1 / 1
3.19
 assertSafeStaticPath
100.00% covered (success)
100.00%
6 / 6
85.71% covered (warning)
85.71%
12 / 14
15.00% covered (danger)
15.00%
3 / 20
100.00% covered (success)
100.00%
1 / 1
13.83
 isInsideDirectory
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Router;
6
7use Celemas\Router\Exception\InvalidArgumentException;
8use Celemas\Router\Exception\MethodNotAllowedException;
9use Celemas\Router\Exception\NotFoundException;
10use Celemas\Router\Exception\RuntimeException;
11use Closure;
12use Override;
13use Psr\Http\Message\ServerRequestInterface as Request;
14use Stringable;
15
16/** @psalm-api */
17class Router implements RouteAdder
18{
19    use AddsRoutes;
20
21    protected readonly string $globalPrefix;
22
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
28
29    protected const string ANY = 'ANY';
30
31    /** @var array<string, list<Route>> */
32    protected array $routes = [];
33
34    /** @var array<string, StaticRoute> */
35    protected array $staticRoutes = [];
36
37    /** @var array<string, Route> */
38    protected array $names = [];
39
40    #[Override]
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name) {
56            if (array_key_exists($name, $this->names)) {
57                throw new RuntimeException('Duplicate route name: ' . $name);
58            }
59
60            $this->names[$name] = $route;
61        }
62
63        return $route;
64    }
65
66    #[Override]
67    public function group(
68        string $patternPrefix,
69        Closure $createClosure,
70        string $namePrefix = '',
71    ): void {
72        $group = Group::make($patternPrefix, $createClosure, $namePrefix);
73        $group->register($this);
74    }
75
76    public function addStatic(
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
86            throw new RuntimeException(
87                'Duplicate static route: '
88                . $name
89                . '. If you want to use the same '
90                . 'url prefix you have to create static routes with names.',
91            );
92        }
93
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
97                dir: $dir,
98            );
99        } else {
100            throw new RuntimeException("The static directory does not exist: {$dir}");
101        }
102    }
103
104    public function asset(
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
113            throw new NotFoundException('Static route not found: ' . $name);
114        }
115
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
129
130    /**
131     * @param array<string, mixed> $params
132     * @param array<string, mixed> $query
133     */
134    public function url(
135        string $name,
136        array $params = [],
137        array $query = [],
138        ?string $host = null,
139    ): string {
140        $route = $this->names[$name] ?? null;
141
142        if (!$route) {
143            throw new NotFoundException('Route not found: ' . $name);
144        }
145
146        $url = $this->applyGlobalPrefix($route->url($params));
147        $queryString = $this->queryString($query);
148
149        if ($queryString !== '') {
150            $url .= '?' . $queryString;
151        }
152
153        return $this->prependHost($url, $host);
154    }
155
156    public function match(Request $request): RouteMatch
157    {
158        $url = rawurldecode($request->getUri()->getPath());
159        $requestMethod = strtoupper($request->getMethod());
160
161        foreach ([$requestMethod, self::ANY] as $method) {
162            foreach ($this->routes[$method] ?? [] as $route) {
163                $params = $route->match($url, $this->globalPrefix);
164
165                if ($params !== null) {
166                    return new RouteMatch($route, $params, $requestMethod);
167                }
168            }
169        }
170
171        /** @var list<string> $allowedMethods */
172        $allowedMethods = [];
173
174        foreach ($this->routes as $method => $routes) {
175            if ($method === $requestMethod || $method === self::ANY) {
176                continue;
177            }
178
179            foreach ($routes as $route) {
180                if ($route->match($url, $this->globalPrefix) === null) {
181                    continue;
182                }
183
184                $allowedMethods[] = $method;
185
186                break;
187            }
188        }
189
190        if (count($allowedMethods) > 0) {
191            throw new MethodNotAllowedException($allowedMethods);
192        }
193
194        throw new NotFoundException();
195    }
196
197    private function applyGlobalPrefix(string $path): string
198    {
199        if ($this->globalPrefix === '') {
200            return $path;
201        }
202
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
204    }
205
206    private function prependHost(string $path, ?string $host): string
207    {
208        return $host === null ? $path : rtrim($host, '/') . $path;
209    }
210
211    /** @param array<string, mixed> $query */
212    private function queryString(array $query): string
213    {
214        $normalized = $this->normalizeQuery($query);
215
216        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
217    }
218
219    /**
220     * @param array<string, mixed> $query
221     * @return array<string, bool|int|float|string|list<bool|int|float|string>>
222     */
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
253
254    private function queryValue(mixed $value, string $name): bool|int|float|string
255    {
256        if (is_scalar($value)) {
257            return $value;
258        }
259
260        if ($value instanceof Stringable) {
261            return (string) $value;
262        }
263
264        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
265        . $name);
266    }
267
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
273            return '';
274        }
275
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
280            return '';
281        }
282
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
287
288    /** @return array{string, bool} */
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
294            return [$path, false];
295        }
296
297        return [substr($path, 0, $queryStart), true];
298    }
299
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
305            throw new InvalidArgumentException('Static path must stay inside static root');
306        }
307
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
314
315    private function isInsideDirectory(string $file, string $dir): bool
316    {
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
318    }
319}