Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions benchmarks/MatchOperationBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\OpenApiContract\Benchmarks;

use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ServerRequestInterface;
use Rasuvaeff\OpenApiContract\Contract;
use Rasuvaeff\OpenApiContract\MatchedOperation;
use Testo\Bench;

/**
* Matching against a document the size of a real API. Routes are bucketed by
* method, segment count and first literal segment, so finding an operation
* does not walk the document. The baseline is what a consumer writes when they
* look one up themselves — a scan over `operations()` — which is also what this
* package did internally until the bucketing landed.
*/
final class MatchOperationBench
{
private const int OPERATIONS = 1_000;

private static ?Contract $contract = null;

private static ?ServerRequestInterface $request = null;

#[Bench(
callables: ['scan operations() by hand' => [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]);
}
}
8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
51 changes: 51 additions & 0 deletions examples/budgets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Rasuvaeff\OpenApiContract\Contract;
use Rasuvaeff\OpenApiContract\InvalidContract;
use Rasuvaeff\OpenApiContract\Limits;

// Budgets are policy, and the policy is the caller's.

$document = [
'openapi' => '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());
}
63 changes: 63 additions & 0 deletions examples/gate-a-request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Rasuvaeff\OpenApiContract\Contract;
use Rasuvaeff\OpenApiContract\ValidationResult;
use Rasuvaeff\OpenApiContract\ValidationResultFormatter;

// The shape a PSR-15 middleware takes: validate the request, hand it on, then
// validate what came back. Written as a plain callable so the example runs
// without a middleware dispatcher.

$contract = Contract::fromFile(__DIR__ . '/openapi/pets.yaml');
$formatter = new ValidationResultFormatter();

$gate = static function (ServerRequestInterface $request, callable $handler) use ($contract, $formatter): ResponseInterface {
$incoming = $contract->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);
21 changes: 21 additions & 0 deletions examples/openapi/pets.yaml
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 8 additions & 0 deletions examples/openapi/schemas/pet.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Pet:
type: object
required: [id, name]
properties:
id:
type: integer
name:
type: string
75 changes: 60 additions & 15 deletions src/Contract.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<int, array<string, list<array{0: Operation, 1: array{scheme: null|non-empty-string, host: null|non-empty-string, port: null|int, base: non-empty-string}, 2: int, 3: string}>>>>
*/
private array $routes;

/**
* @param list<Operation> $operations
* @param array<string, CompiledSecurityScheme> $securitySchemes
Expand All @@ -63,6 +84,17 @@
$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<string, mixed> $document */
Expand Down Expand Up @@ -165,25 +197,23 @@
$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<array{0: Operation, 1: array<string, string>, 2: int, 3: int, 4: string, 5: int}> $candidates */
usort($candidates, static function (array $a, array $b): int {
Expand All @@ -191,7 +221,7 @@
$bRoute = $b[4] ?? null;
$aOperation = $a[0] ?? null;
$bOperation = $b[0] ?? null;
if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) {

Check warning on line 224 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation && !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }

Check warning on line 224 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) || !is_string($bRoute) && !$aOperation instanceof Operation || !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }

Check warning on line 224 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) && !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }
throw new \LogicException('Operation match candidate has an invalid shape');
}

Expand Down Expand Up @@ -337,6 +367,21 @@
/**
* @return array<string, string>|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<string> $routeParts
*/
private function bucket(array $routeParts): string
{
$first = $routeParts[1] ?? null;

return $first === null || str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first;

Check warning on line 382 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOrAllSubExprNegation": @@ @@ { $first = $routeParts[1] ?? null; - return $first === null || str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first; + return !($first === null) || !str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first; } private function matchPath(string $route, string $requestPath): ?array

Check warning on line 382 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "Identical": @@ @@ { $first = $routeParts[1] ?? null; - return $first === null || str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first; + return $first !== null || str_contains($first, '{') ? self::ANY_FIRST_SEGMENT : $first; } private function matchPath(string $route, string $requestPath): ?array
}

private function matchPath(string $route, string $requestPath): ?array
{
$routeParts = $this->segments($route);
Expand All @@ -351,7 +396,7 @@
if (str_contains($requestPart, '/') || str_contains($requestPart, '\\')) {
return null;
}
if (preg_match('/^\{([^{}]+)\}$/', $part, $match) === 1) {

Check warning on line 399 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregMatchMatches": @@ @@ if (str_contains($requestPart, '/') || str_contains($requestPart, '\\')) { return null; } - if (preg_match('/^\{([^{}]+)\}$/', $part, $match) === 1) { + if ((int) ($match = []) === 1) { $params[$match[1]] = $rawRequestPart; continue; }
$params[$match[1]] = $rawRequestPart;
continue;
}
Expand All @@ -360,8 +405,8 @@
if ($captured === null) {
return null;
}
$params = [...$params, ...$captured];

Check warning on line 408 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ if ($captured === null) { return null; } - $params = [...$params, ...$captured]; + $params = [...$captured]; continue; } if ($part !== $requestPart) {
continue;

Check warning on line 409 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "Continue_": @@ @@ return null; } $params = [...$params, ...$captured]; - continue; + break; } if ($part !== $requestPart) { return null;
}
if ($part !== $requestPart) {
return null;
Expand Down Expand Up @@ -407,11 +452,11 @@
$offset = 0;
while (preg_match('/\{([^{}]+)\}/', $template, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
[$placeholder, $position] = $match[0];
$pattern .= preg_quote(substr($template, $offset, $position - $offset), '~') . '([^/]+)';

Check warning on line 455 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregQuote": @@ @@ $offset = 0; while (preg_match('/\{([^{}]+)\}/', $template, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { [$placeholder, $position] = $match[0]; - $pattern .= preg_quote(substr($template, $offset, $position - $offset), '~') . '([^/]+)'; + $pattern .= substr($template, $offset, $position - $offset) . '([^/]+)'; $names[] = $match[1][0]; $offset = $position + strlen($placeholder); }
$names[] = $match[1][0];
$offset = $position + strlen($placeholder);
}
$pattern .= preg_quote(substr($template, $offset), '~');

Check warning on line 459 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregQuote": @@ @@ $names[] = $match[1][0]; $offset = $position + strlen($placeholder); } - $pattern .= preg_quote(substr($template, $offset), '~'); + $pattern .= substr($template, $offset); if (preg_match('~^' . $pattern . '\z~', $rawRequestPart, $captured) !== 1) { return null; }
if (preg_match('~^' . $pattern . '\z~', $rawRequestPart, $captured) !== 1) {
return null;
}
Expand Down
Loading
Loading