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.33
- league/openapi-psr7-validator: ^0.24
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rasuvaeff/property-testing-testo: ^0.4
- rasuvaeff/rector-named-literals: ^1.0
- rasuvaeff/understudy: ^0.4
- rasuvaeff/understudy-testo: ^0.1
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^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
This package is auto-updated.
Last update: 2026-09-01 19:51:34 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)
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, and malformed
document shapes throw InvalidContract, and parameter content
serialization or unsupported styles throw UnsupportedSerialization.
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.
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, a shared node
budget, and — for multi-file documents — file-count and byte budgets shared
across the whole reference graph.
Operations and matching
foreach ($contract->operations() as $operation) { // Operation: key, operationId, method, path, parameters, requestBody, // responses, serverBases, security } $matched = $contract->match($request); // MatchedOperation|null $matched = $contract->requireMatch($request); // throws UnknownOperation $operation = $contract->operation('pets.get'); // throws UnknownOperation
Operation identity is the operationId when present, otherwise the stable
METHOD /path fallback. Compiled parameters keep declared example/
examples values (with $refs resolved) as annotations: validation ignores
them, while the generator package feeds them into its deterministic example
phase. MatchedOperation carries the operation and the raw path parameters
extracted from the URI. Matching honours server base paths,
prefers concrete paths over templated ones, decodes each segment exactly
once, and rejects decoded separators that would escape a template slot.
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. Undeclared variables,
missing or non-enum defaults, unsupported schemes, and userinfo/query/
fragment parts of a server URL fail closed at compile time.
Operation::$serverBases remains the v0.1 base-path projection of the same
list. When the request path is declared but no server authority agrees,
validation reports request.server.mismatch instead of
request.operation.unknown.
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 }
ValidationResult is an immutable list of Violation values with stable
codes (request.parameter.missing, response.body.schema, ...) and JSON
Pointers into the OpenAPI document. Response selection follows exact status,
then the NXX range, then default; an unknown status never cascades into
invented body or header violations. readOnly/writeOnly properties are
applied directionally. 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. multipart/form-data bodies
support bounded part parsing, JSON and binary parts, repeated array parts, and
per-property encoding content types/required headers. 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.
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.
ValidationResultFormatter renders every violation in stable order with
bounded fields, depth, item counts, and expected/actual values. It redacts
header, cookie, query, and recognizably sensitive actual values;
ContractViolation uses the same complete rendering.
Security
Unsupported contract semantics are never ignored: versions, dialects, references, serialization styles, and schema assertions outside the support matrix fail closed. 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.
Examples
Runnable scripts live in examples/.
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.