Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
116 / 116 |
|
91.85% |
124 / 135 |
|
20.91% |
46 / 220 |
|
50.00% |
8 / 16 |
CRAP | |
0.00% |
0 / 1 |
| Router | |
100.00% |
116 / 116 |
|
91.85% |
124 / 135 |
|
20.91% |
46 / 220 |
|
100.00% |
16 / 16 |
1607.51 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
85.71% |
6 / 7 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
2.50 | |||
| addRoute | |
100.00% |
12 / 12 |
|
100.00% |
10 / 10 |
|
27.78% |
5 / 18 |
|
100.00% |
1 / 1 |
14.42 | |||
| group | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addStatic | |
100.00% |
15 / 15 |
|
90.91% |
10 / 11 |
|
62.50% |
5 / 8 |
|
100.00% |
1 / 1 |
4.84 | |||
| asset | |
100.00% |
10 / 10 |
|
91.67% |
11 / 12 |
|
55.56% |
5 / 9 |
|
100.00% |
1 / 1 |
7.19 | |||
| url | |
100.00% |
8 / 8 |
|
100.00% |
5 / 5 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| match | |
100.00% |
19 / 19 |
|
100.00% |
23 / 23 |
|
2.48% |
3 / 121 |
|
100.00% |
1 / 1 |
102.74 | |||
| applyGlobalPrefix | |
100.00% |
3 / 3 |
|
83.33% |
5 / 6 |
|
66.67% |
2 / 3 |
|
100.00% |
1 / 1 |
3.33 | |||
| prependHost | |
100.00% |
1 / 1 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| queryString | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| normalizeQuery | |
100.00% |
16 / 16 |
|
100.00% |
10 / 10 |
|
66.67% |
4 / 6 |
|
100.00% |
1 / 1 |
5.93 | |||
| queryValue | |
100.00% |
6 / 6 |
|
100.00% |
5 / 5 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| getCacheBuster | |
100.00% |
9 / 9 |
|
84.62% |
11 / 13 |
|
30.77% |
4 / 13 |
|
100.00% |
1 / 1 |
13.30 | |||
| splitStaticPath | |
100.00% |
4 / 4 |
|
77.78% |
7 / 9 |
|
33.33% |
2 / 6 |
|
100.00% |
1 / 1 |
3.19 | |||
| assertSafeStaticPath | |
100.00% |
6 / 6 |
|
85.71% |
12 / 14 |
|
15.00% |
3 / 20 |
|
100.00% |
1 / 1 |
13.83 | |||
| isInsideDirectory | |
100.00% |
1 / 1 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celemas\Router; |
| 6 | |
| 7 | use Celemas\Router\Exception\InvalidArgumentException; |
| 8 | use Celemas\Router\Exception\MethodNotAllowedException; |
| 9 | use Celemas\Router\Exception\NotFoundException; |
| 10 | use Celemas\Router\Exception\RuntimeException; |
| 11 | use Closure; |
| 12 | use Override; |
| 13 | use Psr\Http\Message\ServerRequestInterface as Request; |
| 14 | use Stringable; |
| 15 | |
| 16 | /** @psalm-api */ |
| 17 | class 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 | } |