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 | } |
Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not
necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once.
Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement
always has an else as part of its logical flow even if you didn't write one.
| 23 | public function __construct(string $globalPrefix = '') |
| 24 | { |
| 25 | $globalPrefix = trim($globalPrefix, '/'); |
| 25 | $globalPrefix = trim($globalPrefix, '/'); |
| 25 | $globalPrefix = trim($globalPrefix, '/'); |
| 25 | $globalPrefix = trim($globalPrefix, '/'); |
| 26 | $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix; |
| 26 | $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix; |
| 26 | $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix; |
| 26 | $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix; |
| 27 | } |
| 41 | public function addRoute(Route $route): Route |
| 42 | { |
| 43 | $name = $route->name(); |
| 44 | $noMethodGiven = true; |
| 45 | |
| 46 | foreach ($route->methods() as $method) { |
| 46 | foreach ($route->methods() as $method) { |
| 46 | foreach ($route->methods() as $method) { |
| 47 | $noMethodGiven = false; |
| 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) { |
| 55 | if ($name) { |
| 56 | if (array_key_exists($name, $this->names)) { |
| 57 | throw new RuntimeException('Duplicate route name: ' . $name); |
| 60 | $this->names[$name] = $route; |
| 61 | } |
| 62 | |
| 63 | return $route; |
| 63 | return $route; |
| 64 | } |
| 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)) { |
| 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.', |
| 94 | if (is_dir($dir)) { |
| 95 | $this->staticRoutes[$name] = new StaticRoute( |
| 96 | prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/', |
| 96 | prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/', |
| 96 | prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/', |
| 94 | if (is_dir($dir)) { |
| 95 | $this->staticRoutes[$name] = new StaticRoute( |
| 96 | prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/', |
| 100 | throw new RuntimeException("The static directory does not exist: {$dir}"); |
| 102 | } |
| 197 | private function applyGlobalPrefix(string $path): string |
| 198 | { |
| 199 | if ($this->globalPrefix === '') { |
| 200 | return $path; |
| 203 | return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path; |
| 203 | return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path; |
| 203 | return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path; |
| 203 | return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path; |
| 204 | } |
| 300 | private function assertSafeStaticPath(string $path): void |
| 301 | { |
| 302 | $decodedPath = str_replace('\\', '/', rawurldecode($path)); |
| 302 | $decodedPath = str_replace('\\', '/', rawurldecode($path)); |
| 302 | $decodedPath = str_replace('\\', '/', rawurldecode($path)); |
| 302 | $decodedPath = str_replace('\\', '/', rawurldecode($path)); |
| 303 | |
| 304 | if (str_contains($decodedPath, "\0")) { |
| 304 | if (str_contains($decodedPath, "\0")) { |
| 304 | if (str_contains($decodedPath, "\0")) { |
| 304 | if (str_contains($decodedPath, "\0")) { |
| 305 | throw new InvalidArgumentException('Static path must stay inside static root'); |
| 308 | foreach (explode('/', $decodedPath) as $segment) { |
| 308 | foreach (explode('/', $decodedPath) as $segment) { |
| 309 | if ($segment === '..') { |
| 310 | throw new InvalidArgumentException('Static path must stay inside static root'); |
| 308 | foreach (explode('/', $decodedPath) as $segment) { |
| 308 | foreach (explode('/', $decodedPath) as $segment) { |
| 309 | if ($segment === '..') { |
| 310 | throw new InvalidArgumentException('Static path must stay inside static root'); |
| 311 | } |
| 312 | } |
| 313 | } |
| 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); |
| 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; |
| 123 | $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster; |
| 123 | $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster; |
| 123 | $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | return $this->prependHost($route->prefix . trim($path, '/'), $host); |
| 127 | return $this->prependHost($route->prefix . trim($path, '/'), $host); |
| 127 | return $this->prependHost($route->prefix . trim($path, '/'), $host); |
| 127 | return $this->prependHost($route->prefix . trim($path, '/'), $host); |
| 127 | return $this->prependHost($route->prefix . trim($path, '/'), $host); |
| 128 | } |
| 268 | protected function getCacheBuster(string $dir, string $path): string |
| 269 | { |
| 270 | $root = realpath($dir); |
| 271 | |
| 272 | if ($root === false) { |
| 273 | return ''; |
| 276 | $ds = DIRECTORY_SEPARATOR; |
| 277 | $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds)); |
| 277 | $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds)); |
| 277 | $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds)); |
| 277 | $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds)); |
| 278 | |
| 279 | if ($file === false || !$this->isInsideDirectory($file, $root)) { |
| 279 | if ($file === false || !$this->isInsideDirectory($file, $root)) { |
| 279 | if ($file === false || !$this->isInsideDirectory($file, $root)) { |
| 280 | return ''; |
| 283 | $mtime = filemtime($file); |
| 284 | |
| 285 | return $mtime === false ? '' : hash('xxh32', (string) $mtime); |
| 285 | return $mtime === false ? '' : hash('xxh32', (string) $mtime); |
| 285 | return $mtime === false ? '' : hash('xxh32', (string) $mtime); |
| 285 | return $mtime === false ? '' : hash('xxh32', (string) $mtime); |
| 286 | } |
| 68 | string $patternPrefix, |
| 69 | Closure $createClosure, |
| 70 | string $namePrefix = '', |
| 71 | ): void { |
| 72 | $group = Group::make($patternPrefix, $createClosure, $namePrefix); |
| 73 | $group->register($this); |
| 74 | } |
| 315 | private function isInsideDirectory(string $file, string $dir): bool |
| 316 | { |
| 317 | return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
| 317 | return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
| 317 | return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
| 317 | return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
| 318 | } |
| 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) { |
| 161 | foreach ([$requestMethod, self::ANY] as $method) { |
| 162 | foreach ($this->routes[$method] ?? [] as $route) { |
| 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); |
| 162 | foreach ($this->routes[$method] ?? [] as $route) { |
| 161 | foreach ([$requestMethod, self::ANY] as $method) { |
| 162 | foreach ($this->routes[$method] ?? [] as $route) { |
| 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) { |
| 174 | foreach ($this->routes as $method => $routes) { |
| 174 | foreach ($this->routes as $method => $routes) { |
| 175 | if ($method === $requestMethod || $method === self::ANY) { |
| 175 | if ($method === $requestMethod || $method === self::ANY) { |
| 175 | if ($method === $requestMethod || $method === self::ANY) { |
| 176 | continue; |
| 179 | foreach ($routes as $route) { |
| 179 | foreach ($routes as $route) { |
| 180 | if ($route->match($url, $this->globalPrefix) === null) { |
| 181 | continue; |
| 184 | $allowedMethods[] = $method; |
| 185 | |
| 186 | break; |
| 174 | foreach ($this->routes as $method => $routes) { |
| 175 | if ($method === $requestMethod || $method === self::ANY) { |
| 176 | continue; |
| 177 | } |
| 178 | |
| 179 | foreach ($routes as $route) { |
| 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); |
| 194 | throw new NotFoundException(); |
| 195 | } |
| 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) { |
| 228 | foreach ($query as $name => $value) { |
| 228 | foreach ($query as $name => $value) { |
| 229 | if ($value === null) { |
| 230 | continue; |
| 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, |
| 240 | $normalized[$name] = array_map( |
| 241 | fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name), |
| 242 | $value, |
| 243 | ); |
| 244 | |
| 245 | continue; |
| 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); |
| 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 | } |
| 206 | private function prependHost(string $path, ?string $host): string |
| 207 | { |
| 208 | return $host === null ? $path : rtrim($host, '/') . $path; |
| 208 | return $host === null ? $path : rtrim($host, '/') . $path; |
| 208 | return $host === null ? $path : rtrim($host, '/') . $path; |
| 208 | return $host === null ? $path : rtrim($host, '/') . $path; |
| 209 | } |
| 212 | private function queryString(array $query): string |
| 213 | { |
| 214 | $normalized = $this->normalizeQuery($query); |
| 215 | |
| 216 | return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986); |
| 217 | } |
| 254 | private function queryValue(mixed $value, string $name): bool|int|float|string |
| 255 | { |
| 256 | if (is_scalar($value)) { |
| 257 | return $value; |
| 260 | if ($value instanceof Stringable) { |
| 261 | return (string) $value; |
| 264 | throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: ' |
| 265 | . $name); |
| 266 | } |
| 289 | private function splitStaticPath(string $path): array |
| 290 | { |
| 291 | $queryStart = strpos($path, '?'); |
| 291 | $queryStart = strpos($path, '?'); |
| 291 | $queryStart = strpos($path, '?'); |
| 291 | $queryStart = strpos($path, '?'); |
| 292 | |
| 293 | if ($queryStart === false) { |
| 294 | return [$path, false]; |
| 297 | return [substr($path, 0, $queryStart), true]; |
| 297 | return [substr($path, 0, $queryStart), true]; |
| 297 | return [substr($path, 0, $queryStart), true]; |
| 297 | return [substr($path, 0, $queryStart), true]; |
| 298 | } |
| 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); |
| 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); |
| 153 | return $this->prependHost($url, $host); |
| 154 | } |
| 241 | fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name), |