rasuvaeff/property-testing-openapi

OpenAPI request-case generators and PSR-7 materialization for rasuvaeff/property-testing

Maintainers

Package info

github.com/rasuvaeff/property-testing-openapi

pkg:composer/rasuvaeff/property-testing-openapi

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 3

v0.2.0 2026-09-01 20:00 UTC

README

Latest Stable Version Total Downloads Build Static analysis License

Русская версия

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.