diff --git a/src/DecodedBody.php b/src/DecodedBody.php new file mode 100644 index 0000000..a1d812e --- /dev/null +++ b/src/DecodedBody.php @@ -0,0 +1,77 @@ +getContent(); if ($content === '') { - return null; + return DecodedBody::absent(); } if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { - return null; + return DecodedBody::absent(); } // The return lives inside the try so its dependence on a successful @@ -1152,7 +1153,7 @@ private function extractRequestBody(Request $request, string $contentType): mixe /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); - return $decoded ?? PresentJsonNull::Body; + return DecodedBody::present($decoded); } catch (JsonException $e) { $this->failOpenApi( 'Request body could not be parsed as JSON: ' . $e->getMessage() @@ -1164,27 +1165,28 @@ private function extractRequestBody(Request $request, string $contentType): mixe /** * Extract the response body in the shape OpenApiResponseValidator expects. * Mirrors {@see self::extractRequestBody()}: parse JSON only when the - * Content-Type claims it (or is absent), stay `null` on empty or non-JSON - * bodies so the validator decides whether the spec required one. + * Content-Type claims it (or is absent), report an absent body on empty + * or non-JSON content so the validator decides whether the spec required + * one. * - * Issue #246: decoding goes through `json_decode()` rather than + * Issues #246 / #248: decoding goes through `json_decode()` rather than * `TestResponse::json()` so a body of the literal JSON `null` is not * tripped up by Laravel's "null decode == invalid JSON" heuristic, and a * scalar body is returned as-is for schema type-checking instead of being - * forced into an array. A present literal `null` yields a - * {@see PresentJsonNull} marker so it is type-checked rather than read as - * an absent body — keeping this adapter aligned with the Symfony one. + * forced into an array. A present literal `null` is returned as a present + * {@see DecodedBody} carrying `null` so it is type-checked rather than + * read as an absent body — keeping this adapter aligned with the Symfony one. */ - private function extractJsonBody(string $content, string $contentType): mixed + private function extractJsonBody(string $content, string $contentType): DecodedBody { if ($content === '') { - return null; + return DecodedBody::absent(); } - // Non-JSON Content-Type: return null so the validator can decide - // whether the spec requires a JSON body for this endpoint. + // Non-JSON Content-Type: report an absent body so the validator can + // decide whether the spec requires a JSON body for this endpoint. if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { - return null; + return DecodedBody::absent(); } // See extractRequestBody(): the return is inside the try so its @@ -1193,7 +1195,7 @@ private function extractJsonBody(string $content, string $contentType): mixed /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); - return $decoded ?? PresentJsonNull::Body; + return DecodedBody::present($decoded); } catch (JsonException $e) { $this->failOpenApi( 'Response body could not be parsed as JSON: ' . $e->getMessage() diff --git a/src/OpenApiRequestValidator.php b/src/OpenApiRequestValidator.php index 21f1998..3841fc2 100644 --- a/src/OpenApiRequestValidator.php +++ b/src/OpenApiRequestValidator.php @@ -97,6 +97,13 @@ public function __construct( * @param array $queryParams parsed query string (string|array per key) * @param array $headers request headers (string|array per key, case-insensitive name match; non-string keys are silently dropped) * @param array $cookies request cookies (string values per key). Used for apiKey security schemes with `in: cookie`. Caller is expected to pass framework-parsed cookies (e.g. Laravel's `$request->cookies->all()`) — this validator does not parse a `Cookie` header. + * @param mixed $requestBody the decoded request body. Accepts either a + * {@see DecodedBody} envelope (what the framework + * adapters pass) or a bare decoded value for + * backward compatibility. A bare `null` is read + * as an absent body; a caller that needs to + * assert a literal JSON `null` body must pass + * `DecodedBody::present(null)` explicitly. * @param null|int $responseStatusCode optional response status the request produced; enables the documented-4xx downgrade when set */ public function validate( @@ -110,6 +117,12 @@ public function validate( array $cookies = [], ?int $responseStatusCode = null, ): OpenApiValidationResult { + // The `mixed` body parameter is kept for backward compatibility. + // Framework adapters now pass a DecodedBody envelope directly; legacy + // direct callers pass a bare value, which fromLegacy() normalizes + // (a plain `null` becomes an absent body — see {@see DecodedBody}). + $body = DecodedBody::fromLegacy($requestBody); + $spec = OpenApiSpecLoader::load($specName); $version = OpenApiVersion::fromSpec($spec); @@ -164,7 +177,7 @@ public function validate( ...ValidatorErrorBoundary::safely('query', $specName, $method, $matchedPath, fn(): array => $this->queryValidator->validate($method, $matchedPath, $collected->parameters, $queryParams, $version)), ...ValidatorErrorBoundary::safely('header', $specName, $method, $matchedPath, fn(): array => $this->headerValidator->validate($method, $matchedPath, $collected->parameters, $headers, $version)), ...ValidatorErrorBoundary::safely('security', $specName, $method, $matchedPath, fn(): array => $this->securityValidator->validate($method, $matchedPath, $spec, $operation, $headers, $queryParams, $cookies)), - ...ValidatorErrorBoundary::safely('request-body', $specName, $method, $matchedPath, fn(): array => $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $requestBody, $contentType, $version)), + ...ValidatorErrorBoundary::safely('request-body', $specName, $method, $matchedPath, fn(): array => $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $body, $contentType, $version)), ]; if ($errors === []) { diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 96c5179..b22c632 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use RuntimeException; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension; use Studio\OpenApiContractTesting\Spec\OpenApiPathMatcher; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; @@ -77,6 +76,13 @@ public function __construct( } /** + * @param mixed $responseBody the decoded response body. Accepts either a + * {@see DecodedBody} envelope (what the framework + * adapters pass) or a bare decoded value for + * backward compatibility. A bare `null` is read + * as an absent body; a caller that needs to + * assert a literal JSON `null` body must pass + * `DecodedBody::present(null)` explicitly. * @param null|array $responseHeaders the response's actual headers * (as returned by HeaderBag::all() — a map of name to list-of-values * or to a single string). When null, header validation is skipped @@ -92,6 +98,12 @@ public function validate( ?string $responseContentType = null, ?array $responseHeaders = null, ): OpenApiValidationResult { + // The `mixed` body parameter is kept for backward compatibility. + // Framework adapters now pass a DecodedBody envelope directly; legacy + // direct callers pass a bare value, which fromLegacy() normalizes + // (a plain `null` becomes an absent body — see {@see DecodedBody}). + $body = DecodedBody::fromLegacy($responseBody); + $spec = OpenApiSpecLoader::load($specName); $version = OpenApiVersion::fromSpec($spec); @@ -183,7 +195,7 @@ public function validate( $matchedPath, $statusCode, $responseSpec, - $responseBody, + $body, $responseContentType, $version, ); @@ -236,10 +248,9 @@ public function validate( $matchedPath, $statusCodeStr, $bodyResult->matchedContentType, - // Issue #246: unwrap the present-literal-null marker so the - // strict-required walker observes the real `null` value - // rather than the marker object. - $responseBody instanceof PresentJsonNull ? null : $responseBody, + // The strict-required walker observes the decoded body value; + // an absent body carries `null` (issues #246 / #248). + $body->value, ); return OpenApiValidationResult::success( @@ -370,7 +381,7 @@ private function validateBody( string $matchedPath, int $statusCode, array $responseSpec, - mixed $responseBody, + DecodedBody $responseBody, ?string $responseContentType, OpenApiVersion $version, ): ResponseBodyValidationResult { diff --git a/src/Symfony/OpenApiAssertions.php b/src/Symfony/OpenApiAssertions.php index 349d615..78c0a55 100644 --- a/src/Symfony/OpenApiAssertions.php +++ b/src/Symfony/OpenApiAssertions.php @@ -10,8 +10,8 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\AssertionFailedError; use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\HttpMethod; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\Internal\StackTraceFilter; use Studio\OpenApiContractTesting\OpenApiRequestValidator; use Studio\OpenApiContractTesting\OpenApiResponseValidator; @@ -330,26 +330,26 @@ private function symfonyRequestValidator(): OpenApiRequestValidator /** * Decode a JSON request / response body in the shape the validators * expect. Mirrors the Laravel adapter: parse only when the Content-Type - * claims JSON (or is absent), stay `null` on empty or non-JSON bodies so - * the validator decides whether the spec required one. + * claims JSON (or is absent), report an absent body on empty or non-JSON + * content so the validator decides whether the spec required one. * - * Issue #246: when the raw content is non-empty but decodes to the literal - * JSON `null`, a {@see PresentJsonNull} marker is returned instead of a - * bare `null` so the validator type-checks the value against the schema + * Issues #246 / #248: when the raw content is non-empty but decodes to the + * literal JSON `null`, a present {@see DecodedBody} carrying `null` is + * returned so the validator type-checks the value against the schema * rather than mistaking it for an absent body. Non-null decoded values - * (scalars, arrays) pass through unchanged. + * (scalars, arrays) are wrapped in a present envelope unchanged. * * @param string $subject either `Request` or `Response`, used only for the * error message when the body is not valid JSON */ - private function extractSymfonyJsonBody(string $content, string $contentType, string $subject): mixed + private function extractSymfonyJsonBody(string $content, string $contentType, string $subject): DecodedBody { if ($content === '') { - return null; + return DecodedBody::absent(); } if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { - return null; + return DecodedBody::absent(); } // The return is inside the try so its dependence on a successful @@ -359,7 +359,7 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); - return $decoded ?? PresentJsonNull::Body; + return DecodedBody::present($decoded); } catch (JsonException $e) { $this->failOpenApi(sprintf( '%s body could not be parsed as JSON: %s%s', diff --git a/src/Validation/Request/RequestBodyValidator.php b/src/Validation/Request/RequestBodyValidator.php index f747d7f..80c31c2 100644 --- a/src/Validation/Request/RequestBodyValidator.php +++ b/src/Validation/Request/RequestBodyValidator.php @@ -5,7 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Request; use stdClass; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\SchemaContext; use Studio\OpenApiContractTesting\Spec\OpenApiSchemaConverter; @@ -47,7 +47,7 @@ public function validate( string $method, string $matchedPath, array $operation, - mixed $requestBody, + DecodedBody $requestBody, ?string $contentType, OpenApiVersion $version, ): array { @@ -142,17 +142,11 @@ public function validate( return []; } - // Issue #246: see the matching comment in ResponseBodyValidator. A - // PresentJsonNull marker means the wire carried a literal JSON `null` - // body; unwrap it and flag the body as present so it is type-checked - // against the schema instead of taking the empty-body branch. - $bodyWasPresent = false; - if ($requestBody instanceof PresentJsonNull) { - $requestBody = null; - $bodyWasPresent = true; - } - - if ($requestBody === null && !$bodyWasPresent) { + // An absent body is acceptable unless the spec marks the requestBody + // `required`. A literal JSON `null` body is distinct — `->present` is + // true with a `null` value (issues #246 / #248), so it falls through + // to schema type-checking below instead of taking this branch. + if (!$requestBody->present) { if (!$required) { return []; } @@ -162,6 +156,8 @@ public function validate( ]; } + $bodyValue = $requestBody->value; + /** @var array $schema */ $schema = $content[$jsonContentType]['schema']; $jsonSchema = OpenApiSchemaConverter::convert($schema, $version, SchemaContext::Request); @@ -175,12 +171,12 @@ public function validate( // the empty-object-against-type-object case (very common for // "create with defaults" bodies) validates. Mirrors the response-side // fix at ResponseBodyValidator::validate(). - if ($requestBody === [] && self::schemaAcceptsObject($schema)) { - $requestBody = new stdClass(); + if ($bodyValue === [] && self::schemaAcceptsObject($schema)) { + $bodyValue = new stdClass(); } $schemaObject = ObjectConverter::convert($jsonSchema); - $dataObject = ObjectConverter::convert($requestBody); + $dataObject = ObjectConverter::convert($bodyValue); $formatted = $this->runner->validate($schemaObject, $dataObject); diff --git a/src/Validation/Response/ResponseBodyValidator.php b/src/Validation/Response/ResponseBodyValidator.php index 319d333..4a2ab7e 100644 --- a/src/Validation/Response/ResponseBodyValidator.php +++ b/src/Validation/Response/ResponseBodyValidator.php @@ -5,7 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Response; use stdClass; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiValidationResult; use Studio\OpenApiContractTesting\OpenApiVersion; @@ -56,7 +56,7 @@ public function validate( string $matchedPath, int $statusCode, array $content, - mixed $responseBody, + DecodedBody $responseBody, ?string $responseContentType, OpenApiVersion $version, ): ResponseBodyValidationResult { @@ -107,18 +107,12 @@ public function validate( return new ResponseBodyValidationResult([], $jsonContentType); } - // Issue #246: an adapter that saw non-empty raw content but decoded a - // literal JSON `null` passes the PresentJsonNull marker so the null is - // type-checked against the schema below, rather than short-circuiting - // as an absent body. Unwrap it to a real null and remember the body - // WAS present so the empty-body branch is bypassed. - $bodyWasPresent = false; - if ($responseBody instanceof PresentJsonNull) { - $responseBody = null; - $bodyWasPresent = true; - } - - if ($responseBody === null && !$bodyWasPresent) { + // An absent body fails the contract: this validator only runs once the + // spec is known to declare a JSON-compatible schema for the response. + // A literal JSON `null` body is distinct — `$responseBody->present` is + // true with a `null` value (issues #246 / #248), so it falls through + // to schema type-checking below instead of taking this branch. + if (!$responseBody->present) { return new ResponseBodyValidationResult( [ "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.", @@ -127,6 +121,8 @@ public function validate( ); } + $bodyValue = $responseBody->value; + /** @var array $schema */ $schema = $content[$jsonContentType]['schema']; $jsonSchema = OpenApiSchemaConverter::convert($schema, $version, SchemaContext::Response); @@ -140,12 +136,12 @@ public function validate( // error. Coerce `[]` → stdClass when the schema explicitly accepts // an object so the empty-object-against-type-object case (very // common for status acks and "no items yet" responses) validates. - if ($responseBody === [] && self::schemaAcceptsObject($schema)) { - $responseBody = new stdClass(); + if ($bodyValue === [] && self::schemaAcceptsObject($schema)) { + $bodyValue = new stdClass(); } $schemaObject = ObjectConverter::convert($jsonSchema); - $dataObject = ObjectConverter::convert($responseBody); + $dataObject = ObjectConverter::convert($bodyValue); $formatted = $this->runner->validate($schemaObject, $dataObject); diff --git a/tests/Unit/DecodedBodyTest.php b/tests/Unit/DecodedBodyTest.php new file mode 100644 index 0000000..2a430d2 --- /dev/null +++ b/tests/Unit/DecodedBodyTest.php @@ -0,0 +1,85 @@ +assertFalse($body->present); + $this->assertNull($body->value); + } + + #[Test] + public function present_carries_the_decoded_value(): void + { + $body = DecodedBody::present(['id' => 1]); + + $this->assertTrue($body->present); + $this->assertSame(['id' => 1], $body->value); + } + + #[Test] + public function present_distinguishes_a_literal_null_value_from_an_absent_body(): void + { + // The whole reason the envelope exists: a body of the literal JSON + // `null` is PRESENT — `present` stays true even though the value is + // `null`, which an `absent()` body cannot express. + $body = DecodedBody::present(null); + + $this->assertTrue($body->present); + $this->assertNull($body->value); + // The contrast an absent body cannot express: same `null` value, but + // `present` flips — that single bit is the whole point of the envelope. + $this->assertFalse(DecodedBody::absent()->present); + } + + #[Test] + public function present_carries_scalar_values(): void + { + $body = DecodedBody::present(42); + + $this->assertTrue($body->present); + $this->assertSame(42, $body->value); + } + + #[Test] + public function from_legacy_maps_a_plain_null_to_an_absent_body(): void + { + // Backward compatibility: callers of the public validators have always + // signalled "no body" with a plain PHP `null`. fromLegacy() preserves + // that meaning so the `mixed` validate() signature stays unchanged. + $body = DecodedBody::fromLegacy(null); + + $this->assertFalse($body->present); + $this->assertNull($body->value); + } + + #[Test] + public function from_legacy_maps_a_non_null_value_to_a_present_body(): void + { + $body = DecodedBody::fromLegacy(['name' => 'Rex']); + + $this->assertTrue($body->present); + $this->assertSame(['name' => 'Rex'], $body->value); + } + + #[Test] + public function from_legacy_passes_a_decoded_body_through_unchanged(): void + { + // Adapters already build a DecodedBody; fromLegacy() must not double-wrap + // it when that envelope reaches the public validator's `mixed` parameter. + $original = DecodedBody::present(null); + + $this->assertSame($original, DecodedBody::fromLegacy($original)); + } +} diff --git a/tests/Unit/OpenApiRequestValidatorTest.php b/tests/Unit/OpenApiRequestValidatorTest.php index 099e17d..060311b 100644 --- a/tests/Unit/OpenApiRequestValidatorTest.php +++ b/tests/Unit/OpenApiRequestValidatorTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use stdClass; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiRequestValidator; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; use Studio\OpenApiContractTesting\Validation\Request\SecurityValidator; @@ -3330,6 +3331,75 @@ public function downgrade_via_default_fallback_emits_suspicious_keys_warning(): $this->assertSame([], $captured); } + // ======================================== + // DecodedBody envelope passed directly to validate() (issue #248) + // ======================================== + + #[Test] + public function validate_accepts_decoded_body_envelope_passed_directly(): void + { + // The `mixed` body parameter accepts a DecodedBody envelope directly, + // not just a bare value — the framework adapters rely on this. The + // outcome must match the equivalent bare-value call + // (v30_valid_request_body_passes). A regression that double-wrapped + // the envelope in fromLegacy() would fail validation here. + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + [], + [], + DecodedBody::present(['name' => 'Fido', 'tag' => 'dog']), + 'application/json', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function validate_type_checks_present_literal_null_body_envelope(): void + { + // A DecodedBody carrying a literal `null` is a PRESENT body — it is + // type-checked against the schema, not short-circuited as absent. + // Against the required `type: object` body it fails with a schema + // type error, NOT the "Request body is empty" message a bare `null` + // (absent) would yield — contrast v30_empty_body_when_required_fails. + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + [], + [], + DecodedBody::present(null), + 'application/json', + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('must match the type', $result->errorMessage()); + $this->assertStringNotContainsString('Request body is empty', $result->errorMessage()); + } + + #[Test] + public function validate_treats_absent_body_envelope_like_a_bare_null(): void + { + // DecodedBody::absent() is equivalent to the legacy bare `null` — both + // mean "no body on the wire" and yield the "Request body is empty" + // failure against an operation with a required JSON request body. + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + [], + [], + DecodedBody::absent(), + 'application/json', + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Request body is empty', $result->errorMessage()); + } + /** * Run a callable while suppressing the silent-pass `[security]` * `E_USER_WARNING` emitted for oauth2 / openIdConnect / mutualTLS / diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index c78a17e..58f17f1 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use stdClass; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; @@ -1907,4 +1908,70 @@ public function optional_header_with_no_schema_passes_silently_when_value_presen $this->assertTrue($result->isValid()); } + + // ======================================== + // DecodedBody envelope passed directly to validate() (issue #248) + // ======================================== + + #[Test] + public function validate_accepts_decoded_body_envelope_passed_directly(): void + { + // The `mixed` body parameter accepts a DecodedBody envelope directly, + // not just a bare value — the framework adapters rely on this. The + // outcome must match the equivalent bare-value call + // (v30_valid_response_passes). A regression that double-wrapped the + // envelope in fromLegacy() would fail validation here. + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 200, + DecodedBody::present([ + 'data' => [ + ['id' => 1, 'name' => 'Fido', 'tag' => 'dog'], + ], + ]), + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function validate_type_checks_present_literal_null_body_envelope(): void + { + // A DecodedBody carrying a literal `null` is a PRESENT body — it must + // be type-checked against the schema, not short-circuited as absent. + // Against `type: object` it fails with a schema type error, NOT the + // "Response body is empty" message a bare `null` (absent) would yield. + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 200, + DecodedBody::present(null), + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('must match the type', $result->errorMessage()); + $this->assertStringNotContainsString('Response body is empty', $result->errorMessage()); + } + + #[Test] + public function validate_treats_absent_body_envelope_like_a_bare_null(): void + { + // DecodedBody::absent() is equivalent to the legacy bare `null` — both + // mean "no body on the wire" and yield the "Response body is empty" + // failure against a response that declares a JSON schema. + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 200, + DecodedBody::absent(), + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Response body is empty', $result->errorMessage()); + } } diff --git a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php index a7877d5..cdd7957 100644 --- a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php +++ b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -29,7 +29,7 @@ public function validate_returns_empty_when_operation_defines_no_body(): void 'GET', '/pets', [], - null, + DecodedBody::absent(), null, OpenApiVersion::V3_0, ); @@ -54,7 +54,7 @@ public function validate_flags_missing_required_body(): void 'POST', '/pets', $operation, - null, + DecodedBody::absent(), 'application/json', OpenApiVersion::V3_0, ); @@ -70,8 +70,8 @@ public function validate_flags_present_literal_null_body_against_object_schema_w // JSON `null` against an OPTIONAL `type: object` body must NOT pass: // before the fix the validator read the decoded `null` as "no body" // and, because the body was optional, returned no errors — letting a - // malformed `null` body slip through unchecked. A present `null` is - // now type-checked against the schema and fails loudly. + // malformed `null` body slip through unchecked. A present DecodedBody + // carrying `null` is now type-checked against the schema and fails loudly. $operation = [ 'requestBody' => [ 'required' => false, @@ -86,7 +86,7 @@ public function validate_flags_present_literal_null_body_against_object_schema_w 'POST', '/pets', $operation, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_0, ); @@ -115,7 +115,7 @@ public function validate_flags_present_literal_null_body_against_object_schema_w 'POST', '/pets', $operation, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_0, ); @@ -143,7 +143,7 @@ public function validate_accepts_present_literal_null_body_against_oas_31_nullab 'POST', '/pets', $operation, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_1, ); @@ -173,7 +173,7 @@ public function validate_accepts_present_literal_null_body_against_oas_30_nullab 'POST', '/pets', $operation, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_0, ); @@ -182,12 +182,12 @@ public function validate_accepts_present_literal_null_body_against_oas_30_nullab } #[Test] - public function validate_still_treats_plain_null_as_absent_body(): void + public function validate_still_treats_absent_body_as_no_body(): void { - // Regression guard for issue #246: a plain PHP `null` (absent body, - // raw content was empty) keeps the historical "no body" semantics — - // it is NOT type-checked. An optional absent body still passes; the - // PresentJsonNull marker is the ONLY value whose handling changed. + // Regression guard for issue #246: an absent body (raw content was + // empty) keeps the historical "no body" semantics — it is NOT + // type-checked. An optional absent body still passes; only a present + // DecodedBody carrying `null` is type-checked against the schema. $operation = [ 'requestBody' => [ 'required' => false, @@ -202,7 +202,7 @@ public function validate_still_treats_plain_null_as_absent_body(): void 'POST', '/pets', $operation, - null, + DecodedBody::absent(), 'application/json', OpenApiVersion::V3_0, ); @@ -226,7 +226,7 @@ public function validate_flags_unknown_non_json_content_type(): void 'POST', '/pets', $operation, - null, + DecodedBody::absent(), 'application/xml', OpenApiVersion::V3_0, ); @@ -245,7 +245,7 @@ public function validate_flags_malformed_non_array_request_body(): void 'POST', '/pets', $operation, - null, + DecodedBody::absent(), null, OpenApiVersion::V3_0, ); @@ -270,7 +270,7 @@ public function validate_flags_malformed_media_type_schema(): void 'POST', '/pets', $operation, - null, + DecodedBody::absent(), null, OpenApiVersion::V3_0, ); @@ -302,7 +302,7 @@ public function validate_validates_json_body_against_schema(): void 'POST', '/pets', $operation, - ['name' => 'Fido'], + DecodedBody::present(['name' => 'Fido']), 'application/json', OpenApiVersion::V3_0, ); @@ -332,7 +332,7 @@ public function validate_accepts_empty_object_body_against_type_object(): void 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -358,7 +358,7 @@ public function validate_accepts_empty_object_body_against_oas_31_nullable_objec 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_1, ); @@ -391,7 +391,7 @@ public function validate_does_not_coerce_empty_array_when_schema_has_no_explicit 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -429,7 +429,7 @@ public function validate_does_not_coerce_empty_array_for_oneof_with_object_branc 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -467,7 +467,7 @@ public function validate_does_not_coerce_empty_array_when_schema_is_array_type() 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -506,7 +506,7 @@ public function validate_still_flags_missing_required_property_after_empty_objec 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -520,10 +520,10 @@ public function validate_accepts_empty_object_body_when_request_body_is_optional { // Request-side-specific invariant: the coercion fires regardless of // `required: true|false` because an empty `{}` body arrives as PHP - // `[]`, not as `null` — only `null` short-circuits the `required` - // branch. A future refactor that moved the optional-body fast-path - // to also match `[]` would silently skip the coercion gate; this - // test pins the current behaviour. + // `[]`, not as an absent body — only an absent body short-circuits the + // `required` branch. A future refactor that moved the optional-body + // fast-path to also match `[]` would silently skip the coercion gate; + // this test pins the current behaviour. $operation = [ 'requestBody' => [ 'required' => false, @@ -538,7 +538,7 @@ public function validate_accepts_empty_object_body_when_request_body_is_optional 'POST', '/p', $operation, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); diff --git a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php index 0cb54df..b7e79ad 100644 --- a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php +++ b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Studio\OpenApiContractTesting\Internal\PresentJsonNull; +use Studio\OpenApiContractTesting\DecodedBody; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Response\ResponseBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -40,7 +40,7 @@ public function validate_passes_valid_json_body_against_schema(): void '/pets/{id}', 200, $content, - ['id' => 1], + DecodedBody::present(['id' => 1]), 'application/json', OpenApiVersion::V3_0, ); @@ -62,7 +62,7 @@ public function validate_flags_empty_body_against_json_schema(): void '/pets', 200, $content, - null, + DecodedBody::absent(), 'application/json', OpenApiVersion::V3_0, ); @@ -80,8 +80,8 @@ public function validate_type_checks_present_literal_null_body_against_object_sc // short-circuited as an absent body. Against `type: object` it is a // contract violation and must surface a schema type error — NOT the // "Response body is empty" message reserved for a genuinely absent - // body. The PresentJsonNull marker is how an adapter signals "the - // wire carried a body and its decoded value is null". + // body. A present DecodedBody carrying `null` is how an adapter + // signals "the wire carried a body and its decoded value is null". $content = [ 'application/json' => ['schema' => ['type' => 'object']], ]; @@ -92,7 +92,7 @@ public function validate_type_checks_present_literal_null_body_against_object_sc '/pets', 200, $content, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_0, ); @@ -119,7 +119,7 @@ public function validate_accepts_present_literal_null_body_against_oas_31_nullab '/pets', 200, $content, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_1, ); @@ -146,7 +146,7 @@ public function validate_accepts_present_literal_null_body_against_oas_30_nullab '/pets', 200, $content, - PresentJsonNull::Body, + DecodedBody::present(null), 'application/json', OpenApiVersion::V3_0, ); @@ -168,7 +168,7 @@ public function validate_accepts_non_json_content_type_when_defined_in_spec(): v '/robots.txt', 200, $content, - 'User-agent: *', + DecodedBody::present('User-agent: *'), 'text/plain; charset=utf-8', OpenApiVersion::V3_0, ); @@ -194,7 +194,7 @@ public function validate_preserves_spec_content_type_casing(): void '/pets', 422, $content, - ['detail' => 'oops'], + DecodedBody::present(['detail' => 'oops']), 'application/problem+json', OpenApiVersion::V3_0, ); @@ -216,7 +216,7 @@ public function validate_flags_non_json_content_type_not_in_spec(): void '/pets', 200, $content, - 'blob', + DecodedBody::present('blob'), 'application/xml', OpenApiVersion::V3_0, ); @@ -238,7 +238,7 @@ public function validate_returns_empty_when_no_json_content_defined(): void '/pets', 200, $content, - '', + DecodedBody::present(''), null, OpenApiVersion::V3_0, ); @@ -266,7 +266,7 @@ public function validate_accepts_empty_object_body_against_type_object(): void '/p', 200, $content, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -290,7 +290,7 @@ public function validate_accepts_empty_object_body_against_oas_31_nullable_objec '/p', 200, $content, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_1, ); @@ -319,7 +319,7 @@ public function validate_does_not_coerce_empty_array_when_schema_has_no_explicit '/p', 200, $content, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -357,7 +357,7 @@ public function validate_does_not_coerce_empty_array_for_oneof_with_object_branc '/p', 200, $content, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -385,7 +385,7 @@ public function validate_does_not_coerce_empty_array_when_schema_is_array_type() '/p', 200, $content, - [], + DecodedBody::present([]), 'application/json', OpenApiVersion::V3_0, ); @@ -412,7 +412,7 @@ public function validate_flags_schema_mismatch(): void '/pets/{id}', 200, $content, - ['id' => 'not-an-int'], + DecodedBody::present(['id' => 'not-an-int']), 'application/json', OpenApiVersion::V3_0, );