rasuvaeff / property-testing-openapi
OpenAPI request-case generators and PSR-7 materialization for rasuvaeff/property-testing
Package info
github.com/rasuvaeff/property-testing-openapi
pkg:composer/rasuvaeff/property-testing-openapi
Requires
- php: 8.3 - 8.5
- ext-mbstring: *
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- rasuvaeff/openapi-contract: ^0.2
- rasuvaeff/property-testing-core: ^0.4
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^11.5 || ^12 || ^13
- predis/predis: ^2.2 || ^3.0
- rasuvaeff/property-testing-testo: ^0.6
- rasuvaeff/rector-named-literals: ^1.0
- 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
- ext-redis: Required for PROPERTY_DB=redis://... shared regression corpora
- predis/predis: Required for PROPERTY_DB=redis://... when ext-redis is unavailable
This package is auto-updated.
Last update: 2026-09-01 20:01:47 UTC
README
Data-only OpenAPI request-case generators and PSR-7 materialization for
rasuvaeff/property-testing-core and rasuvaeff/openapi-contract.
The current pre-release slice generates valid scalar, array, and object values from the documented JSON Schema subset, materializes OpenAPI 3 parameter styles and JSON, form-urlencoded, or multipart request bodies, then lets the contract validator check the request before it reaches a transport.
Install
composer require --dev rasuvaeff/property-testing-openapi
Generate And Materialize
use Nyholm\Psr7\Factory\Psr17Factory; use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\RequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; use Rasuvaeff\PropertyTesting\Random; $contract = Contract::fromArray($document); $operation = $contract->operation('pets.get'); $case = (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(42))->value; $request = (new RequestMaterializer(new Psr17Factory(), new Psr17Factory()))->materialize($operation, $case); $contract->validateRequest($request)->assertValid();
RequestCaseData is an associative JSON-compatible array with independent
path, query, headers, cookies, and optional body maps. Form bodies keep
logical values; multipart bodies keep deterministic boundaries and data-only
parts, with binary payloads represented as base64. Multipart parts are scalar
or binary only — nested objects and arrays fail closed as
UnsupportedGeneration — and travel with the OAS default content type of the
item schema unless the Encoding Object names one. Required
parameters and request bodies are always present; optional parameters and JSON
bodies take both present and absent branches. It does not include security
credentials and can therefore be persisted by the property corpus.
An empty array or object has no form-style wire form (RFC 6570 treats it as undefined), so the materializer omits such a parameter or form property and the generator produces required array/object parameters and required form properties non-empty.
Object schemas honor minProperties, maxProperties, and boolean or
schema-valued additionalProperties within the generation budget.
The request target is built against the operation's first effective server
(operation > path > root precedence, server variables substituted with their
defaults by openapi-contract): a relative server such as /api/v1 yields a
path-only URI that stays host-agnostic, an absolute server yields an absolute
URI (https://api.example.com:8443/v2/pets/42). withBaseUri() replaces the
declared server with an explicit scheme://host[:port][/base] or root-relative
/base for a consumer environment; the contract still decides whether the
result matches, so an absolute override that contradicts every declared server
fails validation with request.server.mismatch instead of silently matching.
Credentials are applied after the URI is chosen and cannot change scheme or
host.
$local = (new RequestMaterializer($factory, $factory))->withBaseUri('/v1');
The constructive not subset supports const, enum, and type exclusions;
other negative assertions remain fail-closed as UnsupportedGeneration.
Security Credentials
Security requirements are inherited by operations; an explicit empty
security list means anonymous access, and an empty requirement object ({})
is an anonymous alternative. SecuritySelector tries alternatives in
document order through a CredentialsProviderInterface; a provider can reject
one alternative with CredentialsUnavailable:
$selection = (new SecuritySelector())->select($operation, $credentials); $request = (new RequestMaterializer($requests, $streams))->materialize( $operation, $case, $selection['credentials'] ?? null, );
Credentials accepts either a plain string or a list of strings for each
header, query, and cookie value. Its public maps are normalized to lists. The
credentials are applied only at materialization time, so secrets never enter
RequestCaseData or persisted property examples:
$credentials = new Credentials( headers: ['Authorization' => 'Bearer token'], query: ['tenant' => ['public', 'fallback']], );
NegativeRequestCaseArbitrary provides constructive negative categories. The
forOperation() arbitrary removes one required path, query, header, cookie, or
body component and records misuse.kind = 'missing-required'. The
typeMismatchForOperation() arbitrary replaces one required scalar
integer/number/boolean/null parameter with a deliberately invalid wire
value and records misuse.kind = 'type'. enumMismatchForOperation() similarly
uses a value outside a required scalar enum and records misuse.kind = 'enum',
and constMismatchForOperation() uses a value different from a required scalar
const and records misuse.kind = 'const'.
boundaryMismatchForOperation() replaces one required integer/number
parameter with a wire value just outside its minimum/maximum bound
(honouring boolean exclusive bounds) and records misuse.kind = 'boundary'.
lengthMismatchForOperation() replaces one required string parameter with a
value whose length falls just outside its minLength/maxLength bound and
records misuse.kind = 'length'; parameters carrying enum, const,
pattern, or format are skipped because a pure length mismatch cannot be
promised there.
formatMismatchForOperation() replaces one required string parameter with a
fixed witness that provably violates its format (uuid, email, ipv4,
uri, uri-reference, date, date-time) and records
misuse.kind = 'format'; url is excluded because the validation backend does
not assert it.
patternMismatchForOperation() replaces one required string parameter with a
searched wire value that provably fails its pattern and records
misuse.kind = 'pattern'. The pattern itself is the oracle: bounded candidates
(alphabet samples and mutations of an accepted value) are checked with
preg_match() against the exact regex the validation backend compiles.
Parameters carrying enum, const, or format are skipped, the witness stays
inside the minLength/maxLength window, and a PCRE error or an exhausted
candidate/time budget fails closed instead of guessing.
additionalPropertyForOperation() adds one undeclared property to a required
JSON object body whose schema sets additionalProperties: false and records
misuse.kind = 'additional-properties' with the injected property name.
mediaTypeMismatchForOperation() keeps the schema-valid JSON body but sends it
under an undeclared Content-Type and records misuse.kind = 'media-type';
operations declaring wildcard media types fail closed.
malformedJsonForOperation() replaces the required JSON body with a raw
malformed payload (encoding: 'raw') under the declared media type and records
misuse.kind = 'json-syntax'.
Resulting requests are expected to
fail contract validation before a transport is called; other negative
categories remain unsupported until they have their own invalidation oracle.
Unsupported schema assertions and non-JSON request bodies throw
UnsupportedGeneration; they are never silently widened to arbitrary strings.
Transports
Execution is explicit. Use CallableTransport for a closure or
Psr15Transport for an in-process PSR-15 handler:
use Rasuvaeff\PropertyTesting\OpenApi\Psr15Transport; $transport = new Psr15Transport( $handler, $serverRequestFactory, afterRequest: static fn() => $stateResetter->reset(), ); $response = $transport->send($request);
Neither transport performs network I/O beyond the callable or PSR-15 boundary
it was given. Transport exceptions propagate to the caller unchanged. The
optional afterRequest hook runs exactly once in finally, including when the
handler throws. For Yii workers, pass Yiisoft\Di\StateResetter::reset()
through this hook while reusing the booted runner and handler; do not construct
a runner for every generated request.
Suite
ContractSuite is the framework-neutral suite model: explicit operation
selection, a transport, optional credentials, and the built-in per-trial
checks.
use Rasuvaeff\PropertyTesting\OpenApi\ContractSuite; $suite = ContractSuite::fromContract($contract, $requestFactory, $streamFactory) ->operations(['pets.get']) ->transport(new Psr15Transport($handler, $serverRequestFactory)); $case = $suite->validCases('pets.get')->generate(new Random(42))->value; $suite->checkValid('pets.get', $case);
The default selection is empty. operations() adds explicit operation keys,
allSafeOperations() is the opt-in for every GET/HEAD operation, and
exclude() removes keys. An operation with an unsafe HTTP method must be
listed explicitly and enabled with allowUnsafeOperations(); a selection
naming an unsafe operation without that gate throws SuiteConfigurationError
instead of silently filtering it out.
baseUri() materializes every request against an explicit base URI instead
of the operation's declared server — a root-relative /v1 keeps in-process
requests host-agnostic when the document declares a production host, while an
absolute override must agree with a declared server or checkValid() fails
closed with request.server.mismatch before transport.
checkValid() materializes the case (applying credentials selected through
the configured CredentialsProviderInterface), requires the request to be
valid before transport, sends it, and fails with CheckFailed on a 5xx status
or a non-conforming exchange. checkNegative() requires the case to carry
misuse metadata and to be invalid before transport, then asserts that
invalid input does not produce a 5xx. A stricter oracle is the opt-in
RejectionPolicy — OpenAPI itself does not promise invalid -> 4xx:
A CheckFailed raised for a validation result keeps it in $result and
renders every violation in its message through openapi-contract's
ValidationResultFormatter — operation, code, location, instance path, spec
pointer, bounded expected/actual — deterministically, with header, cookie,
query and secret-like actual values redacted. OperationPropertyFailed
carries that text as the cause, so a falsified phase shows the complete
diagnostics without inspecting objects:
Exchange for operation "pets.get" violates the contract
OpenAPI contract validation failed with 1 violation(s)
1. code: "response.body.schema"
operation: "pets.get"
location: "body"
instancePath: "/name"
specPointer: "/components/schemas/Pet"
expected: {"type":"string"}
actual: null
message: "..."
use Rasuvaeff\PropertyTesting\OpenApi\RejectionPolicy; $suite = $suite->rejectionPolicy( RejectionPolicy::rejectWith('4XX')->forOperation('legacy.get', 200), );
With a policy configured, checkNegative() additionally fails when the
response status matches neither the default selectors nor the per-operation
override (exact codes or NXX ranges).
negativeCases() is a weighted choice among every negative category the
operation supports constructively; an operation with no constructible category
throws UnsupportedGeneration.
exampleCases() derives named valid cases from the document's own
example/examples declarations (DocumentExamples): every parameter and
the request body contribute their named examples (examples map of Example
Objects with a value) and one unnamed example (example, then the Schema
Object's example, then the first entry of its examples list). One case is
produced per distinct example name across all parts, plus a case named
example when any part declares an unnamed one; a part without an example
under a given name falls back to its unnamed example, then to a deterministic
base case drawn with a fixed seed. The result is identical under every
PROPERTY_SEED, JSON-compatible, and free of credentials. Example Objects with
only externalValue and multipart bodies contribute nothing; an example that
cannot be represented as a wire value (a nested object in a parameter) throws
UnsupportedGeneration. The cases are not validated here — an example that
violates its own schema fails checkValid() before transport like any other
case, as a diagnosable document defect rather than a silently skipped example.
reproduce() renders one case as a redacted curl command. Credentials are
never applied there, so provider secrets cannot leak by construction;
RedactionPolicy additionally redacts named headers, query parameters,
cookies, and dot-separated JSON body paths, on top of a default header set
(Authorization, Proxy-Authorization, Cookie, Set-Cookie). Body
previews are byte-bounded and never cut a UTF-8 sequence in half.
use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; echo $suite->reproduce('pets.get', $case, new RedactionPolicy(bodyPaths: ['owner.card']));
Response Generation
For testing an API client without live traffic, the package generates the responses a documented provider may return — and the ones it must not:
use Rasuvaeff\PropertyTesting\OpenApi\NegativeResponseCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\ResponseCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\ResponseMaterializer; $case = (new ResponseCaseArbitrary())->forOperation($operation, 200)->generate(new Random(42))->value; $response = (new ResponseMaterializer($responseFactory, $streamFactory))->materialize($operation, $case); $contract->validateResponse('payments.get', $response)->assertValid();
ResponseCaseArbitrary::forOperation() takes an explicit concrete status; the
Response Object is the one the contract resolves it to (exact code, then
NXX, then default — the same selection validateResponse() applies, via
Operation::responseFor()). Required response headers are always present,
optional ones take both branches, and the JSON body is generated with
writeOnly properties left out. ResponseCaseData is JSON-compatible and
corpus-safe like its request counterpart. An undeclared status, a required
header without a schema, or a body without a JSON media type fail closed as
UnsupportedGeneration.
NegativeResponseCaseArbitrary mutates a valid case constructively:
undeclared-status, a missing-required header, and the body categories
missing-required, type, enum, const, boundary, length, pattern
(through the same bounded preg_match() oracle as request generation),
additional-properties, media-type, and json-syntax — on top-level
object properties, or on a scalar/array body root (misuse.name is $).
forOperation() is the weighted union of every constructible category.
Every case is provably invalid under Contract::validateResponse(), the
misuse metadata survives shrinking, and a category without a constructible
witness fails closed. Feed the valid cases through your client's decoder to
exercise documented optional fields and enum states, and the invalid ones to
prove the client rejects a malformed provider payload instead of mapping it.
Test Runner Integration
OperationProperty is the framework-neutral runner surface: each operation is
one test case, and each generated request is one property trial. It works
unchanged under Testo and PHPUnit because neither side needs framework hooks —
the data provider is a plain static method and the check is a plain call:
use Rasuvaeff\PropertyTesting\OpenApi\OpenApiOperations; use Rasuvaeff\PropertyTesting\OpenApi\OperationProperty; #[Test] final class ApiContractTest { #[DataProvider('operations')] public function operationConforms(string $operationKey): void { OperationProperty::check(self::suite(), $operationKey, runs: 100); } /** @return iterable<string, array{string}> */ public static function operations(): iterable { return OpenApiOperations::keys(self::suite()); } }
check() runs the valid phase always and the negative phase when the
operation supports at least one constructible misuse category. A falsified
phase throws OperationPropertyFailed carrying the operation key, the phase,
the seed, the shrunk minimal case, and a redacted curl reproducer.
The valid phase starts with the document's examples (exampleCases()): they
run before corpus replay and the random phase under every seed and run count,
so a point fault the document itself describes — one specific id the server
chokes on — is found on the first run instead of by chance. A failing example
throws OperationPropertyFailed with its name in $example, the case
unshrunk, runsBeforeFailure of zero, and the same curl reproducer:
Operation "pets.get" failed the valid phase on document example "legacy": Operation "pets.get" responded with server error status 500
Case: {"operationKey":"pets.get","path":{"id":"7"},...}
Reproduce: curl -X GET '/pets/7?verbose=true'
The parity is CI-verified: composer build runs the PHPUnit fixture suite
(tests/PhpUnit/, composer test:phpunit) alongside the Testo suite, and the
fixture passes unchanged on PHPUnit 11.5, 12, and 13.
Environment parity with the property-testing adapters: PROPERTY_RUNS
overrides the run count, PROPERTY_SEED fixes the seed unless an explicit
seed: argument is given (a pinned seed also disables corpus replay), and
PROPERTY_DB names either a directory-backed regression corpus or a shared
Redis corpus. Redis DSNs use redis://host[:port][/key-prefix]; the prefix
defaults to property-testing:corpus:. Install ext-redis or predis/predis.
Connections are opened lazily, and unsupported schemes fail closed.
See examples for the runnable scripts.