Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
Router
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
2 / 2
10
100.00% covered (success)
100.00%
1 / 1
 add
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 match
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server;
6
7use LambdaTwelve\OneRecord\Server\Endpoint\Endpoint;
8use 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 */
16final 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}