Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
118 / 118
92.14% covered (success)
92.14%
129 / 140
12.34% covered (danger)
12.34%
47 / 381
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
100.00% covered (success)
100.00%
118 / 118
92.14% covered (success)
92.14%
129 / 140
12.34% covered (danger)
12.34%
47 / 381
100.00% covered (success)
100.00%
16 / 16
2324.32
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%
1 / 1
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%
22 / 22
100.00% covered (success)
100.00%
28 / 28
1.42% covered (danger)
1.42%
4 / 282
100.00% covered (success)
100.00%
1 / 1
149.96
 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 Celema\Router;
6
7use Celema\Router\Exception\InvalidArgumentException;
8use Celema\Router\Exception\MethodNotAllowedException;
9use Celema\Router\Exception\NotFoundException;
10use Celema\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::register($this, $patternPrefix, $createClosure, $namePrefix);
73    }
74
75    public function addStatic(
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
85            throw new RuntimeException(
86                'Duplicate static route: '
87                    . $name
88                    . '. If you want to use the same '
89                    . 'url prefix you have to create static routes with names.',
90            );
91        }
92
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
96                dir: $dir,
97            );
98        } else {
99            throw new RuntimeException("The static directory does not exist: {$dir}");
100        }
101    }
102
103    public function asset(
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
112            throw new NotFoundException('Static route not found: ' . $name);
113        }
114
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
128
129    /**
130     * @param array<string, mixed> $params
131     * @param array<string, mixed> $query
132     */
133    public function url(
134        string $name,
135        array $params = [],
136        array $query = [],
137        ?string $host = null,
138    ): string {
139        $route = $this->names[$name] ?? null;
140
141        if (!$route) {
142            throw new NotFoundException('Route not found: ' . $name);
143        }
144
145        $url = $this->applyGlobalPrefix($route->url($params));
146        $queryString = $this->queryString($query);
147
148        if ($queryString !== '') {
149            $url .= '?' . $queryString;
150        }
151
152        return $this->prependHost($url, $host);
153    }
154
155    public function match(Request $request): RouteMatch
156    {
157        $url = rawurldecode($request->getUri()->getPath());
158        $requestMethod = strtoupper($request->getMethod());
159        // HEAD is GET without a body (RFC 9110), so GET routes answer it
160        // unless a HEAD route matches first.
161        $methods = $requestMethod === 'HEAD' ? ['HEAD', 'GET', self::ANY] : [$requestMethod, self::ANY];
162
163        foreach ($methods as $method) {
164            foreach ($this->routes[$method] ?? [] as $route) {
165                $params = $route->match($url, $this->globalPrefix);
166
167                if ($params !== null) {
168                    return new RouteMatch($route, $params, $requestMethod);
169                }
170            }
171        }
172
173        $allowedMethods = [];
174
175        foreach ($this->routes as $method => $routes) {
176            if ($method === $requestMethod || $method === self::ANY) {
177                continue;
178            }
179
180            foreach ($routes as $route) {
181                if ($route->match($url, $this->globalPrefix) === null) {
182                    continue;
183                }
184
185                $allowedMethods[] = $method;
186
187                if ($method === 'GET') {
188                    $allowedMethods[] = 'HEAD';
189                }
190
191                break;
192            }
193        }
194
195        if (count($allowedMethods) > 0) {
196            throw new MethodNotAllowedException($allowedMethods);
197        }
198
199        throw new NotFoundException();
200    }
201
202    private function applyGlobalPrefix(string $path): string
203    {
204        if ($this->globalPrefix === '') {
205            return $path;
206        }
207
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
209    }
210
211    private function prependHost(string $path, ?string $host): string
212    {
213        return $host === null ? $path : rtrim($host, '/') . $path;
214    }
215
216    /** @param array<string, mixed> $query */
217    private function queryString(array $query): string
218    {
219        $normalized = $this->normalizeQuery($query);
220
221        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
222    }
223
224    /**
225     * @param array<string, mixed> $query
226     * @return array<string, bool|int|float|string|list<bool|int|float|string>>
227     */
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
258
259    private function queryValue(mixed $value, string $name): bool|int|float|string
260    {
261        if (is_scalar($value)) {
262            return $value;
263        }
264
265        if ($value instanceof Stringable) {
266            return (string) $value;
267        }
268
269        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
270            . $name);
271    }
272
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
278            return '';
279        }
280
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
285            return '';
286        }
287
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
292
293    /** @return array{string, bool} */
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
299            return [$path, false];
300        }
301
302        return [substr($path, 0, $queryStart), true];
303    }
304
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
310            throw new InvalidArgumentException('Static path must stay inside static root');
311        }
312
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
319
320    private function isInsideDirectory(string $file, string $dir): bool
321    {
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
323    }
324}