From 0ed43d326e36c7d72c251e174be98327f8ca774e Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Fri, 18 Sep 2026 23:04:46 +0300 Subject: [PATCH 01/11] Build on openapi-contract 0.12: typed operation shapes, the contract's directional rewrite Require rasuvaeff/openapi-contract ^0.12. Operation::$serverBases is gone (the first server's base already carried it), so a hand-built operation without servers is materialized against the root. The request body and responses are typed shapes now, so the guards that re-checked them are removed along with the tests that fed hand-built shapes the type excludes. The directional schema rewrite is delegated to SchemaCheck::effective(): this package carried its own copy, which never recursed into additionalProperties. The contract's view keeps a readOnly property declared and typed (only its required entry goes), so whether such a property is generated is now the compiler's decision: given a direction, an object never emits a property the other direction owns and never generates an undeclared member under its name either, because the contract would type-check that member as the declared property. The negative body witnesses skip those properties for the same reason. A JSON media type without a schema, or with the true schema, is generated unconstrained, as the contract reads it. The dead JsonException branch in WitnessCheck goes. --- composer.json | 2 +- src/DocumentExamples.php | 12 +- src/Internal/Compile/ContainerArbitraries.php | 14 +- src/Internal/DirectionalSchemas.php | 109 ++++--------- src/Internal/Negative/BodyTargets.php | 2 +- src/Internal/Negative/JsonBodyWitness.php | 12 +- src/Internal/Negative/WitnessCheck.php | 2 +- src/Internal/RequestSchemas.php | 11 +- src/Internal/ResponseSchemas.php | 10 +- src/RequestCaseArbitrary.php | 29 ++-- src/RequestMaterializer.php | 38 ++--- src/ResponseCaseArbitrary.php | 3 +- src/SchemaArbitraryCompiler.php | 11 +- tests/DirectionalSchemasTest.php | 57 +++---- tests/DocumentExamplesTest.php | 25 --- tests/RequestCaseArbitraryTest.php | 8 +- tests/RequestMaterializerTest.php | 47 +----- tests/ResponseCaseArbitraryTest.php | 42 ++--- tests/WireAgreementTest.php | 149 ++++++++++++++++++ 19 files changed, 305 insertions(+), 278 deletions(-) create mode 100644 tests/WireAgreementTest.php diff --git a/composer.json b/composer.json index 8025138..5a10ee5 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", - "rasuvaeff/openapi-contract": "^0.11", + "rasuvaeff/openapi-contract": "^0.12", "rasuvaeff/property-testing-core": "^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10" }, "require-dev": { diff --git a/src/DocumentExamples.php b/src/DocumentExamples.php index 234294b..aaff21e 100644 --- a/src/DocumentExamples.php +++ b/src/DocumentExamples.php @@ -122,11 +122,7 @@ private function parts(Operation $operation): array */ private function bodyPart(Operation $operation): ?array { - $content = isset($operation->requestBody['content']) && is_array($operation->requestBody['content']) ? $operation->requestBody['content'] : []; - foreach ($content as $mediaType => $definition) { - if (!is_string($mediaType) || !is_array($definition)) { - continue; - } + foreach ($operation->requestBody['content'] ?? [] as $mediaType => $definition) { $encoding = match (true) { MediaType::isJson($mediaType) => 'json', MediaType::normalize($mediaType) === 'application/x-www-form-urlencoded' => 'form', @@ -139,10 +135,12 @@ private function bodyPart(Operation $operation): ?array continue; } $schema = $definition['schema'] ?? []; - if (!is_array($schema) || array_is_list($schema)) { + if ($schema === true) { + $schema = []; + } + if ($schema === false) { continue; } - /** @var array $schema */ $unnamed = $this->unnamed($definition, $schema); $named = $this->named($definition['examples'] ?? null, sprintf('request body "%s"', $mediaType)); if ($unnamed === null && $named === []) { diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 677272f..002e178 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler; @@ -21,6 +22,7 @@ public function __construct( private SchemaArbitraryCompiler $compiler, private SchemaFacts $facts, + private ?SchemaDirection $direction = null, ) {} /** @param array $schema */ @@ -93,12 +95,22 @@ public function object(array $schema): ArbitraryInterface } $requiredNames[$name] = true; } + $omitted = $this->direction?->foreignFlag(); + /** @var array $reserved */ + $reserved = []; /** @var array $requiredNames */ foreach ($properties as $name => $property) { if (!is_array($property) || array_is_list($property)) { throw UnsupportedGeneration::forSchema('object properties must contain named schema objects'); } /** @var array $property */ + if ($omitted !== null && ($property[$omitted] ?? false) === true) { + // Owned by the other direction: never sent, still declared. + $reserved[$name] = true; + unset($requiredNames[$name]); + + continue; + } $compiled = $this->compiler->compile($property); $shape[$name] = isset($requiredNames[$name]) ? $compiled : $this->optionalProperty($compiled); } @@ -145,7 +157,7 @@ public function object(array $schema): ArbitraryInterface $key = Gen::map( Gen::filter( Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8), - static fn(string $name): bool => !array_key_exists($name, $shape), + static fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]), ), static fn(string $name): string => $name, ); diff --git a/src/Internal/DirectionalSchemas.php b/src/Internal/DirectionalSchemas.php index e92fb65..c4662fc 100644 --- a/src/Internal/DirectionalSchemas.php +++ b/src/Internal/DirectionalSchemas.php @@ -4,97 +4,52 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal; +use Rasuvaeff\OpenApiContract\SchemaCheck; +use Rasuvaeff\OpenApiContract\SchemaDirection; + /** - * One direction of a schema: properties flagged for the other direction - * (`readOnly` for a request, `writeOnly` for a response) are dropped, along - * with their `required` entries; nested schemas under `items` and the - * combinators follow. Malformed members pass through untouched, so the - * compiler still fails closed on them. + * One direction of a schema and of a value. * - * Direction is the only reason a property is dropped, which is also how - * `openapi-contract` reads its own effective schema — the two used to differ, - * because the contract additionally discarded any member shape it did not - * recognise, and that silently unchecked part of the document. Dropping the - * last property drops `properties` itself rather than leaving an empty map, - * for the same reason the contract does: an empty `properties` forbids - * nothing, and what the document says about undeclared members keeps saying - * it. + * The schema view is the contract's own ({@see SchemaCheck::effective()}): + * a property flagged for the other direction (`readOnly` for a request, + * `writeOnly` for a response) loses its `required` entry and keeps its + * subschema, recursively. This package used to carry its own copy of that + * rewrite, which drifted from the validator's — it never recursed into + * `additionalProperties`, for one — so the rewrite is no longer written here. + * Whether such a property is *generated* is the compiler's decision + * ({@see \Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler}), not the + * schema's: a request never carries a `readOnly` member, but its name stays + * declared so no undeclared member is generated under it. * * @internal */ final readonly class DirectionalSchemas { + private SchemaCheck $check; + + public function __construct() + { + $this->check = new SchemaCheck(); + } + /** - * @param 'readOnly'|'writeOnly' $flag * @param array $schema * @return array */ - public function effective(array $schema, string $flag): array + public function effective(array $schema, SchemaDirection $direction): array { - if (is_array($schema['properties'] ?? null)) { - /** @var array $properties */ - $properties = (array) $schema['properties']; - /** @var array $kept */ - $kept = []; - $dropped = []; - foreach (array_keys($properties) as $name) { - $property = $properties[$name]; - if (!is_array($property) || array_is_list($property)) { - $kept += [$name => $property]; - - continue; - } - if (($property[$flag] ?? false) === true) { - $dropped[$name] = true; - - continue; - } - /** @var array $property */ - $kept += [$name => $this->effective($property, $flag)]; - } - if ($kept === []) { - unset($schema['properties']); - } else { - $schema['properties'] = $kept; - } - if (array_key_exists('required', $schema) && is_array($schema['required'])) { - $schema['required'] = array_values(array_filter($schema['required'], static fn(mixed $name): bool => !is_string($name) || !isset($dropped[$name]))); - } - } - if (is_array($schema['items'] ?? null) && !array_is_list((array) $schema['items'])) { - /** @var array $items */ - $items = (array) $schema['items']; - $schema['items'] = $this->effective($items, $flag); - } - foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) { - if (!is_array($schema[$keyword] ?? null) || !array_is_list((array) $schema[$keyword])) { - continue; - } - /** @var list $parts */ - $parts = (array) $schema[$keyword]; - $schema[$keyword] = array_map(function (mixed $part) use ($flag): mixed { - if (!is_array($part) || array_is_list($part)) { - return $part; - } - - /** @var array $part */ - return $this->effective($part, $flag); - }, $parts); - } - - return $schema; + return $this->check->effective($schema, $direction); } /** - * Drops the flagged members from a value the way {@see effective()} drops - * them from its schema, so a document example shared between directions - * stays valid for one of them. + * Drops the members flagged for the other direction from a value, so a + * document example shared between directions stays valid for one of them. * - * @param 'readOnly'|'writeOnly' $flag * @param array $schema */ - public function value(mixed $value, array $schema, string $flag): mixed + public function value(mixed $value, array $schema, SchemaDirection $direction): mixed { + $flag = $direction->foreignFlag(); if (!is_array($value)) { return $value; } @@ -105,7 +60,7 @@ public function value(mixed $value, array $schema, string $flag): mixed } /** @var array $items */ - return array_map(fn(mixed $item): mixed => $this->value($item, $items, $flag), $value); + return array_map(fn(mixed $item): mixed => $this->value($item, $items, $direction), $value); } $properties = $schema['properties'] ?? null; if (!is_array($properties)) { @@ -117,26 +72,24 @@ public function value(mixed $value, array $schema, string $flag): mixed if ($this->isFlagged($properties[$name] ?? null, $flag)) { continue; } - $result += [$name => $this->member($value[$name], $properties[$name] ?? null, $flag)]; + $result += [$name => $this->member($value[$name], $properties[$name] ?? null, $direction)]; } return $result; } - /** @param 'readOnly'|'writeOnly' $flag */ private function isFlagged(mixed $property, string $flag): bool { return is_array($property) && !array_is_list($property) && ($property[$flag] ?? false) === true; } - /** @param 'readOnly'|'writeOnly' $flag */ - private function member(mixed $member, mixed $property, string $flag): mixed + private function member(mixed $member, mixed $property, SchemaDirection $direction): mixed { if (!is_array($property) || array_is_list($property)) { return $member; } /** @var array $property */ - return $this->value($member, $property, $flag); + return $this->value($member, $property, $direction); } } diff --git a/src/Internal/Negative/BodyTargets.php b/src/Internal/Negative/BodyTargets.php index 58bcbf7..4932d91 100644 --- a/src/Internal/Negative/BodyTargets.php +++ b/src/Internal/Negative/BodyTargets.php @@ -83,7 +83,7 @@ public function mediaTypeMismatch(Operation $operation): array throw new UnsupportedGeneration('Request body content must be an object'); } foreach (array_keys($content) as $declared) { - if (is_string($declared) && str_contains($declared, '*')) { + if (str_contains($declared, '*')) { throw new UnsupportedGeneration(sprintf('Operation "%s" declares wildcard media type "%s"; an undeclared media type cannot be promised', $operation->key, $declared)); } } diff --git a/src/Internal/Negative/JsonBodyWitness.php b/src/Internal/Negative/JsonBodyWitness.php index bb4b148..8fa5541 100644 --- a/src/Internal/Negative/JsonBodyWitness.php +++ b/src/Internal/Negative/JsonBodyWitness.php @@ -58,7 +58,7 @@ public function __construct( public function findAll(array $schema, string $kind, SchemaDialect $dialect, SchemaDirection $direction): array { $targets = []; - foreach ($this->candidates($schema) as $name => $property) { + foreach ($this->candidates($schema, $direction) as $name => $property) { $invalid = $this->check->firstDiscriminating($this->witnesses($property, $kind), $property, $kind, $dialect, $direction); if ($invalid !== null) { /** @var Witness $invalid */ @@ -79,19 +79,25 @@ public function findAll(array $schema, string $kind, SchemaDialect $dialect, Sch * whose only constrained property is numeric with no constructible body * value category at all (#98). * + * A property the other direction owns (`readOnly` on a request, + * `writeOnly` on a response) is not a candidate: the contract types it + * but a message should not carry it, so a witness written over it would + * contradict a member the application is entitled to ignore. + * * @param array $schema * @return array> keyed by property name, or `$` for a scalar root */ - private function candidates(array $schema): array + private function candidates(array $schema, SchemaDirection $direction): array { if (!$this->isObject($schema)) { return [self::ROOT => $schema]; } $candidates = []; $properties = $this->mapOf($schema['properties'] ?? null); + $foreign = $direction->foreignFlag(); /** @var mixed $property */ foreach ($properties as $name => $property) { - if ((is_int($name) || $name !== '') && is_array($property) && !array_is_list($property)) { + if ((is_int($name) || $name !== '') && is_array($property) && !array_is_list($property) && ($property[$foreign] ?? false) !== true) { /** @var array $property */ $candidates[$name] = $property; } diff --git a/src/Internal/Negative/WitnessCheck.php b/src/Internal/Negative/WitnessCheck.php index 15bfbba..5045b47 100644 --- a/src/Internal/Negative/WitnessCheck.php +++ b/src/Internal/Negative/WitnessCheck.php @@ -107,7 +107,7 @@ public function discriminates(mixed $value, array $schema, string $kind, SchemaD // category fails closed rather than promising a contradiction. $answer = !$this->schemas->accepts($decoded, $schema, $dialect, $direction) && $this->schemas->accepts($decoded, $stripped, $dialect, $direction); - } catch (ContractException|\JsonException) { + } catch (ContractException) { $answer = false; } diff --git a/src/Internal/RequestSchemas.php b/src/Internal/RequestSchemas.php index bc88fbe..0421b3d 100644 --- a/src/Internal/RequestSchemas.php +++ b/src/Internal/RequestSchemas.php @@ -4,10 +4,11 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal; +use Rasuvaeff\OpenApiContract\SchemaDirection; + /** - * The request-direction view of a schema: `readOnly` properties are not - * part of a request, so they leave `properties` and `required` the way the - * contract validator drops them before checking a request body. + * The request-direction view of a schema, exactly as the contract validator + * reads a request body: a `readOnly` property is not required on a request. * * @internal */ @@ -23,7 +24,7 @@ public function __construct( */ public function effective(array $schema): array { - return $this->schemas->effective($schema, 'readOnly'); + return $this->schemas->effective($schema, SchemaDirection::Request); } /** @@ -33,6 +34,6 @@ public function effective(array $schema): array */ public function value(mixed $value, array $schema): mixed { - return $this->schemas->value($value, $schema, 'readOnly'); + return $this->schemas->value($value, $schema, SchemaDirection::Request); } } diff --git a/src/Internal/ResponseSchemas.php b/src/Internal/ResponseSchemas.php index aa2fb79..7f25bbd 100644 --- a/src/Internal/ResponseSchemas.php +++ b/src/Internal/ResponseSchemas.php @@ -4,10 +4,12 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal; +use Rasuvaeff\OpenApiContract\SchemaDirection; + /** - * The response-direction view of a schema: `writeOnly` properties are not - * part of a response, so they leave `properties` and `required` the way the - * contract validator drops them before checking a response body. + * The response-direction view of a schema, exactly as the contract validator + * reads a response body: a `writeOnly` property is not required on a + * response. * * @internal */ @@ -23,6 +25,6 @@ public function __construct( */ public function effective(array $schema): array { - return $this->schemas->effective($schema, 'writeOnly'); + return $this->schemas->effective($schema, SchemaDirection::Response); } } diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index 359c041..ce37ef4 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -5,6 +5,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi; use Rasuvaeff\OpenApiContract\Operation; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\MediaType; @@ -39,6 +40,9 @@ { private SchemaArbitraryCompiler $schemas; + /** The body compiler: a request never carries a `readOnly` member. */ + private SchemaArbitraryCompiler $bodySchemas; + private ParameterSchemas $parameterSchemas; private RequestSchemas $requestSchemas; @@ -46,6 +50,7 @@ public function __construct() { $this->schemas = new SchemaArbitraryCompiler(); + $this->bodySchemas = new SchemaArbitraryCompiler(direction: SchemaDirection::Request); $this->parameterSchemas = new ParameterSchemas(); $this->requestSchemas = new RequestSchemas(); } @@ -149,19 +154,21 @@ private function body(Operation $operation): ArbitraryInterface /** @var list}> $bodies */ $bodies = []; foreach ($content as $mediaType => $definition) { - if (!is_string($mediaType) || !is_array($definition)) { - continue; - } + // A media type without a schema, or with the `true` schema, admits + // any value — the contract reads both as unconstrained. Only the + // `false` schema admits nothing, and nothing can be generated for it. $schema = $definition['schema'] ?? []; - if (!is_array($schema) || array_is_list($schema)) { - throw new UnsupportedGeneration('JSON request body schema must be an object'); + if ($schema === true) { + $schema = []; + } + if ($schema === false) { + throw new UnsupportedGeneration(sprintf('Request body "%s" declares the false schema, which admits no value', $mediaType)); } - /** @var array $schema */ $normalized = MediaType::normalize($mediaType); $schema = $this->requestSchemas->effective($schema); if (MediaType::isJson($mediaType)) { /** @var ArbitraryInterface $json */ - $json = Gen::map($this->schemas->compile($schema), static fn(mixed $value): array => [ + $json = Gen::map($this->bodySchemas->compile($schema), static fn(mixed $value): array => [ 'mediaType' => $mediaType, 'encoding' => 'json', 'value' => $value, @@ -171,7 +178,7 @@ private function body(Operation $operation): ArbitraryInterface $this->assertObjectSchema($schema, 'Form request body schema must be an object'); $this->assertFormEncoding($definition['encoding'] ?? []); /** @var ArbitraryInterface $form */ - $form = Gen::map($this->schemas->compile($this->nonEmptyRequiredProperties($schema)), static fn(mixed $value): array => [ + $form = Gen::map($this->bodySchemas->compile($this->nonEmptyRequiredProperties($schema)), static fn(mixed $value): array => [ 'mediaType' => $mediaType, 'encoding' => 'form', 'value' => $value, @@ -302,6 +309,10 @@ private function multipartValues(array $schema): ArbitraryInterface throw new UnsupportedGeneration('Multipart properties must contain named schema objects'); } /** @var array $property */ + if (($property['readOnly'] ?? false) === true) { + // Owned by the response: declared, typed, never sent. + continue; + } $required = isset($requiredNames[$name]); // A required container has to be generated non-empty here as well // as for a form body: an empty array becomes zero parts, and a @@ -367,7 +378,7 @@ private function multipartProperty(array $schema): ArbitraryInterface // shape removed here is one no client sends on purpose, and refusing // it costs a percent of draws. return Gen::filter( - $this->schemas->compile($schema), + $this->bodySchemas->compile($schema), static fn(mixed $value): bool => !is_string($value) || trim($value) === $value, ); } diff --git a/src/RequestMaterializer.php b/src/RequestMaterializer.php index 37a9cc6..a573bb1 100644 --- a/src/RequestMaterializer.php +++ b/src/RequestMaterializer.php @@ -25,6 +25,8 @@ * contradicts every declared server fails closed before transport. * * @api + * + * @psalm-import-type CompiledMediaType from Operation */ final readonly class RequestMaterializer { @@ -197,7 +199,7 @@ private function requestTarget(Operation $operation, string $path): string } $server = $operation->servers[0] ?? null; if ($server === null) { - return $this->joinBase($operation->serverBases[0] ?? '/', $path); + return $this->joinBase('/', $path); } $authority = $server['host'] === null ? '' @@ -325,16 +327,7 @@ private function scalarValue(mixed $value): string /** @return array */ private function bodyEncoding(Operation $operation, string $mediaType): array { - $content = $operation->requestBody['content'] ?? null; - if (!is_array($content) || !is_array($content[$mediaType] ?? null)) { - return []; - } - $definition = $content[$mediaType] ?? null; - if (!is_array($definition)) { - return []; - } - - return (array) ($definition['encoding'] ?? []); + return $operation->requestBody['content'][$mediaType]['encoding'] ?? []; } /** @param list}> $parts */ @@ -382,36 +375,31 @@ private function quoteHeader(string $value): string */ private function bodySchema(Operation $operation, string $mediaType, ?array $misuse): array { - $content = $operation->requestBody['content'] ?? null; - if (!is_array($content)) { - throw new UnsupportedGeneration('Request body content must be an object'); - } + $content = $operation->requestBody['content'] ?? []; $definition = $content[$mediaType] ?? null; - if (!is_array($definition) && $misuse !== null && $misuse['kind'] === 'media-type' && $misuse['location'] === 'body') { + if ($definition === null && $misuse !== null && $misuse['kind'] === 'media-type' && $misuse['location'] === 'body') { $definition = $this->declaredJsonDefinition($content); } - if (!is_array($definition)) { + if ($definition === null) { throw new UnsupportedGeneration(sprintf('Request body media type "%s" is not declared', $mediaType)); } $schema = $definition['schema'] ?? []; - if (!is_array($schema) || array_is_list($schema)) { - throw new UnsupportedGeneration('JSON request body schema must be an object'); + if (!is_array($schema)) { + // A boolean schema constrains no member shape: `true` admits any + // value, and a body under `false` is being sent to be refused. + return []; } - /** @var array $schema */ return $schema; } /** - * @param array $content - * @return array|null + * @param array $content + * @return null|CompiledMediaType */ private function declaredJsonDefinition(array $content): ?array { foreach ($content as $mediaType => $definition) { - if (!is_string($mediaType) || !is_array($definition)) { - continue; - } if (MediaType::isJson($mediaType)) { return $definition; } diff --git a/src/ResponseCaseArbitrary.php b/src/ResponseCaseArbitrary.php index 4d2c670..515f26a 100644 --- a/src/ResponseCaseArbitrary.php +++ b/src/ResponseCaseArbitrary.php @@ -5,6 +5,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi; use Rasuvaeff\OpenApiContract\Operation; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\MediaType; @@ -41,7 +42,7 @@ public function __construct() { - $this->schemas = new SchemaArbitraryCompiler(); + $this->schemas = new SchemaArbitraryCompiler(direction: SchemaDirection::Response); $this->responseSchemas = new ResponseSchemas(); $this->parameterSchemas = new ParameterSchemas(); } diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index 0c603c3..e3c5eee 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; @@ -30,14 +31,20 @@ * @param string $excludedCharacters characters no generated plain string * may contain — the separator of a delimited parameter style, which * that style has no way to escape + * @param null|SchemaDirection $direction the direction the values travel + * in, when they travel in one: a property the other direction owns + * (`readOnly` on a request, `writeOnly` on a response) is declared + * and typed by the contract but must not be sent, so an object + * never carries it — while its name stays reserved, so no undeclared + * member is generated under it either. A parameter has no direction. */ - public function __construct(string $excludedCharacters = '') + public function __construct(string $excludedCharacters = '', ?SchemaDirection $direction = null) { $facts = new SchemaFacts(); $this->facts = $facts; $this->composition = new CompositionArbitraries($this, $facts); $this->scalars = new ScalarArbitraries($facts, $excludedCharacters); - $this->containers = new ContainerArbitraries($this, $facts); + $this->containers = new ContainerArbitraries($this, $facts, $direction); } /** diff --git a/tests/DirectionalSchemasTest.php b/tests/DirectionalSchemasTest.php index 86098f3..8132d12 100644 --- a/tests/DirectionalSchemasTest.php +++ b/tests/DirectionalSchemasTest.php @@ -4,6 +4,8 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Tests; +use Rasuvaeff\OpenApiContract\SchemaCheck; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\OpenApi\Internal\DirectionalSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\RequestSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ResponseSchemas; @@ -33,49 +35,32 @@ final class DirectionalSchemasTest 'anyOf' => [['properties' => ['z' => ['writeOnly' => true]]]], ]; - public function requestViewDropsReadOnlyMembersEverywhere(): void + /** + * The schema view is the contract's own rewrite, not a copy of it: a + * property the other direction owns loses its `required` entry and keeps + * its subschema, recursively — including under `additionalProperties`, + * which the copy this package used to carry never visited. + */ + public function requestViewIsTheContractsRewrite(): void { - $view = (new RequestSchemas())->effective(self::SCHEMA); + $schema = self::SCHEMA + ['additionalProperties' => ['type' => 'object', 'required' => ['at'], 'properties' => ['at' => ['readOnly' => true]]]]; - Assert::same($view, [ - 'type' => 'object', - 'required' => ['name', 'secret', 7], - 'properties' => [ - 'name' => ['type' => 'string'], - 'secret' => ['type' => 'string', 'writeOnly' => true], - 'nested' => ['type' => 'object', 'required' => [], 'properties' => ['note' => ['type' => 'string']]], - 'list' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['v' => []]]], - 'bad' => 'not a schema', - ], - // Dropping the last property drops `properties` itself, as the - // contract's own effective schema does: an empty map forbids - // nothing, and what the document said about undeclared members - // keeps saying it. - 'oneOf' => [[], 'x'], - 'allOf' => [[]], - 'anyOf' => [['properties' => ['z' => ['writeOnly' => true]]]], - ]); - } - - public function malformedMembersDoNotStopTheWalk(): void - { - $view = (new RequestSchemas())->effective([ - 'properties' => ['bad' => 'x', 'id' => ['readOnly' => true], 'name' => ['type' => 'string']], - 'required' => ['bad', 'id', 'name'], - ]); + $view = (new RequestSchemas())->effective($schema); - Assert::same($view, [ - 'properties' => ['bad' => 'x', 'name' => ['type' => 'string']], - 'required' => ['bad', 'name'], - ]); + Assert::same($view, (new SchemaCheck())->effective($schema, SchemaDirection::Request)); + Assert::same($view['required'], ['name', 'secret', 7]); + Assert::same(array_keys($view['properties']), ['id', 'name', 'secret', 'nested', 'list', 'bad']); + Assert::same($view['properties']['nested']['required'], []); + Assert::same($view['additionalProperties']['required'], []); } - public function responseViewDropsWriteOnlyMembersOnly(): void + public function responseViewIsTheContractsRewrite(): void { $view = (new ResponseSchemas())->effective(self::SCHEMA); + Assert::same($view, (new SchemaCheck())->effective(self::SCHEMA, SchemaDirection::Response)); Assert::same($view['required'], ['id', 'name', 7]); - Assert::same(array_keys($view['properties']), ['id', 'name', 'nested', 'list', 'bad']); + Assert::same(array_keys($view['properties']), ['id', 'name', 'secret', 'nested', 'list', 'bad']); Assert::same($view['properties']['nested'], self::SCHEMA['properties']['nested']); } @@ -84,8 +69,8 @@ public function leavesSchemasWithoutFlagsUntouched(): void $schemas = new DirectionalSchemas(); foreach ([['type' => 'string'], ['properties' => 'x', 'required' => ['a']], ['items' => ['a']], ['allOf' => 'x'], []] as $schema) { - Assert::same($schemas->effective($schema, 'readOnly'), $schema); - Assert::same($schemas->effective($schema, 'writeOnly'), $schema); + Assert::same($schemas->effective($schema, SchemaDirection::Request), $schema); + Assert::same($schemas->effective($schema, SchemaDirection::Response), $schema); } } diff --git a/tests/DocumentExamplesTest.php b/tests/DocumentExamplesTest.php index 3c3321d..815b71b 100644 --- a/tests/DocumentExamplesTest.php +++ b/tests/DocumentExamplesTest.php @@ -54,19 +54,6 @@ public function namedExamplesAloneProduceNoUnnamedCase(): void Assert::same(array_keys((new DocumentExamples())->forOperation($contract->operation('pets.get'))), ['first']); } - public function malformedContentEntriesAreSkippedBeforeTheJsonOne(): void - { - $operation = new Operation(key: 'op', operationId: 'op', method: 'POST', path: '/op', requestBody: ['content' => [ - 0 => ['schema' => ['type' => 'string'], 'example' => 'not a media type'], - 'text/csv' => 'not a definition', - 'application/json' => ['schema' => ['type' => 'object'], 'example' => ['a' => 1]], - ]]); - - $cases = (new DocumentExamples())->forOperation($operation); - - Assert::same($cases['example']['body'] ?? null, ['mediaType' => 'application/json', 'encoding' => 'json', 'value' => ['a' => 1]]); - } - /** * `content` is a map, so an entry this phase cannot encode says nothing * about the entries after it. Giving up on the first one lost every JSON @@ -97,18 +84,6 @@ public function anExamplelessMediaTypeDoesNotHideALaterOne(): void Assert::same($cases['example']['body'] ?? null, ['mediaType' => 'application/json', 'encoding' => 'json', 'value' => ['a' => 1]]); } - public function handBuiltOperationsWithMalformedBodyContentContributeNoBodyExample(): void - { - $operation = new Operation(key: 'x', operationId: 'x', method: 'POST', path: '/x', requestBody: ['content' => ['application/json' => 'oops']]); - Assert::same((new DocumentExamples())->forOperation($operation), []); - - $operation = new Operation(key: 'x', operationId: 'x', method: 'POST', path: '/x', requestBody: ['content' => ['application/json' => ['schema' => ['a', 'b'], 'example' => []]]]); - Assert::same((new DocumentExamples())->forOperation($operation), []); - - $operation = new Operation(key: 'x', operationId: 'x', method: 'POST', path: '/x', requestBody: ['content' => 'oops']); - Assert::same((new DocumentExamples())->forOperation($operation), []); - } - #[DataProvider('jsonMediaTypeProvider')] public function recognizesJsonMediaTypesWithParametersAndSuffixes(string $mediaType): void { diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index 48d06f8..b20c6d9 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -1519,7 +1519,7 @@ public function jsonBodyDetectionNormalizesDeclaredMediaTypes(): void { $negative = new NegativeRequestCaseArbitrary(); - $upper = $this->bodyOperation([0 => ['schema' => ['type' => 'object']], 'Application/JSON ; charset=utf-8' => ['schema' => ['type' => 'object']]]); + $upper = $this->bodyOperation(['Application/JSON ; charset=utf-8' => ['schema' => ['type' => 'object']]]); $case = $negative->malformedJsonForOperation($upper)->generate(new Random(71))->value; Assert::same($case['misuse']['kind'] ?? null, 'json-syntax'); @@ -2053,11 +2053,8 @@ public function bodyContentScanningFailsClosed(): void { $arbitrary = new RequestCaseArbitrary(); - $case = $arbitrary->forOperation($this->bodyOperation([0 => 'junk', 'application/json' => ['schema' => ['type' => 'object']]]))->generate(new Random(13))->value; - Assert::true(is_array($case['body']) && $case['body']['mediaType'] === 'application/json'); - foreach ([ - ['application/json' => ['schema' => 'invalid']], + ['application/json' => ['schema' => false]], ['text/plain' => ['schema' => ['type' => 'object']]], ] as $content) { try { @@ -2345,7 +2342,6 @@ public static function failClosedBodyProvider(): iterable yield 'multipart array items not a schema' => [['content' => [$multipart => ['schema' => ['type' => 'object', 'properties' => ['a' => ['type' => 'array', 'items' => ['x']]]]]]], 'Multipart array items must be a schema object']; yield 'request body content not an object' => [['content' => 'oops'], 'Request body content must be an object']; yield 'no supported media type' => [['content' => ['text/csv' => ['schema' => ['type' => 'string']]]], 'Request body has no supported media type']; - yield 'json schema is a list' => [['content' => ['application/json' => ['schema' => ['a']]]], 'JSON request body schema must be an object']; } public function formRequiredPropertyThatIsNotASchemaFailsClosed(): void diff --git a/tests/RequestMaterializerTest.php b/tests/RequestMaterializerTest.php index fa8bb41..10eb308 100644 --- a/tests/RequestMaterializerTest.php +++ b/tests/RequestMaterializerTest.php @@ -360,11 +360,11 @@ public function rejectsCaseForAnotherOperation(): void public function rejectsMissingBodyContentDefinition(): void { - Expect::exception(UnsupportedGeneration::class); + Expect::exception(UnsupportedGeneration::class)->withMessage('Request body media type "application/json" is not declared'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( - $this->bodyOperation(['content' => 'invalid']), + $this->bodyOperation(['required' => true]), $this->bodyCase('body.test', ['mediaType' => 'application/json', 'encoding' => 'json', 'value' => 'value']), ); } @@ -380,17 +380,6 @@ public function rejectsUndeclaredBodyMediaType(): void ); } - public function rejectsListBodySchema(): void - { - Expect::exception(UnsupportedGeneration::class); - - $factory = new Psr17Factory(); - (new RequestMaterializer($factory, $factory))->materialize( - $this->bodyOperation(['content' => ['application/json' => ['schema' => ['invalid']]]]), - $this->bodyCase('body.test', ['mediaType' => 'application/json', 'encoding' => 'json', 'value' => 'value']), - ); - } - public function appliesCredentialsToABodylessRequest(): void { $factory = new Psr17Factory(); @@ -437,17 +426,6 @@ public function keepsReservedCharactersForAnAllowReservedQueryParameter(): void Assert::same($request->getUri()->getQuery(), 'q=a/b:c'); } - public function reportsMissingBodyContentWithAnExactMessage(): void - { - Expect::exception(UnsupportedGeneration::class)->withMessage('Request body content must be an object'); - - $factory = new Psr17Factory(); - (new RequestMaterializer($factory, $factory))->materialize( - $this->bodyOperation(['content' => 'invalid']), - $this->bodyCase('body.test', ['mediaType' => 'application/json', 'encoding' => 'json', 'value' => 'value']), - ); - } - public function reportsUndeclaredMediaTypeWithAnExactMessage(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Request body media type "application/problem+json" is not declared'); @@ -515,23 +493,12 @@ public function fallbackSkipsANonJsonDefinitionBeforeTheJsonOne(): void Assert::same($request->getHeaderLine('Content-Type'), 'application/xml'); } - public function fallbackSkipsANonArrayJsonDefinition(): void - { - Expect::exception(UnsupportedGeneration::class)->withMessage('Request body media type "application/xml" is not declared'); - - $factory = new Psr17Factory(); - (new RequestMaterializer($factory, $factory))->materialize( - $this->bodyOperation(['content' => ['application/json' => 'garbage']]), - $this->misuseBodyCase('media-type', ['mediaType' => 'application/xml', 'encoding' => 'json', 'value' => ['a' => 'x']]), - ); - } - - public function fallbackContinuesPastANonArrayDefinitionToTheJsonOne(): void + public function fallbackContinuesPastANonJsonDefinitionToTheJsonOne(): void { $factory = new Psr17Factory(); $request = (new RequestMaterializer($factory, $factory))->materialize( $this->bodyOperation(['content' => [ - 'text/csv' => 'garbage', + 'text/csv' => ['schema' => ['type' => 'string']], 'application/json' => ['schema' => ['type' => 'object']], ]]), $this->misuseBodyCase('media-type', ['mediaType' => 'application/xml', 'encoding' => 'json', 'value' => ['a' => 'x']]), @@ -897,12 +864,12 @@ public function honorsOperationLevelServerPrecedence(): void Assert::true($contract->validateRequest($delete)->isValid()); } - public function fallsBackToTheBasePathProjectionOfAHandBuiltOperation(): void + public function aHandBuiltOperationWithoutServersIsMaterializedAgainstTheRoot(): void { - $operation = new Operation(key: 'legacy.get', operationId: 'legacy.get', method: 'GET', path: '/pets', serverBases: ['/legacy']); + $operation = new Operation(key: 'legacy.get', operationId: 'legacy.get', method: 'GET', path: '/pets'); $request = $this->materializer()->materialize($operation, ['operationKey' => 'legacy.get', 'path' => [], 'query' => [], 'headers' => [], 'cookies' => [], 'body' => null, 'misuse' => null]); - Assert::same((string) $request->getUri(), '/legacy/pets'); + Assert::same((string) $request->getUri(), '/pets'); } #[DataProvider('baseUriProvider')] diff --git a/tests/ResponseCaseArbitraryTest.php b/tests/ResponseCaseArbitraryTest.php index 9ba4688..e6fc7eb 100644 --- a/tests/ResponseCaseArbitraryTest.php +++ b/tests/ResponseCaseArbitraryTest.php @@ -148,46 +148,22 @@ public function jsonBodyExposesTheResponseDirectionSchema(): void $body = (new ResponseCaseArbitrary())->jsonBody($operation, 200); Assert::same($body['mediaType'] ?? null, 'application/json'); - Assert::true(!array_key_exists('secret', $body['schema']['properties'] ?? ['secret' => true])); + // The `writeOnly` property stays declared — the contract still types + // it — and only stops being required; the generator never emits it. + Assert::true(array_key_exists('secret', $body['schema']['properties'] ?? [])); Assert::same($body['schema']['required'] ?? null, ['id', 'name', 'status', 'kind', 'tags']); Assert::same((new ResponseCaseArbitrary())->jsonBody(ResponseContracts::pets()->operation('ping'), 204), null); } - public function writeOnlyPropertiesLeaveNestedSchemasToo(): void + public function writeOnlyPropertiesAreNeverGeneratedAndTheirNamesStayReserved(): void { - $schema = (new ResponseSchemas())->effective([ - 'type' => 'object', - 'required' => ['w', 'a'], - 'properties' => [ - 'w' => ['type' => 'string', 'writeOnly' => true], - 'a' => ['type' => 'array', 'items' => ['type' => 'object', 'required' => ['w', 'x'], 'properties' => ['x' => ['type' => 'string'], 'w' => ['type' => 'string', 'writeOnly' => true]]]], - 'c' => ['allOf' => [['type' => 'object', 'properties' => ['w' => ['writeOnly' => true, 'type' => 'string'], 'k' => ['type' => 'string']]], 'not-a-schema']], - 'd' => ['oneOf' => [['type' => 'object', 'properties' => ['w' => ['writeOnly' => true, 'type' => 'string']]]]], - ], - ]); + $operation = ResponseContracts::pets()->operation('pets.get'); - Assert::same($schema['required'], ['a']); - Assert::same(array_keys($schema['properties']), ['a', 'c', 'd']); - Assert::same($schema['properties']['a']['items']['required'], ['x']); - Assert::same(array_keys($schema['properties']['a']['items']['properties']), ['x']); - Assert::same(array_keys($schema['properties']['c']['allOf'][0]['properties']), ['k']); - Assert::same($schema['properties']['c']['allOf'][1], 'not-a-schema'); - // Dropping the last property drops `properties` itself — the same - // reading `openapi-contract` applies, so an empty map never forbids - // what the document left open. - Assert::false(array_key_exists('properties', $schema['properties']['d']['oneOf'][0])); - } + foreach (range(1, 40) as $seed) { + $case = (new ResponseCaseArbitrary())->forOperation($operation, 200)->generate(new Random($seed))->value; - public function malformedSchemaShapesPassThroughTheResponseView(): void - { - $schemas = new ResponseSchemas(); - - Assert::same($schemas->effective(['properties' => 'x']), ['properties' => 'x']); - Assert::same($schemas->effective(['items' => 'x']), ['items' => 'x']); - Assert::same($schemas->effective(['properties' => ['bad' => ['x'], 'w' => ['type' => 'string', 'writeOnly' => true], 'k' => ['type' => 'string']], 'required' => ['w', 'k', 7]]), ['properties' => ['bad' => ['x'], 'k' => ['type' => 'string']], 'required' => ['k', 7]]); - Assert::same($schemas->effective(['properties' => ['a' => ['type' => 'string']], 'required' => 'x']), ['properties' => ['a' => ['type' => 'string']], 'required' => 'x']); - Assert::same($schemas->effective(['items' => ['a', 'b']]), ['items' => ['a', 'b']]); - Assert::same($schemas->effective(['allOf' => 'x']), ['allOf' => 'x']); + Assert::false(array_key_exists('secret', (array) ($case['body']['value'] ?? []))); + } } #[DataProvider('unsupportedProvider')] diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php new file mode 100644 index 0000000..50a169c --- /dev/null +++ b/tests/WireAgreementTest.php @@ -0,0 +1,149 @@ +jsonBodyContract([ + 'type' => 'object', + 'required' => ['id'], + 'minProperties' => 1, + 'properties' => ['id' => ['type' => 'integer', 'readOnly' => true]], + ]); + + foreach ($this->validCases($contract, 'things.create', 300) as $case) { + Assert::false(array_key_exists('id', (array) ($case['body']['value'] ?? []))); + } + } + + /** + * A JSON media type without a `schema`, or with the `true` schema, admits + * any value in OpenAPI, and the contract reads both as unconstrained. + */ + public function aJsonBodyWithoutASchemaIsGeneratedUnconstrained(): void + { + foreach ([[], ['schema' => true]] as $definition) { + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/things' => ['post' => [ + 'operationId' => 'things.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => $definition]], + 'responses' => ['201' => []], + ]]], + ]); + + Assert::same(count($this->validCases($contract, 'things.create', 50)), 50); + } + } + + /** + * Every draw must be accepted by the contract; the cases are returned so + * a test can pin what they carry as well. + * + * @return list> + */ + private function validCases(Contract $contract, string $operationKey, int $draws = self::DRAWS): array + { + $operation = $contract->operation($operationKey); + $factory = new Psr17Factory(); + $materializer = new RequestMaterializer($factory, $factory); + $arbitrary = (new RequestCaseArbitrary())->forOperation($operation); + $cases = []; + foreach (range(1, $draws) as $seed) { + $case = $arbitrary->generate(new Random($seed))->value; + $result = $contract->validateRequest($materializer->materialize($operation, $case)); + Assert::true($result->isValid(), sprintf('Seed %d: %s', $seed, json_encode([$case, $result->violations], JSON_THROW_ON_ERROR))); + $cases[] = $case; + } + + return $cases; + } + + /** + * Every draw must be rejected by the contract. + * + * @param ArbitraryInterface> $arbitrary + * @return list> + */ + private function negativeCases(Contract $contract, string $operationKey, ArbitraryInterface $arbitrary, int $draws = self::DRAWS): array + { + $operation = $contract->operation($operationKey); + $factory = new Psr17Factory(); + $materializer = new RequestMaterializer($factory, $factory); + $cases = []; + foreach (range(1, $draws) as $seed) { + $case = $arbitrary->generate(new Random($seed))->value; + $result = $contract->validateRequest($materializer->materialize($operation, $case)); + Assert::false($result->isValid(), sprintf('Seed %d accepted: %s', $seed, json_encode($case, JSON_THROW_ON_ERROR))); + $cases[] = $case; + } + + return $cases; + } + + /** @param array $schema */ + private function jsonBodyContract(array $schema, string $version = '3.1.0'): Contract + { + return Contract::fromArray([ + 'openapi' => $version, + 'paths' => ['/things' => ['post' => [ + 'operationId' => 'things.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => $schema]]], + 'responses' => ['201' => []], + ]]], + ]); + } + + /** + * @param list> $parameters + */ + private function parameterContract(array $parameters, string $path = '/things'): Contract + { + return Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => [$path => ['get' => [ + 'operationId' => 'things.list', + 'parameters' => $parameters, + 'responses' => ['200' => []], + ]]], + ]); + } +} From 799b39203de8baa61a9e3f20e94df16309d8a268 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 13:53:59 +0300 Subject: [PATCH 02/11] Spell floats as JSON does, keep + encoded under allowReserved, encode a JSON multipart part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A float went on the wire through (string), which rounds to precision=14 and put a different number there for any value that needed more digits; it is spelled as json_encode spells it now. The multipleOf generator also rounded k * m back to the decimal it meant, which from about 64 upwards is one ulp away from the product the validator computes and tolerates — the decimal spelling is kept only where the validator agrees with it (#117). + leaves RESERVED_RAW: a raw plus in a query is a space to the validator and to every SAPI (#119). A multipart part declared application/json carries the JSON encoding of its value; text/* and application/octet-stream stay verbatim, and any other part media type fails closed (#122). --- src/Internal/Compile/ScalarArbitraries.php | 34 +++++- src/Internal/ParameterSerializer.php | 13 ++- src/Internal/WireValue.php | 18 +++- src/RequestCaseArbitrary.php | 23 +++- tests/WireAgreementTest.php | 119 +++++++++++++++++++++ 5 files changed, 196 insertions(+), 11 deletions(-) diff --git a/src/Internal/Compile/ScalarArbitraries.php b/src/Internal/Compile/ScalarArbitraries.php index a5c0043..71245a8 100644 --- a/src/Internal/Compile/ScalarArbitraries.php +++ b/src/Internal/Compile/ScalarArbitraries.php @@ -260,18 +260,42 @@ public function number(array $schema): ArbitraryInterface throw UnsupportedGeneration::forSchema('number multipleOf leaves no value'); } - // `3 * 0.1` is `0.30000000000000004`. Our own oracle tolerates that, - // but a server checking `fmod` without a tolerance does not, and the - // failure would be reported against the user's API. Round back to the - // precision the multiple itself carries. $decimals = $this->decimals((float) $multiple); return Gen::map( Gen::intBetween($first, $last), - static fn(mixed $value): float => round((float) $value * (float) $multiple, $decimals), + static fn(mixed $value): float => self::multipleOf((int) $value, (float) $multiple, $decimals), ); } + /** + * The `$index`-th multiple, spelled as the decimal it means where the + * validator agrees that it is one. + * + * `3 * 0.1` is `0.30000000000000004`, and `0.3` is the number a reader of + * the JSON text recognises, so the product is rounded back to the + * precision the multiple carries. But the validator does not read the + * text: it divides the double it parsed, rounds, multiplies back and + * tolerates `1e-14` — and from about `64` upwards one ulp of a double is + * already more than that, so the rounded decimal and the product disagree + * in a third of the draws for `0.1` and the case is rejected as a + * multipleOf violation (#117). Where the decimal spelling is not the + * product to that tolerance, the product goes, which is the value the + * validator computes and therefore accepts by construction. + * + * With `ext-bcmath` loaded the contract evaluates `multipleOf` in decimal + * arithmetic over the double's own expansion, and no spelling can make a + * double that is not a decimal multiple into one; that verdict is the + * contract's (openapi-contract#151), not something a generator can meet. + */ + private static function multipleOf(int $index, float $multiple, int $decimals): float + { + $product = (float) $index * $multiple; + $decimal = round($product, $decimals); + + return abs($decimal - round($decimal / $multiple) * $multiple) < 1e-14 ? $decimal : $product; + } + private function ceilDiv(int $dividend, int $divisor): int { $quotient = intdiv($dividend, $divisor); diff --git a/src/Internal/ParameterSerializer.php b/src/Internal/ParameterSerializer.php index 6066113..2f11d09 100644 --- a/src/Internal/ParameterSerializer.php +++ b/src/Internal/ParameterSerializer.php @@ -220,11 +220,18 @@ private function pair(string $name, string $value, bool $allowReserved, bool $va return $this->encode($name) . '=' . ($valueIsEncoded ? $value : $this->encode($value, $allowReserved)); } - /** @var list */ - private const array RESERVED_ENCODED = ['%3A', '%2F', '%3F', '%5B', '%5D', '%40', '%21', '%24', '%27', '%28', '%29', '%2A', '%2B', '%2C', '%3B']; + /** + * RFC 3986 reserved characters `allowReserved` may hand back raw — all of + * them except `+`, which stays `%2B`: a raw plus in a query is read as a + * space by the validator and by every SAPI (`parse_str()`), so handing it + * back would not widen the wire but change what it says (#119). + * + * @var list + */ + private const array RESERVED_ENCODED = ['%3A', '%2F', '%3F', '%5B', '%5D', '%40', '%21', '%24', '%27', '%28', '%29', '%2A', '%2C', '%3B']; /** @var list */ - private const array RESERVED_RAW = [':', '/', '?', '[', ']', '@', '!', '$', "'", '(', ')', '*', '+', ',', ';']; + private const array RESERVED_RAW = [':', '/', '?', '[', ']', '@', '!', '$', "'", '(', ')', '*', ',', ';']; /** * @param string $keepEncoded reserved characters this style uses as a diff --git a/src/Internal/WireValue.php b/src/Internal/WireValue.php index 89090fd..e1a2b0e 100644 --- a/src/Internal/WireValue.php +++ b/src/Internal/WireValue.php @@ -11,16 +11,30 @@ * the failure — so the conversion is here and the wording stays with the * caller, which is the one that knows what the value was supposed to be. * + * A float is spelled the way `json_encode()` spells it: the shortest + * decimal that reads back as the same double (`serialize_precision=-1`), + * with an exponent where that is shorter. `(string)` rounds to `precision` + * — fourteen significant digits — and put a *different* number on the wire + * for any value that needed more: `846608010056.187` went as + * `846608010056.19`, which is no longer a multiple of `0.001`, and the + * value just below `1000.0` went as `1000`. The JSON number grammar is the + * one the validator reads a numeric parameter by (#117), and an exponent + * is part of it. + * * @internal */ final readonly class WireValue { - /** Returns `null` for a value that has no wire spelling. */ + /** + * Returns `null` for a value that has no wire spelling — a container, an + * object, or a float that is not a number. + */ public static function of(mixed $value): ?string { return match (true) { is_string($value) => $value, - is_int($value), is_float($value) => (string) $value, + is_int($value) => (string) $value, + is_float($value) => is_finite($value) ? json_encode($value, JSON_THROW_ON_ERROR) : null, is_bool($value) => $value ? 'true' : 'false', $value === null => 'null', default => null, diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index ce37ef4..1c09d74 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -411,7 +411,7 @@ private function multipartBody(string $mediaType, array $schema, array $definiti return [ 'name' => $name, - 'value' => $binary ? (string) ($partValue['value'] ?? '') : $this->scalar($partValue), + 'value' => $binary ? (string) ($partValue['value'] ?? '') : $this->partText((string) $name, $partValue, $contentType), 'encoding' => $binary ? 'base64' : 'text', 'contentType' => $contentType, 'headers' => $headers, @@ -434,6 +434,27 @@ private function multipartContentType(array $schema): string return ($schema['format'] ?? null) === 'binary' ? 'application/octet-stream' : 'text/plain'; } + /** + * The text of a non-binary part, as its media type reads it. A `text/*` + * or `application/octet-stream` part is the value verbatim; a JSON part + * carries the JSON encoding of the value — the validator decodes it as + * JSON, so a bare `abc` under `application/json` is a decoding failure, + * not a string (#122). A media type this package can write neither way + * fails closed: a body it cannot vouch for is not a valid case. + */ + private function partText(string $name, mixed $value, string $contentType): string + { + $normalized = MediaType::normalize($contentType); + if (MediaType::isJson($normalized)) { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + if (str_starts_with($normalized, 'text/') || $normalized === 'application/octet-stream') { + return $this->scalar($value); + } + + throw new UnsupportedGeneration(sprintf('Multipart property "%s" declares content type "%s", which this generator can write neither as text nor as JSON', $name, $contentType)); + } + /** * RFC 6570 treats an empty list or map as undefined, so the materializer * omits it; a required container therefore has to be generated non-empty. diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 50a169c..b89b889 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -13,9 +13,12 @@ use Rasuvaeff\PropertyTesting\OpenApi\RequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; use Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler; +use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; use Rasuvaeff\PropertyTesting\Random; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; +use Testo\Expect; use Testo\Test; /** @@ -74,6 +77,103 @@ public function aJsonBodyWithoutASchemaIsGeneratedUnconstrained(): void } } + /** + * A float goes on the wire as the shortest decimal that reads back as the + * same double, not rounded to fourteen significant digits: the validator + * checks `multipleOf` and the bounds on the double it parses (#117). + */ + #[DataProvider('preciseNumberProvider')] + public function numericParametersSurviveTheWireAtFullPrecision(array $schema): void + { + if (isset($schema['multipleOf']) && extension_loaded('bcmath')) { + // With bcmath the contract evaluates multipleOf in decimal + // arithmetic over the double's binary expansion, and its verdict + // is not one a generator can meet (openapi-contract#151). + return; + } + $contract = $this->parameterContract([['name' => 'v', 'in' => 'query', 'required' => true, 'schema' => $schema]]); + + $this->validCases($contract, 'things.list', 200); + } + + public static function preciseNumberProvider(): iterable + { + yield 'a decimal multiple of a large number' => [['type' => 'number', 'multipleOf' => 0.001, 'minimum' => 1e11, 'maximum' => 1e12]]; + yield 'a decimal multiple past the tolerance of one ulp' => [['type' => 'number', 'multipleOf' => 0.1, 'minimum' => 0, 'maximum' => 10000]]; + yield 'a window narrower than fourteen digits' => [['type' => 'number', 'minimum' => 0.123456789012345, 'maximum' => 0.1234567890123456]]; + yield 'an integer beyond fourteen digits' => [['type' => 'integer', 'minimum' => 123456789012345678, 'maximum' => 123456789012345680]]; + } + + #[DataProvider('wireSpellingProvider')] + public function spellsAScalarTheWayJsonDoes(mixed $value, ?string $expected): void + { + Assert::same(WireValue::of($value), $expected); + } + + public static function wireSpellingProvider(): iterable + { + yield 'a fraction that precision=14 rounds' => [846608010056.187, '846608010056.187']; + yield 'the value just below 1000' => [999.9999999999999, '999.9999999999999']; + yield 'a whole float' => [10.0, '10']; + yield 'negative zero' => [-0.0, '-0']; + yield 'a large magnitude takes the exponent form' => [1e25, '1.0e+25']; + yield 'a small magnitude takes the exponent form' => [1e-7, '1.0e-7']; + yield 'an integer' => [-42, '-42']; + yield 'a string' => ['x', 'x']; + yield 'booleans' => [true, 'true']; + yield 'null' => [null, 'null']; + yield 'infinity has no spelling the grammar admits' => [INF, null]; + yield 'nan has no spelling' => [NAN, null]; + yield 'a container has no spelling' => [['a'], null]; + } + + /** + * `allowReserved` hands the RFC 3986 reserved characters back raw, except + * `+`: a raw plus in a query is a space to the validator and to every + * SAPI (#119). + */ + public function allowReservedKeepsThePlusEncoded(): void + { + $contract = $this->parameterContract([['name' => 'v', 'in' => 'query', 'required' => true, 'allowReserved' => true, 'schema' => ['type' => 'string', 'enum' => ['a+b', 'c d', 'e&f', 'g/h']]]]); + + $seen = []; + foreach ($this->validCases($contract, 'things.list') as $case) { + $seen[$case['query']['v']] = true; + } + ksort($seen); + + Assert::same(array_keys($seen), ['a+b', 'c d', 'e&f', 'g/h']); + } + + /** + * A multipart part declared `application/json` carries the JSON encoding + * of its value: the validator decodes such a part as JSON (#122). + */ + public function aJsonMultipartPartIsJsonEncoded(): void + { + $contract = $this->multipartContract( + ['meta' => ['type' => 'string'], 'count' => ['type' => 'integer'], 'note' => ['type' => 'string']], + ['meta' => ['contentType' => 'application/json'], 'count' => ['contentType' => 'application/vnd.api+json']], + ); + + foreach ($this->validCases($contract, 'uploads.create', 100) as $case) { + foreach ($case['body']['parts'] ?? [] as $part) { + if ($part['name'] === 'meta') { + Assert::true(str_starts_with($part['value'], '"'), 'a JSON part carries a JSON string'); + } + } + } + } + + public function aPartUnderAMediaTypeThatIsNeitherTextNorJsonFailsClosed(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart property "meta" declares content type "application/xml", which this generator can write neither as text nor as JSON'); + + $contract = $this->multipartContract(['meta' => ['type' => 'string']], ['meta' => ['contentType' => 'application/xml']]); + + (new RequestCaseArbitrary())->forOperation($contract->operation('uploads.create'))->generate(new Random(1)); + } + /** * Every draw must be accepted by the contract; the cases are returned so * a test can pin what they carry as well. @@ -146,4 +246,23 @@ private function parameterContract(array $parameters, string $path = '/things'): ]]], ]); } + + /** + * @param array> $properties + * @param array> $encoding + */ + private function multipartContract(array $properties, array $encoding): Contract + { + return Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/uploads' => ['post' => [ + 'operationId' => 'uploads.create', + 'requestBody' => ['required' => true, 'content' => ['multipart/form-data' => [ + 'schema' => ['type' => 'object', 'required' => array_keys($properties), 'properties' => $properties], + 'encoding' => $encoding, + ]]], + 'responses' => ['201' => []], + ]]], + ]); + } } From d86c28e2cbfa8b03c760b5b61a174c9c122e7f6b Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:03:19 +0300 Subject: [PATCH 03/11] One marker for every exception, a case error of its own, refusals placed in their operation, redaction reachable from the suite OpenApiPropertyTestingException is implemented by every exception the package throws. A case that does not have the shape the package writes is InvalidCase, not UnsupportedGeneration: the latter is a document limitation, the former a caller error. Psr15Transport's configuration error is a SuiteConfigurationError. A schema refusal names the operation and the parameter or body it was compiling (#127). ContractSuite::redaction() applies a policy to both the curl reproducer and the minimal case OperationPropertyFailed prints; the counterexample keeps the case as generated (#124). CheckFailed::$result is readonly, assigned through the constructor (#126). ResponseMaterializer builds its @internal collaborators itself (#125). --- src/CheckFailed.php | 19 +++--- src/ContractSuite.php | 39 ++++++++++- src/CoverageIncomplete.php | 2 +- src/CredentialsUnavailable.php | 2 +- src/Internal/JsonBodyEncoder.php | 6 +- src/Internal/ParameterSerializer.php | 19 +++--- src/InvalidCase.php | 22 ++++++ src/OpenApiPropertyTestingException.php | 26 +++++++ src/OperationProperty.php | 17 +++++ src/OperationPropertyFailed.php | 28 +++++--- src/Psr15Transport.php | 2 +- src/RequestCaseArbitrary.php | 87 +++++++++++++++-------- src/RequestMaterializer.php | 28 ++++---- src/RequestReproducer.php | 23 +++++-- src/ResponseCaseArbitrary.php | 25 +++++-- src/ResponseMaterializer.php | 15 ++-- src/SuiteConfigurationError.php | 2 +- src/UnsupportedGeneration.php | 39 +++++++++-- tests/ContractSuiteTest.php | 24 +++++-- tests/ExceptionsTest.php | 91 +++++++++++++++++++++++++ tests/OperationPropertyTest.php | 46 ++++++++++++- tests/ParameterSerializerTest.php | 23 +++++-- tests/RequestCaseArbitraryTest.php | 5 +- tests/RequestMaterializerTest.php | 17 ++--- tests/ResponseMaterializerTest.php | 8 +-- tests/TransportTest.php | 3 +- 26 files changed, 491 insertions(+), 127 deletions(-) create mode 100644 src/InvalidCase.php create mode 100644 src/OpenApiPropertyTestingException.php create mode 100644 tests/ExceptionsTest.php diff --git a/src/CheckFailed.php b/src/CheckFailed.php index 5f98ff3..00b0fe9 100644 --- a/src/CheckFailed.php +++ b/src/CheckFailed.php @@ -18,13 +18,19 @@ * * @api */ -final class CheckFailed extends \RuntimeException +final class CheckFailed extends \RuntimeException implements OpenApiPropertyTestingException { /** - * The structured validation result behind the message, when the failure - * is a validation outcome; assigned by the factory, `null` otherwise. + * @param ValidationResult|null $result the structured validation result + * behind the message when the failure is a validation outcome, + * `null` otherwise */ - public ?ValidationResult $result = null; + private function __construct( + string $message, + public readonly ?ValidationResult $result = null, + ) { + parent::__construct($message); + } public static function invalidGeneratedRequest(string $operationKey, ValidationResult $result): self { @@ -53,10 +59,7 @@ public static function exchangeViolations(string $operationKey, ValidationResult private static function withResult(string $message, ValidationResult $result): self { - $failure = new self($message); - $failure->result = $result; - - return $failure; + return new self($message, $result); } private static function diagnostics(string $headline, ValidationResult $result): string diff --git a/src/ContractSuite.php b/src/ContractSuite.php index 53fc3dc..8287a94 100644 --- a/src/ContractSuite.php +++ b/src/ContractSuite.php @@ -57,6 +57,8 @@ final class ContractSuite private ?OperationCoverage $coverage = null; + private ?RedactionPolicy $redaction = null; + private function __construct( private readonly Contract $contract, private RequestMaterializer $materializer, @@ -172,6 +174,23 @@ public function coverage(OperationCoverage $coverage): self return $suite; } + /** + * The redaction policy every rendering of a case goes through: the curl + * reproducer of {@see reproduce()} and the minimal case + * {@see OperationProperty} prints when a phase is falsified. Without it + * only the default header set (`Authorization`, `Proxy-Authorization`, + * `Set-Cookie`) is redacted, and a secret the document carries in a + * query parameter, a cookie, an `X-Api-Key` header or a body member is + * printed as generated (#124). + */ + public function redaction(RedactionPolicy $policy): self + { + $suite = clone $this; + $suite->redaction = $policy; + + return $suite; + } + /** * The configured coverage record restricted to the resolved selection. */ @@ -370,13 +389,27 @@ public function checkNegative(string $operationKey, array $case): void /** * Redacted curl reproducer for one case of a selected operation. - * Credentials are never applied here. + * Credentials are never applied here. The policy defaults to the one + * configured through {@see redaction()}. + * + * @param CaseData $case + */ + public function reproduce(string $operationKey, array $case, ?RedactionPolicy $policy = null): string + { + return (new RequestReproducer($this->materializer))->curl($this->requireSelected($operationKey), $case, $policy ?? $this->redaction ?? new RedactionPolicy()); + } + + /** + * The case with the configured redaction applied: the default header set + * and everything the policy names is replaced by the redaction marker, + * shape preserved. This is the form a failure message may print. * * @param CaseData $case + * @return CaseData */ - public function reproduce(string $operationKey, array $case, RedactionPolicy $policy = new RedactionPolicy()): string + public function redact(array $case): array { - return (new RequestReproducer($this->materializer))->curl($this->requireSelected($operationKey), $case, $policy); + return (new RequestReproducer($this->materializer))->redact($case, $this->redaction ?? new RedactionPolicy()); } private function requireSelected(string $operationKey): Operation diff --git a/src/CoverageIncomplete.php b/src/CoverageIncomplete.php index a79dc1c..2763312 100644 --- a/src/CoverageIncomplete.php +++ b/src/CoverageIncomplete.php @@ -10,7 +10,7 @@ * * @api */ -final class CoverageIncomplete extends \RuntimeException +final class CoverageIncomplete extends \RuntimeException implements OpenApiPropertyTestingException { private function __construct( public readonly CoverageReport $report, diff --git a/src/CredentialsUnavailable.php b/src/CredentialsUnavailable.php index e7239f0..49fa80a 100644 --- a/src/CredentialsUnavailable.php +++ b/src/CredentialsUnavailable.php @@ -9,4 +9,4 @@ * * @api */ -final class CredentialsUnavailable extends \RuntimeException {} +final class CredentialsUnavailable extends \RuntimeException implements OpenApiPropertyTestingException {} diff --git a/src/Internal/JsonBodyEncoder.php b/src/Internal/JsonBodyEncoder.php index b8aa281..260af9f 100644 --- a/src/Internal/JsonBodyEncoder.php +++ b/src/Internal/JsonBodyEncoder.php @@ -4,7 +4,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal; -use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; /** * Encodes a JSON-compatible logical value as a JSON document, turning maps @@ -105,10 +105,10 @@ public function schemaObject(mixed $value, string $message): array private function memberMap(mixed $value, string $message): array { if (!is_array($value)) { - throw new UnsupportedGeneration($message); + throw new InvalidCase($message); } if ($value !== [] && array_is_list($value)) { - throw new UnsupportedGeneration($message); + throw new InvalidCase($message); } /** @var array $result */ diff --git a/src/Internal/ParameterSerializer.php b/src/Internal/ParameterSerializer.php index 2f11d09..ccf635d 100644 --- a/src/Internal/ParameterSerializer.php +++ b/src/Internal/ParameterSerializer.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Internal; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; /** @@ -78,10 +79,10 @@ private function simple(string|array $value, bool $explode, string $pairSeparato public static function assertTransmittableHeader(string $name, string $value): void { if (preg_match('/\A[!#$%&\'*+\-.^_`|~0-9A-Za-z]+\z/', $name) !== 1) { - throw new UnsupportedGeneration(sprintf('Header name "%s" is invalid', $name)); + throw new InvalidCase(sprintf('Header name "%s" is invalid', $name)); } if ($value !== '' && preg_match('/\A[\x21-\x7e\x80-\xff](?:[\x20-\x7e\x80-\xff]*[\x21-\x7e\x80-\xff])?\z/', $value) !== 1) { - throw new UnsupportedGeneration(sprintf('Header "%s" carries a value no HTTP field can', $name)); + throw new InvalidCase(sprintf('Header "%s" carries a value no HTTP field can', $name)); } } @@ -181,14 +182,14 @@ private function form(string $name, string|array $value, bool $explode, bool $al private function delimited(string $name, string|array $value, string $delimiter, string $wireDelimiter, bool $allowReserved): string { if (is_string($value) || !array_is_list($value)) { - throw new UnsupportedGeneration('Delimited query parameters require a list value'); + throw new InvalidCase('Delimited query parameters require a list value'); } $items = $this->list($value); foreach ($items as $item) { // The style has no escape for its own separator: an item carrying // one is unrepresentable, not merely awkward to encode. if (str_contains($item, $delimiter)) { - throw new UnsupportedGeneration(sprintf('Delimited query parameter values cannot contain "%s"', $delimiter)); + throw new InvalidCase(sprintf('Delimited query parameter values cannot contain "%s"', $delimiter)); } } @@ -202,7 +203,7 @@ private function deepObject(string $name, string|array $value, bool $allowReserv // empty deepObject has no pairs on the wire, so preserve it as the // valid empty object rather than rejecting it as a list. if (is_string($value) || ($value !== [] && array_is_list($value))) { - throw new UnsupportedGeneration('deepObject parameters require an object value'); + throw new InvalidCase('deepObject parameters require an object value'); } /** @var array $value */ @@ -267,11 +268,11 @@ private function encode(string $value, bool $allowReserved = false, bool $encode private function list(array $value): array { if (!array_is_list($value)) { - throw new UnsupportedGeneration('Parameter requires a list value'); + throw new InvalidCase('Parameter requires a list value'); } foreach ($value as $item) { if (!is_string($item)) { - throw new UnsupportedGeneration('Parameter list values must be strings'); + throw new InvalidCase('Parameter list values must be strings'); } } @@ -294,12 +295,12 @@ private function list(array $value): array private function object(array $value): array { if ($value !== [] && array_is_list($value)) { - throw new UnsupportedGeneration('Parameter requires an object value'); + throw new InvalidCase('Parameter requires an object value'); } $result = []; foreach ($value as $key => $item) { if (!is_string($item)) { - throw new UnsupportedGeneration('Parameter object keys and values must be strings'); + throw new InvalidCase('Parameter object keys and values must be strings'); } // A numeric member name arrives as an integer array key. It is a // name the document wrote, not a malformed key — the cast that diff --git a/src/InvalidCase.php b/src/InvalidCase.php new file mode 100644 index 0000000..96cf48c --- /dev/null +++ b/src/InvalidCase.php @@ -0,0 +1,22 @@ +counterExample(), self::reproducer($suite, $operationKey, $result->counterExample()->shrunkArguments), $failure, + self::redacted($suite, $result->counterExample()->shrunkArguments), ); } if ($result instanceof ExampleFailed) { @@ -118,6 +119,7 @@ private static function run( $case, self::reproducer($suite, $operationKey, ['case' => $case]), $result->exception, + self::redacted($suite, ['case' => $case]), ); } @@ -140,6 +142,21 @@ private static function reproducer(ContractSuite $suite, string $operationKey, a } } + /** + * @param array $arguments + * @return array + */ + private static function redacted(ContractSuite $suite, array $arguments): array + { + $case = $arguments['case'] ?? null; + if (!is_array($case)) { + return []; + } + + /** @var CaseData $case */ + return $suite->redact($case); + } + private static function resolveRuns(int $runs): int { if ($runs < 1) { diff --git a/src/OperationPropertyFailed.php b/src/OperationPropertyFailed.php index 0c2b877..0afb8c1 100644 --- a/src/OperationPropertyFailed.php +++ b/src/OperationPropertyFailed.php @@ -8,13 +8,17 @@ /** * One operation property falsified: carries the shrunk minimal case and the - * redacted curl reproducer alongside the engine's counterexample rendering. - * A document example that failed is reported under its name, unshrunk, with - * a counterexample of zero runs. + * redacted curl reproducer alongside the engine's counterexample. A document + * example that failed is reported under its name, unshrunk, with a + * counterexample of zero runs. + * + * The message prints the case through the suite's redaction policy, the same + * one the reproducer went through; `$counterExample` keeps the case as + * generated, for code that needs it rather than a log that must not (#124). * * @api */ -final class OperationPropertyFailed extends \RuntimeException +final class OperationPropertyFailed extends \RuntimeException implements OpenApiPropertyTestingException { /** @param 'valid'|'negative' $phase */ private function __construct( @@ -31,7 +35,9 @@ private function __construct( /** * @param 'valid'|'negative' $phase - * @param array $case + * @param array $case the case as generated + * @param array $redactedCase the case as the message may + * print it */ public static function forExample( string $operationKey, @@ -40,6 +46,7 @@ public static function forExample( array $case, string $reproducer, \Throwable $failure, + array $redactedCase, ): self { $cause = $failure->getPrevious() ?? $failure; $counterExample = new CounterExample( @@ -57,7 +64,7 @@ public static function forExample( $phase, $example, $cause->getMessage(), - json_encode($case, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + json_encode($redactedCase, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), $reproducer, ), $operationKey, @@ -69,13 +76,18 @@ public static function forExample( ); } - /** @param 'valid'|'negative' $phase */ + /** + * @param 'valid'|'negative' $phase + * @param array $redactedCase the shrunk case as the + * message may print it + */ public static function forCounterExample( string $operationKey, string $phase, CounterExample $counterExample, string $reproducer, \Throwable $failure, + array $redactedCase, ): self { $cause = $counterExample->failure ?? $failure; @@ -87,7 +99,7 @@ public static function forCounterExample( $counterExample->runsBeforeFailure, $counterExample->seed, $cause->getMessage(), - $counterExample->toJson(), + json_encode($redactedCase, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), $reproducer, ), $operationKey, diff --git a/src/Psr15Transport.php b/src/Psr15Transport.php index 335dbe4..19bea6d 100644 --- a/src/Psr15Transport.php +++ b/src/Psr15Transport.php @@ -95,7 +95,7 @@ private function withBody(ServerRequestInterface $serverRequest, RequestInterfac } elseif ($this->streams instanceof StreamFactoryInterface) { $stream = $this->streams->createStream($contents); } else { - throw new \LogicException('Psr15Transport needs a StreamFactoryInterface (fourth constructor argument) to buffer a non-seekable form or multipart body'); + throw new SuiteConfigurationError('Psr15Transport needs a StreamFactoryInterface (fourth constructor argument) to buffer a non-seekable form or multipart body'); } $serverRequest = $serverRequest->withBody($stream); if ($form) { diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index 1c09d74..6e79859 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -98,8 +98,13 @@ private function location(Operation $operation, string $location): ArbitraryInte continue; } $separator = ParameterSchemas::separatorOf($location, $parameter['style'], $parameter['schema']); - $schema = $this->parameterSchemas->forLocation($parameter['schema'], $location, $parameter['style']); - $compiled = $this->compilerFor($separator)->compile($parameter['required'] ? $this->nonEmptyContainer($schema) : $schema); + + try { + $schema = $this->parameterSchemas->forLocation($parameter['schema'], $location, $parameter['style']); + $compiled = $this->compilerFor($separator)->compile($parameter['required'] ? $this->nonEmptyContainer($schema) : $schema); + } catch (UnsupportedGeneration $refusal) { + throw $refusal->inOperation($operation->key, sprintf('%s parameter "%s"', $location, $parameter['name'])); + } if ($location === 'path') { $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isPathSafe($value)); } @@ -166,33 +171,15 @@ private function body(Operation $operation): ArbitraryInterface } $normalized = MediaType::normalize($mediaType); $schema = $this->requestSchemas->effective($schema); - if (MediaType::isJson($mediaType)) { - /** @var ArbitraryInterface $json */ - $json = Gen::map($this->bodySchemas->compile($schema), static fn(mixed $value): array => [ - 'mediaType' => $mediaType, - 'encoding' => 'json', - 'value' => $value, - ]); - $bodies[] = [1, $json]; - } elseif ($normalized === 'application/x-www-form-urlencoded') { - $this->assertObjectSchema($schema, 'Form request body schema must be an object'); - $this->assertFormEncoding($definition['encoding'] ?? []); - /** @var ArbitraryInterface $form */ - $form = Gen::map($this->bodySchemas->compile($this->nonEmptyRequiredProperties($schema)), static fn(mixed $value): array => [ - 'mediaType' => $mediaType, - 'encoding' => 'form', - 'value' => $value, - ]); - $bodies[] = [1, $form]; - } elseif (str_starts_with($normalized, 'multipart/')) { - $this->assertObjectSchema($schema, 'Multipart request body schema must be an object'); - $this->assertMultipartEncoding($definition['encoding'] ?? []); - /** - * @var array $definition - * @var ArbitraryInterface $multipart - */ - $multipart = Gen::map($this->multipartValues($schema), fn(array $value): array => $this->multipartBody($mediaType, $schema, $definition, $value)); - $bodies[] = [1, $multipart]; + + /** @var array $definition */ + try { + $body = $this->bodyArbitrary($mediaType, $normalized, $schema, $definition); + } catch (UnsupportedGeneration $refusal) { + throw $refusal->inOperation($operation->key, sprintf('request body "%s"', $mediaType)); + } + if ($body instanceof ArbitraryInterface) { + $bodies[] = [1, $body]; } } if ($bodies === []) { @@ -215,6 +202,48 @@ private function body(Operation $operation): ArbitraryInterface return Gen::nullable($body); } + /** + * @param array $schema + * @param array $definition + * @return null|ArbitraryInterface `null` for a media type this + * package does not generate + */ + private function bodyArbitrary(string $mediaType, string $normalized, array $schema, array $definition): ?ArbitraryInterface + { + if (MediaType::isJson($mediaType)) { + /** @var ArbitraryInterface $json */ + $json = Gen::map($this->bodySchemas->compile($schema), static fn(mixed $value): array => [ + 'mediaType' => $mediaType, + 'encoding' => 'json', + 'value' => $value, + ]); + + return $json; + } + if ($normalized === 'application/x-www-form-urlencoded') { + $this->assertObjectSchema($schema, 'Form request body schema must be an object'); + $this->assertFormEncoding($definition['encoding'] ?? []); + /** @var ArbitraryInterface $form */ + $form = Gen::map($this->bodySchemas->compile($this->nonEmptyRequiredProperties($schema)), static fn(mixed $value): array => [ + 'mediaType' => $mediaType, + 'encoding' => 'form', + 'value' => $value, + ]); + + return $form; + } + if (str_starts_with($normalized, 'multipart/')) { + $this->assertObjectSchema($schema, 'Multipart request body schema must be an object'); + $this->assertMultipartEncoding($definition['encoding'] ?? []); + /** @var ArbitraryInterface $multipart */ + $multipart = Gen::map($this->multipartValues($schema), fn(array $value): array => $this->multipartBody($mediaType, $schema, $definition, $value)); + + return $multipart; + } + + return null; + } + private function included(ArbitraryInterface $value): ArbitraryInterface { return Gen::map($value, static fn(mixed $value): array => ['included' => true, 'value' => $value]); diff --git a/src/RequestMaterializer.php b/src/RequestMaterializer.php index a573bb1..34e4e35 100644 --- a/src/RequestMaterializer.php +++ b/src/RequestMaterializer.php @@ -70,7 +70,7 @@ public function withBaseUri(string $baseUri): self public function materialize(Operation $operation, array $case, ?Credentials $credentials = null): RequestInterface { if ($case['operationKey'] !== $operation->key) { - throw new \InvalidArgumentException(sprintf('Request case targets "%s", not "%s"', $case['operationKey'], $operation->key)); + throw new InvalidCase(sprintf('Request case targets "%s", not "%s"', $case['operationKey'], $operation->key)); } $path = $operation->path; $query = []; @@ -130,7 +130,7 @@ public function materialize(Operation $operation, array $case, ?Credentials $cre if ($body['encoding'] === 'raw') { $rawValue = $body['value'] ?? null; if (!is_string($rawValue)) { - throw new UnsupportedGeneration('Raw request body value must be a string'); + throw new InvalidCase('Raw request body value must be a string'); } $payload = $rawValue; } elseif ($body['encoding'] === 'form') { @@ -140,7 +140,7 @@ public function materialize(Operation $operation, array $case, ?Credentials $cre $parts = $body['parts'] ?? null; $boundary = $body['boundary'] ?? null; if (!is_array($parts) || !is_string($boundary)) { - throw new UnsupportedGeneration('Multipart request body has an invalid shape'); + throw new InvalidCase('Multipart request body has an invalid shape'); } $payload = $this->multipartBody($parts, $boundary); $body['mediaType'] .= '; boundary=' . $boundary; @@ -239,17 +239,17 @@ private function assertBaseUri(string $baseUri): void private function formBody(mixed $value, array $schema, array $encoding): string { if (!is_array($value) || !SchemaShape::isObject($schema)) { - throw new UnsupportedGeneration('Form request body value must be an object'); + throw new InvalidCase('Form request body value must be an object'); } if ($value !== [] && array_is_list($value)) { - throw new UnsupportedGeneration('Form request body value must be an object'); + throw new InvalidCase('Form request body value must be an object'); } /** @var array $value */ $properties = $this->schemaObject($schema['properties'] ?? [], 'Form object properties must be an object'); $parts = []; foreach (array_keys($value) as $name) { if (!is_string($name)) { - throw new UnsupportedGeneration('Form object keys must be strings'); + throw new InvalidCase('Form object keys must be strings'); } $property = $this->schemaObject($properties[$name] ?? [], 'Form object property must be a schema object'); $configuration = $this->formConfiguration($encoding[$name] ?? null); @@ -293,7 +293,7 @@ private function formWireValue(mixed $value, array $schema): string|array { if (SchemaShape::isArray($schema)) { if (!is_array($value) || !array_is_list($value)) { - throw new UnsupportedGeneration('Form array value must be a list'); + throw new InvalidCase('Form array value must be a list'); } $items = $this->schemaObject($schema['items'] ?? null, 'Form array items must be a schema object'); @@ -301,7 +301,7 @@ private function formWireValue(mixed $value, array $schema): string|array } if (SchemaShape::isObject($schema)) { if (!is_array($value) || ($value !== [] && array_is_list($value))) { - throw new UnsupportedGeneration('Form object value must be an object'); + throw new InvalidCase('Form object value must be an object'); } /** @var array $value */ $properties = $this->memberMap($schema['properties'] ?? [], 'Form object properties must be an object'); @@ -321,7 +321,7 @@ private function formWireValue(mixed $value, array $schema): string|array private function scalarValue(mixed $value): string { return WireValue::of($value) - ?? throw new UnsupportedGeneration('Form scalar value has an unsupported type'); + ?? throw new InvalidCase('Form scalar value has an unsupported type'); } /** @return array */ @@ -334,7 +334,7 @@ private function bodyEncoding(Operation $operation, string $mediaType): array private function multipartBody(array $parts, string $boundary): string { if ($boundary === '' || strlen($boundary) > 70 || preg_match("/^[0-9A-Za-z'()+_,.\/:=? -]+\\z/", $boundary) !== 1) { - throw new UnsupportedGeneration('Multipart boundary is invalid'); + throw new InvalidCase('Multipart boundary is invalid'); } $payload = ''; foreach ($parts as $part) { @@ -343,7 +343,7 @@ private function multipartBody(array $parts, string $boundary): string $contentType = $part['contentType']; $value = $part['encoding'] === 'base64' ? base64_decode($part['value'], strict: true) : $part['value']; if ($value === false) { - throw new UnsupportedGeneration('Multipart base64 value is invalid'); + throw new InvalidCase('Multipart base64 value is invalid'); } $payload .= '--' . $boundary . "\r\n"; $payload .= 'Content-Disposition: form-data; name="' . $this->quoteHeader($name) . '"' @@ -381,7 +381,7 @@ private function bodySchema(Operation $operation, string $mediaType, ?array $mis $definition = $this->declaredJsonDefinition($content); } if ($definition === null) { - throw new UnsupportedGeneration(sprintf('Request body media type "%s" is not declared', $mediaType)); + throw new InvalidCase(sprintf('Request body media type "%s" is not declared', $mediaType)); } $schema = $definition['schema'] ?? []; if (!is_array($schema)) { @@ -436,10 +436,10 @@ private function schemaObject(mixed $value, string $message): array private function memberMap(mixed $value, string $message): array { if (!is_array($value)) { - throw new UnsupportedGeneration($message); + throw new InvalidCase($message); } if ($value !== [] && array_is_list($value)) { - throw new UnsupportedGeneration($message); + throw new InvalidCase($message); } /** @var array $result */ diff --git a/src/RequestReproducer.php b/src/RequestReproducer.php index c6f707d..0b781d3 100644 --- a/src/RequestReproducer.php +++ b/src/RequestReproducer.php @@ -22,7 +22,13 @@ * {@see RedactionPolicy::$cookies} unobservable, which is to say inert. It is * not in the default set for that reason. * - * @internal Reach it through {@see ContractSuite::reproduce()}. + * Redaction is applied to the case, not to the request: {@see redact()} is + * what {@see curl()} renders, and it is also what {@see OperationProperty} + * prints as the minimal case, so a secret the policy names appears in + * neither (#124). + * + * @internal Reach it through {@see ContractSuite::reproduce()} and + * {@see ContractSuite::redact()}. */ final readonly class RequestReproducer { @@ -49,16 +55,13 @@ public function __construct( */ public function curl(Operation $operation, array $case, RedactionPolicy $policy = new RedactionPolicy()): string { - $case = $this->redactCase($case, $policy); - $request = $this->materializer->materialize($operation, $case); + $request = $this->materializer->materialize($operation, $this->redact($case, $policy)); - $redactedHeaders = array_merge(self::DEFAULT_REDACTED_HEADERS, array_map(strtolower(...), $policy->headers)); $parts = ['curl', '-X', $request->getMethod(), $this->quote((string) $request->getUri())]; foreach (array_keys($request->getHeaders()) as $name) { $name = (string) $name; - $value = in_array(strtolower($name), $redactedHeaders, strict: true) ? self::REDACTED : $request->getHeaderLine($name); $parts[] = '-H'; - $parts[] = $this->quote($name . ': ' . $value); + $parts[] = $this->quote($name . ': ' . $request->getHeaderLine($name)); } $body = (string) $request->getBody(); if ($body !== '') { @@ -89,8 +92,14 @@ public function curl(Operation $operation, array $case, RedactionPolicy $policy * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, * } */ - private function redactCase(array $case, RedactionPolicy $policy): array + public function redact(array $case, RedactionPolicy $policy): array { + $redactedHeaders = array_merge(self::DEFAULT_REDACTED_HEADERS, array_map(strtolower(...), $policy->headers)); + foreach (array_keys($case['headers']) as $name) { + if (in_array(strtolower($name), $redactedHeaders, strict: true)) { + $case['headers'][$name] = $this->redactedLike($case['headers'][$name]); + } + } foreach ($policy->queryParameters as $name) { if (array_key_exists($name, $case['query'])) { $case['query'][$name] = $this->redactedLike($case['query'][$name]); diff --git a/src/ResponseCaseArbitrary.php b/src/ResponseCaseArbitrary.php index 515f26a..fb7f5cf 100644 --- a/src/ResponseCaseArbitrary.php +++ b/src/ResponseCaseArbitrary.php @@ -60,8 +60,8 @@ public function forOperation(Operation $operation, int $status): ArbitraryInterf { $definition = $this->definition($operation, $status); $arbitrary = Gen::map(Gen::record([ - 'headers' => $this->headers($definition), - 'body' => $this->body($definition), + 'headers' => $this->headers($definition, $operation->key, $status), + 'body' => $this->body($definition, $operation->key, $status), ]), static fn(array $parts): array => [ 'operationKey' => $operation->key, 'status' => $status, @@ -107,7 +107,7 @@ private function definition(Operation $operation, int $status): array } /** @param array $definition */ - private function headers(array $definition): ArbitraryInterface + private function headers(array $definition, string $operationKey, int $status): ArbitraryInterface { $headers = $definition['headers'] ?? []; if (!is_array($headers)) { @@ -132,8 +132,13 @@ private function headers(array $definition): ArbitraryInterface // the alphabet to what a field value may carry, and the filter // refuses what a `pattern` or a `format` can still put outside it. $separator = ParameterSchemas::separatorOf('header', 'simple', $schema); - $compiled = (new SchemaArbitraryCompiler($separator ?? '')) - ->compile($this->parameterSchemas->forLocation($schema, 'header', 'simple')); + + try { + $compiled = (new SchemaArbitraryCompiler($separator ?? '')) + ->compile($this->parameterSchemas->forLocation($schema, 'header', 'simple')); + } catch (UnsupportedGeneration $refusal) { + throw $refusal->inOperation($operationKey, sprintf('response "%d" header "%s"', $status, $name)); + } $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isHeaderSafe($value)); $value = Gen::map($compiled, fn(mixed $value): string|array => $this->headerValue($value, $name)); // An optional header takes both branches; `null` stands for "absent" @@ -183,7 +188,7 @@ private function scalar(mixed $value, string $name): string } /** @param array $definition */ - private function body(array $definition): ArbitraryInterface + private function body(array $definition, string $operationKey, int $status): ArbitraryInterface { $media = $this->jsonMedia($definition, 'Response content declares no JSON media type'); if ($media === null) { @@ -191,7 +196,13 @@ private function body(array $definition): ArbitraryInterface } $mediaType = $media['mediaType']; - return Gen::map($this->schemas->compile($media['schema']), static fn(mixed $value): array => [ + try { + $compiled = $this->schemas->compile($media['schema']); + } catch (UnsupportedGeneration $refusal) { + throw $refusal->inOperation($operationKey, sprintf('response "%d" body "%s"', $status, $mediaType)); + } + + return Gen::map($compiled, static fn(mixed $value): array => [ 'mediaType' => $mediaType, 'encoding' => 'json', 'value' => $value, diff --git a/src/ResponseMaterializer.php b/src/ResponseMaterializer.php index e820b55..4da5c3a 100644 --- a/src/ResponseMaterializer.php +++ b/src/ResponseMaterializer.php @@ -25,18 +25,23 @@ */ final readonly class ResponseMaterializer { + private JsonBodyEncoder $json; + + private ParameterSerializer $parameters; + public function __construct( private ResponseFactoryInterface $responses, private StreamFactoryInterface $streams, - private JsonBodyEncoder $json = new JsonBodyEncoder(), - private ParameterSerializer $parameters = new ParameterSerializer(), - ) {} + ) { + $this->json = new JsonBodyEncoder(); + $this->parameters = new ParameterSerializer(); + } /** @param ResponseCaseData $case */ public function materialize(Operation $operation, array $case): ResponseInterface { if ($case['operationKey'] !== $operation->key) { - throw new \InvalidArgumentException(sprintf('Response case targets "%s", not "%s"', $case['operationKey'], $operation->key)); + throw new InvalidCase(sprintf('Response case targets "%s", not "%s"', $case['operationKey'], $operation->key)); } $response = $this->responses->createResponse($case['status']); foreach ($case['headers'] as $name => $value) { @@ -60,7 +65,7 @@ public function materialize(Operation $operation, array $case): ResponseInterfac if ($body['encoding'] === 'raw') { $raw = $body['value']; if (!is_string($raw)) { - throw new UnsupportedGeneration('Raw response body value must be a string'); + throw new InvalidCase('Raw response body value must be a string'); } $payload = $raw; } else { diff --git a/src/SuiteConfigurationError.php b/src/SuiteConfigurationError.php index 6bc129a..9226f3e 100644 --- a/src/SuiteConfigurationError.php +++ b/src/SuiteConfigurationError.php @@ -9,4 +9,4 @@ * * @api */ -final class SuiteConfigurationError extends \LogicException {} +final class SuiteConfigurationError extends \LogicException implements OpenApiPropertyTestingException {} diff --git a/src/UnsupportedGeneration.php b/src/UnsupportedGeneration.php index 72e47fe..9579b40 100644 --- a/src/UnsupportedGeneration.php +++ b/src/UnsupportedGeneration.php @@ -5,15 +5,46 @@ namespace Rasuvaeff\PropertyTesting\OpenApi; /** - * The contract uses a generation or serialization feature outside the - * currently implemented support matrix. + * The document uses a generation or serialization feature outside the + * currently implemented support matrix. A refusal over a schema names the + * operation and the parameter or body it was compiling once that is known + * ({@see inOperation()}), so a refusal over a forty-operation document is + * not a search. * * @api */ -final class UnsupportedGeneration extends \InvalidArgumentException +final class UnsupportedGeneration extends \InvalidArgumentException implements OpenApiPropertyTestingException { + private const string PREFIX = 'Unsupported OpenAPI schema generation'; + + private ?string $reason = null; + public static function forSchema(string $reason): self { - return new self(sprintf('Unsupported OpenAPI schema generation: %s', $reason)); + $refusal = new self(sprintf('%s: %s', self::PREFIX, $reason)); + $refusal->reason = $reason; + + return $refusal; + } + + /** + * The same refusal, placed: `Unsupported OpenAPI schema generation for + * operation "pets.list", query parameter "limit": minLength exceeds + * maxLength`. A refusal that did not come from {@see forSchema()} already + * names what it refuses and is returned as is. + * + * @param non-empty-string $subject what was being compiled — `query + * parameter "limit"`, `request body "application/json"`, + * `response "200" header "X-Total"` + */ + public function inOperation(string $operationKey, string $subject): self + { + if ($this->reason === null) { + return $this; + } + $placed = new self(sprintf('%s for operation "%s", %s: %s', self::PREFIX, $operationKey, $subject, $this->reason), 0, $this); + $placed->reason = $this->reason; + + return $placed; } } diff --git a/tests/ContractSuiteTest.php b/tests/ContractSuiteTest.php index 53ad86f..401cd00 100644 --- a/tests/ContractSuiteTest.php +++ b/tests/ContractSuiteTest.php @@ -21,6 +21,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\CredentialsUnavailable; use Rasuvaeff\PropertyTesting\OpenApi\NegativeRequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\OperationCoverage; +use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; use Rasuvaeff\PropertyTesting\OpenApi\RejectionPolicy; use Rasuvaeff\PropertyTesting\OpenApi\SuiteConfigurationError; use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\ZooContracts; @@ -704,6 +705,21 @@ public static function zooValidCasesPassTheBuiltInChecksExamples(): iterable } } + /** + * The configured policy is what `redact()` applies and what + * `reproduce()` renders by default; the wire-level check of the + * reproducer under a declared secret is in `OperationPropertyTest` (#124). + */ + public function appliesTheConfiguredRedactionPolicy(): void + { + $suite = $this->suite()->operations(['pets.get'])->redaction(new RedactionPolicy(queryParameters: ['limit'])); + $case = ['operationKey' => 'pets.get', 'path' => ['id' => '3'], 'query' => ['limit' => '7'], 'headers' => ['Authorization' => 'Bearer x', 'X-Trace' => 't1'], 'cookies' => [], 'body' => null, 'misuse' => null]; + + Assert::same($suite->redact($case), ['operationKey' => 'pets.get', 'path' => ['id' => '3'], 'query' => ['limit' => '[redacted]'], 'headers' => ['Authorization' => '[redacted]', 'X-Trace' => 't1'], 'cookies' => [], 'body' => null, 'misuse' => null]); + Assert::same($this->suite()->operations(['pets.get'])->redact($case)['query'], ['limit' => '7']); + Assert::same($suite->reproduce('pets.get', $case), "curl -X GET '/pets/3'"); + } + public function zooOperationsTheGeneratorCannotServeFailClosedAtSelection(): void { $factory = new Psr17Factory(); @@ -712,15 +728,15 @@ public function zooOperationsTheGeneratorCannotServeFailClosedAtSelection(): voi ->allowUnsafeOperations(); foreach ([ - 'uuid.get' => 'format "uuid" cannot satisfy the length window', - 'links.get' => 'path parameter format "uri" always carries a slash', - 'conflict.create' => 'allOf branch bounding additionalProperties cannot admit sibling property "b"', + 'uuid.get' => 'for operation "uuid.get", path parameter "id": format "uuid" cannot satisfy the length window', + 'links.get' => 'for operation "links.get", path parameter "href": path parameter format "uri" always carries a slash', + 'conflict.create' => 'for operation "conflict.create", request body "application/json": allOf branch bounding additionalProperties cannot admit sibling property "b"', ] as $key => $message) { try { $suite->validCases($key); Assert::true(actual: false, message: 'Expected unsupported generation exception'); } catch (UnsupportedGeneration $exception) { - Assert::same($exception->getMessage(), 'Unsupported OpenAPI schema generation: ' . $message); + Assert::same($exception->getMessage(), 'Unsupported OpenAPI schema generation ' . $message); } } } diff --git a/tests/ExceptionsTest.php b/tests/ExceptionsTest.php new file mode 100644 index 0000000..5c2e7e0 --- /dev/null +++ b/tests/ExceptionsTest.php @@ -0,0 +1,91 @@ + $class */ + #[DataProvider('exceptionClassProvider')] + public function everyExceptionOfThePackageImplementsTheMarker(string $class): void + { + Assert::true(is_subclass_of($class, OpenApiPropertyTestingException::class), $class); + Assert::true(is_subclass_of($class, \Throwable::class), $class); + } + + public static function exceptionClassProvider(): iterable + { + yield CheckFailed::class => [CheckFailed::class]; + yield CoverageIncomplete::class => [CoverageIncomplete::class]; + yield CredentialsUnavailable::class => [CredentialsUnavailable::class]; + yield InvalidCase::class => [InvalidCase::class]; + yield OperationPropertyFailed::class => [OperationPropertyFailed::class]; + yield SuiteConfigurationError::class => [SuiteConfigurationError::class]; + yield UnsupportedGeneration::class => [UnsupportedGeneration::class]; + } + + /** + * The list above is the whole set: a new exception class in `src/` that + * forgets the marker is caught here, not by a consumer. + */ + public function theProviderNamesEveryExceptionClassInSrc(): void + { + $found = []; + foreach (glob(__DIR__ . '/../src/*.php') ?: [] as $file) { + $class = 'Rasuvaeff\\PropertyTesting\\OpenApi\\' . basename($file, '.php'); + if (class_exists($class) && is_subclass_of($class, \Throwable::class)) { + $found[] = $class; + } + } + sort($found); + + Assert::same($found, array_keys(iterator_to_array(self::exceptionClassProvider()))); + } + + public function invalidCaseNamesTheMissingKey(): void + { + $failure = InvalidCase::missingKey('misuse'); + + Assert::same($failure->getMessage(), 'Case is missing the "misuse" key'); + Assert::instanceOf($failure, \InvalidArgumentException::class); + } + + public function aSchemaRefusalIsPlacedInItsOperation(): void + { + $refusal = UnsupportedGeneration::forSchema('minLength exceeds maxLength'); + $placed = $refusal->inOperation('pets.list', 'query parameter "limit"'); + + Assert::same($refusal->getMessage(), 'Unsupported OpenAPI schema generation: minLength exceeds maxLength'); + Assert::same($placed->getMessage(), 'Unsupported OpenAPI schema generation for operation "pets.list", query parameter "limit": minLength exceeds maxLength'); + Assert::same($placed->getPrevious(), $refusal); + Assert::same($placed->inOperation('other', 'body')->getMessage(), 'Unsupported OpenAPI schema generation for operation "other", body: minLength exceeds maxLength'); + } + + public function aRefusalThatAlreadyNamesItsSubjectIsReturnedAsIs(): void + { + $refusal = new UnsupportedGeneration('Operation "pets.list" has no required request component to invalidate'); + + Assert::same($refusal->inOperation('pets.list', 'query parameter "limit"'), $refusal); + } +} diff --git a/tests/OperationPropertyTest.php b/tests/OperationPropertyTest.php index e27adef..934708b 100644 --- a/tests/OperationPropertyTest.php +++ b/tests/OperationPropertyTest.php @@ -14,6 +14,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\OperationCoverage; use Rasuvaeff\PropertyTesting\OpenApi\OperationProperty; use Rasuvaeff\PropertyTesting\OpenApi\OperationPropertyFailed; +use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; use Rasuvaeff\PropertyTesting\OpenApi\SuiteConfigurationError; use Testo\Assert; use Testo\Codecov\Covers; @@ -89,6 +90,47 @@ public function reportsAFalsifiedValidPhaseWithAReproducer(): void } } + /** + * A secret the policy names is printed neither in the reproducer nor in + * the minimal case of the message; the counterexample keeps the case as + * generated (#124). + */ + public function printsTheMinimalCaseThroughTheSuiteRedactionPolicy(): void + { + $suite = $this->suite(static fn(): Response => new Response(500), [ + ['name' => 'X-Api-Key', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'const' => 'sk-live-secret']], + ['name' => 'token', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'const' => 'tok-secret']], + ])->redaction(new RedactionPolicy(headers: ['X-Api-Key'], queryParameters: ['token'])); + + try { + OperationProperty::check($suite, 'pets.get', runs: 5, seed: 23); + Assert::true(actual: false, message: 'Expected a falsified valid phase'); + } catch (OperationPropertyFailed $failure) { + Assert::string($failure->getMessage())->notContains('sk-live-secret')->notContains('tok-secret'); + Assert::string($failure->getMessage())->contains('"X-Api-Key":"[redacted]"')->contains('"token":"[redacted]"'); + Assert::string($failure->reproducer)->notContains('sk-live-secret')->notContains('tok-secret'); + $shrunk = $failure->counterExample->shrunkArguments['case'] ?? null; + Assert::true(is_array($shrunk)); + Assert::same($shrunk['headers']['X-Api-Key'] ?? null, 'sk-live-secret'); + } + } + + public function printsAFailedExampleThroughTheSuiteRedactionPolicy(): void + { + $suite = $this->suite(static fn(): Response => new Response(500), [ + ['name' => 'X-Api-Key', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string'], 'example' => 'sk-live-secret'], + ])->redaction(new RedactionPolicy(headers: ['X-Api-Key'])); + + try { + OperationProperty::check($suite, 'pets.get', runs: 5, seed: 23); + Assert::true(actual: false, message: 'Expected a falsified valid phase'); + } catch (OperationPropertyFailed $failure) { + Assert::same($failure->example, 'example'); + Assert::string($failure->getMessage())->notContains('sk-live-secret')->contains('"X-Api-Key":"[redacted]"'); + Assert::same($failure->counterExample->shrunkArguments['case']['headers']['X-Api-Key'] ?? null, 'sk-live-secret'); + } + } + public function reportsAFalsifiedNegativePhase(): void { $suite = $this->suite(static function (Contract $contract, RequestInterface $request): Response { @@ -362,7 +404,8 @@ public function enumeratesTheSuiteSelectionAsNamedTuples(): void } /** @param callable(Contract, RequestInterface): Response $handler */ - private function suite(callable $handler): ContractSuite + /** @param list> $parameters */ + private function suite(callable $handler, array $parameters = []): ContractSuite { $contract = Contract::fromArray([ 'openapi' => '3.1.0', @@ -372,6 +415,7 @@ private function suite(callable $handler): ContractSuite 'operationId' => 'pets.get', 'parameters' => [ ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10]], + ...$parameters, ], 'responses' => ['204' => [], '400' => []], ], diff --git a/tests/ParameterSerializerTest.php b/tests/ParameterSerializerTest.php index 753a3f9..665f55b 100644 --- a/tests/ParameterSerializerTest.php +++ b/tests/ParameterSerializerTest.php @@ -5,6 +5,7 @@ namespace Rasuvaeff\PropertyTesting\OpenApi\Tests; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSerializer; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; use Testo\Assert; use Testo\Codecov\Covers; @@ -125,11 +126,22 @@ public static function serializationCases(): iterable #[DataProvider('invalidShapeCases')] public function rejectsInvalidShapes(string|array $value, string $style): void { - Expect::exception(UnsupportedGeneration::class); + Expect::exception(InvalidCase::class); (new ParameterSerializer())->serialize('value', $value, $style, explode: true); } + /** + * A style the document declares and this package does not serialize is a + * document limitation, not a malformed case. + */ + public function refusesAnUnknownStyleAsUnsupportedGeneration(): void + { + Expect::exception(UnsupportedGeneration::class); + + (new ParameterSerializer())->serialize('value', 'value', 'unknown', explode: true); + } + public function preservesReservedCharactersInEveryEncodedListForm(): void { $serializer = new ParameterSerializer(); @@ -164,7 +176,7 @@ public function escapesLabelDotsSoListBoundariesRemainUnambiguous(): void #[DataProvider('unrepresentableDelimitedCases')] public function refusesDelimitedValuesCarryingTheirOwnSeparator(array $value, string $style, string $message): void { - Expect::exception(UnsupportedGeneration::class)->withMessage($message); + Expect::exception(InvalidCase::class)->withMessage($message); (new ParameterSerializer())->serialize('value', $value, $style, explode: false); } @@ -180,7 +192,7 @@ public function rejectsDelimitedNonListValuesWithoutTypeCoercion(): void { $serializer = new ParameterSerializer(); - Expect::exception(UnsupportedGeneration::class); + Expect::exception(InvalidCase::class); $serializer->serialize('value', ['key' => 'item'], 'spaceDelimited', explode: false); } @@ -192,7 +204,7 @@ public function rejectsUnknownStyleExplicitly(): void public function rejectsNonStringItemsInListShapes(): void { - Expect::exception(UnsupportedGeneration::class); + Expect::exception(InvalidCase::class); (new ParameterSerializer())->serialize('value', ['item', 42], 'simple', explode: false); } @@ -208,7 +220,7 @@ public function keepsEmptyObjectAndListBoundariesObservable(): void public function rejectsADeepObjectListWithTheDeepObjectMessage(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('deepObject parameters require an object value'); + Expect::exception(InvalidCase::class)->withMessage('deepObject parameters require an object value'); (new ParameterSerializer())->serialize('value', ['item'], 'deepObject', explode: true); } @@ -223,6 +235,5 @@ public static function invalidShapeCases(): iterable yield 'deep scalar' => ['value', 'deepObject']; yield 'deep list' => [['value'], 'deepObject']; yield 'object non-string value' => [['key' => 42], 'simple']; - yield 'unknown style' => ['value', 'unknown']; } } diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index b20c6d9..d59bd99 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -16,6 +16,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\Internal\Negative\ParameterTargets; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Negative\PatternWitness; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Negative\SchemaProbe; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\NegativeRequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\RequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; @@ -43,7 +44,7 @@ final class RequestCaseArbitraryTest { public function multipartEncodingRejectsHeaderInjection(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Header "X-Trace" carries a value no HTTP field can'); + Expect::exception(InvalidCase::class)->withMessage('Header "X-Trace" carries a value no HTTP field can'); $operation = new Operation( key: 'upload.create', @@ -76,7 +77,7 @@ public function multipartEncodingRejectsHeaderInjection(): void public function multipartEncodingRejectsContentTypeInjection(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Header "Content-Type" carries a value no HTTP field can'); + Expect::exception(InvalidCase::class)->withMessage('Header "Content-Type" carries a value no HTTP field can'); $operation = new Operation( key: 'upload.create', diff --git a/tests/RequestMaterializerTest.php b/tests/RequestMaterializerTest.php index 10eb308..17ac7ba 100644 --- a/tests/RequestMaterializerTest.php +++ b/tests/RequestMaterializerTest.php @@ -9,6 +9,7 @@ use Rasuvaeff\OpenApiContract\Operation; use Rasuvaeff\PropertyTesting\OpenApi\Credentials; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSerializer; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\ServerContracts; use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; @@ -360,7 +361,7 @@ public function rejectsCaseForAnotherOperation(): void public function rejectsMissingBodyContentDefinition(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Request body media type "application/json" is not declared'); + Expect::exception(InvalidCase::class)->withMessage('Request body media type "application/json" is not declared'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -371,7 +372,7 @@ public function rejectsMissingBodyContentDefinition(): void public function rejectsUndeclaredBodyMediaType(): void { - Expect::exception(UnsupportedGeneration::class); + Expect::exception(InvalidCase::class); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -428,7 +429,7 @@ public function keepsReservedCharactersForAnAllowReservedQueryParameter(): void public function reportsUndeclaredMediaTypeWithAnExactMessage(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Request body media type "application/problem+json" is not declared'); + Expect::exception(InvalidCase::class)->withMessage('Request body media type "application/problem+json" is not declared'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -644,7 +645,7 @@ public function escapesMultipartPartNamesWithoutAllowingHeaderInjection(): void #[DataProvider('unsafeMultipartPartHeaderProvider')] public function rejectsMultipartPartHeadersThatCannotTravel(string $contentType, array $headers, string $message): void { - Expect::exception(UnsupportedGeneration::class)->withMessage($message); + Expect::exception(InvalidCase::class)->withMessage($message); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -686,7 +687,7 @@ public static function unsafeMultipartPartHeaderProvider(): iterable public function rejectsMultipartWithoutParts(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart request body has an invalid shape'); + Expect::exception(InvalidCase::class)->withMessage('Multipart request body has an invalid shape'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -701,7 +702,7 @@ public function rejectsMultipartWithoutParts(): void public function rejectsMultipartWithoutBoundary(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart request body has an invalid shape'); + Expect::exception(InvalidCase::class)->withMessage('Multipart request body has an invalid shape'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -717,7 +718,7 @@ public function rejectsMultipartWithoutBoundary(): void #[DataProvider('invalidMultipartBoundaryProvider')] public function rejectsInvalidMultipartBoundary(string $boundary): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart boundary is invalid'); + Expect::exception(InvalidCase::class)->withMessage('Multipart boundary is invalid'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( @@ -741,7 +742,7 @@ public static function invalidMultipartBoundaryProvider(): iterable public function rejectsInvalidMultipartBase64Value(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart base64 value is invalid'); + Expect::exception(InvalidCase::class)->withMessage('Multipart base64 value is invalid'); $factory = new Psr17Factory(); (new RequestMaterializer($factory, $factory))->materialize( diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index 4c1660e..16182b5 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -7,9 +7,9 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Rasuvaeff\OpenApiContract\Operation; use Rasuvaeff\PropertyTesting\OpenApi\Internal\JsonBodyEncoder; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\ResponseMaterializer; use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\ResponseContracts; -use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Expect; @@ -75,7 +75,7 @@ public function refusesAHeaderValueNoHttpFieldCanCarry(): void { $operation = new Operation(key: 'op', operationId: 'op', method: 'GET', path: '/op', responses: ['204' => []]); - Expect::exception(UnsupportedGeneration::class)->withMessage('Header "X-Trace" carries a value no HTTP field can'); + Expect::exception(InvalidCase::class)->withMessage('Header "X-Trace" carries a value no HTTP field can'); $this->materializer()->materialize($operation, [ 'operationKey' => 'op', @@ -99,7 +99,7 @@ public function writesARawBodyVerbatim(): void public function rejectsANonStringRawBody(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Raw response body value must be a string'); + Expect::exception(InvalidCase::class)->withMessage('Raw response body value must be a string'); $this->materializer()->materialize(ResponseContracts::pets()->operation('pets.get'), [ 'operationKey' => 'pets.get', 'status' => 200, 'headers' => [], @@ -169,7 +169,7 @@ public function rejectsAListSchemaWhileEncoding(): void { $operation = new Operation(key: 'op', operationId: 'op', method: 'GET', path: '/op', responses: ['200' => ['content' => ['application/json' => ['schema' => ['a']]]]]); - Expect::exception(UnsupportedGeneration::class)->withMessage('Response JSON schema must be an object'); + Expect::exception(InvalidCase::class)->withMessage('Response JSON schema must be an object'); $this->materializer()->materialize($operation, [ 'operationKey' => 'op', 'status' => 200, 'headers' => [], diff --git a/tests/TransportTest.php b/tests/TransportTest.php index c3c2b2c..6417d66 100644 --- a/tests/TransportTest.php +++ b/tests/TransportTest.php @@ -17,6 +17,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\Internal\MultipartParser; use Rasuvaeff\PropertyTesting\OpenApi\Psr15Transport; use Rasuvaeff\PropertyTesting\OpenApi\RequestMaterializer; +use Rasuvaeff\PropertyTesting\OpenApi\SuiteConfigurationError; use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\BodyContracts; use Rasuvaeff\PropertyTesting\OpenApi\TransportInterface; use Rasuvaeff\PropertyTesting\Property; @@ -134,7 +135,7 @@ public function psr15TransportBuffersANonSeekableFormBodyThroughTheStreamFactory public function psr15TransportRefusesANonSeekableFormBodyWithoutAStreamFactory(): void { - Expect::exception(\LogicException::class) + Expect::exception(SuiteConfigurationError::class) ->withMessage('Psr15Transport needs a StreamFactoryInterface (fourth constructor argument) to buffer a non-seekable form or multipart body'); $factory = new Psr17Factory(); $handler = $this->recorder(); From 387e5ab9c87786c732f8e416c1e6ad072d3d5cfd Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:09:27 +0300 Subject: [PATCH 04/11] Keep an integer and a number oneOf branch apart; leave path parameters out of missing-required Every integer is also a number, and JSON Schema reads 1.0 as an integer, so oneOf over the two was never a choice between disjoint branches: half the draws matched both and the contract rejected them. The number branch is generated without integral values, the integer branch keeps only what the number branch's bounds and multiple refuse, and a number branch that admits every value outside the integer branch's reach fails closed (#121). A required path parameter cannot be omitted into invalidity: dropping it leaves the template literal in the request target, which a string schema accepts. It is out of the missing-required category and of negativeCoverage(), and an operation with no other required component names the reason (#118). --- .../Compile/CompositionArbitraries.php | 147 +++++++++++++++++- src/Internal/Negative/ParameterTargets.php | 12 +- tests/ContractSuiteTest.php | 18 +-- tests/ExceptionsTest.php | 2 +- tests/RequestCaseArbitraryTest.php | 27 +++- tests/WireAgreementTest.php | 34 ++++ 6 files changed, 214 insertions(+), 26 deletions(-) diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index b7f85d3..be39b93 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -6,8 +6,10 @@ use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; +use Rasuvaeff\PropertyTesting\GenerationExhaustedException; use Rasuvaeff\PropertyTesting\OpenApi\SchemaArbitraryCompiler; use Rasuvaeff\PropertyTesting\OpenApi\UnsupportedGeneration; +use Rasuvaeff\PropertyTesting\Random; /** * Compiles the supported, constructive subset of composition keywords. @@ -16,6 +18,10 @@ */ final readonly class CompositionArbitraries { + private const int PROBES = 8; + + private const int PROBE_SEED = 11; + public function __construct( private SchemaArbitraryCompiler $compiler, private SchemaFacts $facts, @@ -35,7 +41,12 @@ public function combinator(array $schema): ?ArbitraryInterface return $this->compiler->compile($this->mergeAllOf($schemas)); } if ($keyword === 'oneOf' && !$this->areDisjoint($schemas)) { - throw UnsupportedGeneration::forSchema('oneOf branches must be provably disjoint'); + $numeric = $this->integerAndNumberBranches($schemas); + if ($numeric === null) { + throw UnsupportedGeneration::forSchema('oneOf branches must be provably disjoint'); + } + + return $this->numericOneOf($schemas, $numeric[0], $numeric[1]); } $pairs = []; @@ -49,6 +60,125 @@ public function combinator(array $schema): ?ArbitraryInterface return null; } + /** + * The one overlap `oneOf` can carry between branches of different + * declared types: a single `integer` branch beside a single `number` + * branch, every other branch disjoint from both. `[$integerIndex, + * $numberIndex]`, or `null` for any other overlap. + * + * @param list> $branches + * @return null|array{int, int} + */ + private function integerAndNumberBranches(array $branches): ?array + { + $byType = []; + foreach ($branches as $index => $branch) { + $types = $this->facts->types($branch['type'] ?? null); + if ($types === null || count($types) !== 1) { + return null; + } + $byType[$types[0]][] = $index; + } + foreach ($byType as $type => $indexes) { + if (count($indexes) !== 1) { + return null; + } + } + if (!isset($byType['integer'], $byType['number'])) { + return null; + } + + return [$byType['integer'][0], $byType['number'][0]]; + } + + /** + * `oneOf` over an `integer` and a `number` branch. Every integer is also + * a number, and JSON Schema reads `1.0` as an integer, so a value is + * valid only when exactly one branch admits it: a non-integral float, or + * an integer the number branch's own keywords reject (#121). The number + * branch is generated without integral values; the integer branch keeps + * only what the number branch's bounds and multiple refuse, and is left + * out when they refuse nothing. A number branch that carries a keyword + * this cannot read is refused, because an integer it may admit cannot be + * told from one it does not. + * + * @param list> $branches + */ + private function numericOneOf(array $branches, int $integerIndex, int $numberIndex): ArbitraryInterface + { + $number = $branches[$numberIndex]; + foreach (array_keys($number) as $keyword) { + if (!in_array($keyword, ['type', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'description', 'title', '$comment', 'deprecated', 'examples', 'example'], strict: true)) { + throw UnsupportedGeneration::forSchema(sprintf('oneOf over integer and number cannot read number keyword "%s" to keep the branches apart', $keyword)); + } + } + $pairs = []; + foreach ($branches as $index => $branch) { + if ($index === $numberIndex) { + $floats = Gen::filter($this->compiler->compile($branch), static fn(mixed $value): bool => is_float($value) && floor($value) !== $value); + if (!$this->yieldsSomething($floats)) { + throw UnsupportedGeneration::forSchema('oneOf number branch admits no value outside the integer branch'); + } + $pairs[] = [1, $floats]; + } elseif ($index === $integerIndex) { + $integers = Gen::filter($this->compiler->compile($branch), fn(mixed $value): bool => is_int($value) && !$this->numberBranchAdmits($value, $number)); + if ($this->yieldsSomething($integers)) { + $pairs[] = [1, $integers]; + } + } else { + $pairs[] = [1, $this->compiler->compile($branch)]; + } + } + + return Gen::frequency($pairs); + } + + /** @param array $number */ + private function numberBranchAdmits(int $value, array $number): bool + { + $minimum = $this->facts->numberBound($number, 'minimum', -INF); + $maximum = $this->facts->numberBound($number, 'maximum', INF); + if ($value < $minimum || $value > $maximum) { + return false; + } + if ((($number['exclusiveMinimum'] ?? false) === true && (float) $value === $minimum) + || (($number['exclusiveMaximum'] ?? false) === true && (float) $value === $maximum)) { + return false; + } + /** @var mixed $multiple */ + $multiple = $number['multipleOf'] ?? null; + if (is_int($multiple) && $multiple > 0) { + return $value % $multiple === 0; + } + if (is_float($multiple) && $multiple > 0) { + return abs((float) $value - round((float) $value / $multiple) * $multiple) < 1e-14; + } + + return true; + } + + /** + * Whether a filtered branch produces anything at all, judged the way the + * pattern probe does: deterministic draws, twice the budget the filter + * gets at run time, so a branch that fails here is one that would have + * exhausted mid-run. + */ + private function yieldsSomething(ArbitraryInterface $arbitrary): bool + { + $random = new Random(self::PROBE_SEED); + for ($probe = 0; $probe < self::PROBES; ++$probe) { + try { + $arbitrary->generate($random); + + return true; + } catch (GenerationExhaustedException) { + continue; + } + } + + return false; + } + /** @param array $schema */ public function not(array $schema): ArbitraryInterface { @@ -314,7 +444,14 @@ private function schemaBranches(mixed $value, string $keyword): array return $schemas; } - /** @param list> $branches */ + /** + * Whether no value can satisfy two of the branches, knowable from their + * declared types alone. `integer` and `number` are one class here: every + * integer is also a number, so a branch of each is not a disjoint pair + * but an overlap the checked `oneOf` path has to resolve (#121). + * + * @param list> $branches + */ private function areDisjoint(array $branches): bool { $seen = []; @@ -323,11 +460,11 @@ private function areDisjoint(array $branches): bool if ($types === null || count($types) !== 1) { return false; } - $type = $types[0]; - if (isset($seen[$type])) { + $class = $types[0] === 'integer' ? 'number' : $types[0]; + if (isset($seen[$class])) { return false; } - $seen[$type] = true; + $seen[$class] = true; } return true; diff --git a/src/Internal/Negative/ParameterTargets.php b/src/Internal/Negative/ParameterTargets.php index 3f3804f..6170342 100644 --- a/src/Internal/Negative/ParameterTargets.php +++ b/src/Internal/Negative/ParameterTargets.php @@ -46,13 +46,19 @@ public function __construct( ) {} /** - * @return non-empty-list + * Every required component whose absence the validator can observe. A + * path parameter is not one: dropping it from the case leaves the + * template literal in the request target, which is a string like any + * other to a `string` schema, so the category was generated and the + * contract accepted a quarter of the "negative" cases (#118). + * + * @return non-empty-list */ public function missingRequired(Operation $operation): array { $targets = []; foreach ($operation->parameters as $parameter) { - if ($parameter['required']) { + if ($parameter['required'] && $parameter['in'] !== 'path') { $targets[] = ['location' => $parameter['in'], 'name' => $parameter['name']]; } } @@ -60,7 +66,7 @@ public function missingRequired(Operation $operation): array $targets[] = ['location' => 'body', 'name' => 'body']; } if ($targets === []) { - throw new UnsupportedGeneration(sprintf('Operation "%s" has no required request component to invalidate', $operation->key)); + throw new UnsupportedGeneration(sprintf('Operation "%s" has no required request component whose absence is observable to invalidate (a path parameter cannot be omitted)', $operation->key)); } return $targets; diff --git a/tests/ContractSuiteTest.php b/tests/ContractSuiteTest.php index 401cd00..c90dbd6 100644 --- a/tests/ContractSuiteTest.php +++ b/tests/ContractSuiteTest.php @@ -365,24 +365,16 @@ public function negativeCasesCombineConstructibleCategories(): void { $suite = $this->suite()->operations(['pets.get']); $kinds = []; - foreach ([3, 7, 19, 41, 53, 67, 71, 97] as $seed) { + // The only required component is the path parameter, which cannot be + // omitted into invalidity (#118): two categories remain. + foreach (range(1, 24) as $seed) { $case = $suite->negativeCases('pets.get')->generate(new Random($seed))->value; - Assert::true(in_array($case['misuse']['kind'], ['missing-required', 'type', 'boundary'], strict: true)); + Assert::true(in_array($case['misuse']['kind'], ['type', 'boundary'], strict: true)); $kinds[$case['misuse']['kind']] = true; } Assert::true(count($kinds) > 1); - - $missingRequired = false; - foreach (range(1, 40) as $seed) { - $case = $suite->negativeCases('pets.get')->generate(new Random($seed))->value; - if ($case['misuse']['kind'] === 'missing-required') { - $missingRequired = true; - break; - } - } - Assert::true($missingRequired); } /** @@ -591,7 +583,7 @@ public function negativeCoverageIsEmptyForAnOperationWithNoMisuse(): void Assert::same($coverage['health.get']['covered'], []); Assert::same($coverage['health.get']['skipped'][0]['kind'], 'missing-required'); - Assert::string($coverage['health.get']['skipped'][0]['reason'])->contains('no required request component to invalidate'); + Assert::string($coverage['health.get']['skipped'][0]['reason'])->contains('no required request component whose absence is observable to invalidate'); } /** diff --git a/tests/ExceptionsTest.php b/tests/ExceptionsTest.php index 5c2e7e0..e4ee4d7 100644 --- a/tests/ExceptionsTest.php +++ b/tests/ExceptionsTest.php @@ -84,7 +84,7 @@ public function aSchemaRefusalIsPlacedInItsOperation(): void public function aRefusalThatAlreadyNamesItsSubjectIsReturnedAsIs(): void { - $refusal = new UnsupportedGeneration('Operation "pets.list" has no required request component to invalidate'); + $refusal = new UnsupportedGeneration('Operation "pets.list" declares no response for status 200'); Assert::same($refusal->inOperation('pets.list', 'query parameter "limit"'), $refusal); } diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index d59bd99..d07041f 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -180,9 +180,28 @@ public function missingRequiredComponentIsInvalidBeforeTransport(): void $dropped = array_keys($seen); sort($dropped); - // Every required component is dropped across draws, not only the one - // declared first (#99). - Assert::same($dropped, ['cookie:session', 'header:X-Tenant', 'path:id']); + // Every required component whose absence the validator can see is + // dropped across draws, not only the one declared first (#99); the + // path parameter is not among them, because omitting it leaves the + // template literal in the target, which a string schema accepts (#118). + Assert::same($dropped, ['cookie:session', 'header:X-Tenant']); + } + + public function missingRequiredSkipsAnOperationWhoseOnlyRequiredComponentIsAPathParameter(): void + { + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Operation "users.get" has no required request component whose absence is observable to invalidate (a path parameter cannot be omitted)'); + + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/users/{username}' => ['get' => [ + 'operationId' => 'users.get', + 'parameters' => [['name' => 'username', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]], + 'responses' => ['200' => []], + ]]], + ]); + + (new NegativeRequestCaseArbitrary())->forOperation($contract->operation('users.get')); } public function typeMismatchIsInvalidBeforeTransport(): void @@ -1174,7 +1193,7 @@ private function negativeCategories(): iterable $main, 'pets.update', static fn(NegativeRequestCaseArbitrary $negative, Operation $operation): ArbitraryInterface => $negative->forOperation($operation), - ['kind' => 'missing-required', 'location' => 'path', 'name' => 'id'], + ['kind' => 'missing-required', 'location' => 'header', 'name' => 'X-Tenant'], ]; yield 'type' => [ $main, diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index b89b889..2c97f2f 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -165,6 +165,40 @@ public function aJsonMultipartPartIsJsonEncoded(): void } } + /** + * Every integer is also a number, so `oneOf` over the two is not a plain + * choice between disjoint branches: a draw from the integer branch would + * match both and the contract rejects it. The checked path keeps a branch + * value only when every other branch rejects it (#121). + */ + public function oneOfOverIntegerAndNumberIsNotTreatedAsDisjoint(): void + { + $kinds = []; + foreach ($this->validCases($this->jsonBodyContract(['oneOf' => [['type' => 'integer'], ['type' => 'number']]]), 'things.create', 60) as $case) { + $kinds[get_debug_type($case['body']['value'] ?? null)] = true; + } + // An unbounded number branch admits every integer, so the only valid + // values are the non-integral floats. + Assert::same(array_keys($kinds), ['float']); + + $kinds = []; + foreach ($this->validCases($this->jsonBodyContract(['oneOf' => [['type' => 'integer', 'minimum' => -5, 'maximum' => 5], ['type' => 'number', 'minimum' => 0, 'maximum' => 10, 'multipleOf' => 0.5]]]), 'things.create', 120) as $case) { + $value = $case['body']['value'] ?? null; + $kinds[is_int($value) ? ($value < 0 ? 'negative int' : 'int') : 'float'] = true; + } + ksort($kinds); + // The number branch refuses the negative integers, so those stay valid + // for the integer branch; 0..5 are admitted by both and never drawn. + Assert::same(array_keys($kinds), ['float', 'negative int']); + } + + public function oneOfOverIntegerAndNumberFailsClosedWhenNoValueCanBeKeptApart(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.create", request body "application/json": oneOf number branch admits no value outside the integer branch'); + + (new RequestCaseArbitrary())->forOperation($this->jsonBodyContract(['oneOf' => [['type' => 'integer'], ['type' => 'number', 'multipleOf' => 2]]])->operation('things.create')); + } + public function aPartUnderAMediaTypeThatIsNeitherTextNorJsonFailsClosed(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart property "meta" declares content type "application/xml", which this generator can write neither as text nor as JSON'); From b6a61b37966c7e4372585dd715010637b98923b8 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:17:12 +0300 Subject: [PATCH 05/11] Refuse at compile time what used to exhaust at run time; read a header member as the wire does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A header value is judged the way the validator and RFC 9110 read it: obs-text is a field value (a UTF-8 enum member travels), whitespace at either end is stripped by the reader and so refused, an interior space is read as sent and so kept — enum: ["New York"] is generated and accepted (#129). A header const or enum member no field value can carry is refused when the operation is compiled, not discovered as an exhaustion. A path or header pattern none of whose strings survives the wire is probed at compile time and refused by name. An object meets minProperties and maxProperties by construction — an optional past the ceiling is left out, one needed for the floor brought in — instead of drawing independent presence choices and filtering three runs in four away. uniqueItems over a bounded integer domain smaller than minItems fails closed (#123). --- src/Internal/Compile/ContainerArbitraries.php | 69 +++++++++----- src/Internal/ParameterSchemas.php | 89 ++++++++++++++----- src/RequestCaseArbitrary.php | 50 ++++++++++- src/ResponseCaseArbitrary.php | 3 +- tests/NegativeResponseCaseArbitraryTest.php | 4 +- tests/ParameterSchemasTest.php | 51 ++++++++--- tests/WireAgreementTest.php | 59 ++++++++++++ 7 files changed, 268 insertions(+), 57 deletions(-) diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 002e178..9910f9c 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -66,6 +66,16 @@ private function finiteDomain(array $items): ?int return count(array_unique(array_map(serialize(...), (array) $items['enum']))); } + if (($items['type'] ?? null) === 'integer' && is_int($items['minimum'] ?? null) && is_int($items['maximum'] ?? null)) { + // An upper bound on the domain, not its size: a multipleOf would + // thin it further. Enough to refuse `minItems: 3` over `0..1` + // before a run-time exhaustion does (#123). + $minimum = (int) $items['minimum'] + ((($items['exclusiveMinimum'] ?? false) === true) ? 1 : 0); + $maximum = (int) $items['maximum'] - ((($items['exclusiveMaximum'] ?? false) === true) ? 1 : 0); + + return max(0, $maximum - $minimum + 1); + } + return match ($items['type'] ?? null) { 'boolean' => 2, 'null' => 1, @@ -124,10 +134,21 @@ public function object(array $schema): ArbitraryInterface throw UnsupportedGeneration::forSchema('required properties exceed maxProperties'); } + $additional = $this->facts->additionalPropertiesSchema($schema); + if ($additional === false && $minProperties > count($shape)) { + throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false'); + } + // The declared optionals meet the cardinality by construction: an + // optional past `maxProperties` is left out, an optional needed for + // `minProperties` is brought in — declared ones first, extras only for + // what they cannot cover. A filter here drew twelve independent + // presence choices against `maxProperties: 1` and exhausted three + // runs in four (#123). + $declaredFloor = $additional === false ? $minProperties : min($minProperties, count($shape)); /** @var ArbitraryInterface> $base */ $base = $shape === [] ? Gen::constant(value: []) - : Gen::map(Gen::record($shape), static function (array $values) use ($requiredNames): array { + : Gen::map(Gen::record($shape), static function (array $values) use ($requiredNames, $declaredFloor, $maxProperties): array { /** @var array $typed */ $typed = []; foreach (array_keys($values) as $name) { @@ -137,19 +158,11 @@ public function object(array $schema): ArbitraryInterface $typed = array_replace($typed, [$name => $values[$name]]); } - return self::objectValues($typed, $requiredNames); + return self::objectValues($typed, $requiredNames, $declaredFloor, $maxProperties); }); - // Keep optional-property branches within maxProperties. Additional - // properties are materialized only when minProperties requires them; - // this keeps generated objects small while still honoring cardinality. - $base = Gen::filter($base, static fn(array $values): bool => count($values) <= $maxProperties); - $additional = $this->facts->additionalPropertiesSchema($schema); - if ($additional === false && $minProperties > count($shape)) { - throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false'); - } if ($additional === false || $minProperties <= 0 && $shape !== []) { - return Gen::filter($base, static fn(array $values): bool => count($values) >= $minProperties); + return $base; } $keyAlphabet = 'abcdefghijklmnopqrstuvwxyz'; @@ -225,25 +238,29 @@ private function additionalProperties( return $result; } + /** + * An optional property carries a value whether or not it is present, so + * the cardinality pass can bring an absent one in without a second draw. + */ private function optionalProperty(ArbitraryInterface $compiled): ArbitraryInterface { - /** @var ArbitraryInterface $absent */ - $absent = Gen::map(Gen::constant(value: false), static fn(bool $present): array => ['present' => $present, 'value' => null]); - /** @var ArbitraryInterface $present */ - $present = Gen::map($compiled, static fn(mixed $value): array => ['present' => true, 'value' => $value]); - - return Gen::frequency([[1, $absent], [1, $present]]); + return Gen::record(['present' => Gen::bool(), 'value' => $compiled]); } /** * @param array $values * @param array $requiredNames + * @param int $declaredFloor the member count the declared optionals have + * to reach, absent ones brought in in declaration order + * @param int $maxProperties the member count past which a present + * optional is left out, last declared first * @return array keyed by member name; a numeric name is * an integer key, because that is the only way PHP can hold it */ - private static function objectValues(array $values, array $requiredNames): array + private static function objectValues(array $values, array $requiredNames, int $declaredFloor, int $maxProperties): array { $result = []; + $absent = []; foreach (array_keys($values) as $name) { // A numeric property name arrives as an integer key and is kept as // one: it normalizes back the moment it is used as an array key, @@ -253,9 +270,21 @@ private static function objectValues(array $values, array $requiredNames): array $result = array_replace($result, [$name => $values[$name]]); continue; } - if (is_array($values[$name]) && ($values[$name]['present'] ?? false) === true && array_key_exists('value', $values[$name])) { - $result = array_replace($result, [$name => $values[$name]['value']]); + $optional = $values[$name]; + if (!is_array($optional) || !array_key_exists('value', $optional)) { + throw new \LogicException('Generated optional property has an invalid shape'); + } + if (($optional['present'] ?? false) === true && count($result) < $maxProperties) { + $result = array_replace($result, [$name => $optional['value']]); + } else { + $absent = array_replace($absent, [$name => $optional['value']]); + } + } + foreach (array_keys($absent) as $name) { + if (count($result) >= $declaredFloor) { + break; } + $result = array_replace($result, [$name => $absent[$name]]); } return $result; diff --git a/src/Internal/ParameterSchemas.php b/src/Internal/ParameterSchemas.php index f9f1de5..2ea1f93 100644 --- a/src/Internal/ParameterSchemas.php +++ b/src/Internal/ParameterSchemas.php @@ -45,6 +45,10 @@ */ public function forLocation(array $schema, string $location, string $style = 'form'): array { + if ($location === 'header') { + return $this->rewrite($schema, false, null, header: SchemaShape::isArray($schema) || SchemaShape::isObject($schema) ? 'delimited' : 'scalar'); + } + return $this->rewrite($schema, $location === 'path', self::separatorOf($location, $style, $schema)); } @@ -65,11 +69,13 @@ public function forLocation(array $schema, string $location, string $style = 'fo public static function separatorOf(string $location, string $style, array $schema = []): ?string { if ($location === 'header') { - // A space is out whatever the shape: a field value is read with - // the optional whitespace stripped from both ends, and a generator - // does not control where in a string its space lands. A list or an - // object loses the comma too, which is what separates its members - // now that nothing escapes it. + // A space is out of the generated alphabet whatever the shape: a + // field value is read with the optional whitespace stripped from + // both ends, and a generator does not control where in a string + // its space lands. A list or an object loses the comma too, which + // is what separates its members now that nothing escapes it. An + // enum member or a const is judged by {@see isHeaderSafe()} + // instead — an interior space is read as sent (#129). return SchemaShape::isArray($schema) || SchemaShape::isObject($schema) ? ', ' : ' '; } @@ -106,28 +112,39 @@ public function isSeparatorSafe(mixed $value, string $separator): bool } /** - * Whether every string of a generated value can travel as an HTTP field - * value at all. RFC 9110 admits visible characters and interior - * whitespace, and a PSR-7 implementation refuses the rest outright — a - * newline in a header is a request smuggling primitive, not a value. + * Whether every string of a value can travel as an HTTP field value and + * be read back as sent. RFC 9110 admits visible characters, obs-text + * (`\x80`–`\xff`, which is how a UTF-8 value travels) and interior + * whitespace; a PSR-7 implementation refuses the rest outright — a + * newline in a header is a request smuggling primitive, not a value. The + * whitespace at either end is stripped by the reader, so a string that + * starts or ends with it is not read as sent; an interior space is + * (#129). A member of a list or an object (`$delimited`) additionally + * cannot carry the comma that separates the members, because nothing + * escapes it. * - * The schema rewrite already keeps generated strings inside printable - * ASCII; this guards what it cannot see, a `pattern` or a `format` whose - * alphabet is its own. + * The schema rewrite keeps generated plain strings inside a narrower + * alphabet; this is the judgement for what it cannot see — a `pattern`, a + * `format`, an enum member or a const, whose alphabet is their own — the + * same one {@see ParameterSerializer::assertTransmittableHeader()} makes. */ - public function isHeaderSafe(mixed $value): bool + public function isHeaderSafe(mixed $value, bool $delimited = false): bool { if (is_string($value)) { - return preg_match('/\A[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?\z/', $value) === 1 || $value === ''; + if ($delimited && str_contains($value, ',')) { + return false; + } + + return preg_match('/\A[\x21-\x7e\x80-\xff](?:[\x20-\x7e\x80-\xff]*[\x21-\x7e\x80-\xff])?\z/', $value) === 1 || $value === ''; } if (!is_array($value)) { return true; } foreach (array_keys($value) as $key) { - if (is_string($key) && !$this->isHeaderSafe($key)) { + if (is_string($key) && !$this->isHeaderSafe($key, $delimited)) { return false; } - if (!$this->isHeaderSafe($value[$key])) { + if (!$this->isHeaderSafe($value[$key], $delimited)) { return false; } } @@ -161,9 +178,11 @@ public function isPathSafe(mixed $value): bool /** * @param array $schema + * @param null|'scalar'|'delimited' $header the header shape the value is + * judged by, `null` off the header wire * @return array */ - private function rewrite(array $schema, bool $path, ?string $separator): array + private function rewrite(array $schema, bool $path, ?string $separator, ?string $header = null): array { unset($schema['nullable']); $schema = $this->withoutNullType($schema); @@ -183,11 +202,14 @@ private function rewrite(array $schema, bool $path, ?string $separator): array if ($separator !== null) { $schema = $this->delimitedItem($schema, $separator); } + if ($header !== null) { + $schema = $this->headerMember($schema, $header === 'delimited'); + } foreach (['items', 'additionalProperties', 'not'] as $keyword) { if (is_array($schema[$keyword] ?? null) && !array_is_list((array) $schema[$keyword])) { /** @var array $nested */ $nested = $schema[$keyword]; - $schema[$keyword] = $this->rewrite($nested, $path, $separator); + $schema[$keyword] = $this->rewrite($nested, $path, $separator, $header); } } if (is_array($schema['properties'] ?? null)) { @@ -197,7 +219,7 @@ private function rewrite(array $schema, bool $path, ?string $separator): array if (is_array($properties[$name]) && !array_is_list($properties[$name])) { /** @var array $property */ $property = $properties[$name]; - $properties[$name] = $this->rewrite($property, $path, $separator); + $properties[$name] = $this->rewrite($property, $path, $separator, $header); } } $schema['properties'] = $properties; @@ -208,13 +230,13 @@ private function rewrite(array $schema, bool $path, ?string $separator): array } /** @var list $branches */ $branches = (array) $schema[$keyword]; - $schema[$keyword] = array_map(function (mixed $branch) use ($path, $separator): mixed { + $schema[$keyword] = array_map(function (mixed $branch) use ($path, $separator, $header): mixed { if (!is_array($branch) || array_is_list($branch)) { return $branch; } /** @var array $branch */ - return $this->rewrite($branch, $path, $separator); + return $this->rewrite($branch, $path, $separator, $header); }, $branches); } @@ -313,6 +335,31 @@ private function delimitedItem(array $schema, string $separator): array return $schema; } + /** + * A header const or enum member that cannot be read back as sent is + * refused at compile time, not discovered as a run-time exhaustion: a + * member with whitespace at either end, a control character, or — for a + * list or an object — a comma (#123, #129). + * + * @param array $schema + * @return array + */ + private function headerMember(array $schema, bool $delimited): array + { + if (array_key_exists('const', $schema) && !$this->isHeaderSafe($schema['const'], $delimited)) { + throw UnsupportedGeneration::forSchema('a header const cannot be carried by a field value as sent'); + } + if (is_array($schema['enum'] ?? null)) { + $safe = array_values(array_filter((array) $schema['enum'], fn(mixed $member): bool => $this->isHeaderSafe($member, $delimited))); + if ($safe === []) { + throw UnsupportedGeneration::forSchema('no header enum member can be carried by a field value as sent'); + } + $schema['enum'] = $safe; + } + + return $schema; + } + /** @param array $schema */ private function isStringSchema(array $schema): bool { diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index 6e79859..e37fedd 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -8,12 +8,14 @@ use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; +use Rasuvaeff\PropertyTesting\GenerationExhaustedException; use Rasuvaeff\PropertyTesting\OpenApi\Internal\MediaType; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSerializer; use Rasuvaeff\PropertyTesting\OpenApi\Internal\RequestSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\SchemaShape; use Rasuvaeff\PropertyTesting\OpenApi\Internal\WireValue; +use Rasuvaeff\PropertyTesting\Random; /** * Produces valid, corpus-safe request cases for one compiled operation. @@ -38,6 +40,10 @@ */ final readonly class RequestCaseArbitrary { + private const int PROBES = 8; + + private const int PROBE_SEED = 11; + private SchemaArbitraryCompiler $schemas; /** The body compiler: a request never carries a `readOnly` member. */ @@ -111,15 +117,23 @@ private function location(Operation $operation, string $location): ArbitraryInte if ($location === 'header') { // Same division of labour as the path: the rewrite narrows the // alphabet, this refuses what a `pattern` or a `format` can - // still put outside an HTTP field value. - $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isHeaderSafe($value)); - } - if ($separator !== null) { + // still put outside an HTTP field value — or, for a list or + // an object, on its separating comma. + $delimited = $separator === ', '; + $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isHeaderSafe($value, $delimited)); + } elseif ($separator !== null) { // The rewrite and the narrowed alphabet construct values // without those characters; this only guards what neither can // see, a `pattern`, whose alphabet is the pattern's own. $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isSeparatorSafe($value, $separator)); } + if (($location === 'path' || $location === 'header') && $this->mentionsPattern($schema) && !$this->yieldsSomething($compiled)) { + // The rewrite cannot see inside a pattern; the filter above + // can, and a pattern none of whose strings survives the wire + // is refused here, by name, instead of exhausting mid-run. + throw UnsupportedGeneration::forSchema(sprintf('no value the pattern admits can be carried by a %s', $location === 'path' ? 'template segment' : 'field value')) + ->inOperation($operation->key, sprintf('%s parameter "%s"', $location, $parameter['name'])); + } $value = Gen::map( $compiled, fn(mixed $value): string|array => $this->wireValue($value, $schema), @@ -135,6 +149,34 @@ private function location(Operation $operation, string $location): ArbitraryInte return Gen::map(Gen::record($shape), fn(array $values): array => $this->includedValues($values)); } + /** @param array $schema */ + private function mentionsPattern(array $schema): bool + { + return str_contains(json_encode($schema, JSON_THROW_ON_ERROR), '"pattern"'); + } + + /** + * Whether a filtered arbitrary produces anything, judged the way the + * compiler's pattern probe does: deterministic draws, each with the + * filter's own retry budget, so an arbitrary that fails here is one that + * would have exhausted mid-run. + */ + private function yieldsSomething(ArbitraryInterface $arbitrary): bool + { + $random = new Random(self::PROBE_SEED); + for ($probe = 0; $probe < self::PROBES; ++$probe) { + try { + $arbitrary->generate($random); + + return true; + } catch (GenerationExhaustedException) { + continue; + } + } + + return false; + } + /** * A delimited style cannot escape its own separator, so no generated * string may carry one — the compiler is built with that character out of diff --git a/src/ResponseCaseArbitrary.php b/src/ResponseCaseArbitrary.php index fb7f5cf..907673e 100644 --- a/src/ResponseCaseArbitrary.php +++ b/src/ResponseCaseArbitrary.php @@ -139,7 +139,8 @@ private function headers(array $definition, string $operationKey, int $status): } catch (UnsupportedGeneration $refusal) { throw $refusal->inOperation($operationKey, sprintf('response "%d" header "%s"', $status, $name)); } - $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isHeaderSafe($value)); + $delimited = $separator === ', '; + $compiled = Gen::filter($compiled, fn(mixed $value): bool => $this->parameterSchemas->isHeaderSafe($value, $delimited)); $value = Gen::map($compiled, fn(mixed $value): string|array => $this->headerValue($value, $name)); // An optional header takes both branches; `null` stands for "absent" // because a present header always carries a string value. diff --git a/tests/NegativeResponseCaseArbitraryTest.php b/tests/NegativeResponseCaseArbitraryTest.php index 8cad822..d5be858 100644 --- a/tests/NegativeResponseCaseArbitraryTest.php +++ b/tests/NegativeResponseCaseArbitraryTest.php @@ -422,7 +422,9 @@ public function mutationsChangeExactlyOneThing(): void Assert::same($extra['body']['value'], array_merge($base['body']['value'], ['__openapi_extra_property__' => true])); $type = $negative->typeMismatchForOperation($operation, 200)->generate(new Random($seed))->value; - Assert::same($type['body']['value'], array_merge($base['body']['value'], ['id' => 'not-a-integer'])); + $name = $type['misuse']['name']; + Assert::true(is_string($name) && str_starts_with((string) $type['body']['value'][$name], 'not-a-'), 'the target carries a type witness'); + Assert::same($type['body']['value'], array_replace($base['body']['value'], [$name => $type['body']['value'][$name]])); $scalarOperation = $contract->operation('pets.count'); $scalarType = $negative->typeMismatchForOperation($scalarOperation, 200)->generate(new Random($seed))->value; diff --git a/tests/ParameterSchemasTest.php b/tests/ParameterSchemasTest.php index 9f82f9f..c55fad9 100644 --- a/tests/ParameterSchemasTest.php +++ b/tests/ParameterSchemasTest.php @@ -237,28 +237,59 @@ public static function separatorProvider(): iterable } /** - * RFC 9110 admits visible characters and interior whitespace in a field - * value, and a PSR-7 implementation refuses the rest outright — a newline - * in a header is a request smuggling primitive, not a value. Since the - * validator reads a header as sent (openapi-contract#66), nothing encodes - * such a value away any more. + * RFC 9110 admits visible characters, obs-text and interior whitespace in + * a field value, and a PSR-7 implementation refuses the rest outright — a + * newline in a header is a request smuggling primitive, not a value. Since + * the validator reads a header as sent (openapi-contract#66), nothing + * encodes such a value away any more; whitespace at either end is + * stripped by the reader, so it is not read as sent, while an interior + * space is (#123, #129). */ public function judgesWhetherAValueCanTravelAsAFieldValue(): void { $schemas = new ParameterSchemas(); Assert::true($schemas->isHeaderSafe('a b')); + Assert::true($schemas->isHeaderSafe('New York')); + Assert::true($schemas->isHeaderSafe('žluť')); Assert::true($schemas->isHeaderSafe('')); Assert::true($schemas->isHeaderSafe(42)); Assert::true($schemas->isHeaderSafe(null)); Assert::true($schemas->isHeaderSafe(['a', 'b c'])); - Assert::false($schemas->isHeaderSafe("a\r\nb")); - Assert::false($schemas->isHeaderSafe("a\tb")); + Assert::true($schemas->isHeaderSafe('a,b')); + Assert::false($schemas->isHeaderSafe('a,b', delimited: true)); + Assert::false($schemas->isHeaderSafe(['a', 'b,c'], delimited: true)); Assert::false($schemas->isHeaderSafe(' a')); Assert::false($schemas->isHeaderSafe('a ')); - Assert::false($schemas->isHeaderSafe('ć')); - Assert::false($schemas->isHeaderSafe(['ok', "bad\n"])); - Assert::false($schemas->isHeaderSafe(["bad\n" => 'ok'])); + Assert::false($schemas->isHeaderSafe("a\tb")); + Assert::false($schemas->isHeaderSafe("a\r\nb")); + } + + /** + * A header enum keeps every member the wire can carry as sent — an + * interior space included — and refuses at compile time when none can + * (#123, #129). + */ + public function narrowsAHeaderEnumToTheMembersReadAsSent(): void + { + $schemas = new ParameterSchemas(); + + Assert::same($schemas->forLocation(['type' => 'string', 'enum' => ['New York', ' padded', 'plain', "a\nb", 'žluť']], 'header', 'simple')['enum'], ['New York', 'plain', 'žluť']); + Assert::same($schemas->forLocation(['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a,b', 'c d']]], 'header', 'simple')['items']['enum'], ['c d']); + + try { + $schemas->forLocation(['type' => 'string', 'enum' => [' a', 'b ']], 'header', 'simple'); + Assert::true(actual: false, message: 'Expected a refusal'); + } catch (UnsupportedGeneration $refusal) { + Assert::same($refusal->getMessage(), 'Unsupported OpenAPI schema generation: no header enum member can be carried by a field value as sent'); + } + + try { + $schemas->forLocation(['type' => 'string', 'const' => "a\rb"], 'header', 'simple'); + Assert::true(actual: false, message: 'Expected a refusal'); + } catch (UnsupportedGeneration $refusal) { + Assert::same($refusal->getMessage(), 'Unsupported OpenAPI schema generation: a header const cannot be carried by a field value as sent'); + } } public function judgesPathSafetyOfEveryStringInAValue(): void diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 2c97f2f..8a5b963 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -199,6 +199,65 @@ public function oneOfOverIntegerAndNumberFailsClosedWhenNoValueCanBeKeptApart(): (new RequestCaseArbitrary())->forOperation($this->jsonBodyContract(['oneOf' => [['type' => 'integer'], ['type' => 'number', 'multipleOf' => 2]]])->operation('things.create')); } + /** + * Three legal documents reached a run-time `GenerationExhausted` the + * package's own rules call a defect (#123): a header enum outside ASCII, + * a path pattern that always carries a slash, an object whose optionals + * outnumber `maxProperties`. The first and the third generate; the + * second is refused at compile time, by name. + */ + public function legalDocumentsNeverExhaustAtRunTime(): void + { + $header = $this->parameterContract([['name' => 'X-Lang', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['žluť', 'New York']]]]); + $seen = []; + foreach ($this->validCases($header, 'things.list', 40) as $case) { + $seen[$case['headers']['X-Lang']] = true; + } + ksort($seen); + Assert::same(array_keys($seen), ['New York', 'žluť']); + + $properties = []; + foreach (range('a', 'l') as $name) { + $properties[$name] = ['type' => 'integer']; + } + foreach ($this->validCases($this->jsonBodyContract(['type' => 'object', 'properties' => $properties, 'maxProperties' => 1, 'additionalProperties' => false]), 'things.create', 150) as $case) { + Assert::true(count($case['body']['value'] ?? []) <= 1); + } + foreach ($this->validCases($this->jsonBodyContract(['type' => 'object', 'properties' => $properties, 'minProperties' => 10, 'maxProperties' => 11, 'additionalProperties' => false]), 'things.create', 50) as $case) { + $count = count($case['body']['value'] ?? []); + Assert::true($count >= 10 && $count <= 11); + } + } + + #[DataProvider('compileTimeRefusalProvider')] + public function unsatisfiableParametersAreRefusedAtCompileTime(array $parameter, string $message, string $path = '/things'): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage($message); + + (new RequestCaseArbitrary())->forOperation($this->parameterContract([$parameter], $path)->operation('things.list')); + } + + public static function compileTimeRefusalProvider(): iterable + { + yield 'a path pattern that always carries a slash' => [ + ['name' => 'slug', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'pattern' => '^[a-z]+/[a-z]+$']], + 'Unsupported OpenAPI schema generation for operation "things.list", path parameter "slug": no value the pattern admits can be carried by a template segment', + '/things/{slug}', + ]; + yield 'a header pattern that always starts with a space' => [ + ['name' => 'X-Pad', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'pattern' => '^ [a-z]+$']], + 'Unsupported OpenAPI schema generation for operation "things.list", header parameter "X-Pad": no value the pattern admits can be carried by a field value', + ]; + yield 'a header enum no member of which is read as sent' => [ + ['name' => 'X-Pad', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'enum' => [' a', 'b ']]], + 'Unsupported OpenAPI schema generation for operation "things.list", header parameter "X-Pad": no header enum member can be carried by a field value as sent', + ]; + yield 'uniqueItems over an integer domain smaller than minItems' => [ + ['name' => 'ids', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'array', 'uniqueItems' => true, 'minItems' => 3, 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 1]]], + 'Unsupported OpenAPI schema generation for operation "things.list", query parameter "ids": uniqueItems cannot fill minItems from the finite item domain', + ]; + } + public function aPartUnderAMediaTypeThatIsNeitherTextNorJsonFailsClosed(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart property "meta" declares content type "application/xml", which this generator can write neither as text nor as JSON'); From fcac3983b03c48782a297bf2460d57dbca9e4537 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:19:31 +0300 Subject: [PATCH 06/11] Generate a nested exploded form object without undeclared members A form property with an object schema and explode: true is written as flat member=value pairs, and the contract claims only the declared members for it: an undeclared member the generator added landed as a top-level member of the body, where it collided with a declared property or violated additionalProperties: false. Such an object is compiled without extras, and one whose minProperties its declared properties cannot meet fails closed when the operation is compiled (#120). --- src/RequestCaseArbitrary.php | 43 +++++++++++++++++++++++++++++++++++- tests/WireAgreementTest.php | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index e37fedd..695b9ab 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -266,7 +266,7 @@ private function bodyArbitrary(string $mediaType, string $normalized, array $sch $this->assertObjectSchema($schema, 'Form request body schema must be an object'); $this->assertFormEncoding($definition['encoding'] ?? []); /** @var ArbitraryInterface $form */ - $form = Gen::map($this->bodySchemas->compile($this->nonEmptyRequiredProperties($schema)), static fn(mixed $value): array => [ + $form = Gen::map($this->bodySchemas->compile($this->nonEmptyRequiredProperties($this->explodedObjectsWithoutExtras($schema, $definition['encoding'] ?? []))), static fn(mixed $value): array => [ 'mediaType' => $mediaType, 'encoding' => 'form', 'value' => $value, @@ -549,6 +549,47 @@ private function nonEmptyContainer(array $schema): array return $schema; } + /** + * A form property with an object schema and `explode: true` (the form + * default) is written as flat `member=value` pairs, so its wire form can + * carry only the members the document declares: an undeclared member the + * generator added would land as a top-level member of the body, where it + * collides with a declared property or violates `additionalProperties: + * false`. Such an object is generated without extras, and one whose + * `minProperties` its declared properties cannot meet fails closed here + * rather than as a run-time exhaustion (#120). + * + * @param array $schema + * @return array + */ + private function explodedObjectsWithoutExtras(array $schema, mixed $encoding): array + { + $properties = is_array($schema['properties'] ?? null) ? (array) $schema['properties'] : []; + foreach (array_keys($properties) as $name) { + $property = $properties[$name]; + if (!is_array($property) || array_is_list($property)) { + continue; + } + /** @var array $property */ + if (!SchemaShape::isObject($property)) { + continue; + } + $configuration = is_array($encoding) && is_array($encoding[$name] ?? null) ? (array) $encoding[$name] : []; + if (($configuration['explode'] ?? true) !== true) { + continue; + } + $declared = is_array($property['properties'] ?? null) ? count((array) $property['properties']) : 0; + $minimum = is_int($property['minProperties'] ?? null) ? (int) $property['minProperties'] : 0; + if ($minimum > $declared) { + throw UnsupportedGeneration::forSchema(sprintf('form property "%s" is an exploded object whose minProperties %d cannot be met by its %d declared properties, and its wire form carries no undeclared member', (string) $name, $minimum, $declared)); + } + $property['additionalProperties'] = false; + $properties[$name] = $property; + } + + return array_merge($schema, ['properties' => $properties]); + } + /** * @param array $schema * @return array diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 8a5b963..4bebf38 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -258,6 +258,32 @@ public static function compileTimeRefusalProvider(): iterable ]; } + /** + * A nested exploded form object is written as flat pairs, so the + * contract claims only its declared members for it: an undeclared member + * would be read as a top-level one. It is generated without extras, and + * a `minProperties` its declared properties cannot meet is refused at + * compile time (#120). + */ + public function aNestedExplodedFormObjectCarriesOnlyDeclaredMembers(): void + { + $contract = $this->formBodyContract(['type' => 'object', 'required' => ['t'], 'additionalProperties' => false, 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 4], + 't' => ['type' => 'object', 'minProperties' => 1, 'properties' => ['x' => ['type' => 'integer'], 'y' => ['type' => 'string', 'maxLength' => 3]]], + ]]); + + foreach ($this->validCases($contract, 'things.create', 200) as $case) { + Assert::same(array_diff(array_keys($case['body']['value']['t']), ['x', 'y']), []); + } + } + + public function aNestedExplodedFormObjectThatNeedsUndeclaredMembersFailsClosed(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.create", request body "application/x-www-form-urlencoded": form property "t" is an exploded object whose minProperties 1 cannot be met by its 0 declared properties, and its wire form carries no undeclared member'); + + (new RequestCaseArbitrary())->forOperation($this->formBodyContract(['type' => 'object', 'properties' => ['t' => ['type' => 'object', 'minProperties' => 1]]])->operation('things.create')); + } + public function aPartUnderAMediaTypeThatIsNeitherTextNorJsonFailsClosed(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart property "meta" declares content type "application/xml", which this generator can write neither as text nor as JSON'); @@ -312,6 +338,19 @@ private function negativeCases(Contract $contract, string $operationKey, Arbitra return $cases; } + /** @param array $schema */ + private function formBodyContract(array $schema): Contract + { + return Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/things' => ['post' => [ + 'operationId' => 'things.create', + 'requestBody' => ['required' => true, 'content' => ['application/x-www-form-urlencoded' => ['schema' => $schema]]], + 'responses' => ['201' => []], + ]]], + ]); + } + /** @param array $schema */ private function jsonBodyContract(array $schema, string $version = '3.1.0'): Contract { From d23769c3b7eee028649410334253722f34214dbc Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:22:18 +0300 Subject: [PATCH 07/11] Declare the case shape once and check it at the @api boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaseData and its parts (ParameterMap, PartData, BodyData, MisuseData) live on ContractSuite and are imported everywhere else; the RequestCaseData and NegativeRequestCaseData aliases and the inline copies in the materializer and the reproducer are gone. checkValid(), checkNegative(), reproduce(), redact() and RequestMaterializer::materialize() refuse a case missing a key with InvalidCase naming it — a hand-written case without misuse used to raise a warning and pass checkValid() (#128). --- src/ContractSuite.php | 41 ++++++----- src/Internal/CaseShape.php | 53 ++++++++++++++ src/Internal/JsonBodyEncoder.php | 2 +- src/NegativeRequestCaseArbitrary.php | 91 +++++++++++------------ src/RequestCaseArbitrary.php | 22 +----- src/RequestMaterializer.php | 21 ++---- src/RequestReproducer.php | 41 +++-------- tests/CaseShapeTest.php | 105 +++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 128 deletions(-) create mode 100644 src/Internal/CaseShape.php create mode 100644 tests/CaseShapeTest.php diff --git a/src/ContractSuite.php b/src/ContractSuite.php index 8287a94..2f6d0c7 100644 --- a/src/ContractSuite.php +++ b/src/ContractSuite.php @@ -11,6 +11,7 @@ use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\Operation; use Rasuvaeff\PropertyTesting\ArbitraryInterface; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\CaseShape; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ConstructibleCategories; /** @@ -23,14 +24,24 @@ * a selection that names an unsafe operation without that gate fails closed * instead of silently filtering it out. * + * The case shape is declared here once and imported everywhere else + * (`@psalm-import-type CaseData from ContractSuite`); a case that does not + * have it is refused at every `@api` entry point with {@see InvalidCase} + * naming the missing key (#128). A valid case carries `misuse: null`, a + * negative one the misuse it was built with. + * + * @psalm-type ParameterMap = array|array> + * @psalm-type PartData = array{name: string, value: string, encoding: 'text'|'base64', contentType: string, headers: array} + * @psalm-type BodyData = array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list, value?: mixed} + * @psalm-type MisuseData = array{kind: non-empty-string, location: non-empty-string, name: string} * @psalm-type CaseData = array{ * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, + * path: ParameterMap, + * query: ParameterMap, + * headers: ParameterMap, + * cookies: ParameterMap, + * body: null|BodyData, + * misuse: null|MisuseData, * } * * @psalm-import-type CoverageData from NegativeRequestCaseArbitrary @@ -225,17 +236,7 @@ public function operationKeys(): array return $keys; } - /** - * @return ArbitraryInterface|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null, - * }> - */ + /** @return ArbitraryInterface */ public function validCases(string $operationKey): ArbitraryInterface { return $this->valid->forOperation($this->requireSelected($operationKey)); @@ -336,6 +337,7 @@ public function negativeCoverage(): array */ public function checkValid(string $operationKey, array $case): void { + CaseShape::assert($case); if ($case['misuse'] !== null) { throw new \InvalidArgumentException('A valid check requires a case without misuse metadata'); } @@ -368,6 +370,7 @@ public function checkValid(string $operationKey, array $case): void */ public function checkNegative(string $operationKey, array $case): void { + CaseShape::assert($case); if ($case['misuse'] === null) { throw new \InvalidArgumentException('A negative check requires a case with misuse metadata'); } @@ -396,6 +399,8 @@ public function checkNegative(string $operationKey, array $case): void */ public function reproduce(string $operationKey, array $case, ?RedactionPolicy $policy = null): string { + CaseShape::assert($case); + return (new RequestReproducer($this->materializer))->curl($this->requireSelected($operationKey), $case, $policy ?? $this->redaction ?? new RedactionPolicy()); } @@ -409,6 +414,8 @@ public function reproduce(string $operationKey, array $case, ?RedactionPolicy $p */ public function redact(array $case): array { + CaseShape::assert($case); + return (new RequestReproducer($this->materializer))->redact($case, $this->redaction ?? new RedactionPolicy()); } diff --git a/src/Internal/CaseShape.php b/src/Internal/CaseShape.php new file mode 100644 index 0000000..fb154e0 --- /dev/null +++ b/src/Internal/CaseShape.php @@ -0,0 +1,53 @@ + $case + */ + public static function assert(array $case): void + { + foreach (['operationKey', ...self::PARAMETER_MAPS, 'body', 'misuse'] as $key) { + if (!array_key_exists($key, $case)) { + throw InvalidCase::missingKey($key); + } + } + if (!is_string($case['operationKey'])) { + throw new InvalidCase('Case "operationKey" must be a string'); + } + foreach (self::PARAMETER_MAPS as $key) { + if (!is_array($case[$key])) { + throw new InvalidCase(sprintf('Case "%s" must be a map of parameter values', $key)); + } + } + $body = $case['body']; + if ($body !== null) { + if (!is_array($body) || !is_string($body['encoding'] ?? null) || !is_string($body['mediaType'] ?? null)) { + throw new InvalidCase('Case "body" must be null or carry string "encoding" and "mediaType" members'); + } + } + $misuse = $case['misuse']; + if ($misuse !== null) { + if (!is_array($misuse) || !is_string($misuse['kind'] ?? null) || !is_string($misuse['location'] ?? null) || !is_string($misuse['name'] ?? null)) { + throw new InvalidCase('Case "misuse" must be null or carry string "kind", "location" and "name" members'); + } + } + } +} diff --git a/src/Internal/JsonBodyEncoder.php b/src/Internal/JsonBodyEncoder.php index 260af9f..803e6cb 100644 --- a/src/Internal/JsonBodyEncoder.php +++ b/src/Internal/JsonBodyEncoder.php @@ -17,7 +17,7 @@ * whose names run 0, 1, … without a gap — in a JSON-compatible PHP value that * is a list, and a list is what a negative case sends when it means to * violate an object schema. Distinguishing them would need a marker in - * `RequestCaseData`, which has to stay data-only. + * `CaseData`, which has to stay data-only. * * @internal */ diff --git a/src/NegativeRequestCaseArbitrary.php b/src/NegativeRequestCaseArbitrary.php index ca23215..7a5a090 100644 --- a/src/NegativeRequestCaseArbitrary.php +++ b/src/NegativeRequestCaseArbitrary.php @@ -17,22 +17,19 @@ * The generated value remains corpus-safe; `misuse` identifies the deliberate * invalidation and is never interpreted as a secret or a PSR-7 object. * - * @psalm-import-type RequestCaseData from RequestCaseArbitrary + * @psalm-import-type CaseData from ContractSuite * @psalm-import-type Kind from JsonBodyWitness * @psalm-type CoverageData = array{ * covered: list, * skipped: list, * } * @psalm-import-type Witness from JsonBodyWitness - * @psalm-type NegativeRequestCaseData = array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: array{kind: 'missing-required'|'type'|'enum'|'const'|'boundary'|'length'|'format'|'pattern'|'additional-properties'|'media-type'|'part-content-type'|'json-syntax', location: 'path'|'query'|'header'|'cookie'|'body', name: string}, - * } + * + * Every arbitrary here yields the exported `CaseData` with `misuse` set to + * one of: `missing-required`, `type`, `enum`, `const`, `boundary`, `length`, + * `format`, `pattern`, `additional-properties`, `media-type`, + * `part-content-type`, `json-syntax`, located in `path`, `query`, `header`, + * `cookie` or `body`. * * @api */ @@ -64,7 +61,7 @@ public function __construct( /** * Drops one required parameter, or the whole required body. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function forOperation(Operation $operation): ArbitraryInterface { @@ -76,7 +73,7 @@ public function forOperation(Operation $operation): ArbitraryInterface $name = $target['name']; return static function (array $case) use ($location, $name): array { - /** @var RequestCaseData $case */ + /** @var CaseData $case */ if ($location === 'body') { $case['body'] = null; } else { @@ -93,7 +90,7 @@ public function forOperation(Operation $operation): ArbitraryInterface * Replaces one scalar parameter with a wire value that cannot satisfy its * integer, number, boolean, or null schema type. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function typeMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -104,7 +101,7 @@ public function typeMismatchForOperation(Operation $operation): ArbitraryInterfa * Replaces one scalar parameter with a value absent from its finite * enum. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function enumMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -115,7 +112,7 @@ public function enumMismatchForOperation(Operation $operation): ArbitraryInterfa * Replaces one scalar parameter with a value other than the single one * its `const` admits. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function constMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -126,7 +123,7 @@ public function constMismatchForOperation(Operation $operation): ArbitraryInterf * Replaces one numeric parameter with a wire value just outside its * `minimum`/`maximum` bound, honouring boolean exclusive bounds. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function boundaryMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -137,7 +134,7 @@ public function boundaryMismatchForOperation(Operation $operation): ArbitraryInt * Replaces one string parameter with a wire value whose length falls * just outside its `minLength`/`maxLength` bound. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function lengthMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -148,7 +145,7 @@ public function lengthMismatchForOperation(Operation $operation): ArbitraryInter * Replaces one string parameter with a wire value that provably violates * its asserted `format`. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function formatMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -160,7 +157,7 @@ public function formatMismatchForOperation(Operation $operation): ArbitraryInter * fails its `pattern`; the pattern itself is the oracle, and an exhausted * search budget fails closed. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function patternMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -171,7 +168,7 @@ public function patternMismatchForOperation(Operation $operation): ArbitraryInte * Adds one undeclared property to a required JSON object body whose schema * sets `additionalProperties: false`. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function additionalPropertyForOperation(Operation $operation): ArbitraryInterface { @@ -196,7 +193,7 @@ public function additionalPropertyForOperation(Operation $operation): ArbitraryI * Keeps the schema-valid JSON body but sends it under an undeclared * Content-Type, so the media type is the only deviation. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function mediaTypeMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -224,7 +221,7 @@ public function mediaTypeMismatchForOperation(Operation $operation): ArbitraryIn * `encoding.contentType` and ignoring it: neglecting the keyword is * fail-open, so every valid case passes either way (#80). * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function partContentTypeMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -256,7 +253,7 @@ public function partContentTypeMismatchForOperation(Operation $operation): Arbit * Replaces the required JSON body with a deliberately malformed raw JSON * payload under the declared media type. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function malformedJsonForOperation(Operation $operation): ArbitraryInterface { @@ -283,7 +280,7 @@ public function malformedJsonForOperation(Operation $operation): ArbitraryInterf * Replaces one top-level value of the required JSON body with one that * cannot satisfy its single declared schema type. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyTypeMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -294,7 +291,7 @@ public function bodyTypeMismatchForOperation(Operation $operation): ArbitraryInt * Replaces one top-level value of the required JSON body with a value * absent from its finite scalar enum. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyEnumMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -305,7 +302,7 @@ public function bodyEnumMismatchForOperation(Operation $operation): ArbitraryInt * Replaces one top-level value of the required JSON body with a value * other than the single one its `const` admits. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyConstMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -317,7 +314,7 @@ public function bodyConstMismatchForOperation(Operation $operation): ArbitraryIn * just outside its `minimum`/`maximum` bound, honouring boolean exclusive * bounds. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyBoundaryMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -329,7 +326,7 @@ public function bodyBoundaryMismatchForOperation(Operation $operation): Arbitrar * with one whose length falls just outside its `minLength`/`maxLength` or * `minItems`/`maxItems` bound. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyLengthMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -340,7 +337,7 @@ public function bodyLengthMismatchForOperation(Operation $operation): ArbitraryI * Replaces one top-level string value of the required JSON body with a * fixed witness that provably violates its asserted `format`. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyFormatMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -352,7 +349,7 @@ public function bodyFormatMismatchForOperation(Operation $operation): ArbitraryI * searched witness that provably fails its `pattern`; the pattern itself * is the oracle, and an exhausted search budget fails closed. * - * @return ArbitraryInterface + * @return ArbitraryInterface */ public function bodyPatternMismatchForOperation(Operation $operation): ArbitraryInterface { @@ -369,7 +366,7 @@ public function bodyPatternMismatchForOperation(Operation $operation): Arbitrary * under each of them, and only the JSON one has the value to overwrite. * * @param Kind $kind - * @return ArbitraryInterface + * @return ArbitraryInterface */ private function bodyWitness(string $kind, Operation $operation): ArbitraryInterface { @@ -382,7 +379,7 @@ private function bodyWitness(string $kind, Operation $operation): ArbitraryInter $invalid = $target['invalid']; return static function (array $case) use ($kind, $name, $invalid): array { - /** @var RequestCaseData $case */ + /** @var CaseData $case */ $body = $case['body']; $members = $body['value'] ?? null; if ($body === null) { @@ -421,14 +418,14 @@ private function bodyWitness(string $kind, Operation $operation): ArbitraryInter * * @param 'type'|'enum'|'const'|'boundary'|'length'|'format'|'pattern' $kind * @param non-empty-list $targets - * @return ArbitraryInterface + * @return ArbitraryInterface */ private function parameter(string $kind, Operation $operation, array $targets): ArbitraryInterface { return $this->overTargets($this->valid->forOperation($operation), $targets, static function (array $target) use ($kind): \Closure { /** @var array{location: 'path'|'query'|'header'|'cookie', name: string, invalid: string} $target */ return static function (array $case) use ($kind, $target): array { - /** @var RequestCaseData $case */ + /** @var CaseData $case */ $case[self::CASE_KEYS[$target['location']]][$target['name']] = $target['invalid']; $case['misuse'] = ['kind' => $kind, 'location' => $target['location'], 'name' => $target['name']]; @@ -555,10 +552,10 @@ private function located(array $targets): array * declaration order, so the minimal counterexample is the target the * first-match search used to return. * - * @param ArbitraryInterface $valid + * @param ArbitraryInterface $valid * @param non-empty-list> $targets - * @param \Closure(array): \Closure(RequestCaseData): NegativeRequestCaseData $mutationFor - * @return ArbitraryInterface + * @param \Closure(array): \Closure(CaseData): CaseData $mutationFor + * @return ArbitraryInterface */ private function overTargets(ArbitraryInterface $valid, array $targets, \Closure $mutationFor): ArbitraryInterface { @@ -567,9 +564,9 @@ private function overTargets(ArbitraryInterface $valid, array $targets, \Closure // lands beside what the valid draw built, never in place of it. $drawn = Gen::record(['case' => $valid, 'target' => Gen::elements($targets)]); - /** @var ArbitraryInterface $mutated */ + /** @var ArbitraryInterface $mutated */ $mutated = Gen::map($drawn, static function (array $draw) use ($mutationFor): array { - /** @var array{case: RequestCaseData, target: array} $draw */ + /** @var array{case: CaseData, target: array} $draw */ return $mutationFor($draw['target'])($draw['case']); }); @@ -587,12 +584,12 @@ private function overTargets(ArbitraryInterface $valid, array $targets, \Closure * target was found under rather than on the unfiltered valid cases (#97). * * @param non-empty-string $mediaType - * @param \Closure(RequestCaseData): NegativeRequestCaseData $mutation - * @return ArbitraryInterface + * @param \Closure(CaseData): CaseData $mutation + * @return ArbitraryInterface */ private function mutateJsonBody(Operation $operation, string $mediaType, \Closure $mutation): ArbitraryInterface { - /** @var ArbitraryInterface $mutated */ + /** @var ArbitraryInterface $mutated */ $mutated = Gen::map($this->jsonCases($operation, $mediaType), $mutation); return $mutated; @@ -602,12 +599,12 @@ private function mutateJsonBody(Operation $operation, string $mediaType, \Closur * The valid cases of this operation that carry one JSON media type. * * @param non-empty-string $mediaType - * @return ArbitraryInterface + * @return ArbitraryInterface */ private function jsonCases(Operation $operation, string $mediaType): ArbitraryInterface { $carriesJson = static function (array $case) use ($mediaType): bool { - /** @var RequestCaseData $case */ + /** @var CaseData $case */ $body = $case['body']; return $body !== null && $body['encoding'] === 'json' && $body['mediaType'] === $mediaType; @@ -617,12 +614,12 @@ private function jsonCases(Operation $operation, string $mediaType): ArbitraryIn } /** - * @param \Closure(RequestCaseData): NegativeRequestCaseData $mutation - * @return ArbitraryInterface + * @param \Closure(CaseData): CaseData $mutation + * @return ArbitraryInterface */ private function mutate(Operation $operation, \Closure $mutation): ArbitraryInterface { - /** @var ArbitraryInterface $mutated */ + /** @var ArbitraryInterface $mutated */ $mutated = Gen::map($this->valid->forOperation($operation), $mutation); return $mutated; diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index 695b9ab..51449de 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -26,15 +26,7 @@ * template segment after percent-decoding. Request bodies are generated * from the request direction of their schema, without `readOnly` members. * - * @psalm-type RequestCaseData = array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null, - * } + * @psalm-import-type CaseData from ContractSuite * * @api */ @@ -61,7 +53,7 @@ public function __construct() $this->requestSchemas = new RequestSchemas(); } - /** @return ArbitraryInterface */ + /** @return ArbitraryInterface */ public function forOperation(Operation $operation): ArbitraryInterface { $arbitrary = Gen::map(Gen::record([ @@ -80,15 +72,7 @@ public function forOperation(Operation $operation): ArbitraryInterface 'misuse' => null, ]); - /** @var ArbitraryInterface|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null, - * }> $arbitrary */ + /** @var ArbitraryInterface $arbitrary */ return $arbitrary; } diff --git a/src/RequestMaterializer.php b/src/RequestMaterializer.php index 34e4e35..389cef9 100644 --- a/src/RequestMaterializer.php +++ b/src/RequestMaterializer.php @@ -8,6 +8,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Rasuvaeff\OpenApiContract\Operation; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\CaseShape; use Rasuvaeff\PropertyTesting\OpenApi\Internal\JsonBodyEncoder; use Rasuvaeff\PropertyTesting\OpenApi\Internal\MediaType; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSerializer; @@ -27,6 +28,9 @@ * @api * * @psalm-import-type CompiledMediaType from Operation + * @psalm-import-type CaseData from ContractSuite + * @psalm-import-type PartData from ContractSuite + * @psalm-import-type MisuseData from ContractSuite */ final readonly class RequestMaterializer { @@ -56,19 +60,10 @@ public function withBaseUri(string $baseUri): self return new self($this->requests, $this->streams, $baseUri); } - /** - * @param array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{mediaType: string, encoding: 'json'|'raw'|'form', value: mixed}|array{mediaType: string, encoding: 'multipart', boundary: string, parts: list}>}, - * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, - * } $case - */ + /** @param CaseData $case */ public function materialize(Operation $operation, array $case, ?Credentials $credentials = null): RequestInterface { + CaseShape::assert($case); if ($case['operationKey'] !== $operation->key) { throw new InvalidCase(sprintf('Request case targets "%s", not "%s"', $case['operationKey'], $operation->key)); } @@ -330,7 +325,7 @@ private function bodyEncoding(Operation $operation, string $mediaType): array return $operation->requestBody['content'][$mediaType]['encoding'] ?? []; } - /** @param list}> $parts */ + /** @param list $parts */ private function multipartBody(array $parts, string $boundary): string { if ($boundary === '' || strlen($boundary) > 70 || preg_match("/^[0-9A-Za-z'()+_,.\/:=? -]+\\z/", $boundary) !== 1) { @@ -370,7 +365,7 @@ private function quoteHeader(string $value): string * body is still encoded with the declared JSON schema so the media type is * the only deviation. * - * @param null|array{kind: non-empty-string, location: non-empty-string, name: string} $misuse + * @param null|MisuseData $misuse * @return array */ private function bodySchema(Operation $operation, string $mediaType, ?array $misuse): array diff --git a/src/RequestReproducer.php b/src/RequestReproducer.php index 0b781d3..37ff954 100644 --- a/src/RequestReproducer.php +++ b/src/RequestReproducer.php @@ -27,6 +27,9 @@ * prints as the minimal case, so a secret the policy names appears in * neither (#124). * + * @psalm-import-type CaseData from ContractSuite + * @psalm-import-type BodyData from ContractSuite + * * @internal Reach it through {@see ContractSuite::reproduce()} and * {@see ContractSuite::redact()}. */ @@ -43,15 +46,7 @@ public function __construct( ) {} /** - * @param array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, - * } $case + * @param CaseData $case */ public function curl(Operation $operation, array $case, RedactionPolicy $policy = new RedactionPolicy()): string { @@ -73,24 +68,8 @@ public function curl(Operation $operation, array $case, RedactionPolicy $policy } /** - * @param array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, - * } $case - * @return array{ - * operationKey: string, - * path: array|array>, - * query: array|array>, - * headers: array|array>, - * cookies: array|array>, - * body: null|array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed}, - * misuse: null|array{kind: non-empty-string, location: non-empty-string, name: string}, - * } + * @param CaseData $case + * @return CaseData */ public function redact(array $case, RedactionPolicy $policy): array { @@ -127,9 +106,9 @@ public function redact(array $case, RedactionPolicy $policy): array } /** - * @param array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed} $body + * @param BodyData $body * @param list $paths - * @return array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed} + * @return BodyData */ private function redactBodyValue(array $body, array $paths): array { @@ -146,9 +125,9 @@ private function redactBodyValue(array $body, array $paths): array } /** - * @param array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed} $body + * @param BodyData $body * @param list $paths - * @return array{boundary?: string, encoding: 'form'|'json'|'multipart'|'raw', mediaType: string, parts?: list}>, value?: mixed} + * @return BodyData */ private function redactBodyParts(array $body, array $paths): array { diff --git a/tests/CaseShapeTest.php b/tests/CaseShapeTest.php new file mode 100644 index 0000000..a072f86 --- /dev/null +++ b/tests/CaseShapeTest.php @@ -0,0 +1,105 @@ + 'pets.get', 'path' => ['id' => '3'], 'query' => [], 'headers' => [], 'cookies' => [], 'body' => null, 'misuse' => null]; + + #[DataProvider('missingKeyProvider')] + public function aMissingKeyIsNamed(string $key): void + { + Expect::exception(InvalidCase::class)->withMessage(sprintf('Case is missing the "%s" key', $key)); + + $case = self::CASE; + unset($case[$key]); + CaseShape::assert($case); + } + + public static function missingKeyProvider(): iterable + { + foreach (array_keys(self::CASE) as $key) { + yield $key => [$key]; + } + } + + #[DataProvider('wrongTypeProvider')] + public function aMemberOfTheWrongTypeIsNamed(array $case, string $message): void + { + Expect::exception(InvalidCase::class)->withMessage($message); + + CaseShape::assert($case); + } + + public static function wrongTypeProvider(): iterable + { + yield 'operation key' => [array_replace(self::CASE, ['operationKey' => 5]), 'Case "operationKey" must be a string']; + yield 'query' => [array_replace(self::CASE, ['query' => 'a=b']), 'Case "query" must be a map of parameter values']; + yield 'body without encoding' => [array_replace(self::CASE, ['body' => ['mediaType' => 'application/json']]), 'Case "body" must be null or carry string "encoding" and "mediaType" members']; + yield 'body as a string' => [array_replace(self::CASE, ['body' => '{}']), 'Case "body" must be null or carry string "encoding" and "mediaType" members']; + yield 'misuse without name' => [array_replace(self::CASE, ['misuse' => ['kind' => 'type', 'location' => 'query']]), 'Case "misuse" must be null or carry string "kind", "location" and "name" members']; + } + + public function aWellFormedCasePasses(): void + { + CaseShape::assert(self::CASE); + CaseShape::assert(array_replace(self::CASE, ['body' => ['encoding' => 'json', 'mediaType' => 'application/json', 'value' => 1], 'misuse' => ['kind' => 'type', 'location' => 'body', 'name' => 'body']])); + + Assert::true(actual: true); + } + + /** + * The check stands at every entry point that takes a case, not only at + * the materializer the suite happens to route through. + */ + #[DataProvider('entryPointProvider')] + public function everyEntryPointRefusesACaseWithoutMisuse(\Closure $entry): void + { + Expect::exception(InvalidCase::class)->withMessage('Case is missing the "misuse" key'); + + $case = self::CASE; + unset($case['misuse']); + $factory = new Psr17Factory(); + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/pets/{id}' => ['get' => [ + 'operationId' => 'pets.get', + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'responses' => ['204' => []], + ]]], + ]); + $suite = ContractSuite::fromContract($contract, $factory, $factory)->operations(['pets.get']); + + $entry($suite, $contract, new RequestMaterializer($factory, $factory), $case); + } + + public static function entryPointProvider(): iterable + { + yield 'checkValid' => [static fn(ContractSuite $suite, Contract $contract, RequestMaterializer $materializer, array $case) => $suite->checkValid('pets.get', $case)]; + yield 'checkNegative' => [static fn(ContractSuite $suite, Contract $contract, RequestMaterializer $materializer, array $case) => $suite->checkNegative('pets.get', $case)]; + yield 'reproduce' => [static fn(ContractSuite $suite, Contract $contract, RequestMaterializer $materializer, array $case) => $suite->reproduce('pets.get', $case)]; + yield 'redact' => [static fn(ContractSuite $suite, Contract $contract, RequestMaterializer $materializer, array $case) => $suite->redact($case)]; + yield 'materialize' => [static fn(ContractSuite $suite, Contract $contract, RequestMaterializer $materializer, array $case) => $materializer->materialize($contract->operation('pets.get'), $case)]; + } +} From 448020cf2006c9b0a97916b399404732963f5d81 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:31:13 +0300 Subject: [PATCH 08/11] Document the wave: exceptions, redaction, the case shape, the wire rules; zoo for the five new rules; 0.15.0 Requires openapi-contract ^0.12 and accepts property-testing-core ^0.11. Five zoo operations exercise the header-member judgement, object cardinality by construction, oneOf over integer and number, the nested exploded form object and numbers at full precision beside allowReserved; the contract's generated corpus is re-recorded from them (openapi-contract#152). --- AGENTS.md | 44 +++++++-- CHANGELOG.md | 98 +++++++++++++++++++ README.md | 92 +++++++++++++---- README.ru.md | 91 +++++++++++++---- composer.json | 4 +- llms.txt | 68 ++++++++++--- .../Compile/CompositionArbitraries.php | 14 ++- src/Internal/ParameterSchemas.php | 2 +- tests/Support/ZooContracts.php | 69 +++++++++++++ tests/WireAgreementTest.php | 23 ----- 10 files changed, 411 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 030d678..35ce840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,18 +44,29 @@ into the monorepo) plus `git config --global --add safe.directory "*"`. ## Invariants & gotchas -- Keep `RequestCaseData` JSON-compatible. It must never contain PSR-7 objects, - credentials, closures, or application DTOs. +- Keep `CaseData` JSON-compatible. It must never contain PSR-7 objects, + credentials, closures, or application DTOs. The shape is declared once on + `ContractSuite` (`CaseData` and its parts) and imported everywhere else; + `Internal\CaseShape::assert()` is the run-time check every `@api` entry + point that takes a case runs first, and `InvalidCase` — not + `UnsupportedGeneration` — is what a malformed *case* raises. Keep the two + apart: `UnsupportedGeneration` is a document limitation. - A materialized valid case must pass `Contract::validateRequest()` before a transport may observe it. - A header is written verbatim, a path and a query are percent-encoded, and a cookie is percent-encoded. That is not a style question but a wire question: the validator reads a header field value as sent (openapi-contract#66), so encoding one here would put a string on the wire that no client sends. - `ParameterSchemas::separatorOf()` narrows the alphabet accordingly and - `isHeaderSafe()` guards what a `pattern` or a `format` can still put outside - a field value — the same two halves as the path rule below. A CR or an LF - reaching a materializer is refused by name, never encoded away. + `ParameterSchemas::separatorOf()` narrows the alphabet of generated plain + strings accordingly and `isHeaderSafe()` is the judgement for everything + the alphabet cannot see — a `pattern`, a `format`, an enum member, a const: + obs-text and an interior space are read as sent and kept, whitespace at an + end is stripped by the reader and refused, a comma is refused only in a + list/object header (#123, #129). `forLocation()` narrows a header enum by + that judgement and refuses at compile time when nothing remains; a path or + header `pattern` is probed at `forOperation()` time for the same reason — + the same two halves as the path rule below. A CR or an LF reaching a + materializer is refused by name, never encoded away. - Keep parameter serialization location-aware. A path value must not escape its template segment after percent decoding: `Internal\ParameterSchemas` raises `minLength` to 1 on every path string, drops unsafe `enum` members, refuses @@ -70,10 +81,23 @@ into the monorepo) plus `git config --global --add safe.directory "*"`. `null` enum members and a `null` const at every nesting level. - Every unsatisfiable combination the compiler can recognise fails closed at compile time (`pattern` + asserted `format`, format length bands, - `uniqueItems` over a finite domain, `not.type` covering the source, `allOf` - branch bounding `additionalProperties` without its siblings' properties). - Do not push such checks into `Gen::filter()`; a run-time - `GenerationExhausted` is a defect here. + `uniqueItems` over a finite domain including a bounded integer, `not.type` + covering the source, `allOf` branch bounding `additionalProperties` without + its siblings' properties, an exploded form object whose `minProperties` its + declared properties cannot meet, `oneOf` over `integer`/`number` with no + value to keep apart). Object cardinality is met by construction in + `ContainerArbitraries::objectValues()`, never by a count filter. Do not push + such checks into `Gen::filter()`; a run-time `GenerationExhausted` is a + defect here. Where a filter is unavoidable (a `pattern` on the path or + header wire), probe it at compile time with the same retry budget and refuse + by name. +- A generated float goes on the wire through `WireValue` as `json_encode` + spells it, never `(string)` (precision=14 rounds); a decimal `multipleOf` + product is kept as the clean decimal only where the validator's float-mode + arithmetic agrees, else as the product itself + (`ScalarArbitraries::multipleOf()`, #117). Under `ext-bcmath` the contract's + verdict is its own (openapi-contract#151); tests that pin multipleOf + agreement skip there. - The end-to-end oracle for the valid phase is `tests/Support/ZooContracts.php` + `ContractSuiteTest::zooValidCasesPassTheBuiltInChecks`: one operation per schema feature, checked through materialize → validate → transport → diff --git a/CHANGELOG.md b/CHANGELOG.md index 1edb44b..b4c5266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,104 @@ 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.15.0 — 2026-09-19 + +The 1.0-readiness review of 2026-09-18 (#117–#129). A minor on 0.x: the +constructor of `ResponseMaterializer` narrows, the `OperationPropertyFailed` +factories take one more argument, the `RequestCaseData` / +`NegativeRequestCaseData` psalm aliases are gone, and case-shape errors are a +type of their own. + +- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.12` and accepts + `rasuvaeff/property-testing-core` `^0.11` (develops against + `rasuvaeff/property-testing-testo` `^0.11` too). The directional schema + rewrite is delegated to `SchemaCheck::effective()` — this package's own + copy never recursed into `additionalProperties` — and the request body and + responses arrive as the contract's typed shapes, so the guards that + re-checked them are gone. +- **Fixed.** A numeric parameter reached the wire through `(string)`, which + rounds to `precision=14` and put a different number there for any value + that needed more digits (`846608010056.187` went as `846608010056.19`). A + float is spelled the way `json_encode` spells it now. The `multipleOf` + generator also rounded `k × m` back to the decimal it meant, which from + about `64` upwards is one ulp away from the product the validator + computes and tolerates, so a third of the draws for `0.1` were rejected; + the decimal spelling is kept only where the validator agrees with it. + Under `ext-bcmath` the contract evaluates `multipleOf` in decimal + arithmetic and its verdict is its own — openapi-contract#151 (#117). +- **Fixed.** `missing-required` no longer targets a path parameter, and + `negativeCoverage()` no longer lists it: omitting one leaves the template + literal in the request target, which a `string` schema accepts, so the + negative phase failed on any `/{slug}` operation with + `unexpectedlyValidRequest`. An operation whose only required component is a + path parameter skips the category and names the reason (#118). +- **Fixed.** `+` stays `%2B` under `allowReserved`: a raw plus in a query is + a space to the validator and to every SAPI (#119). +- **Fixed.** A form body property with an object schema and `explode: true` + is generated without undeclared members — its flat `member=value` pairs can + carry only the declared ones, and an extra landed as a top-level member of + the body. A nested object whose `minProperties` its declared properties + cannot meet fails closed at compile time (#120). +- **Fixed.** `oneOf` over an `integer` and a `number` branch was compiled as a + plain choice between disjoint branches, and half the draws matched both. + The number branch is generated without integral values, the integer branch + keeps only what the number branch's bounds and multiple refuse, and a + number branch that admits every value outside the integer branch fails + closed. `1.0` is an integer to JSON Schema, and is treated as one (#121). +- **Fixed.** A multipart part declared under a JSON media type carries the + JSON encoding of its value; `text/*` and `application/octet-stream` parts + carry it verbatim; any other part media type fails closed instead of + sending a body the generator cannot vouch for (#122). +- **Fixed.** Three legal documents reached a run-time `GenerationExhausted`: + a header enum outside ASCII (`isHeaderSafe()` admits obs-text now, as RFC + 9110 and the validator do), a path pattern that always carries a slash + (probed at compile time and refused by name, as is a header pattern none + of whose strings survives the wire), and an object whose twelve optionals + were drawn as independent presence choices against `maxProperties: 1` + (`minProperties`/`maxProperties` are met by construction: an optional past + the ceiling is left out, one needed for the floor brought in). `uniqueItems` + with `minItems` over a bounded integer domain smaller than it fails closed + at compile time (#123). +- **Fixed.** A header enum member with an interior space (`New York`) was + refused at compile time although the validator accepts it: a scalar + header's members are refused only for whitespace at an end or a control + character, a list or object header's additionally for a comma (#129). +- **Added.** `ContractSuite::redaction(RedactionPolicy)` configures the policy + every rendering goes through: `reproduce()` by default (its policy + argument is optional now) and the minimal case `OperationPropertyFailed` + prints, which used to be the unredacted counterexample JSON. + `ContractSuite::redact(array $case): array` applies it to a case. The + READMEs and `llms.txt` claimed `Cookie` was in the default redacted header + set; it is not, by design since #73, and they say so now (#124). +- **Added.** `OpenApiPropertyTestingException`, an empty marker interface + implemented by every exception of the package; `InvalidCase` + (`\InvalidArgumentException`) for a case that does not have the exported + shape — the materializer and the serializer threw `UnsupportedGeneration` + for it, which is a document limitation, not a caller error; a + `Psr15Transport` configuration error is a `SuiteConfigurationError` rather + than a bare `\LogicException`. `UnsupportedGeneration::forSchema()` + refusals name the operation and the parameter or body being compiled + (`Unsupported OpenAPI schema generation for operation "pets.list", query + parameter "limit": minLength exceeds maxLength`) through + `inOperation()` (#127). +- **Changed.** The case shape is declared once, on `ContractSuite` + (`CaseData` with `ParameterMap`, `BodyData`, `PartData`, `MisuseData`), and + imported everywhere else; the `RequestCaseData` and + `NegativeRequestCaseData` aliases and the inline copies are gone. + `checkValid()`, `checkNegative()`, `reproduce()`, `redact()` and + `RequestMaterializer::materialize()` refuse a case missing a key with + `InvalidCase` naming it — a hand-written case without `misuse` used to + raise a PHP warning and pass `checkValid()` (#128). +- **Changed.** `ResponseMaterializer::__construct()` no longer takes the + `@internal` `JsonBodyEncoder` and `ParameterSerializer` as defaulted + parameters; it builds them, as `RequestMaterializer` does (#125). + `CheckFailed::$result` is `readonly`, assigned through the constructor by + the factories (#126). `OperationPropertyFailed::forCounterExample()` and + `forExample()` take the redacted case to print as their last argument. +- Zoo: `labels.get`, `sparse.create`, `amounts.create`, `settings.create`, + `precise.get`; the contract's generated corpus is re-recorded from it + (openapi-contract#152). + ## 0.14.1 — 2026-09-18 - **Fixed.** Valid number schemas now keep exclusive bounds at the adjacent diff --git a/README.md b/README.md index 5647049..cdc5f4f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ before it reaches a transport. - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.11 and `rasuvaeff/property-testing-core` ^0.5–^0.10 +- `rasuvaeff/openapi-contract` ^0.12 and `rasuvaeff/property-testing-core` ^0.5–^0.11 - `psr/http-message`, `psr/http-factory` and `psr/http-server-handler` implementations — a PSR-17 factory materializes requests, and `ContractSuite` drives a PSR-15 handler in process @@ -55,17 +55,37 @@ $request = (new RequestMaterializer(new Psr17Factory(), new Psr17Factory()))->ma $contract->validateRequest($request)->assertValid(); ``` -`RequestCaseData` is an associative JSON-compatible array with independent -`path`, `query`, `headers`, `cookies`, and optional `body` maps. Form bodies keep +A case (`CaseData`, declared once on `ContractSuite` as a psalm type and +imported everywhere else) is an associative JSON-compatible array with +`operationKey`, independent `path`, `query`, `headers`, `cookies` maps, a +`body` (or `null`) and `misuse` (`null` for a valid case). Every `@api` entry +point that takes a case — `checkValid()`, `checkNegative()`, `reproduce()`, +`redact()`, `RequestMaterializer::materialize()` — refuses one that lacks a +key with `InvalidCase` naming it. 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 +item schema unless the Encoding Object names one. A part declared under a +JSON media type carries the JSON encoding of its value; `text/*` and +`application/octet-stream` parts carry it verbatim; any other part media type +fails closed. 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. +A header value is generated the way the validator reads it: as sent, with +the optional whitespace at either end stripped. Generated strings stay +inside printable ASCII without a space; an enum member or a const is judged +as a whole, so an interior space (`New York`) and obs-text (`žluť`) are kept +and a member with whitespace at an end, a control character or — in a list +or object header — a comma is dropped, and a header no member of which can +be sent is refused at compile time. A path or header `pattern` none of whose +strings survives the wire is refused at compile time too, and a query `+` +stays `%2B` under `allowReserved` (a raw plus is a space to every reader). +A form property with an exploded object schema is generated without +undeclared members, since its flat pairs can carry only the declared ones. + 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 @@ -109,13 +129,13 @@ the same way. | Keyword | Generation | |---|---| | `type` (single or list), `const`, `enum`, `nullable` (OAS 3.0) | supported; a type list is a weighted union | -| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | supported; a fractional bound on an integer rounds inward, an open bound steps inside by a tenth (or a quarter of a narrow window) | +| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | supported; a fractional bound on an integer rounds inward, an open bound steps to the adjacent double; a float is spelled on the wire as `json_encode` spells it, and a decimal multiple as the validator computes it (`ext-bcmath` changes the contract's own verdict — openapi-contract#151) | | `minLength`, `maxLength` (capped at 64), `pattern` (PCRE subset) | supported | | `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (annotation) | supported; a length window the format cannot satisfy, or `pattern` combined with an asserted format, fails closed | | `items`, `minItems`, `maxItems` (capped at 16), `uniqueItems` | supported; `uniqueItems` over a finite item domain smaller than `minItems` fails closed | -| `properties`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported | +| `properties`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; the cardinality is met by construction (an optional past the ceiling is left out, one needed for the floor brought in) | | `readOnly` (requests), `writeOnly` (responses) | dropped per direction | -| `anyOf`, `oneOf` (provably disjoint branches), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported | +| `anyOf`, `oneOf` (provably disjoint branches, or one `integer` beside one `number` branch: a value is kept only when exactly one admits it), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported | | `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed | | `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | @@ -143,7 +163,7 @@ $request = (new RequestMaterializer($requests, $streams))->materialize( `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: +a case or persisted property examples: ```php $credentials = new Credentials( @@ -153,8 +173,11 @@ $credentials = new Credentials( ``` `NegativeRequestCaseArbitrary` provides constructive negative categories. The -`forOperation()` arbitrary removes one required path, query, header, cookie, or -body component and records `misuse.kind = 'missing-required'`. That is the +`forOperation()` arbitrary removes one required query, header, cookie, or +body component and records `misuse.kind = 'missing-required'`. A path +parameter is never the target: omitting it leaves the template literal in +the request target, which a `string` schema accepts, so its absence is not +observable. That is the only category that needs a required component: every value category below writes its invalid value into the case, so an optional parameter the valid case leaves out is present in the negative one and is judged by its schema @@ -244,6 +267,25 @@ 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. +A refusal over a schema names the operation and the parameter or body it was +compiling (`Unsupported OpenAPI schema generation for operation "pets.list", +query parameter "limit": minLength exceeds maxLength`). + +### Exceptions + +Every exception the package throws implements the marker interface +`OpenApiPropertyTestingException`, so `catch (OpenApiPropertyTestingException)` +catches whatever the package reports; each keeps its SPL parent too. + +| Exception | Parent | When | +|---|---|---| +| `UnsupportedGeneration` | `InvalidArgumentException` | the document uses a feature outside the support matrix | +| `InvalidCase` | `InvalidArgumentException` | a hand-written case does not have the exported shape, or targets another operation | +| `SuiteConfigurationError` | `LogicException` | the suite or a transport is asked to run in a shape its configuration does not allow | +| `CheckFailed` | `RuntimeException` | a built-in check observed a contract failure (`$result` keeps the validation result) | +| `OperationPropertyFailed` | `RuntimeException` | a phase of `OperationProperty::check()` was falsified | +| `CredentialsUnavailable` | `RuntimeException` | a credentials provider cannot satisfy an alternative | +| `CoverageIncomplete` | `RuntimeException` | a selected operation never ran a trial | ## Transports @@ -397,13 +439,25 @@ case, as a diagnosable document defect rather than a silently skipped example. 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. +(`Authorization`, `Proxy-Authorization`, `Set-Cookie`). `Cookie` is not in +the default set: the policy names cookie parameters one by one, so the rest +stay readable. Body previews are byte-bounded and never cut a UTF-8 sequence +in half. + +`redaction()` configures the policy once for the suite: it is what +`reproduce()` renders by default and what `OperationProperty` prints as the +minimal case of a falsified phase, so a secret the document carries in an +`X-Api-Key` header, a query parameter or a body member appears in neither. +`redact()` returns a case with that policy applied, shape preserved. ```php use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; -echo $suite->reproduce('pets.get', $case, new RedactionPolicy(bodyPaths: ['owner.card'])); +$suite = $suite->redaction(new RedactionPolicy(headers: ['X-Api-Key'], bodyPaths: ['owner.card'])); + +echo $suite->reproduce('pets.get', $case); // the configured policy +echo $suite->reproduce('pets.get', $case, new RedactionPolicy()); // one call, another policy +$printable = $suite->redact($case); ``` ### Negative coverage @@ -470,10 +524,10 @@ 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. `ResponseMaterializer` serializes header -values with the `simple` style like request headers — percent-encoded, a list -joined with commas — so control characters never reach the PSR-7 factory and -a comma inside an item survives the round trip. `ResponseCaseData` is JSON-compatible and +`writeOnly` properties left out. `ResponseMaterializer` writes header +values with the `simple` style like request headers — as sent, a list joined +with commas — and the generator keeps a control character or a comma inside +an item out of them, so neither reaches the PSR-7 factory. `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`. @@ -522,7 +576,9 @@ final class ApiContractTest `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 seed, the shrunk minimal case, and a redacted curl reproducer. The message +prints the minimal case through the suite's `redaction()` policy; +`$counterExample` keeps it as generated. 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, diff --git a/README.ru.md b/README.ru.md index 3b3546d..54154dc 100644 --- a/README.ru.md +++ b/README.ru.md @@ -24,7 +24,7 @@ styles и JSON, form-urlencoded или multipart request body, после чег - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.11 и `rasuvaeff/property-testing-core` ^0.5–^0.10 +- `rasuvaeff/openapi-contract` ^0.12 и `rasuvaeff/property-testing-core` ^0.5–^0.11 - реализации `psr/http-message`, `psr/http-factory` и `psr/http-server-handler`: PSR-17 factory материализует запросы, а `ContractSuite` гоняет PSR-15 handler в процессе @@ -54,17 +54,37 @@ $request = (new RequestMaterializer(new Psr17Factory(), new Psr17Factory()))->ma $contract->validateRequest($request)->assertValid(); ``` -`RequestCaseData` - JSON-compatible associative array с раздельными `path`, -`query`, `headers`, `cookies` и optional `body`. Form body хранит logical value, +Case (`CaseData`, объявлен один раз psalm-типом на `ContractSuite` и +импортируется отовсюду) — JSON-compatible associative array с +`operationKey`, раздельными `path`, `query`, `headers`, `cookies`, `body` +(или `null`) и `misuse` (`null` для валидного кейса). Каждая `@api`-точка +входа, принимающая кейс — `checkValid()`, `checkNegative()`, `reproduce()`, +`redact()`, `RequestMaterializer::materialize()` — отвергает кейс без +ключа как `InvalidCase`, называя ключ. Form body хранит logical value, а multipart body - deterministic boundary и data-only parts; binary payload представлен как base64. Части multipart — только скаляры и binary: вложенные объекты и массивы падают как `UnsupportedGeneration`; content type части — умолчание OAS для схемы элемента, если Encoding Object не задал свой. -Required parameters и +Часть под JSON media type несёт JSON-кодировку значения; `text/*` и +`application/octet-stream` — значение как есть; любой другой media type +части падает fail-closed. Required parameters и request bodies присутствуют всегда; для optional parameters и JSON body генерируются обе ветви - presence и absence. В нём нет credentials, поэтому case можно сохранять в property corpus. +Значение заголовка генерируется так, как его читает валидатор: как +отправлено, с обрезанными пробелами по краям. Генерируемые строки остаются +в printable ASCII без пробела; член enum или const оценивается целиком, +поэтому пробел внутри (`New York`) и obs-text (`žluť`) сохраняются, а член с +пробелом на краю, управляющим символом или — в списочном/объектном +заголовке — запятой отбрасывается; заголовок, ни один член которого +отправить нельзя, отвергается при компиляции. Path- или header-`pattern`, +ни одна строка которого не переживает провод, тоже отвергается при +компиляции; `+` в query остаётся `%2B` под `allowReserved` (сырой плюс — +пробел для любого читателя). Form-свойство с exploded object-схемой +генерируется без необъявленных членов: его плоские пары несут только +объявленные. + У пустого массива или объекта нет form-style представления на проводе (RFC 6570 считает его неопределённым), поэтому materializer опускает такой parameter или form-свойство, а генератор строит required array/object @@ -107,13 +127,13 @@ request-направлению схемы — `readOnly`-свойства ухо | Keyword | Генерация | |---|---| | `type` (один или список), `const`, `enum`, `nullable` (OAS 3.0) | поддержано; список типов — взвешенное объединение | -| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | поддержано; дробная граница у integer округляется внутрь, открытая граница отступает внутрь на десятую (или четверть узкого окна) | +| `minimum`, `maximum`, boolean `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf` | поддержано; дробная граница у integer округляется внутрь, открытая граница отступает на соседний double; float пишется на провод так, как его пишет `json_encode`, а десятичное кратное — так, как его вычисляет валидатор (`ext-bcmath` меняет вердикт самого контракта — openapi-contract#151) | | `minLength`, `maxLength` (не более 64), `pattern` (подмножество PCRE) | поддержано | | `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (аннотация) | поддержано; окно длины, которое format не может удовлетворить, или `pattern` вместе с проверяемым format падают fail-closed | | `items`, `minItems`, `maxItems` (не более 16), `uniqueItems` | поддержано; `uniqueItems` над конечным доменом элементов меньше `minItems` падает fail-closed | -| `properties`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано | +| `properties`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; кардинальность выполняется по построению (optional сверх потолка выпадает, нужный для пола — добавляется) | | `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению | -| `anyOf`, `oneOf` (доказуемо непересекающиеся ветви), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано | +| `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано | | `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed | | `$ref`, `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | @@ -142,7 +162,7 @@ $request = (new RequestMaterializer($requests, $streams))->materialize( `Credentials` принимает обычную строку или список строк для каждого значения header, query и cookie. Публичные maps нормализуются в списки. Credentials применяются только во время materialization, поэтому секреты не попадают в -`RequestCaseData` и сохранённые property examples: +case и сохранённые property examples: ```php $credentials = new Credentials( @@ -152,8 +172,10 @@ $credentials = new Credentials( ``` `NegativeRequestCaseArbitrary` предоставляет конструктивные negative-категории. -`forOperation()` удаляет один обязательный path, query, header, cookie или body -и записывает `misuse.kind = 'missing-required'`. Это единственная категория, +`forOperation()` удаляет один обязательный query, header, cookie или body +и записывает `misuse.kind = 'missing-required'`. Path-параметр целью не +бывает: его пропуск оставляет в request target литерал шаблона, который +`string`-схема принимает, так что отсутствие ненаблюдаемо. Это единственная категория, которой нужен обязательный компонент: каждая категория значений ниже записывает неверное значение в кейс, поэтому необязательный параметр, который валидный кейс опустил, в негативном присутствует и проверяется по @@ -251,6 +273,25 @@ JSON-варианте. Вложенные свойства пока не Неподдерживаемые schema assertions и non-JSON request bodies бросают `UnsupportedGeneration`; они не расширяются молча до произвольных строк. +Отказ по схеме называет операцию и параметр или body, которые компилировал +(`Unsupported OpenAPI schema generation for operation "pets.list", query +parameter "limit": minLength exceeds maxLength`). + +### Исключения + +Каждое исключение пакета реализует маркер-интерфейс +`OpenApiPropertyTestingException`, так что `catch (OpenApiPropertyTestingException)` +ловит всё, что пакет сообщает; SPL-родитель у каждого сохраняется. + +| Исключение | Родитель | Когда | +|---|---|---| +| `UnsupportedGeneration` | `InvalidArgumentException` | документ использует возможность вне матрицы поддержки | +| `InvalidCase` | `InvalidArgumentException` | рукописный кейс не имеет экспортируемой формы или нацелен на другую операцию | +| `SuiteConfigurationError` | `LogicException` | suite или transport просят работать в форме, которую конфигурация не допускает | +| `CheckFailed` | `RuntimeException` | встроенная проверка увидела нарушение контракта (`$result` хранит результат валидации) | +| `OperationPropertyFailed` | `RuntimeException` | фаза `OperationProperty::check()` фальсифицирована | +| `CredentialsUnavailable` | `RuntimeException` | credentials provider не может удовлетворить альтернативу | +| `CoverageIncomplete` | `RuntimeException` | выбранная операция не выполнила ни одного trial | ## Transports @@ -401,14 +442,25 @@ case: диагностируемый дефект документа, а не м не применяются никогда, поэтому секреты provider-а не могут утечь по построению; `RedactionPolicy` дополнительно редактирует названные headers, query-параметры, cookies и dot-separated JSON body paths поверх дефолтного -набора заголовков (`Authorization`, `Proxy-Authorization`, `Cookie`, -`Set-Cookie`). Body preview ограничен по байтам и никогда не режет UTF-8 -последовательность пополам. +набора заголовков (`Authorization`, `Proxy-Authorization`, `Set-Cookie`). +`Cookie` в дефолтный набор не входит: policy называет cookie-параметры по +одному, остальные остаются читаемыми. Body preview ограничен по байтам и +никогда не режет UTF-8 последовательность пополам. + +`redaction()` задаёт policy один раз на suite: её рендерит `reproduce()` по +умолчанию и через неё `OperationProperty` печатает minimal case +фальсифицированной фазы, так что секрет из заголовка `X-Api-Key`, +query-параметра или body-члена не появится ни там, ни там. `redact()` +возвращает кейс с применённой policy, форма сохранена. ```php use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; -echo $suite->reproduce('pets.get', $case, new RedactionPolicy(bodyPaths: ['owner.card'])); +$suite = $suite->redaction(new RedactionPolicy(headers: ['X-Api-Key'], bodyPaths: ['owner.card'])); + +echo $suite->reproduce('pets.get', $case); // настроенная policy +echo $suite->reproduce('pets.get', $case, new RedactionPolicy()); // один вызов, другая policy +$printable = $suite->redact($case); ``` ### Покрытие негативной фазы @@ -473,10 +525,10 @@ Response Object — тот, к которому его резолвит конт `NXX`, затем `default` — тот же выбор, что в `validateResponse()`, через `Operation::responseFor()`). Required response headers присутствуют всегда, optional берут обе ветви, JSON body генерируется без `writeOnly`-свойств. -`ResponseMaterializer` сериализует значения заголовков `simple`-стилем, как и -request-заголовки, — percent-encoded, список через запятую, — так что -управляющие символы не доходят до PSR-7-фабрики, а запятая внутри элемента -переживает round trip. +`ResponseMaterializer` пишет значения заголовков `simple`-стилем, как и +request-заголовки, — как отправлено, список через запятую, — а генератор +не пускает в них управляющий символ и запятую внутри элемента, так что до +PSR-7-фабрики не доходит ни то, ни другое. `ResponseCaseData` JSON-compatible и corpus-safe, как и request-аналог. Необъявленный статус, required header без схемы и body без JSON media type падают как `UnsupportedGeneration`. @@ -527,7 +579,8 @@ final class ApiContractTest `check()` всегда выполняет valid-фазу, а negative-фазу — когда у операции есть хотя бы одна конструктивная misuse-категория. Falsified-фаза бросает `OperationPropertyFailed` с ключом операции, фазой, seed, shrunk minimal case -и redacted curl-репродьюсером. +и redacted curl-репродьюсером. Сообщение печатает minimal case через +`redaction()`-policy suite; `$counterExample` хранит его как сгенерирован. Валидная фаза начинается с примеров документа (`exampleCases()`): они выполняются до replay корпуса и random-фазы при любом seed и числе прогонов, diff --git a/composer.json b/composer.json index 5a10ee5..2fc2e5b 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", "rasuvaeff/openapi-contract": "^0.12", - "rasuvaeff/property-testing-core": "^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10" + "rasuvaeff/property-testing-core": "^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10 || ^0.11" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", @@ -38,7 +38,7 @@ "nyholm/psr7": "^1.8", "phpunit/phpunit": "^11.5 || ^12 || ^13", "predis/predis": "^2.2 || ^3.0", - "rasuvaeff/property-testing-testo": "^0.7 || ^0.8 || ^0.9 || ^0.10", + "rasuvaeff/property-testing-testo": "^0.7 || ^0.8 || ^0.9 || ^0.10 || ^0.11", "rasuvaeff/rector-named-literals": "^1.0", "rasuvaeff/understudy-testo": "^0.3", "rector/rector": "^2.4", diff --git a/llms.txt b/llms.txt index 56a36b4..ead061f 100644 --- a/llms.txt +++ b/llms.txt @@ -16,11 +16,26 @@ Public API: - Compile-time fail-closed (`UnsupportedGeneration`) on: `pattern` + asserted `format`; a length window outside the format's band (uuid 36, date 10, date-time 29, ipv4 7–15, email 6–36, uri/url 12–55); `uniqueItems` - over a finite item domain (`boolean`, `enum`, `const`, `null`) smaller than - `minItems`; `not.type` covering every declared type; an `allOf` branch - bounding `additionalProperties` that does not declare every sibling - property. Fractional integer bounds round inward; an open number bound - steps inside by min(0.1, window/4). + over a finite item domain (`boolean`, `enum`, `const`, `null`, a bounded + `integer`) smaller than `minItems`; `not.type` covering every declared + type; an `allOf` branch bounding `additionalProperties` that does not + declare every sibling property; a header `const`/`enum` no member of which + is read as sent (whitespace at an end, a control character, a comma in a + list/object header); a path or header `pattern` none of whose strings + survives the wire; an exploded form object whose `minProperties` its + declared properties cannot meet; `oneOf` over `integer` and `number` whose + number branch admits no value outside the integer branch. Every refusal + over a schema names the operation and the parameter/body + (`... for operation "op", query parameter "limit": `). Fractional + integer bounds round inward; an open number bound steps to the adjacent + double; a float is spelled as `json_encode` spells it and a decimal + multiple as the validator computes it. Object cardinality is met by + construction; `oneOf` over one `integer` and one `number` branch keeps a + value only when exactly one branch admits it. Header enum members keep an + interior space and obs-text; a query `+` stays `%2B` under `allowReserved`. + A multipart part under a JSON media type carries the JSON encoding of its + value; `text/*`/`application/octet-stream` parts carry it verbatim; any + other part media type fails closed. - `RequestMaterializer::materialize(Operation, array $case): RequestInterface` builds the request target against the operation's first effective server (relative server -> path-only URI, absolute server -> absolute URI); @@ -55,7 +70,10 @@ Public API: time; pass the selected credentials as the optional third argument to `RequestMaterializer::materialize`. - `NegativeRequestCaseArbitrary::forOperation(Operation)` removes one required - request component and records `misuse.kind = 'missing-required'`. It is the + query/header/cookie/body component and records `misuse.kind = + 'missing-required'`; a path parameter is never the target (omitting it + leaves the template literal in the target, which a string schema accepts), + and an operation with no other required component is skipped. It is the only category that needs a required component: every value category below writes its invalid value into the case, so an optional parameter the valid case leaves out is present in the negative one and is judged by its schema @@ -133,7 +151,14 @@ Public API: skipped (an OAS 3.0 `nullable` property keeps its other keywords' cases), and a body declared under several media types is mutated on its JSON alternative only. Nested properties are not reached. -- `UnsupportedGeneration` for unsupported schema/body/parameter input. +- `UnsupportedGeneration` for unsupported schema/body/parameter input; + `InvalidCase` (`InvalidArgumentException`) for a hand-written case that + lacks the exported shape — a missing key is named (`Case is missing the + "misuse" key`) by `checkValid()`, `checkNegative()`, `reproduce()`, + `redact()` and `RequestMaterializer::materialize()`. Every exception of the + package (`UnsupportedGeneration`, `InvalidCase`, `SuiteConfigurationError`, + `CheckFailed`, `OperationPropertyFailed`, `CredentialsUnavailable`, + `CoverageIncomplete`) implements the marker `OpenApiPropertyTestingException`. - `ContractSuite::fromContract(Contract, RequestFactoryInterface, StreamFactoryInterface)` builds the framework-neutral suite model. Fluent configuration: `operations(list)` (explicit allow-list, unknown keys throw), @@ -185,13 +210,19 @@ Public API: oracle from exact status codes or `NXX` ranges; `forOperation(string $operationKey, ...)` overrides selectors per operation; `accepts(string $operationKey, int $status): bool`. -- `ContractSuite::reproduce(string $operationKey, array $case, RedactionPolicy)` - renders a redacted curl command; credentials are never applied there. -- `RequestReproducer::curl(Operation, array $case, RedactionPolicy)` is the - `@internal` standalone form; `RedactionPolicy(headers, queryParameters, cookies, - bodyPaths)` names extra redactions on top of the default header set - (Authorization, Proxy-Authorization, Cookie, Set-Cookie). Body previews are - byte-bounded and UTF-8 safe. +- `ContractSuite::redaction(RedactionPolicy)` configures the policy the suite + renders every case through: `reproduce()` by default and the minimal case + `OperationPropertyFailed` prints. `ContractSuite::redact(array $case): array` + returns the case with it applied, shape preserved. +- `ContractSuite::reproduce(string $operationKey, array $case, ?RedactionPolicy)` + renders a redacted curl command (the configured policy unless one is given); + credentials are never applied there. +- `RequestReproducer::curl(Operation, array $case, RedactionPolicy)` / + `redact(array $case, RedactionPolicy)` are the `@internal` standalone forms; + `RedactionPolicy(headers, queryParameters, cookies, bodyPaths)` names extra + redactions on top of the default header set (Authorization, + Proxy-Authorization, Set-Cookie — not Cookie: cookies are named one by one). + Body previews are byte-bounded and UTF-8 safe. - `SuiteConfigurationError` for selection/transport misconfiguration; `CheckFailed` for built-in check failures. - `ResponseCaseArbitrary::forOperation(Operation, int $status): ArbitraryInterface` @@ -226,7 +257,10 @@ Public API: runs the valid phase always and the negative phase when the operation has a constructible misuse category; a falsified phase throws `OperationPropertyFailed` (operation key, phase, `CounterExample`, redacted - curl reproducer). `PROPERTY_RUNS` overrides runs, `PROPERTY_SEED` fixes the + curl reproducer; the message prints the minimal case through the suite's + redaction policy, `$counterExample` keeps it as generated; + `forCounterExample()`/`forExample()` take the redacted case as their last + argument). `PROPERTY_RUNS` overrides runs, `PROPERTY_SEED` fixes the seed (an explicit `seed:` wins and disables corpus replay), `PROPERTY_DB` names a directory corpus or `redis://host[:port][/key-prefix]` shared corpus; Redis uses ext-redis or predis and connects lazily. @@ -249,7 +283,9 @@ Public API: (stable, `statuses` always a JSON object). Statuses are diagnostics, not a gate. Merge per-process reports outside the package. -`RequestCaseData` has `operationKey`, separate path/query/headers/cookies maps, +`CaseData` (`@psalm-import-type CaseData from ContractSuite`; parts +`ParameterMap`, `BodyData`, `PartData`, `MisuseData`) has `operationKey`, +separate path/query/headers/cookies maps, an optional JSON-compatible body: `{mediaType, encoding: 'json'|'raw'|'form', value}` or `{mediaType, encoding: 'multipart', boundary, parts}` (binary parts use base64), and `misuse: null`; `encoding: 'raw'` appears only in malformed-JSON negative cases. diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index be39b93..f1567d4 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -79,7 +79,7 @@ private function integerAndNumberBranches(array $branches): ?array } $byType[$types[0]][] = $index; } - foreach ($byType as $type => $indexes) { + foreach ($byType as $indexes) { if (count($indexes) !== 1) { return null; } @@ -145,18 +145,22 @@ private function numberBranchAdmits(int $value, array $number): bool || (($number['exclusiveMaximum'] ?? false) === true && (float) $value === $maximum)) { return false; } - /** @var mixed $multiple */ - $multiple = $number['multipleOf'] ?? null; - if (is_int($multiple) && $multiple > 0) { + $multiple = $this->positiveNumber($number['multipleOf'] ?? null); + if (is_int($multiple)) { return $value % $multiple === 0; } - if (is_float($multiple) && $multiple > 0) { + if (is_float($multiple)) { return abs((float) $value - round((float) $value / $multiple) * $multiple) < 1e-14; } return true; } + private function positiveNumber(mixed $value): int|float|null + { + return (is_int($value) || is_float($value)) && $value > 0 ? $value : null; + } + /** * Whether a filtered branch produces anything at all, judged the way the * pattern probe does: deterministic draws, twice the budget the filter diff --git a/src/Internal/ParameterSchemas.php b/src/Internal/ParameterSchemas.php index 2ea1f93..c3e0283 100644 --- a/src/Internal/ParameterSchemas.php +++ b/src/Internal/ParameterSchemas.php @@ -46,7 +46,7 @@ public function forLocation(array $schema, string $location, string $style = 'form'): array { if ($location === 'header') { - return $this->rewrite($schema, false, null, header: SchemaShape::isArray($schema) || SchemaShape::isObject($schema) ? 'delimited' : 'scalar'); + return $this->rewrite($schema, path: false, separator: null, header: SchemaShape::isArray($schema) || SchemaShape::isObject($schema) ? 'delimited' : 'scalar'); } return $this->rewrite($schema, $location === 'path', self::separatorOf($location, $style, $schema)); diff --git a/tests/Support/ZooContracts.php b/tests/Support/ZooContracts.php index f391d04..95bf8b0 100644 --- a/tests/Support/ZooContracts.php +++ b/tests/Support/ZooContracts.php @@ -29,6 +29,7 @@ final class ZooContracts 'delimited.get', 'reserved.get', 'unions.get', 'uploads.create', 'dual.create', 'encoded.create', 'mixed.create', 'numeric.create', 'headers.get', 'verified.get', 'search.get', 'narrowed.create', 'bounded.create', 'pages.get', 'profiles.create', + 'labels.get', 'sparse.create', 'amounts.create', 'settings.create', 'precise.get', ]; /** @@ -354,6 +355,74 @@ public static function document(): array ]]]], 'responses' => ['204' => []], ]], + // A header enum member is read as the wire reads it: an + // interior space and obs-text (a UTF-8 member) are field + // values, and the reader strips only the whitespace at the + // ends (#123, #129). + '/labels' => ['get' => [ + 'operationId' => 'labels.get', + 'parameters' => [ + ['name' => 'X-City', 'in' => 'header', 'required' => true, + 'schema' => ['type' => 'string', 'enum' => ['New York', 'žluť', 'plain']]], + ['name' => 'X-Kinds', 'in' => 'header', 'style' => 'simple', 'explode' => false, + 'schema' => ['type' => 'array', 'minItems' => 1, 'maxItems' => 2, 'items' => ['type' => 'string', 'enum' => ['big apple', 'a,b', 'c']]]], + ], + 'responses' => ['204' => []], + ]], + // Twelve optionals under `maxProperties: 1`: the cardinality + // is met by construction, not by filtering independent + // presence choices until three runs in four exhaust (#123). + '/sparse' => ['post' => [ + 'operationId' => 'sparse.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'minProperties' => 1, + 'maxProperties' => 1, + 'additionalProperties' => false, + 'properties' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'], ['type' => 'integer']), + ]]]], + 'responses' => ['204' => []], + ]], + // Every integer is also a number: a value is valid for the + // union only when exactly one branch admits it (#121). + '/amounts' => ['post' => [ + 'operationId' => 'amounts.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['amount'], + 'properties' => ['amount' => ['oneOf' => [['type' => 'integer', 'minimum' => -9, 'maximum' => 9], ['type' => 'number', 'minimum' => 0, 'maximum' => 9, 'multipleOf' => 0.5]]]], + ]]]], + 'responses' => ['204' => []], + ]], + // A nested exploded form object is written as flat pairs, so + // its wire form carries only the members the document + // declares (#120). + '/settings' => ['post' => [ + 'operationId' => 'settings.create', + 'requestBody' => ['required' => true, 'content' => ['application/x-www-form-urlencoded' => ['schema' => [ + 'type' => 'object', + 'required' => ['theme'], + 'additionalProperties' => false, + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 4], + 'theme' => ['type' => 'object', 'minProperties' => 1, 'properties' => ['mode' => ['type' => 'string', 'enum' => ['dark', 'light']], 'size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 3]]], + ], + ]]]], + 'responses' => ['204' => []], + ]], + // A float goes on the wire as the shortest decimal that reads + // back as the same double, and a decimal multiple is spelled + // as the validator computes it (#117); `+` stays encoded + // under allowReserved (#119). + '/precise' => ['get' => [ + 'operationId' => 'precise.get', + 'parameters' => [ + ['name' => 'v', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'number', 'minimum' => 0.123456789012345, 'maximum' => 0.1234567890123456]], + ['name' => 'step', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'number', 'multipleOf' => 0.1, 'minimum' => 0, 'maximum' => 1000]], + ['name' => 'expr', 'in' => 'query', 'required' => true, 'allowReserved' => true, 'schema' => ['type' => 'string', 'enum' => ['a+b', 'c d', 'e&f', 'g/h']]], + ], + 'responses' => ['204' => []], + ]], '/unions/{id}' => ['get' => [ 'operationId' => 'unions.get', 'parameters' => [ diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 4bebf38..5305e04 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -6,7 +6,6 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Rasuvaeff\OpenApiContract\Contract; -use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ContainerArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\WireValue; use Rasuvaeff\PropertyTesting\OpenApi\NegativeRequestCaseArbitrary; @@ -316,28 +315,6 @@ private function validCases(Contract $contract, string $operationKey, int $draws return $cases; } - /** - * Every draw must be rejected by the contract. - * - * @param ArbitraryInterface> $arbitrary - * @return list> - */ - private function negativeCases(Contract $contract, string $operationKey, ArbitraryInterface $arbitrary, int $draws = self::DRAWS): array - { - $operation = $contract->operation($operationKey); - $factory = new Psr17Factory(); - $materializer = new RequestMaterializer($factory, $factory); - $cases = []; - foreach (range(1, $draws) as $seed) { - $case = $arbitrary->generate(new Random($seed))->value; - $result = $contract->validateRequest($materializer->materialize($operation, $case)); - Assert::false($result->isValid(), sprintf('Seed %d accepted: %s', $seed, json_encode($case, JSON_THROW_ON_ERROR))); - $cases[] = $case; - } - - return $cases; - } - /** @param array $schema */ private function formBodyContract(array $schema): Contract { From 517fe2f978adc1f97200b6aee7c2a53a7f22bcb1 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:33:24 +0300 Subject: [PATCH 09/11] Keep a decimal multipleOf out of the zoo: the CI runners load bcmath and the contract's verdict there is its own --- tests/Support/ZooContracts.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Support/ZooContracts.php b/tests/Support/ZooContracts.php index 95bf8b0..c69f2cd 100644 --- a/tests/Support/ZooContracts.php +++ b/tests/Support/ZooContracts.php @@ -411,14 +411,18 @@ public static function document(): array 'responses' => ['204' => []], ]], // A float goes on the wire as the shortest decimal that reads - // back as the same double, and a decimal multiple is spelled - // as the validator computes it (#117); `+` stays encoded - // under allowReserved (#119). + // back as the same double (#117); `+` stays encoded under + // allowReserved (#119). No decimal `multipleOf` here: with + // ext-bcmath loaded — as it is on the CI runners — the + // contract's verdict on one is its own (openapi-contract#151), + // and this operation is recorded into the contract's corpus. + // `WireAgreementTest` pins the float-mode agreement and skips + // under bcmath. '/precise' => ['get' => [ 'operationId' => 'precise.get', 'parameters' => [ ['name' => 'v', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'number', 'minimum' => 0.123456789012345, 'maximum' => 0.1234567890123456]], - ['name' => 'step', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'number', 'multipleOf' => 0.1, 'minimum' => 0, 'maximum' => 1000]], + ['name' => 'big', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'integer', 'minimum' => 123456789012345678, 'maximum' => 123456789012345680]], ['name' => 'expr', 'in' => 'query', 'required' => true, 'allowReserved' => true, 'schema' => ['type' => 'string', 'enum' => ['a+b', 'c d', 'e&f', 'g/h']]], ], 'responses' => ['204' => []], From ca32d6ad15c05e5f0710c3acd5c5f563e19c5333 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 14:52:23 +0300 Subject: [PATCH 10/11] Require core ^0.10; kill the escaped mutants of the wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compile-time probes catch GenerationExhaustedException by name, which exists since the core's 0.10 contract freeze, so ^0.5–^0.9 are no longer accepted (Prefer lowest was red on the symbol). Testo maps mutants by #[Covers]: WireAgreementTest now covers the composition, scalar and parameter-schema compilers it exercises, and the new behaviour is pinned where the coverage is — object cardinality by construction, the bounded integer domain, every integer/number oneOf branch outcome (an empty branch is dropped, the union refused only when both are), the comma as a separator only in a list or object header, the reproduce() policy precedence, the case-shape check at every entry point. --- CHANGELOG.md | 9 +- README.md | 2 +- README.ru.md | 2 +- composer.json | 4 +- .../Compile/CompositionArbitraries.php | 29 ++-- tests/ContractSuiteTest.php | 53 ++++++ tests/ExceptionsTest.php | 1 + tests/OperationPropertyTest.php | 3 +- tests/ParameterSchemasTest.php | 6 + tests/RequestMaterializerTest.php | 10 ++ tests/ResponseCaseArbitraryTest.php | 20 +++ tests/SchemaArbitraryCompilerTest.php | 163 ++++++++++++++++++ tests/WireAgreementTest.php | 85 ++++++++- 13 files changed, 364 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c5266..32d8071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,12 @@ factories take one more argument, the `RequestCaseData` / `NegativeRequestCaseData` psalm aliases are gone, and case-shape errors are a type of their own. -- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.12` and accepts - `rasuvaeff/property-testing-core` `^0.11` (develops against - `rasuvaeff/property-testing-testo` `^0.11` too). The directional schema +- **Changed.** Requires `rasuvaeff/openapi-contract` `^0.12` and + `rasuvaeff/property-testing-core` `^0.10 || ^0.11` (develops against + `rasuvaeff/property-testing-testo` `^0.10 || ^0.11`): the compile-time + probes catch `GenerationExhaustedException` by name, which exists since the + core's 0.10 contract freeze, so `^0.5`–`^0.9` are no longer accepted. The + directional schema rewrite is delegated to `SchemaCheck::effective()` — this package's own copy never recursed into `additionalProperties` — and the request body and responses arrive as the contract's typed shapes, so the guards that diff --git a/README.md b/README.md index cdc5f4f..ab654e7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ before it reaches a transport. - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.12 and `rasuvaeff/property-testing-core` ^0.5–^0.11 +- `rasuvaeff/openapi-contract` ^0.12 and `rasuvaeff/property-testing-core` ^0.10–^0.11 - `psr/http-message`, `psr/http-factory` and `psr/http-server-handler` implementations — a PSR-17 factory materializes requests, and `ContractSuite` drives a PSR-15 handler in process diff --git a/README.ru.md b/README.ru.md index 54154dc..6f51c0f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -24,7 +24,7 @@ styles и JSON, form-urlencoded или multipart request body, после чег - PHP 8.3 – 8.5 - `ext-mbstring` -- `rasuvaeff/openapi-contract` ^0.12 и `rasuvaeff/property-testing-core` ^0.5–^0.11 +- `rasuvaeff/openapi-contract` ^0.12 и `rasuvaeff/property-testing-core` ^0.10–^0.11 - реализации `psr/http-message`, `psr/http-factory` и `psr/http-server-handler`: PSR-17 factory материализует запросы, а `ContractSuite` гоняет PSR-15 handler в процессе diff --git a/composer.json b/composer.json index 2fc2e5b..705bc58 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", "rasuvaeff/openapi-contract": "^0.12", - "rasuvaeff/property-testing-core": "^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10 || ^0.11" + "rasuvaeff/property-testing-core": "^0.10 || ^0.11" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", @@ -38,7 +38,7 @@ "nyholm/psr7": "^1.8", "phpunit/phpunit": "^11.5 || ^12 || ^13", "predis/predis": "^2.2 || ^3.0", - "rasuvaeff/property-testing-testo": "^0.7 || ^0.8 || ^0.9 || ^0.10 || ^0.11", + "rasuvaeff/property-testing-testo": "^0.10 || ^0.11", "rasuvaeff/rector-named-literals": "^1.0", "rasuvaeff/understudy-testo": "^0.3", "rector/rector": "^2.4", diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index f1567d4..8ac08b3 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -97,10 +97,10 @@ private function integerAndNumberBranches(array $branches): ?array * valid only when exactly one branch admits it: a non-integral float, or * an integer the number branch's own keywords reject (#121). The number * branch is generated without integral values; the integer branch keeps - * only what the number branch's bounds and multiple refuse, and is left - * out when they refuse nothing. A number branch that carries a keyword - * this cannot read is refused, because an integer it may admit cannot be - * told from one it does not. + * only what the number branch's bounds and multiple refuse. A branch + * left with nothing is left out, and the union is refused when both are. + * A number branch that carries a keyword this cannot read is refused, + * because an integer it may admit cannot be told from one it does not. * * @param list> $branches */ @@ -113,22 +113,25 @@ private function numericOneOf(array $branches, int $integerIndex, int $numberInd } } $pairs = []; + $numeric = 0; foreach ($branches as $index => $branch) { if ($index === $numberIndex) { - $floats = Gen::filter($this->compiler->compile($branch), static fn(mixed $value): bool => is_float($value) && floor($value) !== $value); - if (!$this->yieldsSomething($floats)) { - throw UnsupportedGeneration::forSchema('oneOf number branch admits no value outside the integer branch'); - } - $pairs[] = [1, $floats]; + $kept = Gen::filter($this->compiler->compile($branch), static fn(mixed $value): bool => is_float($value) && floor($value) !== $value); } elseif ($index === $integerIndex) { - $integers = Gen::filter($this->compiler->compile($branch), fn(mixed $value): bool => is_int($value) && !$this->numberBranchAdmits($value, $number)); - if ($this->yieldsSomething($integers)) { - $pairs[] = [1, $integers]; - } + $kept = Gen::filter($this->compiler->compile($branch), fn(mixed $value): bool => is_int($value) && !$this->numberBranchAdmits($value, $number)); } else { $pairs[] = [1, $this->compiler->compile($branch)]; + + continue; + } + if ($this->yieldsSomething($kept)) { + $pairs[] = [1, $kept]; + ++$numeric; } } + if ($numeric === 0) { + throw UnsupportedGeneration::forSchema('oneOf over integer and number admits no value exactly one branch accepts'); + } return Gen::frequency($pairs); } diff --git a/tests/ContractSuiteTest.php b/tests/ContractSuiteTest.php index c90dbd6..8cf2260 100644 --- a/tests/ContractSuiteTest.php +++ b/tests/ContractSuiteTest.php @@ -19,6 +19,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\Credentials; use Rasuvaeff\PropertyTesting\OpenApi\CredentialsProviderInterface; use Rasuvaeff\PropertyTesting\OpenApi\CredentialsUnavailable; +use Rasuvaeff\PropertyTesting\OpenApi\InvalidCase; use Rasuvaeff\PropertyTesting\OpenApi\NegativeRequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\OperationCoverage; use Rasuvaeff\PropertyTesting\OpenApi\RedactionPolicy; @@ -32,6 +33,7 @@ use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Expect; use Testo\Test; @@ -712,6 +714,57 @@ public function appliesTheConfiguredRedactionPolicy(): void Assert::same($suite->reproduce('pets.get', $case), "curl -X GET '/pets/3'"); } + /** + * The configured policy is what `reproduce()` renders by default, an + * explicit one wins for that call, and no policy at all leaves the + * default header set only (#124). + */ + public function reproduceRendersThroughTheConfiguredPolicyUnlessOneIsGiven(): void + { + $factory = new Psr17Factory(); + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/pets' => ['get' => [ + 'operationId' => 'pets.list', + 'parameters' => [ + ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer']], + ['name' => 'X-Api-Key', 'in' => 'header', 'schema' => ['type' => 'string']], + ], + 'responses' => ['204' => []], + ]]], + ]); + $case = ['operationKey' => 'pets.list', 'path' => [], 'query' => ['limit' => '7'], 'headers' => ['X-Api-Key' => 'sk-live'], 'cookies' => [], 'body' => null, 'misuse' => null]; + $bare = ContractSuite::fromContract($contract, $factory, $factory)->operations(['pets.list']); + $configured = $bare->redaction(new RedactionPolicy(headers: ['X-Api-Key'], queryParameters: ['limit'])); + + Assert::same($bare->reproduce('pets.list', $case), "curl -X GET '/pets?limit=7' -H 'X-Api-Key: sk-live'"); + Assert::same($configured->reproduce('pets.list', $case), "curl -X GET '/pets?limit=%5Bredacted%5D' -H 'X-Api-Key: [redacted]'"); + Assert::same($configured->reproduce('pets.list', $case, new RedactionPolicy(headers: ['X-Api-Key'])), "curl -X GET '/pets?limit=7' -H 'X-Api-Key: [redacted]'"); + Assert::same($bare->reproduce('pets.list', $case, new RedactionPolicy(queryParameters: ['limit'])), "curl -X GET '/pets?limit=%5Bredacted%5D' -H 'X-Api-Key: sk-live'"); + } + + /** + * Every entry point that takes a case checks its shape first (#128). + */ + #[DataProvider('caseEntryPointProvider')] + public function everyCaseEntryPointRefusesACaseWithoutMisuse(\Closure $entry): void + { + Expect::exception(InvalidCase::class)->withMessage('Case is missing the "misuse" key'); + + $suite = $this->suite()->operations(['pets.get'])->transport(new CallableTransport(static fn(): Response => new Response(204))); + $case = ['operationKey' => 'pets.get', 'path' => ['id' => '3'], 'query' => [], 'headers' => [], 'cookies' => [], 'body' => null]; + + $entry($suite, $case); + } + + public static function caseEntryPointProvider(): iterable + { + yield 'checkValid' => [static fn(ContractSuite $suite, array $case) => $suite->checkValid('pets.get', $case)]; + yield 'checkNegative' => [static fn(ContractSuite $suite, array $case) => $suite->checkNegative('pets.get', $case)]; + yield 'reproduce' => [static fn(ContractSuite $suite, array $case) => $suite->reproduce('pets.get', $case)]; + yield 'redact' => [static fn(ContractSuite $suite, array $case) => $suite->redact($case)]; + } + public function zooOperationsTheGeneratorCannotServeFailClosedAtSelection(): void { $factory = new Psr17Factory(); diff --git a/tests/ExceptionsTest.php b/tests/ExceptionsTest.php index e4ee4d7..8f6cf7b 100644 --- a/tests/ExceptionsTest.php +++ b/tests/ExceptionsTest.php @@ -79,6 +79,7 @@ public function aSchemaRefusalIsPlacedInItsOperation(): void Assert::same($refusal->getMessage(), 'Unsupported OpenAPI schema generation: minLength exceeds maxLength'); Assert::same($placed->getMessage(), 'Unsupported OpenAPI schema generation for operation "pets.list", query parameter "limit": minLength exceeds maxLength'); Assert::same($placed->getPrevious(), $refusal); + Assert::same($placed->getCode(), 0); Assert::same($placed->inOperation('other', 'body')->getMessage(), 'Unsupported OpenAPI schema generation for operation "other", body: minLength exceeds maxLength'); } diff --git a/tests/OperationPropertyTest.php b/tests/OperationPropertyTest.php index 934708b..715ab59 100644 --- a/tests/OperationPropertyTest.php +++ b/tests/OperationPropertyTest.php @@ -100,6 +100,7 @@ public function printsTheMinimalCaseThroughTheSuiteRedactionPolicy(): void $suite = $this->suite(static fn(): Response => new Response(500), [ ['name' => 'X-Api-Key', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'const' => 'sk-live-secret']], ['name' => 'token', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'const' => 'tok-secret']], + ['name' => 'X-Trace', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'const' => 'a/b']], ])->redaction(new RedactionPolicy(headers: ['X-Api-Key'], queryParameters: ['token'])); try { @@ -107,7 +108,7 @@ public function printsTheMinimalCaseThroughTheSuiteRedactionPolicy(): void Assert::true(actual: false, message: 'Expected a falsified valid phase'); } catch (OperationPropertyFailed $failure) { Assert::string($failure->getMessage())->notContains('sk-live-secret')->notContains('tok-secret'); - Assert::string($failure->getMessage())->contains('"X-Api-Key":"[redacted]"')->contains('"token":"[redacted]"'); + Assert::string($failure->getMessage())->contains('"X-Api-Key":"[redacted]"')->contains('"token":"[redacted]"')->contains('"X-Trace":"a/b"'); Assert::string($failure->reproducer)->notContains('sk-live-secret')->notContains('tok-secret'); $shrunk = $failure->counterExample->shrunkArguments['case'] ?? null; Assert::true(is_array($shrunk)); diff --git a/tests/ParameterSchemasTest.php b/tests/ParameterSchemasTest.php index c55fad9..7b8e75c 100644 --- a/tests/ParameterSchemasTest.php +++ b/tests/ParameterSchemasTest.php @@ -258,6 +258,10 @@ public function judgesWhetherAValueCanTravelAsAFieldValue(): void Assert::true($schemas->isHeaderSafe(['a', 'b c'])); Assert::true($schemas->isHeaderSafe('a,b')); Assert::false($schemas->isHeaderSafe('a,b', delimited: true)); + Assert::false($schemas->isHeaderSafe(['a,b' => 'c'], delimited: true)); + Assert::false($schemas->isHeaderSafe([' a' => 'c'])); + Assert::true($schemas->isHeaderSafe(['a b' => 'c d'])); + Assert::true($schemas->isHeaderSafe([1 => 'c'])); Assert::false($schemas->isHeaderSafe(['a', 'b,c'], delimited: true)); Assert::false($schemas->isHeaderSafe(' a')); Assert::false($schemas->isHeaderSafe('a ')); @@ -276,6 +280,8 @@ public function narrowsAHeaderEnumToTheMembersReadAsSent(): void Assert::same($schemas->forLocation(['type' => 'string', 'enum' => ['New York', ' padded', 'plain', "a\nb", 'žluť']], 'header', 'simple')['enum'], ['New York', 'plain', 'žluť']); Assert::same($schemas->forLocation(['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a,b', 'c d']]], 'header', 'simple')['items']['enum'], ['c d']); + Assert::same($schemas->forLocation(['type' => 'object', 'additionalProperties' => ['type' => 'string', 'enum' => ['a,b', 'c d']]], 'header', 'simple')['additionalProperties']['enum'], ['c d']); + Assert::same($schemas->forLocation(['type' => 'string', 'enum' => ['a,b', 'c d']], 'header', 'simple')['enum'], ['a,b', 'c d']); try { $schemas->forLocation(['type' => 'string', 'enum' => [' a', 'b ']], 'header', 'simple'); diff --git a/tests/RequestMaterializerTest.php b/tests/RequestMaterializerTest.php index 17ac7ba..3877a48 100644 --- a/tests/RequestMaterializerTest.php +++ b/tests/RequestMaterializerTest.php @@ -359,6 +359,16 @@ public function rejectsCaseForAnotherOperation(): void (new RequestMaterializer($factory, $factory))->materialize($this->bodyOperation([]), $this->bodyCase('other', null)); } + public function refusesACaseMissingAKeyByName(): void + { + Expect::exception(InvalidCase::class)->withMessage('Case is missing the "cookies" key'); + + $factory = new Psr17Factory(); + $operation = new Operation(key: 'op', operationId: 'op', method: 'GET', path: '/op'); + + (new RequestMaterializer($factory, $factory))->materialize($operation, ['operationKey' => 'op', 'path' => [], 'query' => [], 'headers' => [], 'body' => null, 'misuse' => null]); + } + public function rejectsMissingBodyContentDefinition(): void { Expect::exception(InvalidCase::class)->withMessage('Request body media type "application/json" is not declared'); diff --git a/tests/ResponseCaseArbitraryTest.php b/tests/ResponseCaseArbitraryTest.php index e6fc7eb..2449189 100644 --- a/tests/ResponseCaseArbitraryTest.php +++ b/tests/ResponseCaseArbitraryTest.php @@ -236,6 +236,26 @@ public function headerValuesRenderEveryScalarKind(): void Assert::same($case['headers'], ['X-F' => '0.5', 'X-N' => 'null', 'X-B' => 'true']); } + /** + * A comma separates only the members of a list header; a scalar response + * header carries it as sent, and a list member carrying one is dropped + * from its enum (#129). + */ + public function aCommaSeparatesOnlyTheMembersOfAListHeader(): void + { + $operation = new Operation(key: 'op', operationId: 'op', method: 'GET', path: '/op', responses: ['200' => ['headers' => [ + 'X-Expr' => ['required' => true, 'schema' => ['type' => 'string', 'enum' => ['a,b']]], + 'X-Kinds' => ['required' => true, 'schema' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'string', 'enum' => ['x,y', 'z']]]], + ]]]); + + foreach (range(1, 10) as $seed) { + $case = (new ResponseCaseArbitrary())->forOperation($operation, 200)->generate(new Random($seed))->value; + + Assert::same($case['headers']['X-Expr'], 'a,b'); + Assert::same(array_unique((array) $case['headers']['X-Kinds']), ['z']); + } + } + public function rejectsAStatusOutsideTheHttpRange(): void { Expect::exception(\InvalidArgumentException::class); diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index f4a2b70..65b9a83 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -343,6 +343,7 @@ public static function multiplePrecisionProvider(): iterable yield 'integer multiple' => [2, 0]; yield 'one decimal' => [0.1, 1]; yield 'two decimals' => [0.25, 2]; + yield 'a multiple whose quotient lands just above an integer' => [0.7, 1]; yield 'three decimals' => [0.125, 3]; } @@ -495,6 +496,168 @@ public function honorsObjectCardinalityAndAdditionalPropertyPolicy(): void } } + /** + * The cardinality is met by construction: with three optionals under + * `minProperties: 2, maxProperties: 2` every draw has exactly two, not + * a filtered fraction of the draws (#123). + */ + public function meetsObjectCardinalityByConstruction(): void + { + $arbitrary = (new SchemaArbitraryCompiler())->compile([ + 'type' => 'object', + 'minProperties' => 2, + 'maxProperties' => 2, + 'additionalProperties' => false, + 'properties' => ['a' => ['const' => 1], 'b' => ['const' => 2], 'c' => ['const' => 3]], + ]); + + $seen = []; + foreach (Gen::sample($arbitrary, count: 80, seed: 31) as $value) { + Assert::true(is_array($value) && count($value) === 2); + $names = array_keys($value); + sort($names); + $seen[implode('', $names)] = true; + } + ksort($seen); + Assert::same(array_keys($seen), ['ab', 'ac', 'bc']); + + $floor = (new SchemaArbitraryCompiler())->compile([ + 'type' => 'object', + 'minProperties' => 3, + 'additionalProperties' => false, + 'properties' => ['a' => ['const' => 1], 'b' => ['const' => 2], 'c' => ['const' => 3]], + ]); + foreach (Gen::sample($floor, count: 20, seed: 37) as $value) { + Assert::true(is_array($value)); + ksort($value); + Assert::same($value, ['a' => 1, 'b' => 2, 'c' => 3]); + } + } + + /** + * A bounded integer item domain is finite: `uniqueItems` with a + * `minItems` it cannot fill fails closed at compile time, and exclusive + * bounds shrink the domain by one each (#123). + */ + #[DataProvider('integerDomainProvider')] + public function boundsTheUniqueItemsDomainOfABoundedInteger(array $items, int $minItems, bool $accepted): void + { + $compiler = new SchemaArbitraryCompiler(); + $schema = ['type' => 'array', 'uniqueItems' => true, 'minItems' => $minItems, 'maxItems' => 4, 'items' => $items]; + + if (!$accepted) { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: uniqueItems cannot fill minItems from the finite item domain'); + $compiler->compile($schema); + + return; + } + foreach (Gen::sample($compiler->compile($schema), count: 10, seed: 41) as $value) { + Assert::true(is_array($value) && count($value) >= $minItems && count(array_unique($value)) === count($value)); + } + } + + public static function integerDomainProvider(): iterable + { + yield 'two values, two items' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 1], 2, true]; + yield 'two values, three items' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 1], 3, false]; + yield 'exclusive maximum leaves two of three' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMaximum' => true], 3, false]; + yield 'exclusive minimum leaves two of three' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMinimum' => true], 3, false]; + yield 'both exclusive leave one of three' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], 2, false]; + yield 'both exclusive leave one, one item' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], 1, true]; + yield 'an unbounded integer is not finite' => [['type' => 'integer', 'minimum' => 0], 4, true]; + } + + /** + * `oneOf` over one `integer` and one `number` branch keeps a value only + * when exactly one branch admits it; every other overlap, and a number + * branch this cannot read, is refused (#121). + */ + #[DataProvider('numericOneOfProvider')] + public function keepsAnIntegerAndANumberOneOfBranchApart(array $branches, ?string $refusal, ?\Closure $holds, array $kinds = []): void + { + $compiler = new SchemaArbitraryCompiler(); + + if ($refusal !== null) { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: ' . $refusal); + $compiler->compile(['oneOf' => $branches]); + + return; + } + $seen = []; + foreach (Gen::sample($compiler->compile(['oneOf' => $branches]), count: 120, seed: 43) as $value) { + Assert::true($holds !== null && $holds($value), json_encode($value, JSON_THROW_ON_ERROR)); + $seen[get_debug_type($value)] = true; + } + ksort($seen); + Assert::same(array_keys($seen), $kinds); + } + + public static function numericOneOfProvider(): iterable + { + yield 'the number branch excludes the negative integers by its minimum' => [ + [['type' => 'integer', 'minimum' => -3, 'maximum' => 3], ['type' => 'number', 'minimum' => 0, 'maximum' => 3]], + null, + static fn(mixed $v): bool => is_float($v) ? floor($v) !== $v && $v >= 0 : (is_int($v) && $v < 0), + ['float', 'int'], + ]; + yield 'an exclusive minimum keeps the boundary integer for the integer branch' => [ + [['type' => 'integer', 'minimum' => 0, 'maximum' => 0], ['type' => 'number', 'minimum' => 0, 'maximum' => 3, 'exclusiveMinimum' => true]], + null, + static fn(mixed $v): bool => is_float($v) ? floor($v) !== $v : $v === 0, + ['float', 'int'], + ]; + yield 'an exclusive maximum keeps the boundary integer for the integer branch' => [ + [['type' => 'integer', 'minimum' => 3, 'maximum' => 3], ['type' => 'number', 'minimum' => 0, 'maximum' => 3, 'exclusiveMaximum' => true]], + null, + static fn(mixed $v): bool => is_float($v) ? floor($v) !== $v : $v === 3, + ['float', 'int'], + ]; + yield 'a half-step multipleOf on the number branch admits every integer' => [ + [['type' => 'integer', 'minimum' => 0, 'maximum' => 3], ['type' => 'number', 'minimum' => 0, 'maximum' => 3.5, 'multipleOf' => 0.5]], + null, + static fn(mixed $v): bool => is_float($v) && floor($v) !== $v, + ['float'], + ]; + yield 'a decimal multipleOf on the number branch frees the integers it skips' => [ + [['type' => 'integer', 'minimum' => 0, 'maximum' => 3], ['type' => 'number', 'minimum' => 0, 'maximum' => 3.5, 'multipleOf' => 0.7]], + null, + static fn(mixed $v): bool => is_float($v) ? floor($v) !== $v : in_array($v, [1, 2, 3], strict: true), + ['float', 'int'], + ]; + yield 'a whole multipleOf on the number branch leaves only the odd integers' => [ + [['type' => 'integer', 'minimum' => 1, 'maximum' => 3], ['type' => 'number', 'minimum' => 0, 'maximum' => 4, 'multipleOf' => 2]], + null, + static fn(mixed $v): bool => $v === 1 || $v === 3, + ['int'], + ]; + yield 'nothing left on either side is refused' => [ + [['type' => 'integer', 'minimum' => 2, 'maximum' => 2], ['type' => 'number', 'minimum' => 0, 'maximum' => 4, 'multipleOf' => 2]], + 'oneOf over integer and number admits no value exactly one branch accepts', + null, + ]; + yield 'a third, disjoint branch is kept' => [ + [['type' => 'integer', 'minimum' => -3, 'maximum' => -1], ['type' => 'number', 'minimum' => 0, 'maximum' => 1], ['type' => 'string', 'const' => 's']], + null, + static fn(mixed $v): bool => is_string($v) || (is_float($v) ? floor($v) !== $v : $v < 0), + ['float', 'int', 'string'], + ]; + yield 'two integer branches are not a pair' => [ + [['type' => 'integer', 'minimum' => 0, 'maximum' => 1], ['type' => 'integer', 'minimum' => 5, 'maximum' => 6], ['type' => 'number']], + 'oneOf branches must be provably disjoint', + null, + ]; + yield 'a type list is not a pair' => [ + [['type' => ['integer', 'string']], ['type' => 'number']], + 'oneOf branches must be provably disjoint', + null, + ]; + yield 'a number keyword this cannot read is refused' => [ + [['type' => 'integer'], ['type' => 'number', 'not' => ['const' => 2]]], + 'oneOf over integer and number cannot read number keyword "not" to keep the branches apart', + null, + ]; + } + public function rejectsImpossibleObjectCardinality(): void { Expect::exception(UnsupportedGeneration::class); diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 5305e04..3ff8883 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -6,7 +6,10 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Rasuvaeff\OpenApiContract\Contract; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ContainerArbitraries; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\ScalarArbitraries; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\WireValue; use Rasuvaeff\PropertyTesting\OpenApi\NegativeRequestCaseArbitrary; use Rasuvaeff\PropertyTesting\OpenApi\RequestCaseArbitrary; @@ -31,6 +34,9 @@ #[Covers(NegativeRequestCaseArbitrary::class)] #[Covers(SchemaArbitraryCompiler::class)] #[Covers(ContainerArbitraries::class)] +#[Covers(CompositionArbitraries::class)] +#[Covers(ScalarArbitraries::class)] +#[Covers(ParameterSchemas::class)] #[Covers(WireValue::class)] final class WireAgreementTest { @@ -193,9 +199,9 @@ public function oneOfOverIntegerAndNumberIsNotTreatedAsDisjoint(): void public function oneOfOverIntegerAndNumberFailsClosedWhenNoValueCanBeKeptApart(): void { - Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.create", request body "application/json": oneOf number branch admits no value outside the integer branch'); + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.create", request body "application/json": oneOf over integer and number admits no value exactly one branch accepts'); - (new RequestCaseArbitrary())->forOperation($this->jsonBodyContract(['oneOf' => [['type' => 'integer'], ['type' => 'number', 'multipleOf' => 2]]])->operation('things.create')); + (new RequestCaseArbitrary())->forOperation($this->jsonBodyContract(['oneOf' => [['type' => 'integer', 'minimum' => 2, 'maximum' => 2], ['type' => 'number', 'multipleOf' => 2]]])->operation('things.create')); } /** @@ -283,6 +289,81 @@ public function aNestedExplodedFormObjectThatNeedsUndeclaredMembersFailsClosed() (new RequestCaseArbitrary())->forOperation($this->formBodyContract(['type' => 'object', 'properties' => ['t' => ['type' => 'object', 'minProperties' => 1]]])->operation('things.create')); } + /** + * A comma is a member separator only in a list or object header; a + * scalar header carries it as sent, and an object header drops a member + * whose key or value carries one (#129). + */ + public function aCommaSeparatesOnlyTheMembersOfAListOrObjectHeader(): void + { + $contract = $this->parameterContract([ + ['name' => 'X-Expr', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['a,b']]], + ['name' => 'X-Map', 'in' => 'header', 'required' => true, 'style' => 'simple', 'explode' => true, 'schema' => ['type' => 'object', 'minProperties' => 1, 'properties' => ['k' => ['type' => 'string', 'enum' => ['x,y', 'z']]], 'additionalProperties' => false]], + ]); + + foreach ($this->validCases($contract, 'things.list', 40) as $case) { + Assert::same($case['headers']['X-Expr'], 'a,b'); + Assert::same($case['headers']['X-Map'], ['k' => 'z']); + } + } + + /** + * A JSON part is written with slashes and non-ASCII unescaped, as the + * JSON body encoder writes a body (#122). + */ + public function aJsonMultipartPartKeepsSlashesAndUnicodeUnescaped(): void + { + $contract = $this->multipartContract(['meta' => ['type' => 'string', 'const' => 'a/é']], ['meta' => ['contentType' => 'application/json']]); + + foreach ($this->validCases($contract, 'uploads.create', 3) as $case) { + Assert::same($case['body']['parts'][0]['value'] ?? null, '"a/é"'); + } + } + + /** + * Only an exploded object is written as flat pairs: with `explode: false` + * the object travels as one `name=k,v,k,v` value the contract attributes + * to it, undeclared members included, so nothing is stripped (#120). + */ + public function aNonExplodedFormObjectKeepsItsUndeclaredMembers(): void + { + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/things' => ['post' => [ + 'operationId' => 'things.create', + 'requestBody' => ['required' => true, 'content' => ['application/x-www-form-urlencoded' => [ + 'schema' => ['type' => 'object', 'required' => ['t'], 'properties' => ['t' => ['type' => 'object', 'minProperties' => 1, 'maxProperties' => 2, 'additionalProperties' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 3]]]], + 'encoding' => ['t' => ['explode' => false]], + ]]], + 'responses' => ['201' => []], + ]]], + ]); + + foreach ($this->validCases($contract, 'things.create', 30) as $case) { + Assert::true(count($case['body']['value']['t']) >= 1); + } + } + + /** + * An exploded object whose declared properties exactly meet its + * `minProperties` is generated, and one without any `minProperties` is + * generated too — only a floor the declared members cannot reach is + * refused (#120). + */ + public function anExplodedFormObjectMeetingItsFloorWithDeclaredMembersIsGenerated(): void + { + $contract = $this->formBodyContract(['type' => 'object', 'required' => ['t', 'u'], 'properties' => [ + 't' => ['type' => 'object', 'minProperties' => 2, 'properties' => ['x' => ['type' => 'integer'], 'y' => ['type' => 'integer']]], + 'u' => ['type' => 'object', 'properties' => ['z' => ['type' => 'integer']]], + ]]); + + foreach ($this->validCases($contract, 'things.create', 30) as $case) { + $names = array_keys($case['body']['value']['t']); + sort($names); + Assert::same($names, ['x', 'y']); + } + } + public function aPartUnderAMediaTypeThatIsNeitherTextNorJsonFailsClosed(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart property "meta" declares content type "application/xml", which this generator can write neither as text nor as JSON'); From 886e5d607c0a2157c8dad3b59d26e0d63a870ef3 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 19 Sep 2026 15:05:53 +0300 Subject: [PATCH 11/11] Pin the remaining behaviour of the wave; gate 91 against a measured 91.96% on 4040 mutants The wave added ~630 mutants. What is killable is killed (a disjoint branch declared first, a bounded number item, a domain away from zero, a scalar and a non-exploded object before the refused one, an exploded object with neither floor nor members); what remains is recorded by class in AGENTS.md, and the gate sits below the score again, as its own comment requires. --- AGENTS.md | 29 +++++++++++++++++++++++++-- infection.json5 | 15 +++++++++----- tests/SchemaArbitraryCompilerTest.php | 6 ++++-- tests/WireAgreementTest.php | 17 ++++++++++++---- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 35ce840..51c6f97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,8 +151,9 @@ into the monorepo) plus `git config --global --add safe.directory "*"`. ## Mutation gate: known equivalent classes -`composer mutation` (minMsi 92, against a measured 93.05% — see the comment -in `infection.json5` for why the gate is set below the score and not at it) +`composer mutation` (minMsi 91, against a measured 91.96% on 4040 mutants +after the 0.15.0 wave — see the comment in `infection.json5` for why the +gate is set below the score and not at it) leaves a stable set of escaped mutants that are equivalent by analysis — do not chase them, and re-classify anything new: `Gen::frequency` weight bumps that scale every pair uniformly, values in @@ -304,6 +305,30 @@ message — so the provider carries the both-bounds-at-the-limit cases that make the overflow observable as a `TypeError` instead. +The 1.0-readiness wave (2026-09-19, 0.15.0) adds: the probe loops of +`RequestCaseArbitrary::yieldsSomething()` and +`CompositionArbitraries::yieldsSomething()` (budget and bound variants answer +the same question, as `fitsLengthWindow()`'s do); the `floor`/`ceil`/`round` +choice in the non-integral test of the numeric `oneOf` (`f(v) !== v` detects +a fractional part whichever `f` is) and in `numberBranchAdmits()` (the product +equals the value only when the quotient is exact, whichever way it is +rounded), together with its `<` → `<=` on the `1e-14` tolerance and the +`(float)` casts on operands a float already touches; `++$numeric` → `--`, +since the count is only compared with zero; the `(float)` cast and the `<=` +in `ScalarArbitraries::multipleOf()` for the same reasons; the `&&` → `||` +between `array_key_exists('const')` and the header-safety of the const (an +absent key reads as `null`, which is safe, and the mutant differs only by a +PHP warning); the `CaseShape::assert()` calls in `checkValid()` and +`reproduce()`, which the materializer repeats on the same case and so throw +the same `InvalidCase` a step later (the one in `redact()` is not repeated and +stays killed); the four `||`/`&&` rewrites inside `CaseShape`'s misuse guard, +which agree on every non-array and every array missing a member; the +`declaredFloor` ternary in `ContainerArbitraries::object()`, whose two arms +coincide once the earlier `minProperties`-versus-declared refusal has run; +the `present ?? true` default on an optional that always carries `present`; +and the `(path || header) && pattern` guard on the compile-time probe, whose +widening only probes arbitraries no filter can exhaust. + ## The contract package is the other half of the oracle `rasuvaeff/openapi-contract` depends on nothing here, and this package depends diff --git a/infection.json5 b/infection.json5 index fbb9737..61cf707 100644 --- a/infection.json5 +++ b/infection.json5 @@ -15,11 +15,16 @@ // mutants of margin — and two changes in one wave failed on nothing but // equivalent mutants they had introduced. // - // 92 keeps the alarm the gate is for: a drop of ~36 killed mutants is a - // real regression, not a rounding of the draw. `openapi-contract` runs at - // 94.6% against 92 for the same reason. Raise it when the score has moved - // up and stayed there, and raise it to less than the score. - "minMsi": 92, + // The 1.0-readiness wave (0.15.0) added ~630 mutants with the header + // judgement, the numeric oneOf, cardinality by construction and the + // compile-time probes; the score settled at 3715/4040 = 91.96% once the + // new behaviour was pinned, the remainder being the equivalent classes + // AGENTS.md records for that wave. 91 keeps the alarm the gate is for: a + // drop of ~40 killed mutants is a real regression, not a rounding of the + // draw. `openapi-contract` runs at 94.6% against 92 for the same reason. + // Raise it when the score has moved up and stayed there, and raise it to + // less than the score. + "minMsi": 91, "mutators": { "@default": true } diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 65b9a83..dec6f6e 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -565,6 +565,8 @@ public static function integerDomainProvider(): iterable yield 'both exclusive leave one of three' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], 2, false]; yield 'both exclusive leave one, one item' => [['type' => 'integer', 'minimum' => 0, 'maximum' => 2, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], 1, true]; yield 'an unbounded integer is not finite' => [['type' => 'integer', 'minimum' => 0], 4, true]; + yield 'a bounded number is not finite' => [['type' => 'number', 'minimum' => 0, 'maximum' => 1], 4, true]; + yield 'a domain away from zero is its own size' => [['type' => 'integer', 'minimum' => 5, 'maximum' => 6], 3, false]; } /** @@ -635,8 +637,8 @@ public static function numericOneOfProvider(): iterable 'oneOf over integer and number admits no value exactly one branch accepts', null, ]; - yield 'a third, disjoint branch is kept' => [ - [['type' => 'integer', 'minimum' => -3, 'maximum' => -1], ['type' => 'number', 'minimum' => 0, 'maximum' => 1], ['type' => 'string', 'const' => 's']], + yield 'a third, disjoint branch is kept wherever it is declared' => [ + [['type' => 'string', 'const' => 's'], ['type' => 'integer', 'minimum' => -3, 'maximum' => -1], ['type' => 'number', 'minimum' => 0, 'maximum' => 1]], null, static fn(mixed $v): bool => is_string($v) || (is_float($v) ? floor($v) !== $v : $v < 0), ['float', 'int', 'string'], diff --git a/tests/WireAgreementTest.php b/tests/WireAgreementTest.php index 3ff8883..064167b 100644 --- a/tests/WireAgreementTest.php +++ b/tests/WireAgreementTest.php @@ -286,7 +286,12 @@ public function aNestedExplodedFormObjectThatNeedsUndeclaredMembersFailsClosed() { Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation for operation "things.create", request body "application/x-www-form-urlencoded": form property "t" is an exploded object whose minProperties 1 cannot be met by its 0 declared properties, and its wire form carries no undeclared member'); - (new RequestCaseArbitrary())->forOperation($this->formBodyContract(['type' => 'object', 'properties' => ['t' => ['type' => 'object', 'minProperties' => 1]]])->operation('things.create')); + (new RequestCaseArbitrary())->forOperation($this->formBodyContract(['type' => 'object', 'properties' => [ + 'name' => ['type' => 'string'], + 'plain' => ['type' => 'object', 'properties' => ['k' => ['type' => 'string']]], + 'flat' => ['type' => 'object', 'minProperties' => 1], + 't' => ['type' => 'object', 'minProperties' => 1], + ], 'required' => ['flat']], ['flat' => ['explode' => false]])->operation('things.create')); } /** @@ -355,6 +360,7 @@ public function anExplodedFormObjectMeetingItsFloorWithDeclaredMembersIsGenerate $contract = $this->formBodyContract(['type' => 'object', 'required' => ['t', 'u'], 'properties' => [ 't' => ['type' => 'object', 'minProperties' => 2, 'properties' => ['x' => ['type' => 'integer'], 'y' => ['type' => 'integer']]], 'u' => ['type' => 'object', 'properties' => ['z' => ['type' => 'integer']]], + 'w' => ['type' => 'object'], ]]); foreach ($this->validCases($contract, 'things.create', 30) as $case) { @@ -396,14 +402,17 @@ private function validCases(Contract $contract, string $operationKey, int $draws return $cases; } - /** @param array $schema */ - private function formBodyContract(array $schema): Contract + /** + * @param array $schema + * @param array> $encoding + */ + private function formBodyContract(array $schema, array $encoding = []): Contract { return Contract::fromArray([ 'openapi' => '3.1.0', 'paths' => ['/things' => ['post' => [ 'operationId' => 'things.create', - 'requestBody' => ['required' => true, 'content' => ['application/x-www-form-urlencoded' => ['schema' => $schema]]], + 'requestBody' => ['required' => true, 'content' => ['application/x-www-form-urlencoded' => ['schema' => $schema, 'encoding' => $encoding]]], 'responses' => ['201' => []], ]]], ]);