From bb08ca13186168730c5e252f38a806dbd73e6324 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 6 Sep 2026 19:04:28 +0300 Subject: [PATCH] Settle the three decisions a 1.0 tag would freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Operation` was half an input type and half an output type: its constructor is public, but `Contract` is built only from documents and the validators are internal, so a hand-built operation could be read back and asked for `responseFor()` and nothing else. Mark the constructor `@internal` and keep the class `@api` as the compiled read model consumers actually use, and declare the variance of `CompiledParameter` while it is still free — read-only for consumers, open to new keys in a minor. `allowReserved` is documented as what it is: a hand-off to consumers that render query values and cannot derive it from the schema, not an annotation nobody reads. The byte budgets were constants with no way to raise them, and a body over one was reported as a violation — accusing a message the validator had merely declined to read, so a gate on `isValid()` rejected traffic it never judged. `Limits` makes the policy the caller's, on the factories rather than on three signatures, so later knobs land on the object; `too_large` keeps its code and its two-state result and is documented as a refusal to look. --- AGENTS.md | 14 ++++- CHANGELOG.md | 33 +++++++++++ README.md | 49 ++++++++++++++--- README.ru.md | 48 +++++++++++++--- llms.txt | 26 +++++++-- src/Contract.php | 28 +++++----- src/Internal/Reference/DocumentGraph.php | 4 +- src/Internal/Validation/MessageReading.php | 7 +-- src/Internal/Validation/RequestValidator.php | 8 +-- src/Internal/Validation/ResponseValidator.php | 10 ++-- src/Limits.php | 40 ++++++++++++++ src/Operation.php | 17 +++++- tests/ContractTest.php | 22 +++++++- tests/LimitsTest.php | 55 +++++++++++++++++++ tests/MultiFileContractTest.php | 30 ++++++++++ tests/RequestValidationTest.php | 46 +++++++++++++--- tests/ResponseValidationTest.php | 35 +++++++++++- 17 files changed, 406 insertions(+), 66 deletions(-) create mode 100644 src/Limits.php create mode 100644 tests/LimitsTest.php diff --git a/AGENTS.md b/AGENTS.md index 8acf92b..8d1458e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ A framework-neutral OpenAPI contract validator for complete PSR-7 exchanges, in namespace `Rasuvaeff\OpenApiContract`, published as `rasuvaeff/openapi-contract` (0.x). The feasibility phase is closed — the backend decision and the executable corpus status are recorded in -[FEASIBILITY.md](FEASIBILITY.md). The public API (`Contract`, `Operation`, +[FEASIBILITY.md](FEASIBILITY.md). The public API (`Contract`, `Limits`, `Operation`, `MatchedOperation`, `ValidationResult`, `Violation`, `ValidationResultFormatter`) is documented in README EN/RU and `llms.txt`; milestone types stay under `Internal\` and carry `@internal` — never let one @@ -78,6 +78,18 @@ make release-check they are the only thing that catches this package agreeing with itself while disagreeing with every other reader of the same document. Moving them out of the default suite would take them out of CI. +- **`Operation` is an output type.** Its constructor is `@internal`: nothing + public validates a hand-built operation, and the shapes it takes are the + compiler's output rather than a checked input. The package's own tests still + build one by hand to reach the validators' defensive branches — that is an + internal seam, not a supported path, and it is why those branches stay. + `CompiledParameter` is a read shape whose variance is declared: consumers + read it, minors may add keys to it. +- **A budget is a policy, not a verdict.** `Limits` carries them and every + factory takes one. `*.body.too_large` says the validator declined to read a + body; it must never be reworded into a claim that the message is wrong, and + a third `ValidationResult` state is deliberately not the answer — it would + change what `isValid() === false` means for every existing consumer. - `examples/` is part of the public contract; every listed script must run. - CI actions stay SHA-pinned with read-only permissions and checkout credentials disabled. diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e12ee..04d91dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.9.0 — 2026-09-06 + +The three decisions a 1.0 tag would have frozen, settled while they are still +cheap to settle. Two of them narrow the public surface, which is why this is a +minor: `roave/backward-compatibility-check` reports both, deliberately. + +- **Changed.** `Operation::__construct()` is `@internal`; the class stays + `@api`. `Operation` is the compiled read model a contract exposes — nothing + public validates a hand-built one (`Contract` is built from a document, the + validators are internal), so the constructor was a promise with no product + behind it. The shapes it accepts are the compiler's output, not a checked + input. +- **Changed.** `Contract::MAX_DOCUMENT_BYTES` and + `Contract::MAX_MESSAGE_BODY_BYTES` are gone, replaced by + `Limits::DEFAULT_DOCUMENT_BYTES`, `Limits::DEFAULT_MESSAGE_BODY_BYTES` and + `Limits::DEFAULT_DOCUMENT_FILES`. The values are unchanged. +- **Added.** `Limits` — the budgets a caller may set, accepted by + `fromArray()`, `fromJson()` and `fromFile()` as an optional last argument: + `documentBytes`, `messageBodyBytes`, `documentFiles`. A budget below 1 + throws `\InvalidArgumentException`. Previously a response over 1 MiB could + not be validated at all, with no way to raise the ceiling. +- **Changed.** `request.body.too_large` and `response.body.too_large` are + documented for what they are: a policy refusal — the validator declined to + read that body — rather than a verdict that the message is invalid. A gate + rejecting on `isValid()` would otherwise reject traffic it never judged. The + codes and the two-state `ValidationResult` are unchanged; the budget is now + the caller's to raise. +- **Changed.** The `CompiledParameter` shape declares its variance: read-only + for consumers, and open to new keys in a minor release. `allowReserved` is + documented as what it is — a hand-off to consumers that render query values, + which cannot derive it from the schema — rather than as an annotation + nobody reads. + ## 0.8.0 — 2026-09-06 Three review passes over the package in one day, closing eighteen findings. diff --git a/README.md b/README.md index 3c1b1cf..0904e77 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,30 @@ Documents are bounded: byte size, JSON depth, `$ref` depth, a shared node budget, and — for multi-file documents — file-count and byte budgets shared across the whole reference graph. +#### Budgets + +`Limits` carries the budgets that are the caller's to set, and every factory +takes one: + +```php +use Rasuvaeff\OpenApiContract\Limits; + +$contract = Contract::fromFile('openapi.yaml', new Limits( + documentBytes: 40 * 1024 * 1024, // default 10 MiB + messageBodyBytes: 8 * 1024 * 1024, // default 1 MiB + documentFiles: 256, // default 64 +)); +``` + +A budget is a policy, not a verdict. A body over `messageBodyBytes` is +reported as `request.body.too_large` / `response.body.too_large`, and that +code says the validator declined to read the body — not that the message was +found wrong. A gate that rejects on `isValid()` would therefore reject traffic +it never judged, so an application whose bodies are legitimately larger raises +the budget instead of reading the violation as a failure. The defaults are +small on purpose: an unbounded read inside a middleware is a denial of +service. A budget below 1 is refused with `\InvalidArgumentException`. + ### Operations and matching ```php @@ -117,11 +141,17 @@ $declared = $operation->responseFor(404); // ['key' => '4XX', 'definition' => [. ``` `Operation` identity is the `operationId` when present, otherwise the stable -`METHOD /path` fallback. Compiled parameters carry `allowReserved` as an -annotation, like `example`/`examples`: validation does not read it, because a -value that leaves a reserved character unencoded cannot be told from the -delimiter it looks like — the package reads such a query exactly as the SAPI -does. A Path Item's parameters and an Operation's are +`METHOD /path` fallback. `Operation` is a read model: a contract is built by +compiling a document, and the constructor is `@internal` — nothing public +validates a hand-built operation, and the shapes that constructor takes are +the compiler's output rather than a checked input. The `CompiledParameter` +shape a consumer imports is read-only for it, and a minor release may add keys +to it. Compiled parameters carry `allowReserved` for those consumers: +validation never reads it, because a value that leaves a reserved character +unencoded cannot be told from the delimiter it looks like — the package reads +such a query exactly as the SAPI does — while a consumer that renders a query +value cannot derive it from the schema and needs it to decide whether reserved +characters are percent-encoded. A Path Item's parameters and an Operation's are merged by location and name, and an Operation's declaration replaces the Path Item's for the same pair, as the specification requires; the same pair declared twice *within* one list is rejected, because a parameter is unique by @@ -275,8 +305,9 @@ Body validation reads seekable PSR-7 streams from the beginning and restores their original position, including when reading fails. A body that needs validation but is non-seekable is not consumed: it produces `request.body.non_seekable` or `response.body.non_seekable` instead. -Bodies larger than `Contract::MAX_MESSAGE_BODY_BYTES` (1 MiB) produce the -corresponding `request.body.too_large` or `response.body.too_large` violation. +Bodies larger than the configured `messageBodyBytes` (1 MiB by default) +produce the corresponding `request.body.too_large` or `response.body.too_large` +violation, which says the body was not read rather than that it was wrong. `ValidationResultFormatter` renders every violation in stable order with bounded fields, depth, item counts, and expected/actual values. A value is rendered only where its name can be checked: a body is redacted wholesale — @@ -306,7 +337,7 @@ may be reworded in any release, so pin codes rather than text. | `request.body.decode` | a form or multipart body cannot be decoded as declared | | `request.body.schema` | the body does not satisfy its schema | | `request.body.unsupported` | a non-JSON, non-form media type carries a schema no undecoded payload can be judged against | -| `request.body.too_large` | the body exceeds `Contract::MAX_MESSAGE_BODY_BYTES` | +| `request.body.too_large` | the body is over the configured `messageBodyBytes`, so it was not read | | `request.body.non_seekable` | the body stream cannot be rewound, so it is not consumed | | `request.body.unreadable` | the body stream reports more data and then reads none | | `response.operation.unknown` | `validateResponse()` was given an operation key the contract does not have | @@ -321,7 +352,7 @@ may be reworded in any release, so pin codes rather than text. | `response.body.json` | a JSON response body does not parse | | `response.body.schema` | the response body does not satisfy its schema | | `response.body.unsupported` | as `request.body.unsupported`, on the response side | -| `response.body.too_large` | the response body exceeds `Contract::MAX_MESSAGE_BODY_BYTES` | +| `response.body.too_large` | the response body is over the configured `messageBodyBytes`, so it was not read | | `response.body.non_seekable` | the response body stream cannot be rewound | | `response.body.unreadable` | the response body stream reports more data and then reads none | diff --git a/README.ru.md b/README.ru.md index 4e4fdd2..add4020 100644 --- a/README.ru.md +++ b/README.ru.md @@ -98,6 +98,30 @@ same-document ссылки. Документы ограничены бюджет depth, глубина `$ref`, общий node budget, а для многофайловых документов — общие на весь граф бюджеты числа файлов и байтов. +#### Бюджеты + +`Limits` несёт бюджеты, которые задаёт вызывающая сторона; его принимает +каждая фабрика: + +```php +use Rasuvaeff\OpenApiContract\Limits; + +$contract = Contract::fromFile('openapi.yaml', new Limits( + documentBytes: 40 * 1024 * 1024, // по умолчанию 10 MiB + messageBodyBytes: 8 * 1024 * 1024, // по умолчанию 1 MiB + documentFiles: 256, // по умолчанию 64 +)); +``` + +Бюджет — это политика, а не вердикт. Тело больше `messageBodyBytes` даёт +`request.body.too_large` / `response.body.too_large`, и этот код означает, что +валидатор отказался читать тело, — а не что сообщение признано неверным. Гейт, +отвергающий по `isValid()`, иначе отверг бы трафик, который никто не проверял: +приложению с законно большими телами следует поднять бюджет, а не читать это +нарушение как отказ. Дефолты малы намеренно — неограниченное чтение внутри +middleware это denial of service. Бюджет меньше 1 отвергается +`\InvalidArgumentException`. + ### Операции и matching ```php @@ -117,11 +141,17 @@ $declared = $operation->responseFor(404); // ['key' => '4XX', 'definition' => [. ``` Identity операции — `operationId`, если он есть, иначе стабильный -`METHOD /path`. Скомпилированные параметры несут `allowReserved` как -аннотацию, наравне с `example`/`examples`: валидация его не читает, потому что -значение, оставившее reserved-символ незакодированным, неотличимо от +`METHOD /path`. `Operation` — это read model: контракт собирается компиляцией +документа, а конструктор помечен `@internal` — ни один публичный путь не +валидирует собранную руками операцию, и шейпы, которые конструктор принимает, +это выход компилятора, а не проверяемый вход. Шейп `CompiledParameter`, +который импортирует потребитель, для него доступен на чтение, и минорный +релиз может добавить в него ключи. Скомпилированные параметры несут +`allowReserved` именно для таких потребителей: валидация его не читает, потому +что значение, оставившее reserved-символ незакодированным, неотличимо от разделителя, на который оно похоже, — пакет читает такой query ровно так же, -как его прочитает SAPI. Параметры Path Item и Operation +как его прочитает SAPI, — а потребитель, который рендерит значение query, не +выведет его из схемы и без него не решит, кодировать ли reserved-символы. Параметры Path Item и Operation сливаются по паре «location + name», и объявление операции заменяет объявление Path Item для той же пары, как требует спека; одна и та же пара, объявленная дважды *внутри* одного списка, отвергается: параметр уникален по @@ -277,8 +307,10 @@ type по-прежнему даёт `request.body.media_type` / `response.body.m исходная позиция восстанавливается, в том числе при ошибке чтения. Если body нужно проверить, но stream не поддерживает seek, validator не читает его и возвращает `request.body.non_seekable` или `response.body.non_seekable`. -Body больше `Contract::MAX_MESSAGE_BODY_BYTES` (1 MiB) даёт соответствующее -нарушение `request.body.too_large` или `response.body.too_large`. +Body больше настроенного `messageBodyBytes` (по умолчанию 1 MiB) даёт +соответствующее нарушение `request.body.too_large` или +`response.body.too_large` — оно говорит, что тело не читали, а не что оно +неверно. `ValidationResultFormatter` выводит все нарушения в стабильном порядке и ограничивает поля, глубину, число элементов и expected/actual. Значение печатается только там, где его имя можно проверить: body редактируется @@ -310,7 +342,7 @@ instance path равен `$`; параметр печатается, но каж | `request.body.decode` | form- или multipart-тело не декодируется как объявлено | | `request.body.schema` | тело не удовлетворяет схеме | | `request.body.unsupported` | не-JSON и не-form media type несёт схему, которую нельзя проверить на недекодированном payload | -| `request.body.too_large` | тело больше `Contract::MAX_MESSAGE_BODY_BYTES` | +| `request.body.too_large` | тело больше настроенного `messageBodyBytes`, поэтому не читалось | | `request.body.non_seekable` | поток тела нельзя перемотать, поэтому он не вычитывается | | `request.body.unreadable` | поток тела сообщает, что не закончился, и читает пусто | | `response.operation.unknown` | `validateResponse()` получил ключ операции, которого нет в контракте | @@ -325,7 +357,7 @@ instance path равен `$`; параметр печатается, но каж | `response.body.json` | JSON-тело ответа не парсится | | `response.body.schema` | тело ответа не удовлетворяет схеме | | `response.body.unsupported` | то же, что `request.body.unsupported`, на стороне ответа | -| `response.body.too_large` | тело ответа больше `Contract::MAX_MESSAGE_BODY_BYTES` | +| `response.body.too_large` | тело ответа больше настроенного `messageBodyBytes`, поэтому не читалось | | `response.body.non_seekable` | поток тела ответа нельзя перемотать | | `response.body.unreadable` | поток тела ответа сообщает, что не закончился, и читает пусто | diff --git a/llms.txt b/llms.txt index 5a94a34..3d08b9f 100644 --- a/llms.txt +++ b/llms.txt @@ -76,7 +76,17 @@ Rules: validates clean — authorization is the application's middleware, not this package; - `allowReserved` is compiled and exposed on the parameter, never read by - validation (an annotation, like `example`/`examples`); + validation (after parsing, an encoded reserved character cannot be told from + an unencoded one) — it is a hand-off to consumers that render query values, + which cannot derive it from the schema; +- budgets are `Limits`, not constants: `new Limits(documentBytes:, + messageBodyBytes:, documentFiles:)` passed to any factory; a budget below 1 + throws `\InvalidArgumentException`. `*.body.too_large` means the validator + declined to read that body, NOT that the message is invalid — raise the + budget rather than treating it as a failed verdict; +- `Operation` is a read model: its constructor is `@internal` (nothing public + validates a hand-built operation), and the `CompiledParameter` shape is + read-only for consumers — minor releases may add keys to it; - a response header declaring the boolean schema `false` fails closed when the header is present, as a body declaring it does; - root `security` is inherited by operations; explicit `security: []` marks @@ -96,11 +106,15 @@ Rules: deep `$ref` chains and fast rejection of cross-file cycles. API reference: -- `Contract::fromArray(array $document): Contract` / - `fromJson(string $json, string $source = 'openapi.json')` / - `fromFile(string $path)` (YAML via suggested symfony/yaml; multi-file - relative `$ref`s under the entry directory) build the immutable compiled - contract. +- `Contract::fromArray(array $document, ?Limits $limits = null): Contract` / + `fromJson(string $json, string $source = 'openapi.json', ?Limits $limits = null)` / + `fromFile(string $path, ?Limits $limits = null)` (YAML via suggested + symfony/yaml; multi-file relative `$ref`s under the entry directory) build + the immutable compiled contract. +- `Limits` (readonly): `documentBytes` (default 10 MiB), + `messageBodyBytes` (default 1 MiB), `documentFiles` (default 64), with + `Limits::DEFAULT_DOCUMENT_BYTES` / `DEFAULT_MESSAGE_BODY_BYTES` / + `DEFAULT_DOCUMENT_FILES` as the constants behind them. - `Contract::operations(): list`; `Contract::operation(string $key): Operation` throws `UnknownOperation`. - `Contract::securitySchemes(): array` — diff --git a/src/Contract.php b/src/Contract.php index a9b94b3..0c3dad2 100644 --- a/src/Contract.php +++ b/src/Contract.php @@ -41,9 +41,6 @@ */ final readonly class Contract { - public const int MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; - public const int MAX_MESSAGE_BODY_BYTES = 1024 * 1024; - private RequestValidator $requests; private ResponseValidator $responses; @@ -56,28 +53,30 @@ private function __construct( private SchemaDialect $dialect, private array $operations, private array $securitySchemes, + Limits $limits, ) { // One schema validator for the contract, so the compilation of a // schema is paid once and not once per validated message. Both // directions share it: a request and a response schema differ by the // direction the cache key already carries. $schemas = new SchemaValidator(); - $this->requests = new RequestValidator($schemas); - $this->responses = new ResponseValidator($schemas); + $this->requests = new RequestValidator($limits, $schemas); + $this->responses = new ResponseValidator($limits, $schemas); } /** @param array $document */ - public static function fromArray(array $document): self + public static function fromArray(array $document, ?Limits $limits = null): self { $compiled = (new DocumentCompiler())->compile($document); - return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes); + return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits ?? new Limits()); } - public static function fromJson(string $json, string $source = 'openapi.json'): self + public static function fromJson(string $json, string $source = 'openapi.json', ?Limits $limits = null): self { - if (strlen($json) > self::MAX_DOCUMENT_BYTES) { - throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds %d bytes', $source, self::MAX_DOCUMENT_BYTES)); + $limits ??= new Limits(); + if (strlen($json) > $limits->documentBytes) { + throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds %d bytes', $source, $limits->documentBytes)); } try { @@ -90,7 +89,7 @@ public static function fromJson(string $json, string $source = 'openapi.json'): } /** @var array $document */ - return self::fromArray($document); + return self::fromArray($document, $limits); } /** @@ -98,12 +97,13 @@ public static function fromJson(string $json, string $source = 'openapi.json'): * may reference sibling files with relative $refs; every referenced file * must stay inside the entry file's directory tree. */ - public static function fromFile(string $path): self + public static function fromFile(string $path, ?Limits $limits = null): self { - $graph = DocumentGraph::open($path); + $limits ??= new Limits(); + $graph = DocumentGraph::open($path, $limits->documentFiles, $limits->documentBytes); $compiled = (new DocumentCompiler())->compile($graph->entryDocument(), $graph); - return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes); + return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits); } /** @return list */ diff --git a/src/Internal/Reference/DocumentGraph.php b/src/Internal/Reference/DocumentGraph.php index 408f727..9ab3fd8 100644 --- a/src/Internal/Reference/DocumentGraph.php +++ b/src/Internal/Reference/DocumentGraph.php @@ -4,8 +4,8 @@ namespace Rasuvaeff\OpenApiContract\Internal\Reference; -use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; /** * Loads and caches the files of a multi-file OpenAPI document under one @@ -24,7 +24,7 @@ final class DocumentGraph private function __construct(private readonly string $root, private readonly string $entryPath, private readonly int $maximumFiles, private int $remainingBytes) {} - public static function open(string $path, int $maximumFiles = 64, int $maximumBytes = Contract::MAX_DOCUMENT_BYTES): self + public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DOCUMENT_FILES, int $maximumBytes = Limits::DEFAULT_DOCUMENT_BYTES): self { if ($maximumFiles < 1) { throw new \InvalidArgumentException('Maximum file count must be positive'); diff --git a/src/Internal/Validation/MessageReading.php b/src/Internal/Validation/MessageReading.php index 30eefcd..9ac787c 100644 --- a/src/Internal/Validation/MessageReading.php +++ b/src/Internal/Validation/MessageReading.php @@ -5,7 +5,6 @@ namespace Rasuvaeff\OpenApiContract\Internal\Validation; use Psr\Http\Message\MessageInterface; -use Rasuvaeff\OpenApiContract\Contract; /** * Message-reading helpers shared by the request and response validators: @@ -18,7 +17,7 @@ */ trait MessageReading { - private function bodyContents(MessageInterface $message): ?string + private function bodyContents(MessageInterface $message, int $maxBytes): ?string { $stream = $message->getBody(); if (!$stream->isSeekable()) { @@ -30,7 +29,7 @@ private function bodyContents(MessageInterface $message): ?string $stream->rewind(); $contents = ''; while (!$stream->eof()) { - $remaining = Contract::MAX_MESSAGE_BODY_BYTES - strlen($contents); + $remaining = $maxBytes - strlen($contents); $chunk = $stream->read(min(8192, $remaining + 1)); if ($chunk === '') { if ($stream->eof()) { @@ -40,7 +39,7 @@ private function bodyContents(MessageInterface $message): ?string throw new MessageBodyUnreadable(); } $contents .= $chunk; - if (strlen($contents) > Contract::MAX_MESSAGE_BODY_BYTES) { + if (strlen($contents) > $maxBytes) { throw new MessageBodyTooLarge(); } } diff --git a/src/Internal/Validation/RequestValidator.php b/src/Internal/Validation/RequestValidator.php index 4874b3f..ebb1e74 100644 --- a/src/Internal/Validation/RequestValidator.php +++ b/src/Internal/Validation/RequestValidator.php @@ -5,7 +5,6 @@ namespace Rasuvaeff\OpenApiContract\Internal\Validation; use Psr\Http\Message\RequestInterface; -use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaDialect; use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaValidator; use Rasuvaeff\OpenApiContract\Internal\Serialization\DuplicateParameterValue; @@ -13,6 +12,7 @@ use Rasuvaeff\OpenApiContract\Internal\Serialization\ParameterKind; use Rasuvaeff\OpenApiContract\Internal\Serialization\ParameterStyle; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; use Rasuvaeff\OpenApiContract\MatchedOperation; use Rasuvaeff\OpenApiContract\ValidationResult; use Rasuvaeff\OpenApiContract\Violation; @@ -48,7 +48,7 @@ * as parameters, so a caller cannot hand in a decoder that does not use * the same validator. */ - public function __construct(private SchemaValidator $schemas = new SchemaValidator()) + public function __construct(private Limits $limits, private SchemaValidator $schemas = new SchemaValidator()) { $this->parameters = new ParameterCodec(); $this->headerParameters = new ParameterCodec(percentEncoded: false); @@ -302,7 +302,7 @@ private function validateBody(MatchedOperation $matched, RequestInterface $reque } try { - $body = $this->bodyContents($request); + $body = $this->bodyContents($request, $this->limits->messageBodyBytes); } catch (MessageBodyUnreadable) { return [$this->bodyViolation( $matched, @@ -314,7 +314,7 @@ private function validateBody(MatchedOperation $matched, RequestInterface $reque return [$this->bodyViolation( $matched, 'request.body.too_large', - sprintf('Request body exceeds %d bytes', Contract::MAX_MESSAGE_BODY_BYTES), + sprintf('Request body exceeds %d bytes', $this->limits->messageBodyBytes), 'body exceeds validation byte budget', )]; } diff --git a/src/Internal/Validation/ResponseValidator.php b/src/Internal/Validation/ResponseValidator.php index 9767773..4317ab1 100644 --- a/src/Internal/Validation/ResponseValidator.php +++ b/src/Internal/Validation/ResponseValidator.php @@ -5,7 +5,6 @@ namespace Rasuvaeff\OpenApiContract\Internal\Validation; use Psr\Http\Message\ResponseInterface; -use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\Internal\Response\ResponseSelector; use Rasuvaeff\OpenApiContract\Internal\Response\SelectedResponse; use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaDialect; @@ -14,6 +13,7 @@ use Rasuvaeff\OpenApiContract\Internal\Serialization\ParameterKind; use Rasuvaeff\OpenApiContract\Internal\Serialization\ParameterStyle; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; use Rasuvaeff\OpenApiContract\MatchedOperation; use Rasuvaeff\OpenApiContract\ValidationResult; use Rasuvaeff\OpenApiContract\Violation; @@ -32,7 +32,7 @@ private SchemaValueDecoder $values; /** @see RequestValidator::__construct() for why only this one is injected. */ - public function __construct(private SchemaValidator $schemas = new SchemaValidator()) + public function __construct(private Limits $limits, private SchemaValidator $schemas = new SchemaValidator()) { $this->selector = new ResponseSelector(); // Headers are the only thing this validator deserializes, and a header @@ -118,7 +118,7 @@ public function validate( } try { - $body = $this->bodyContents($response); + $body = $this->bodyContents($response, $this->limits->messageBodyBytes); } catch (MessageBodyUnreadable) { $violations[] = new Violation( code: 'response.body.unreadable', @@ -139,9 +139,9 @@ public function validate( location: 'body', instancePath: '$', specPointer: $basePointer . '/content', - expected: sprintf('body up to %d bytes', Contract::MAX_MESSAGE_BODY_BYTES), + expected: sprintf('body up to %d bytes', $this->limits->messageBodyBytes), actual: 'body exceeds validation byte budget', - message: sprintf('Response body exceeds %d bytes', Contract::MAX_MESSAGE_BODY_BYTES), + message: sprintf('Response body exceeds %d bytes', $this->limits->messageBodyBytes), ); return new ValidationResult($violations); diff --git a/src/Limits.php b/src/Limits.php new file mode 100644 index 0000000..ee7c98a --- /dev/null +++ b/src/Limits.php @@ -0,0 +1,40 @@ +getMessage(), 'OpenAPI document "tiny.json" exceeds 10 bytes'); + } + + Assert::same( + Contract::fromJson($json, 'tiny.json', new Limits(documentBytes: strlen($json)))->operations()[0]->path, + '/h', + ); + } + public function acceptsADocumentAtTheExactByteBudget(): void { $json = '{"openapi":"3.1.0","paths":{"/h":{"get":{"responses":{"200":{}}}}}}'; - $json .= str_repeat(' ', Contract::MAX_DOCUMENT_BYTES - strlen($json)); - Assert::same(strlen($json), Contract::MAX_DOCUMENT_BYTES); + $json .= str_repeat(' ', Limits::DEFAULT_DOCUMENT_BYTES - strlen($json)); + Assert::same(strlen($json), Limits::DEFAULT_DOCUMENT_BYTES); Assert::same(Contract::fromJson($json)->operations()[0]->path, '/h'); diff --git a/tests/LimitsTest.php b/tests/LimitsTest.php new file mode 100644 index 0000000..397bb6f --- /dev/null +++ b/tests/LimitsTest.php @@ -0,0 +1,55 @@ +documentBytes, 10 * 1024 * 1024); + Assert::same($limits->messageBodyBytes, 1024 * 1024); + Assert::same($limits->documentFiles, 64); + } + + public function carriesTheBudgetsItWasGiven(): void + { + $limits = new Limits(documentBytes: 11, messageBodyBytes: 12, documentFiles: 13); + + Assert::same($limits->documentBytes, 11); + Assert::same($limits->messageBodyBytes, 12); + Assert::same($limits->documentFiles, 13); + } + + #[DataProvider('emptyBudgetProvider')] + public function refusesABudgetThatAdmitsNothing(int $documentBytes, int $messageBodyBytes, int $documentFiles, string $message): void + { + try { + new Limits(documentBytes: $documentBytes, messageBodyBytes: $messageBodyBytes, documentFiles: $documentFiles); + Assert::true(actual: false, message: 'Expected an empty budget to be refused'); + } catch (\InvalidArgumentException $exception) { + Assert::same($exception->getMessage(), $message); + } + } + + public static function emptyBudgetProvider(): iterable + { + yield 'zero document bytes' => [0, 1, 1, 'Document byte budget must be positive']; + yield 'negative document bytes' => [-1, 1, 1, 'Document byte budget must be positive']; + yield 'zero message body bytes' => [1, 0, 1, 'Message body byte budget must be positive']; + yield 'negative message body bytes' => [1, -1, 1, 'Message body byte budget must be positive']; + yield 'zero document files' => [1, 1, 0, 'Document file budget must be positive']; + yield 'negative document files' => [1, 1, -1, 'Document file budget must be positive']; + } +} diff --git a/tests/MultiFileContractTest.php b/tests/MultiFileContractTest.php index 645d4f4..59932bf 100644 --- a/tests/MultiFileContractTest.php +++ b/tests/MultiFileContractTest.php @@ -9,12 +9,14 @@ use Rasuvaeff\OpenApiContract\Internal\Reference\DocumentGraph; use Rasuvaeff\OpenApiContract\Internal\Reference\JsonPointerResolver; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; use Testo\Test; #[Test] +#[Covers(Contract::class)] #[Covers(DocumentGraph::class)] #[Covers(JsonPointerResolver::class)] final class MultiFileContractTest @@ -243,6 +245,34 @@ public function reportsRootRelativePathsWithoutLeakingTheHostLocation(): void } } + public function passesTheCallersBudgetsIntoTheDocumentGraph(): void + { + $root = $this->workspace(); + + try { + $this->write($root, 'entry.json', $this->entryWithParameterSchemaRef('shared.json#/A')); + $this->write($root, 'shared.json', '{"A": {"type": "integer"}}'); + + Assert::same(count(Contract::fromFile($root . '/entry.json')->operations()), 1); + + try { + Contract::fromFile($root . '/entry.json', new Limits(documentFiles: 1)); + Assert::true(actual: false, message: 'Expected the configured file budget to refuse the graph'); + } catch (InvalidContract $exception) { + Assert::same($exception->getMessage(), 'OpenAPI document graph exceeds the budget of 1 files'); + } + + try { + Contract::fromFile($root . '/entry.json', new Limits(documentBytes: 1)); + Assert::true(actual: false, message: 'Expected the configured byte budget to refuse the graph'); + } catch (InvalidContract $exception) { + Assert::string($exception->getMessage())->contains('exceeds the shared byte budget'); + } + } finally { + $this->remove($root); + } + } + public function enforcesTheSharedFileAndByteBudgets(): void { $root = $this->workspace(); diff --git a/tests/RequestValidationTest.php b/tests/RequestValidationTest.php index 69405c7..c976d54 100644 --- a/tests/RequestValidationTest.php +++ b/tests/RequestValidationTest.php @@ -19,6 +19,7 @@ use Rasuvaeff\OpenApiContract\Internal\Validation\RequestValidator; use Rasuvaeff\OpenApiContract\Internal\Validation\SchemaValueDecoder; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; use Rasuvaeff\OpenApiContract\MatchedOperation; use Rasuvaeff\OpenApiContract\Operation; use Rasuvaeff\OpenApiContract\ValidationResult; @@ -153,7 +154,7 @@ public function reportsAStreamThatMakesNoProgressAsAViolation(): void public function enforcesTheRequestBodyByteBudgetAndRestoresPosition(): void { - $body = str_repeat(' ', Contract::MAX_MESSAGE_BODY_BYTES + 1); + $body = str_repeat(' ', Limits::DEFAULT_MESSAGE_BODY_BYTES + 1); $request = new ServerRequest('POST', '/b', ['Content-Type' => 'application/json'], $body); $request->getBody()->seek(7); @@ -166,8 +167,8 @@ public function enforcesTheRequestBodyByteBudgetAndRestoresPosition(): void public function acceptsARequestBodyAtTheExactByteBudget(): void { - $body = '{"x":"' . str_repeat('a', Contract::MAX_MESSAGE_BODY_BYTES - 8) . '"}'; - Assert::same(strlen($body), Contract::MAX_MESSAGE_BODY_BYTES); + $body = '{"x":"' . str_repeat('a', Limits::DEFAULT_MESSAGE_BODY_BYTES - 8) . '"}'; + Assert::same(strlen($body), Limits::DEFAULT_MESSAGE_BODY_BYTES); $request = new ServerRequest('POST', '/b', ['Content-Type' => 'application/json'], $body); $result = $this->bodyContract(['application/json' => ['schema' => ['type' => 'object']]]) @@ -176,6 +177,37 @@ public function acceptsARequestBodyAtTheExactByteBudget(): void Assert::true($result->isValid()); } + public function readsARequestBodyOverTheDefaultBudgetWhenTheCallerRaisesIt(): void + { + $body = '{"x":"' . str_repeat('a', Limits::DEFAULT_MESSAGE_BODY_BYTES) . '"}'; + $request = new ServerRequest('POST', '/b', ['Content-Type' => 'application/json'], $body); + + $result = $this->bodyContract( + ['application/json' => ['schema' => ['type' => 'object']]], + new Limits(messageBodyBytes: 2 * Limits::DEFAULT_MESSAGE_BODY_BYTES), + )->validateRequest($request); + + Assert::true($result->isValid()); + } + + /** + * The budget is the caller's policy, so the diagnostic has to name the + * budget in force rather than the default it was configured away from. + */ + public function reportsTheConfiguredBudgetWhenARequestBodyExceedsIt(): void + { + $request = new ServerRequest('POST', '/b', ['Content-Type' => 'application/json'], '{"x":"aa"}'); + + $result = $this->bodyContract( + ['application/json' => ['schema' => ['type' => 'object']]], + new Limits(messageBodyBytes: 4), + )->validateRequest($request); + + Assert::same($result->violations[0]->code, 'request.body.too_large'); + Assert::same($result->violations[0]->message, 'Request body exceeds 4 bytes'); + Assert::same(count($result->violations), 1); + } + public function reportsUnknownOperationWithoutCascadingErrors(): void { $result = $this->contract()->validateRequest(new ServerRequest('GET', '/missing')); @@ -870,7 +902,7 @@ public function anUnsupportedSchemaRaisesInsteadOfBecomingAViolation(): void // Contract through a call graph that attributes the mutant elsewhere. foreach ([ fn(): mixed => $contract->validateRequest(new ServerRequest('GET', '/q?q=x')), - fn(): mixed => (new RequestValidator())->validate( + fn(): mixed => (new RequestValidator(new Limits()))->validate( $contract->match(new ServerRequest('GET', '/q?q=x')) ?? throw new \LogicException('No operation matched'), new ServerRequest('GET', '/q?q=x'), SchemaDialect::OpenApi31, @@ -1664,7 +1696,7 @@ public function reportsAnUnreadableNestedSchemaOfAHandBuiltOperationAsAContractE ); try { - (new RequestValidator())->validate( + (new RequestValidator(new Limits()))->validate( new MatchedOperation($operation, []), new ServerRequest('GET', '/n?ids=1'), SchemaDialect::OpenApi31, @@ -1702,12 +1734,12 @@ private function paramContract(array $parameter): Contract } /** @param array $content */ - private function bodyContract(array $content): Contract + private function bodyContract(array $content, ?Limits $limits = null): Contract { return Contract::fromArray(['openapi' => '3.1.0', 'paths' => ['/b' => ['post' => [ 'requestBody' => ['required' => true, 'content' => $content], 'responses' => ['204' => []], - ]]]]); + ]]]], $limits); } private function contract(): Contract diff --git a/tests/ResponseValidationTest.php b/tests/ResponseValidationTest.php index 75881bb..a882766 100644 --- a/tests/ResponseValidationTest.php +++ b/tests/ResponseValidationTest.php @@ -16,6 +16,7 @@ use Rasuvaeff\OpenApiContract\Internal\Validation\ResponseValidator; use Rasuvaeff\OpenApiContract\Internal\Validation\SchemaValueDecoder; use Rasuvaeff\OpenApiContract\InvalidContract; +use Rasuvaeff\OpenApiContract\Limits; use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; @@ -120,7 +121,7 @@ public function refusesNonSeekableResponseBodiesWithoutReadingThem(): void public function enforcesTheResponseBodyByteBudgetAndRestoresPosition(): void { - $body = str_repeat(' ', Contract::MAX_MESSAGE_BODY_BYTES + 1); + $body = str_repeat(' ', Limits::DEFAULT_MESSAGE_BODY_BYTES + 1); $response = new Response(200, ['Content-Type' => 'application/json'], $body); $response->getBody()->seek(11); @@ -133,6 +134,34 @@ public function enforcesTheResponseBodyByteBudgetAndRestoresPosition(): void Assert::same($response->getBody()->tell(), 11); } + public function readsAResponseBodyOverTheDefaultBudgetWhenTheCallerRaisesIt(): void + { + $body = '{"x":"' . str_repeat('a', Limits::DEFAULT_MESSAGE_BODY_BYTES) . '"}'; + $response = new Response(200, ['Content-Type' => 'application/json'], $body); + + $result = $this->contentContract( + ['application/json' => ['schema' => ['type' => 'object']]], + new Limits(messageBodyBytes: 2 * Limits::DEFAULT_MESSAGE_BODY_BYTES), + )->validateExchange(new ServerRequest('GET', '/h'), $response); + + Assert::true($result->isValid()); + } + + public function reportsTheConfiguredBudgetWhenAResponseBodyExceedsIt(): void + { + $response = new Response(200, ['Content-Type' => 'application/json'], '{"a":1}'); + + $result = $this->contentContract( + ['application/json' => ['schema' => ['type' => 'object']]], + new Limits(messageBodyBytes: 3), + )->validateExchange(new ServerRequest('GET', '/h'), $response); + + Assert::same($result->violations[0]->code, 'response.body.too_large'); + Assert::same($result->violations[0]->expected, 'body up to 3 bytes'); + Assert::same($result->violations[0]->message, 'Response body exceeds 3 bytes'); + Assert::same(count($result->violations), 1); + } + public function reportsStatusHeaderMediaAndSchemaViolations(): void { $response = new Response(200, ['Content-Type' => 'text/plain'], '{"id":"bad"}'); @@ -623,11 +652,11 @@ private function validateBody(Contract $contract, string $body): \Rasuvaeff\Open } /** @param array $content */ - private function contentContract(array $content): Contract + private function contentContract(array $content, ?Limits $limits = null): Contract { return Contract::fromArray(['openapi' => '3.1.0', 'paths' => ['/h' => ['get' => ['responses' => [ '200' => ['content' => $content], - ]]]]]); + ]]]]], $limits); } private function contract(): Contract