Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
25 / 25 |
|
100.00% |
2 / 2 |
CRAP | |
100.00% |
1 / 1 |
| Router | |
100.00% |
25 / 25 |
|
100.00% |
2 / 2 |
10 | |
100.00% |
1 / 1 |
| add | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| match | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
8 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Server; |
| 6 | |
| 7 | use LambdaTwelve\OneRecord\Server\Endpoint\Endpoint; |
| 8 | use LambdaTwelve\OneRecord\Spec\ApiVersion; |
| 9 | |
| 10 | /** |
| 11 | * Maps method and path (below the base path) to an endpoint. Routes declare |
| 12 | * the API version they appeared in, so an endpoint added in 2.3 does not exist |
| 13 | * for a request negotiated at 2.2. Path parameters are URL segments without |
| 14 | * slashes, which is also what keeps object ids from containing one. |
| 15 | */ |
| 16 | final class Router |
| 17 | { |
| 18 | /** @var list<array{methods: list<string>, pattern: string, regex: string, endpoint: Endpoint, since: ApiVersion}> */ |
| 19 | private array $routes = []; |
| 20 | |
| 21 | /** |
| 22 | * @param list<string> $methods |
| 23 | * @param string $pattern e.g. /logistics-objects/{id}/logistics-events |
| 24 | */ |
| 25 | public function add(array $methods, string $pattern, Endpoint $endpoint, ApiVersion $since = ApiVersion::V2_2_0): void |
| 26 | { |
| 27 | // Quote the literal parts only; the placeholders become named groups. |
| 28 | $regex = '#^' . preg_replace_callback( |
| 29 | '/\{(\w+)\}|[^{]+/', |
| 30 | static fn(array $m): string => ($m[1] ?? '') !== '' ? '(?P<' . $m[1] . '>[^/]+)' : preg_quote($m[0], '#'), |
| 31 | $pattern, |
| 32 | ) . '/?$#'; |
| 33 | $this->routes[] = ['methods' => array_map(strtoupper(...), $methods), 'pattern' => $pattern, 'regex' => $regex, 'endpoint' => $endpoint, 'since' => $since]; |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * @return array{endpoint: Endpoint, parameters: array<string, string>, since: ApiVersion}|array{allowed: list<string>}|null |
| 38 | * a match, a 405 hint listing the methods the path does support, or null for an unknown path |
| 39 | */ |
| 40 | public function match(string $method, string $path): ?array |
| 41 | { |
| 42 | $method = strtoupper($method); |
| 43 | $allowed = []; |
| 44 | foreach ($this->routes as $route) { |
| 45 | if (preg_match($route['regex'], $path, $matches) !== 1) { |
| 46 | continue; |
| 47 | } |
| 48 | foreach ($route['methods'] as $allowedMethod) { |
| 49 | $allowed[$allowedMethod] = true; |
| 50 | } |
| 51 | if (!\in_array($method, $route['methods'], true)) { |
| 52 | continue; |
| 53 | } |
| 54 | $parameters = []; |
| 55 | foreach ($matches as $key => $value) { |
| 56 | if (\is_string($key)) { |
| 57 | $parameters[$key] = rawurldecode($value); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | return ['endpoint' => $route['endpoint'], 'parameters' => $parameters, 'since' => $route['since']]; |
| 62 | } |
| 63 | if ($allowed !== []) { |
| 64 | $methods = array_keys($allowed); |
| 65 | sort($methods, SORT_STRING); |
| 66 | |
| 67 | return ['allowed' => $methods]; |
| 68 | } |
| 69 | |
| 70 | return null; |
| 71 | } |
| 72 | } |