rasuvaeff / openapi-contract
Framework-neutral OpenAPI contract validation for PSR-7 exchanges
Requires
- php: 8.3 - 8.5
- opis/json-schema: ^2.6
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- cebe/php-openapi: ^1.7
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- league/openapi-psr7-validator: ^0.24
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rasuvaeff/property-testing-testo: ^1.0
- rasuvaeff/rector-named-literals: ^1.0
- rasuvaeff/understudy: ^0.10
- rasuvaeff/understudy-testo: ^0.3.3
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
- symfony/yaml: Load OpenAPI documents written in YAML
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 06:44:24 UTC
README
Framework-neutral validation of PSR-7 request/response exchanges against OpenAPI 3.0 and 3.1 contracts.
Using an AI coding assistant? llms.txt is a compact, self-contained API reference for this package.
Requirements
- PHP 8.3 – 8.5
psr/http-messageimplementations for the exchanges you validatesymfony/yamlonly when loading YAML documents (suggested, not required)- no extension beyond
json:multipleOfis judged on the decimals the document and the message spell (64.1is a multiple of0.1;64.10000000000001is not), exactly, whether or notext-bcmathis loaded — the backend's own arithmetic, which read the parsed doubles and answered differently with and without the extension, is not used (#151)
Installation
composer require rasuvaeff/openapi-contract
Usage
Loading a contract
Contract is the immutable compiled document:
use Rasuvaeff\OpenApiContract\Contract; $contract = Contract::fromArray($document); $contract = Contract::fromJson($json, source: 'openapi.json'); $contract = Contract::fromFile('openapi.yaml'); // needs symfony/yaml
Loading fails closed: unsupported OpenAPI versions throw
UnsupportedVersion, unknown JSON Schema dialects, remote references,
ambiguous path templates, duplicate operation identities, a document that
declares neither paths nor webhooks operations, and malformed
document shapes throw InvalidContract, and parameter content
serialization or unsupported styles throw UnsupportedSerialization.
Every Schema Object the validators will read — each parameter's, each
request and response media type's, each response header's and multipart
part header's — is compiled while the contract is built, in the direction
it will be read in, so a schema this package cannot evaluate (an assertion
keyword outside the support matrix such as patternProperties, a $schema
naming another dialect, an OAS 3.0 exclusiveMinimum written as a number,
a pattern the backend cannot parse, wherever it is nested) is
InvalidContract out of the factory and never out of a validate*() call.
Every exception this package raises implements ContractException, so a
caller can catch the package as one type: InvalidContract (with
UnsupportedVersion and UnsupportedSerialization under it),
InvalidLimits, UnknownOperation and ContractViolation. The concrete
base classes stay what they were — \InvalidArgumentException and
\RuntimeException — so existing catches keep working.
A header parameter named Accept, Content-Type or Authorization is
ignored, as both specifications require: HTTP gives those three a meaning of
their own, and OpenAPI describes them elsewhere — content negotiation by the
content map, authentication by the security schemes. Under OAS 3.0 a
requestBody on GET, HEAD or DELETE is ignored too, which is what that
dialect tells consumers to do; OAS 3.1 permits it and it is validated.
Every path-template placeholder must have an effective in: path parameter
with the same name and explicit required: true; extra path parameters are
rejected while compiling the contract.
Declarations are read strictly rather than leniently. A requestBody,
parameters, content, encoding, headers or Schema Object whose shape
this package cannot read is InvalidContract at load time, not a silently
unchecked part of the contract — and the check reaches every subschema, so an
unreadable items or properties member is refused where it is written rather
than on the first message that reads it; a boolean field written as a string
(required: "true") is rejected instead of falling back to its default; a
schema carrying a value JSON cannot encode (YAML's .nan and .inf) is
rejected before it can reach the validation backend; a document whose paths
produce no operation at all is rejected rather than compiled into a contract
that answers UnknownOperation to every request; and a YAML file that does
not parse is reported as InvalidContract, never as the parser's own
exception type.
$ref siblings are read by the dialect the document declares. In 3.0 they are
ignored everywhere — the specification says a Reference Object's added
properties "SHALL be ignored", and a 3.0 Schema Object holds a Reference
Object rather than a 2020-12 schema. In 3.1 a Reference Object keeps only
summary and description, which override the referenced ones, while a
Schema Object's siblings apply in addition to what the reference brings, as
2020-12 requires: {$ref: Count, maximum: 10} asserts both Count and the
maximum, and compiles to the corresponding allOf.
fromFile() also resolves relative $refs to sibling JSON/YAML files.
Every referenced file must stay inside the entry file's directory tree:
absolute paths, URI schemes, percent-encoded paths, traversal, and symlink
escapes are rejected before any read, and resolution errors report paths
relative to the document root. fromArray() and fromJson() have no
trusted filesystem root and accept same-document references only.
Documents are bounded: byte size, JSON depth, $ref depth, the number of
nodes a document expands into, a reference-resolution budget, and — for
multi-file documents — file-count, byte and node budgets shared across the
whole reference graph. The node budget is the one that bounds YAML: anchors
and aliases produce nodes out of no bytes at all, so a file well inside the
byte budget can still expand into hundreds of millions of nodes. The
resolution budget (resolvedNodes) bounds the work of inlining: a component
shared by many operations is visited once per use, so a large description
costs more resolution than it has nodes — GitHub's REST API, at 1 239
operations, needs about 360 000 of the default million.
Budgets
Limits carries the budgets that are the caller's to set, and every factory
takes one:
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 documentNodes: 20_000_000, // default 5 000 000 resolvedNodes: 4_000_000, // default 1 000 000 ));
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 InvalidLimits, an
\InvalidArgumentException that implements ContractException.
Operations and matching
foreach ($contract->operations() as $operation) { // Operation: key, operationId, method, path, parameters, requestBody, // responses, security, servers, dialect, webhook } $matched = $contract->match($request); // MatchedOperation|null $matched = $contract->requireMatch($request); // throws UnknownOperation $operation = $contract->operation('pets.get'); // throws UnknownOperation // The Response Object a status resolves to — exact code, then the NXX range, // then `default` — as response validation selects it; null when the status is // not declared, or is not an HTTP status at all. $declared = $operation->responseFor(404); // ['key' => '4XX', 'definition' => [...]] | null
Operation identity is the operationId when present, otherwise the stable
METHOD /path fallback. Operation is a read model: a contract is built by
compiling a document, and the shapes the constructor takes are the compiler's
output rather than a checked input — nothing public validates a hand-built
operation. The constructor is public API all the same, because consumers
build operations by hand in their tests: it is append-only (a minor release
may add a defaulted parameter at the end, never reorder or remove one), so
construct it with named arguments. The shapes a consumer imports —
CompiledParameter, and CompiledRequestBody/CompiledResponses for
$requestBody/$responses — are read-only for it, and a minor release may
add keys to them. CompiledRequestBody and CompiledResponses are the
Request Body Object and the Responses Object as the compiler leaves them:
every $ref on the way to a schema resolved, required a boolean,
content keyed by media type, encoding and headers keyed by property and
header name, and every schema a boolean or a keyword map (the empty map
being the unconstrained schema); what the document wrote beside those keys
is kept as written. A schema that refers to itself — a tree whose
children are trees, a thread, a nested error — cannot be inlined, so the
members of every reference cycle are kept as the schema's $defs, named
after their JSON Pointer (#/components/schemas/Node becomes
components.schemas.Node, a member of another file a.json:Node), and the
reference back to one is a local {$ref: '#/$defs/…'} carrying the
target's type and format; the schema the cycle starts from is inlined
where it is first met and kept as a def as well. A $ref branch of a
oneOf/anyOf that declares a discriminator is kept as the same local
$ref, whatever its depth, so the branch keeps the component's name for
the diagnostics to match the discriminator value against; a branch written
inline stays inline. A schema without a cycle, a shared component or a
discriminated union has no $defs. A reference cycle outside a schema — a Path Item or a
Response that reaches itself — is refused, as is a cycle with no schema in
it.
A component reached again anywhere in the document — the shared-component
DAG a large components section is — is likewise never resolved twice: the
first resolution is kept for the whole document, and every further use at
least one level below the members the wire decoders read (properties
values, items, additionalProperties of the schema and of its direct
properties) becomes the same local {$ref: '#/$defs/…'}, with the defs the
first resolution collected riding along so the references resolve. Uses the
decoders read maps off stay inlined, because a deferred node carries only
type and format. Stripe's published spec3.json (8 MB, OAS 3.0, a
handful of large schemas reached from hundreds of others) loads this way;
its schema positions number in the thousands, so it needs raised limits —
documentBytes past 8 MB and resolvedNodes into the tens of millions —
and costs seconds and gigabytes to compile, which is tracked separately.
CompiledResponses is keyed by status code as PHP reads
it ("200" is int 200), by the NXX range, or by default. 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
name and location and reading either declaration would silently drop the
other. Header names compare case-insensitively, so X-Trace and x-trace are
one parameter. Compiled parameters keep declared example/
examples values as annotations: validation ignores them, while the generator
package feeds them into its deterministic example phase. An Example Object
reached through $ref is resolved; what an example contains is data and is
kept exactly as written, $ref-looking members included — as are a Schema
Object's default/const/enum and every specification extension. MatchedOperation carries the operation and the raw path parameters
extracted from the URI. Matching honours server base paths,
prefers concrete paths over templated ones, and splits the path on the raw
/ before decoding each segment exactly once — so a percent-encoded
separator is part of its segment, never a boundary: /pets/a%2Fb matches
/pets/{name} with name decoded to a/b, the value the application
receives, and /a%2Fb/x does not match /a/b/x. A trailing slash is part of the path: /pets and /pets/ are different
resources, as RFC 3986 has them. A
placeholder may share its segment with literals (/report.{format},
/v{version}/items, /{a}-{b}); the literal runs are matched as written.
Webhooks
An OpenAPI 3.1 webhooks map is compiled too, one Operation per method
each entry declares, and a delivery is validated by the name the receiver
knows it under:
$result = $contract->validateWebhook('payment.completed', $request); $result = $contract->validateWebhook('payment.completed', $request, $response); // the exchange foreach ($contract->webhooks() as $name => $operations) { // 'payment.completed' => [Operation, ...], in document order }
A webhook is a Path Item without a path: no template, no path parameters
(in: path is refused as InvalidContract), no server matching — the
delivery's URL is the receiver's own, and nothing in it names the webhook,
so match() never returns one and operations() lists path operations
only. Everything else is the ordinary request pipeline: parameters in
the query, header and cookie, requestBody by media type, and responses
for what the receiver answered, judged when a response is passed alongside
the request. A webhook operation's identity is its operationId when
present, otherwise WEBHOOK <METHOD> <name>; it is reachable through
operation() and validateResponse() by that key, carries the map key in
Operation::$webhook (null for a path operation), and its violations
point under /webhooks/<name> in the document. A delivery for a name the
document does not declare, or a method the entry does not, is a single
request.operation.unknown violation, as an unmatched request is. A 3.1
document may declare only webhooks; one that declares neither paths
nor webhooks operations is refused, and so is a 3.0 document carrying a
webhooks member, which that version does not have.
Servers are compiled as a full model (Operation::$servers): scheme, host,
port, and base path, with operation > path > root precedence and server
variables substituted with their declared defaults. An absolute server
constrains every URI component the request actually carries — normalized
scheme, host, and effective port (443 for https, 80 for http) — so
the same path on two hosts selects only the right operation; a relative
server and a path-only request URI stay host-agnostic — a request that carries
no authority is matched by path alone, and is deliberately not rejected for
failing to name a host it never claimed. Undeclared variables,
missing or non-enum defaults, unsupported schemes, and userinfo/query/
fragment parts of a server URL fail closed at compile time.
When the request path is declared but no server authority agrees,
validation reports request.server.mismatch instead of
request.operation.unknown.
Parameters are deserialized where an encoding exists and read as sent where
one does not. A path segment and a query string are built out of RFC 3986
delimiters, so a value carrying one has to be escaped and RFC 6570 says how:
both are percent-decoded, and a query is form-encoded content, so + is a
space. A cookie is decoded too, because every SAPI decodes $_COOKIE; its
pairs are split on ; (with the optional whitespace RFC 6265 allows after
it) and never on &, which is an ordinary cookie-octet — sid=abc&def is
one cookie with a seven-character value. A wire string is read as an
integer or number only when it spells one by the JSON number grammar
(-?(0|[1-9][0-9]*)(.[0-9]+)?([eE][+-]?[0-9]+)?, nothing before or after):
.5, 5., 5, 5\n and 0x1A stay strings and fail the schema, and an
integer past PHP's range keeps its magnitude as a float rather than
saturating. A
header field value is read verbatim — HTTP treats it as opaque octets,
nothing in the wild escapes one, and decoding it would rewrite a value the
application receives intact (X-Path: /a%20b is a literal path; X-Discount: 50% is not a broken escape). The price is explicit: a header value cannot
carry its own style delimiter, because there is no escape left for it.
Security schemes
foreach ($contract->securitySchemes() as $name => $scheme) { // $scheme['type']: apiKey | http | mutualTLS | oauth2 | openIdConnect // apiKey: name, in — http: scheme, bearerFormat? — oauth2: flows — // openIdConnect: openIdConnectUrl }
components.securitySchemes is compiled into an immutable typed map keyed by
the names that Operation::$security requirements refer to, so a consumer
never re-reads the raw document to learn that apiKey lives in the
X-Api-Key header. Each scheme carries type plus exactly the fields its
type defines: apiKey — name, in (query/header/cookie); http —
scheme, optional bearerFormat; oauth2 — flows with the declared
implicit/password/clientCredentials/authorizationCode flows, each
with its URLs and scopes; openIdConnect — openIdConnectUrl;
mutualTLS (OpenAPI 3.1 only) — nothing else. Descriptions and extensions
are dropped. A scheme without a supported type, or missing a field its type
requires, fails closed as InvalidContract at compile time. Two things are
checked for shape only: an oauth2 scheme whose flows object declares no
flow compiles to an empty flows, and the URL fields (tokenUrl,
authorizationUrl, refreshUrl, openIdConnectUrl) must be non-empty
strings but are not parsed as URLs.
Validating exchanges
use Rasuvaeff\OpenApiContract\ValidationResultFormatter; $result = $contract->validateRequest($request); $result = $contract->validateExchange($request, $response); $result = $contract->validateResponse('pets.get', $response); $result->assertValid(); // throws ContractViolation when violations exist $diagnostics = (new ValidationResultFormatter())->format($result); foreach ($result->violations as $violation) { // Violation: code, operation, location, instancePath, specPointer, // expected, actual, message, keyword }
ValidationResult is an immutable list of Violation values with stable
codes (request.parameter.missing, response.body.schema, ...) and JSON
Pointers into the OpenAPI document. A body that fails its schema is
reported per leaf failure, not once for the whole body: each
request.body.schema / response.body.schema violation names the failing
member in instancePath ($.age, $.children[2].name, $['a b'] for a
name that is not an identifier), the assertion it failed in keyword
(minimum, type, required, ...; null on every other violation), and
carries that one assertion as expected — {"minimum": 0} rather than
the media type's schema. A required member the value lacks is reported
at the path it would have had, with null as actual. The list is
bounded at twenty leaves in the backend's order, a leaf two branches of a
union report alike is reported once, and a failure of the value itself —
a wrong type at the root, a oneOf that no branch or two branches of
accept — keeps instancePath $. The codes are the contract; the number
of violations a body yields and their paths are diagnostics, and a
consumer that asserted exactly one body violation at $ will now see
more. For a oneOf/anyOf that declares a discriminator, the
diagnostics follow the branch the propertyName value names — through
mapping, as a $ref or a component name, else by the component name the
value spells — instead of reporting every branch's errors; a value that
names no branch is one violation at the discriminator member with the
keyword discriminator. The verdict is untouched: every branch is still
evaluated, as Violation codes pins. So that the branch
can be named, a $ref branch of a discriminated union is compiled as a
local {$ref: '#/$defs/…'} — the form a shared component takes — rather
than inlined; a branch written inline has no name and is never chosen.
Response selection follows exact status,
then the NXX range, then default; an unknown status never cascades into
invented body or header violations. A declared response header is checked
for presence when required, and a present header with a schema is decoded
with the simple style (explode as declared, optional whitespace around
the commas of a multi-valued array or object header dropped) and validated in
the response direction (response.header.schema,
response.header.serialization); a
content-form Header Object or a non-simple style fails closed as
response.header.unsupported, a Content-Type header declaration is ignored
as the specification requires, and a schema-less declaration asserts presence
only. readOnly/writeOnly properties are applied directionally — see
Checking one schema for what that means. Root-level security is inherited by operations, an
explicit empty security list marks an operation anonymous, and credential
acquisition stays in the generator package.
validateResponse() validates a response fixture by operation identity without
requiring a live request. Unknown operation keys produce a single structured
response.operation.unknown violation.
Request bodies with application/x-www-form-urlencoded are decoded using the
same form parameter rules as query parameters, and a property that declares an
encoding content type carries a whole document instead: a JSON media type is
decoded and validated against the property schema, any other is validated as
the string it already is. multipart/form-data bodies support bounded part
parsing, JSON and binary parts, repeated array parts, and per-property
encoding content types and headers — a declared part header must be present
when required and must satisfy its schema, read with the simple style like
a request header parameter. Without an encoding content type a part defaults
to text/plain for primitives, application/octet-stream for binary strings,
application/json for objects, and for arrays to the default of the item type.
Unsupported styles, malformed boundaries, duplicate scalar parts, and invalid
part content fail closed as request.body.decode.
A parameter name that occurs more than once, where its style admits a single
value, is a violation rather than a value. ?n=5&n=999 is a well-formed query
whose meaning depends on the runtime — PHP keeps the last occurrence, Go the
first, Node both — so reading either one would let a request satisfy the
contract with one value and hand the application another. An exploded list is
untouched: repeating the name is what that style means.
A content map is matched by specificity, not by the order its keys were
written in: an exact type/subtype wins over type/*+suffix, which wins over
type/*, which wins over */*; only equally specific keys are settled by
declaration order. Declaring a wildcard above an exact media type therefore
says the same thing as declaring it below one.
A declared non-JSON media type on either side (text/plain, text/csv,
application/octet-stream, ...) is validated as far as its schema allows:
without a schema the body is opaque and passes; with a string-typed schema
(type: string, with minLength/maxLength/pattern and any asserted format) the raw
payload is validated as that string value (request.body.schema /
response.body.schema); any other schema (an XML object, for example) cannot
be evaluated against an undecoded payload and fails closed as
request.body.unsupported / response.body.unsupported. An undeclared media
type stays request.body.media_type / response.body.media_type.
A response that declares a schema and arrives with an empty body produces
response.body.missing, the mirror of request.body.missing. The statuses
that carry no body by definition are excluded: 204, 304, and every response
to a HEAD request, as is a media type entry that declares no schema or the
unconstrained boolean one.
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 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.
A JSON body is decoded with a nesting budget of 64 levels; one nested deeper
is reported as request.body.json / response.body.json — the decoder cannot
tell a budget overrun from malformed JSON, so the code says "not valid JSON"
where "not read" would be more precise. The budget is not configurable.
ValidationResultFormatter renders every violation in stable order with
bounded fields, depth, item counts, and expected/actual values, and the
keyword line where one is set. A value is rendered only where its name
can be checked: a violation of the body as a whole — the instance path
$ — is redacted wholesale, because its member names are the
application's and there is no name to check, and so is a cookie, which is
a credential carrier by definition whatever the document named it. A body
violation that names its member is rendered like a parameter: any member
whose name matches the credential pattern (authorization, api_key,
token, secret, password, cookie) is replaced, and a member or
parameter whose own path matches is redacted outright — $.age renders
-1, $.password and $.user.token render [redacted].
ContractViolation uses the same rendering.
Checking one schema
Contract::accepts() answers, for one Schema Object of this document, the
question validation asks of it: does this value satisfy it? It is the same
compiled schema and the same backend validateRequest() and
validateResponse() use, and the contract's compilation cache is reused, so
checking many values against one schema compiles it once.
use Rasuvaeff\OpenApiContract\SchemaDirection; $schema = $contract->operation('pets.create')->parameters[0]['schema']; $contract->accepts(42, $schema); // request direction $contract->accepts($value, $schema, SchemaDirection::Response);
The direction is not decoration. A readOnly property is not required on a
request and a writeOnly one is not required on a response: before the value
is judged, the property loses its required entry for the foreign direction
and keeps its subschema — it stays declared and typed, so a request that
carries a readOnly id is judged by id's schema and a closed object
(additionalProperties: false) still admits it, exactly as both
specifications have it ("the required will take effect on the response
only"). The rewrite recurses through properties, items,
additionalProperties, the composition keywords and $defs, leaves not alone, and
is what makes the same value and the same schema answer differently in the two
directions.
The rewrite itself is exported, so a consumer that builds values for one direction builds them against the schema they will be checked by rather than against a copy of the rule:
$check = new SchemaCheck(); $requestSchema = $check->effective($schema, SchemaDirection::Request);
effective() returns exactly what the validators compile — a fixed point of
itself, dialect-independent, with every member it does not read passed through
as written.
The value is judged as the backend reads JSON: an object is a stdClass, the
way json_decode() produces one without associative: true. An associative
PHP array is a JSON array, which no type: object schema admits. A
parameter travels as a string on the wire and is decoded before it is judged,
so pass the decoded value rather than the wire spelling.
A consumer holding an Operation and no contract uses SchemaCheck, naming
the dialect the operation carries:
use Rasuvaeff\OpenApiContract\SchemaCheck; $operation = $contract->operation('pets.create'); $check = new SchemaCheck(); $check->accepts($value, $schema, $operation->dialect);
Operation::$dialect is filled by compilation from the document's openapi
version — SchemaDialect::OpenApi30 or SchemaDialect::OpenApi31 — because
the dialect decides how a schema is read: 3.0 spells nullability as
nullable: true and the exclusive bounds as booleans, 3.1 as a type union and
as numbers. A schema a dialect cannot read raises InvalidContract rather
than being silently read as something else.
Two things a hand-written schema can meet that a document schema cannot,
because the compiler settles them at load time. A $ref is resolved only
inside the schema itself (#/$defs/…); a reference to #/components/…, to
another file or to a URL raises InvalidContract. A schema that cannot be
encoded as JSON — NAN/INF, malformed UTF-8, more than 512 levels of
nesting — raises InvalidContract too, as does a list where an object was
expected. The compilation cache behind both methods is keyed by the schema
and never evicts: a Contract holds finitely many schemas, but a
SchemaCheck fed an unbounded stream of distinct schemas grows with it —
keep one per document, not one per generator.
SchemaCheck::isMultipleOf($value, $divisor) is the multipleOf verdict
itself, static and exported for a consumer that has to predict it — a
generator deciding whether the number branch of a oneOf admits an integer
it is about to keep on the integer branch asks this instead of keeping a
second copy of the rule. It judges on the decimals the two numbers spell
(64.1 is a multiple of 0.1, 64.10000000000001 is not), exactly, on
every machine.
Violation codes
The complete set. A code is a stable identifier callers may switch on; the
message text ValidationResultFormatter renders beside it is a diagnostic and
may be reworded in any release, so pin codes rather than text.
| Code | Raised when |
|---|---|
request.operation.unknown |
no operation matches the request, or validateWebhook() was given a name or method the document does not declare |
request.server.mismatch |
the path matches, but no declared server does |
request.parameter.missing |
a required parameter is absent |
request.parameter.duplicate |
a name carries more than one value where its style admits one |
request.parameter.serialization |
a parameter value cannot be deserialized in its style |
request.parameter.schema |
a parameter value does not satisfy its schema |
request.body.missing |
a required body is empty |
request.body.media_type |
the body's media type is not declared (or the body declares no content) |
request.body.json |
a JSON body does not parse |
request.body.decode |
a form or multipart body cannot be decoded as declared |
request.body.schema |
the body does not satisfy its schema — one violation per failing member, with keyword |
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 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 |
response.status.invalid |
the status is not an HTTP status code (outside 100-599) |
response.status.mismatch |
the status is valid but the operation declares no response for it |
response.header.missing |
a required response header is absent |
response.header.serialization |
a response header value cannot be deserialized |
response.header.schema |
a response header value does not satisfy its schema |
response.header.unsupported |
a Header Object uses content or a style other than simple |
response.body.missing |
a response that declares a schema answered with nothing |
response.body.media_type |
the response media type is not declared |
response.body.json |
a JSON response body does not parse |
response.body.schema |
the response body does not satisfy its schema — one violation per failing member, with keyword |
response.body.unsupported |
as request.body.unsupported, on the response side |
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 |
Where the strictness stops is deliberate: this package checks what a verdict
about a message depends on, and does not check what only affects
documentation. A missing url on a server, a security scheme without the
fields its type requires, an operation without responses — all refused,
because validation cannot proceed without them. A Response Object without its
REQUIRED description, or an encoding declared on a media type the
specification does not apply it to — accepted, because neither changes a
verdict. Reach for a linter for the rest.
Three divergences from the specification are deliberate and pinned:
| Where | The specification | This package |
|---|---|---|
A percent-encoded delimiter inside a pipeDelimited or spaceDelimited value |
| and space MUST be percent-encoded inside a value, so ?ids=a%7Cb is the single element a|b |
folds the encoded form into the delimiter and reads two elements — which is what a PHP application reading the same query does, and agreeing with the application is the point of a validator. A value containing the delimiter cannot be expressed |
A Header Object carrying name or in |
both MUST NOT be specified | ignored, not refused: the header's name comes from the map key either way |
example and examples on the same object |
mutually exclusive | both are kept as annotations and handed to consumers; the validator reads neither |
deepObject is not among them: f%5Ba%5D=1 and f[a]=1 are the same
parameter here and in PHP's own query parsing.
Five keywords are accepted and never read for a verdict, because none of
them changes one this package can give: allowEmptyValue (its meaning is
undefined by the specification, and a parameter with an empty value is
judged by its schema), discriminator (a hint for consumers choosing
among oneOf branches; the branches themselves are still evaluated, and
the hint decides only which branch's failures are reported — see
Validating exchanges), xml, externalDocs and
deprecated. They are kept as written on the compiled operation.
Formats
format is asserted where the backend has a checker and is an annotation
everywhere else — a value with an unknown or unchecked format is never
rejected for it:
| Type | Asserted | Annotation only |
|---|---|---|
string |
date, time, date-time, duration, uri, uri-reference, uri-template, regex, ipv4, ipv6, uuid, email, hostname, idn-hostname, idn-email, iri, iri-reference, json-pointer, relative-json-pointer |
byte, binary, password, and any other value |
integer |
int32 (−2³¹ … 2³¹−1), int64 (−2⁶³ … 2⁶³−1; a value that overflows PHP's integer arrives as a float and is judged by magnitude) |
any other value |
number |
— | float, double, and any other value |
Security
Declared security is not enforced. Requirements are compiled, and a
requirement naming an undeclared scheme fails the document — but a request
missing its API key validates clean. This package checks the shape of an
exchange against the contract, not the authorization of the caller; putting
credentials on a request belongs to rasuvaeff/property-testing-openapi, and
enforcing them belongs to the application's middleware.
Unsupported contract semantics are never ignored: versions, dialects, references, serialization styles, and schema assertions outside the support matrix fail closed, and a declared constraint this package cannot evaluate is reported rather than skipped. What it can evaluate, it evaluates: a schema form it does not recognise is handed to the backend instead of being dropped, because silently unchecking part of a contract is the one failure a validator must never produce. User-supplied documents and message bodies are read with byte and JSON-depth budgets, and diagnostics render expected/actual values in bounded form without exposing credential parameters or body members whose name matches the credential pattern.
A pattern keyword is a regular expression from the document, and the
validation backend runs it with preg_match. A contract is a trusted input —
it is your document, not your traffic — but if you compile documents supplied
by someone else, note that a catastrophically backtracking pattern is theirs
to choose. PHP's pcre.backtrack_limit bounds each match and a match that
hits the limit fails closed rather than hanging.
Examples
Runnable scripts live in examples/.
Schema compilation is cached per Contract: the directional rewrite, the JSON
round trip, and the backend's own parse happen once per distinct schema,
direction and dialect rather than once per validated message. A contract
offers the same handful of schemas on every request, so this is where the cost
belongs — composer bench measures the difference.
Development
make install make build make release-check
Tests use property-based checks for laws and serialization round-trips, and a
differential corpus pins verdict agreement with
league/openapi-psr7-validator. A second committed corpus resolves the same
multi-file document trees through cebe/php-openapi (dev-only OAS 3.0 oracle)
and pins the deliberate divergences: our depth budget rejects deep chains the
oracle inlines, and the cross-file cycle that hangs the oracle is a fast,
stable error here. The backend decision and executable corpus status are
recorded in FEASIBILITY.md.
License
BSD-3-Clause. See LICENSE.md.