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
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 40 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |

Expand Down
48 changes: 40 additions & 8 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 для той же пары, как требует спека; одна и та же пара,
объявленная дважды *внутри* одного списка, отвергается: параметр уникален по
Expand Down Expand Up @@ -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 редактируется
Expand Down Expand Up @@ -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()` получил ключ операции, которого нет в контракте |
Expand All @@ -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` | поток тела ответа сообщает, что не закончился, и читает пусто |

Expand Down
26 changes: 20 additions & 6 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Operation>`;
`Contract::operation(string $key): Operation` throws `UnknownOperation`.
- `Contract::securitySchemes(): array<string, CompiledSecurityScheme>` —
Expand Down
Loading
Loading