From c23c0c7f17027530d5285974fd46aa41cce4619e Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 6 Sep 2026 19:57:40 +0300 Subject: [PATCH] Find an operation without walking the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matching scanned every operation and every one of its servers on every request: a thousand-operation document cost 0.83 ms per validated message, the same for a hit as for a miss, because nothing stopped early. Routes are now bucketed by method, by segment count and by first literal segment — each one a property `matchPath()` checks before anything else, so a bucket miss skips exactly the work that would have been thrown away. Selection cannot change: candidates are ordered by a comparator that ends on the operation key, keys are unique, so the winner never depended on the order they were collected in. Routes whose first segment is templated sit in an always-scanned bucket, and a literal one is compared in its decoded form, which is the form matching compares. A thousand operations went from 0.83 ms to 0.006 ms per request, and the cost no longer grows with the document. The benchmark pins it against the lookup a consumer would write by hand over `operations()`. Two examples join the package: the shape a PSR-15 middleware takes over a multi-file document, and what the budgets on `Limits` do. --- AGENTS.md | 6 ++- CHANGELOG.md | 11 +++++ benchmarks/MatchOperationBench.php | 74 +++++++++++++++++++++++++++++ examples/README.md | 8 ++++ examples/budgets.php | 51 ++++++++++++++++++++ examples/gate-a-request.php | 63 +++++++++++++++++++++++++ examples/openapi/pets.yaml | 21 +++++++++ examples/openapi/schemas/pet.yaml | 8 ++++ src/Contract.php | 75 ++++++++++++++++++++++++------ tests/ServerMatchingTest.php | 32 +++++++++++++ 10 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 benchmarks/MatchOperationBench.php create mode 100644 examples/budgets.php create mode 100644 examples/gate-a-request.php create mode 100644 examples/openapi/pets.yaml create mode 100644 examples/openapi/schemas/pet.yaml 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' => [