diff --git a/AGENTS.md b/AGENTS.md index 4e1b4b5..20fe832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,11 @@ and — since the message-reading trait joined the coverage map — the whole chunking arithmetic of `MessageReading::bodyContents()`: a larger `$remaining` or a wider `read()` window still lands on the same `> $maxBytes` check, and `break` against `continue` on an at-eof empty chunk differ only in re-testing -the `while` condition that is already false. The media-type selection helpers +the `while` condition that is already false. Route bucketing escapes in the +widening direction only: the always-scanned bucket is a superset, so a mutant +that puts more routes into it loses the optimization and not a verdict — a +mutant that narrowed bucketing would change selection, and those are killed. +The media-type selection helpers in the same trait escape for the reasons above: the rank sentinel is below every specificity, the key/definition type guard is reachable only through a hand-built `Operation`, and the strict `>` is untestable because no two diff --git a/CHANGELOG.md b/CHANGELOG.md index a46121e..c942beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- **Changed.** Matching indexes routes by method, segment count and first + literal segment instead of scanning every operation and every one of its + servers on each request. On a thousand-operation document a validated request + went from 0.83 ms to 0.006 ms, and the cost no longer grows with the + document. Selection is unchanged by construction: each key is a property + `matchPath()` checks before anything else, and candidates are ordered by a + comparator that ends on the unique operation key, so the winner never + depended on the order they were collected in. +- **Added.** Two examples: `gate-a-request.php` (the shape a PSR-15 middleware + takes, over a multi-file document) and `budgets.php` (`Limits`, and why + `*.body.too_large` is a refusal to read rather than a verdict). - **Documentation.** Three divergences from the specification are now written down rather than merely true: a percent-encoded delimiter inside a `pipeDelimited`/`spaceDelimited` value is folded into the delimiter, a Header diff --git a/benchmarks/MatchOperationBench.php b/benchmarks/MatchOperationBench.php new file mode 100644 index 0000000..5b763f9 --- /dev/null +++ b/benchmarks/MatchOperationBench.php @@ -0,0 +1,74 @@ + [self::class, 'findByScanning']], + calls: 20_000, + iterations: 5, + )] + public static function matchInLargeDocument(): bool + { + return (self::$contract ??= self::contract(self::OPERATIONS)) + ->match(self::$request ??= self::request()) instanceof MatchedOperation; + } + + /** Baseline: the lookup a consumer writes over the public operation list. */ + public static function findByScanning(): bool + { + $contract = self::$contract ??= self::contract(self::OPERATIONS); + $path = (self::$request ??= self::request())->getUri()->getPath(); + foreach ($contract->operations() as $operation) { + if ($operation->method !== 'GET') { + continue; + } + $pattern = '#^' . preg_replace('/\\{[^{}]+\\}/', '[^/]+', preg_quote($operation->path, '#')) . '$#'; + if (preg_match($pattern, $path) === 1) { + return true; + } + } + + return false; + } + + private static function request(): ServerRequestInterface + { + return new ServerRequest('GET', '/resource3/42'); + } + + private static function contract(int $operations): Contract + { + $paths = []; + for ($index = 0; $index < $operations; $index++) { + $paths["/resource{$index}/{id}"] = ['get' => [ + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'responses' => ['200' => ['description' => 'ok']], + ]]; + } + + return Contract::fromArray(['openapi' => '3.1.0', 'paths' => $paths]); + } +} diff --git a/examples/README.md b/examples/README.md index 5c90127..f02253f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,9 +3,17 @@ | Script | Shows | Needs server? | |---|---|---| | `validate-exchange.php` | Loading a document, operation matching, exchange validation, violations, `assertValid()` | No | +| `gate-a-request.php` | The shape a PSR-15 middleware takes — validate in, hand on, validate out — over a multi-file `fromFile()` document, with `ValidationResultFormatter` rendering what failed | No | +| `budgets.php` | `Limits`: why `*.body.too_large` is a refusal to read rather than a verdict, and what the document budgets bound | No | + +`openapi/pets.yaml` and `openapi/schemas/pet.yaml` are the multi-file document +`gate-a-request.php` loads: a relative `$ref` to a sibling file inside the entry +file's directory tree. Run from the package root after `make install`: ```bash docker run --rm -v "$PWD":/app -w /app composer:2 php examples/validate-exchange.php +docker run --rm -v "$PWD":/app -w /app composer:2 php examples/gate-a-request.php +docker run --rm -v "$PWD":/app -w /app composer:2 php examples/budgets.php ``` diff --git a/examples/budgets.php b/examples/budgets.php new file mode 100644 index 0000000..7934e16 --- /dev/null +++ b/examples/budgets.php @@ -0,0 +1,51 @@ + '3.1.0', + 'paths' => ['/reports' => ['get' => ['responses' => ['200' => ['content' => ['application/json' => [ + 'schema' => ['type' => 'object'], + ]]]]]]], +]; + +$request = new ServerRequest('GET', '/reports'); +$big = new Response(200, ['Content-Type' => 'application/json'], '{"rows":[' . str_repeat('1,', 700_000) . '1]}'); + +printf("The response is %.1f MiB.\n\n", strlen((string) $big->getBody()) / 1048576); + +// Under the default budget of 1 MiB the body is not read at all. The violation +// says so: it is a refusal to look, not a verdict about the message. +$result = Contract::fromArray($document)->validateExchange($request, $big); +printf("default budget -> %s\n", $result->violations[0]->code); +printf(" %s\n\n", $result->violations[0]->message); + +// Raise it and the same exchange is judged on its merits. +$raised = Contract::fromArray($document, new Limits(messageBodyBytes: 8 * 1024 * 1024)); +printf("messageBodyBytes 8M -> %s\n\n", $raised->validateExchange($request, $big)->isValid() ? 'valid' : 'invalid'); + +// The document budgets are the other half. `documentNodes` bounds what a +// document expands into rather than what it weighs: YAML anchors make nodes +// out of no bytes at all, so the byte budget alone does not bound the memory a +// document costs. +try { + Contract::fromArray($document, new Limits(documentNodes: 5)); +} catch (InvalidContract $exception) { + printf("documentNodes 5 -> %s\n", $exception->getMessage()); +} + +try { + Contract::fromJson('{"openapi":"3.1.0","paths":{}}', 'tiny.json', new Limits(documentBytes: 8)); +} catch (InvalidContract $exception) { + printf("documentBytes 8 -> %s\n", $exception->getMessage()); +} diff --git a/examples/gate-a-request.php b/examples/gate-a-request.php new file mode 100644 index 0000000..9cde70e --- /dev/null +++ b/examples/gate-a-request.php @@ -0,0 +1,63 @@ +validateRequest($request); + if (!$incoming->isValid()) { + // 400 is the caller's fault. Note what the codes mean before turning + // this into a hard gate: `request.body.too_large` says the validator + // declined to read the body, not that the request was wrong. + echo $formatter->format($incoming), "\n\n"; + + return new Response(400, ['Content-Type' => 'application/json'], '{"error":"request does not match the contract"}'); + } + + $response = $handler($request); + $outgoing = $contract->validateExchange($request, $response); + if (!$outgoing->isValid()) { + // A response that breaks the contract is the service's own fault: + // report it, do not blame the caller. + echo $formatter->format($outgoing), "\n\n"; + } + + return $response; +}; + +$handler = static fn(ServerRequestInterface $request): ResponseInterface => new Response( + 200, + ['Content-Type' => 'application/json'], + '{"id":7,"name":"Rex"}', +); + +echo "A request and a response that both hold:\n"; +$gate(new ServerRequest('GET', 'https://api.test/pets/7'), $handler); +echo " -> passed\n\n"; + +echo "A request the contract refuses:\n"; +$gate(new ServerRequest('GET', 'https://api.test/pets/0'), $handler); + +echo "A response the service got wrong:\n"; +$gate( + new ServerRequest('GET', 'https://api.test/pets/7'), + static fn(): ResponseInterface => new Response(200, ['Content-Type' => 'application/json'], '{"id":"seven"}'), +); + +assert($contract->validateRequest(new ServerRequest('GET', 'https://api.test/pets/7')) instanceof ValidationResult); diff --git a/examples/openapi/pets.yaml b/examples/openapi/pets.yaml new file mode 100644 index 0000000..81fd9cd --- /dev/null +++ b/examples/openapi/pets.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +servers: + - url: https://api.test +paths: + /pets/{id}: + get: + operationId: pets.get + parameters: + - name: id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: one pet + content: + application/json: + schema: + $ref: 'schemas/pet.yaml#/Pet' diff --git a/examples/openapi/schemas/pet.yaml b/examples/openapi/schemas/pet.yaml new file mode 100644 index 0000000..1a914ea --- /dev/null +++ b/examples/openapi/schemas/pet.yaml @@ -0,0 +1,8 @@ +Pet: + type: object + required: [id, name] + properties: + id: + type: integer + name: + type: string diff --git a/src/Contract.php b/src/Contract.php index 263fe69..f93312e 100644 --- a/src/Contract.php +++ b/src/Contract.php @@ -42,10 +42,31 @@ */ final readonly class Contract { + /** + * Not a path segment: `{` cannot appear in a bucket key built from a + * literal, so no route can collide with the always-scanned bucket. + */ + private const string ANY_FIRST_SEGMENT = '{*}'; + private RequestValidator $requests; private ResponseValidator $responses; + /** + * Routes bucketed by method, by how many segments they have, and by their + * first segment when that segment is a literal. Each is a property + * {@see matchPath()} checks before anything else — counts must be equal, a + * literal must equal the decoded request segment — so a bucket miss skips + * exactly the work that would have been thrown away. Routes whose first + * segment is templated sit under `*` and are always scanned. Order inside a + * bucket does not matter: candidates are sorted by a comparator that ends + * on the operation key, and keys are unique, so the winner never depends on + * the order they were collected in. + * + * @var array>>> + */ + private array $routes; + /** * @param list $operations * @param array $securitySchemes @@ -63,6 +84,17 @@ private function __construct( $schemas = new SchemaValidator(); $this->requests = new RequestValidator($limits, $schemas); $this->responses = new ResponseValidator($limits, $schemas); + $routes = []; + foreach ($operations as $operation) { + foreach ($operation->servers as $baseIndex => $server) { + $base = $server['base']; + // Bases are '/'-canonical at compile time: rtrimmed or the bare '/'. + $route = $base === '/' ? $operation->path : $base . $operation->path; + $parts = $this->segments($route); + $routes[$operation->method][count($parts)][$this->bucket($parts)][] = [$operation, $server, $baseIndex, $route]; + } + } + $this->routes = $routes; } /** @param array $document */ @@ -165,25 +197,23 @@ private function matchWithDiagnostics(RequestInterface $request): array $port = $request->getUri()->getPort() ?? $this->defaultPort($scheme); $serverMismatch = false; $candidates = []; - foreach ($this->operations as $operation) { - if ($operation->method !== $method) { + $requestParts = $this->segments($path); + $buckets = $this->routes[$method][count($requestParts)] ?? []; + $candidateRoutes = [ + ...$buckets[self::ANY_FIRST_SEGMENT] ?? [], + ...$buckets[rawurldecode($requestParts[1] ?? '')] ?? [], + ]; + foreach ($candidateRoutes as [$operation, $server, $baseIndex, $route]) { + $matched = $this->matchPath($route, $path); + if ($matched === null) { continue; } - foreach ($operation->servers as $baseIndex => $server) { - $base = $server['base']; - // Bases are '/'-canonical at compile time: rtrimmed or the bare '/'. - $route = $base === '/' ? $operation->path : $base . $operation->path; - $matched = $this->matchPath($route, $path); - if ($matched === null) { - continue; - } - if (!$this->authorityMatches($server, $scheme, $host, $port)) { - $serverMismatch = true; + if (!$this->authorityMatches($server, $scheme, $host, $port)) { + $serverMismatch = true; - continue; - } - $candidates[] = [$operation, $matched, substr_count($operation->path, '{'), strlen($route), $route, $baseIndex]; + continue; } + $candidates[] = [$operation, $matched, substr_count($operation->path, '{'), strlen($route), $route, $baseIndex]; } /** @var list, 2: int, 3: int, 4: string, 5: int}> $candidates */ usort($candidates, static function (array $a, array $b): int { @@ -337,6 +367,21 @@ private function unmatchedResult(RequestInterface $request, bool $serverMismatch /** * @return array|null */ + /** + * The bucket a route belongs to: its first segment when that segment is a + * literal, and the always-scanned one when it is templated or absent. A + * literal first segment is compared to the decoded request segment + * verbatim, which is what makes the bucket safe to skip. + * + * @param list $routeParts + */ + private function bucket(array $routeParts): string + { + $first = $routeParts[1] ?? null; + + return $first === null || str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first; + } + private function matchPath(string $route, string $requestPath): ?array { $routeParts = $this->segments($route); diff --git a/tests/ServerMatchingTest.php b/tests/ServerMatchingTest.php index 5fcb389..da65796 100644 --- a/tests/ServerMatchingTest.php +++ b/tests/ServerMatchingTest.php @@ -20,6 +20,38 @@ #[Covers(DocumentCompiler::class)] final class ServerMatchingTest { + /** + * Routes are bucketed by their first segment when it is a literal, so the + * bucket a request lands in has to agree with what matching would have + * done: a templated first segment is always in play, a literal one is + * compared decoded, and a server base is part of the route it prefixes. + */ + public function matchesRegardlessOfHowRoutesAreBucketed(): void + { + $contract = Contract::fromArray(['openapi' => '3.1.0', 'servers' => [['url' => '/api']], 'paths' => [ + '/{tenant}/items' => ['get' => [ + 'parameters' => [['name' => 'tenant', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]], + 'responses' => ['200' => []], + ]], + '/admin/items' => ['get' => ['responses' => ['200' => []]]], + '/rés/{id}' => ['get' => [ + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]], + 'responses' => ['200' => []], + ]], + ]]); + + // The concrete path wins over the templated one although both are in + // play for the same request. + Assert::same($contract->requireMatch(new Request('GET', '/api/admin/items'))->operation->path, '/admin/items'); + Assert::same($contract->requireMatch(new Request('GET', '/api/acme/items'))->operation->path, '/{tenant}/items'); + Assert::same($contract->requireMatch(new Request('GET', '/api/acme/items'))->pathParameters, ['tenant' => 'acme']); + // A percent-encoded literal first segment is bucketed by its decoded + // form, which is the form matching compares. + Assert::same($contract->requireMatch(new Request('GET', '/api/r%C3%A9s/7'))->operation->path, '/rés/{id}'); + // The base is part of the route: without it nothing matches. + Assert::null($contract->match(new Request('GET', '/admin/items'))); + } + public function selectsTheOperationOfTheMatchingHost(): void { $contract = Contract::fromArray(['openapi' => '3.1.0', 'paths' => [