From dce157f7e0fd67f11cbfa85946cff692f2d7a046 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 13:50:50 +0900 Subject: [PATCH 1/2] fix(adapters): type-check literal JSON null / scalar request & response bodies A request/response body of the literal JSON `null` (or a JSON scalar) decoded to PHP `null`, which the body validators could not distinguish from an absent body. This let a malformed body slip through silently (optional request body) or fail with a misleading "body is empty" message. Introduce an internal `PresentJsonNull` marker enum: the Laravel and Symfony adapters wrap a decoded `null` from non-empty raw content in this marker so `ResponseBodyValidator` / `RequestBodyValidator` type-check it against the schema instead of short-circuiting as "no body". The validators' `null` = "no body" semantics and the public `validate()` signatures are unchanged, so direct callers are unaffected (backward compatible). `extractJsonBody` (Laravel) now decodes via `json_decode()` instead of `TestResponse::json()` and returns `mixed` instead of `?array`, fixing a `TypeError` on scalar response bodies and aligning behaviour with the Symfony adapter. Add regression tests across both body validators and the Laravel / Symfony adapters. Closes #246 --- src/Internal/PresentJsonNull.php | 28 +++++ src/Laravel/ValidatesOpenApiSchema.php | 31 ++++- src/OpenApiResponseValidator.php | 6 +- src/Symfony/OpenApiAssertions.php | 9 +- .../Request/RequestBodyValidator.php | 13 +- .../Response/ResponseBodyValidator.php | 14 ++- tests/Unit/Symfony/OpenApiAssertionsTest.php | 50 ++++++++ ...esOpenApiSchemaAutoValidateRequestTest.php | 20 +++ tests/Unit/ValidatesOpenApiSchemaTest.php | 40 ++++++ .../Request/RequestBodyValidatorTest.php | 118 ++++++++++++++++++ .../Response/ResponseBodyValidatorTest.php | 57 +++++++++ 11 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 src/Internal/PresentJsonNull.php diff --git a/src/Internal/PresentJsonNull.php b/src/Internal/PresentJsonNull.php new file mode 100644 index 0000000..7d4cf17 --- /dev/null +++ b/src/Internal/PresentJsonNull.php @@ -0,0 +1,28 @@ +getStatusCode(), - $this->extractJsonBody($response, $content, $contentType), + $this->extractJsonBody($content, $contentType), $contentType !== '' ? $contentType : null, // HeaderNormalizer is idempotent; HeaderBag's already-lower-cased // keys pass through unchanged. @@ -1127,6 +1128,10 @@ private function resolveBoolConfig(string $key): bool * Mirrors {@see self::extractJsonBody()} for the request side: parse JSON * only when the Content-Type claims it, stay `null` on empty or non-JSON * bodies so the validator decides whether the spec required one. + * + * Issue #246: a non-empty body that decodes to the literal JSON `null` + * yields a {@see PresentJsonNull} marker so the validator type-checks the + * value against the schema instead of mistaking it for an absent body. */ private function extractRequestBody(Request $request, string $contentType): mixed { @@ -1149,11 +1154,24 @@ private function extractRequestBody(Request $request, string $contentType): mixe ); } - return $decoded; + return $decoded ?? PresentJsonNull::Body; } - /** @return null|array */ - private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array + /** + * 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. + * + * Issue #246: 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. + */ + private function extractJsonBody(string $content, string $contentType): mixed { if ($content === '') { return null; @@ -1166,12 +1184,15 @@ private function extractJsonBody(TestResponse $response, string $content, string } try { - return $response->json(); + /** @var mixed $decoded */ + $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { $this->failOpenApi( 'Response body could not be parsed as JSON: ' . $e->getMessage() . ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''), ); } + + return $decoded ?? PresentJsonNull::Body; } } diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index ea3ea60..96c5179 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use RuntimeException; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension; use Studio\OpenApiContractTesting\Spec\OpenApiPathMatcher; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; @@ -235,7 +236,10 @@ public function validate( $matchedPath, $statusCodeStr, $bodyResult->matchedContentType, - $responseBody, + // 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, ); return OpenApiValidationResult::success( diff --git a/src/Symfony/OpenApiAssertions.php b/src/Symfony/OpenApiAssertions.php index 2be00cd..f75b501 100644 --- a/src/Symfony/OpenApiAssertions.php +++ b/src/Symfony/OpenApiAssertions.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\AssertionFailedError; use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\HttpMethod; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\Internal\StackTraceFilter; use Studio\OpenApiContractTesting\OpenApiRequestValidator; use Studio\OpenApiContractTesting\OpenApiResponseValidator; @@ -332,6 +333,12 @@ private function symfonyRequestValidator(): OpenApiRequestValidator * claims JSON, stay `null` on empty or non-JSON bodies 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 + * rather than mistaking it for an absent body. Non-null decoded values + * (scalars, arrays) pass through unchanged. + * * @param string $subject either `Request` or `Response`, used only for the * error message when the body is not valid JSON */ @@ -359,7 +366,7 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st )); } - return $decoded; + return $decoded ?? PresentJsonNull::Body; } /** Like Assert::fail() but with vendor frames stripped from the trace. */ diff --git a/src/Validation/Request/RequestBodyValidator.php b/src/Validation/Request/RequestBodyValidator.php index c366ff0..f747d7f 100644 --- a/src/Validation/Request/RequestBodyValidator.php +++ b/src/Validation/Request/RequestBodyValidator.php @@ -5,6 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Request; use stdClass; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\SchemaContext; use Studio\OpenApiContractTesting\Spec\OpenApiSchemaConverter; @@ -141,7 +142,17 @@ public function validate( return []; } - if ($requestBody === null) { + // 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) { if (!$required) { return []; } diff --git a/src/Validation/Response/ResponseBodyValidator.php b/src/Validation/Response/ResponseBodyValidator.php index 2c0a43c..319d333 100644 --- a/src/Validation/Response/ResponseBodyValidator.php +++ b/src/Validation/Response/ResponseBodyValidator.php @@ -5,6 +5,7 @@ namespace Studio\OpenApiContractTesting\Validation\Response; use stdClass; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiValidationResult; use Studio\OpenApiContractTesting\OpenApiVersion; @@ -106,7 +107,18 @@ public function validate( return new ResponseBodyValidationResult([], $jsonContentType); } - if ($responseBody === null) { + // 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) { return new ResponseBodyValidationResult( [ "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.", diff --git a/tests/Unit/Symfony/OpenApiAssertionsTest.php b/tests/Unit/Symfony/OpenApiAssertionsTest.php index b811c58..63ab497 100644 --- a/tests/Unit/Symfony/OpenApiAssertionsTest.php +++ b/tests/Unit/Symfony/OpenApiAssertionsTest.php @@ -190,6 +190,56 @@ public function malformed_json_request_body_without_content_type_adds_hint(): vo $this->assertRequestMatchesOpenApiSchema($request); } + #[Test] + public function literal_null_response_body_without_content_type_fails_loudly(): void + { + // Issue #246: a response body of the literal JSON `null` with no + // Content-Type is type-checked against the schema, not silently read + // as an absent body. GET /v1/pets declares a `type: object` 200 + // schema, so a null body is a contract violation surfaced as a schema + // type error rather than the misleading "Response body is empty". + $request = Request::create('/v1/pets', 'GET'); + $response = new Response('null', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema($request, $response); + } + + #[Test] + public function scalar_response_body_without_content_type_is_type_checked(): void + { + // Issue #246: a scalar JSON body (here the integer `123`) is decoded + // and type-checked against the schema rather than being treated as no + // body. Regression guard — the Symfony adapter's `mixed` body shape + // already handled scalars; this pins that the #246 fix keeps it so. + $request = Request::create('/v1/pets', 'GET'); + $response = new Response('123', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema($request, $response); + } + + #[Test] + public function literal_null_request_body_without_content_type_fails_loudly(): void + { + // Issue #246: a request body of the literal JSON `null` with no + // Content-Type is type-checked against the requestBody schema. POST + // /v1/pets requires a `type: object` body, so a null body fails + // loudly. Request::create() forces a Content-Type on POST bodies; + // drop it so the no-Content-Type path runs. + $request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null'); + $request->headers->remove('Content-Type'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertRequestMatchesOpenApiSchema($request); + } + #[Test] public function non_json_response_body_passes_as_null_body(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php index d606187..b2cd227 100644 --- a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php @@ -127,6 +127,26 @@ public function auto_validate_request_true_raises_on_invalid_body(): void $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); } + #[Test] + public function auto_validate_request_type_checks_literal_null_body(): void + { + // Issue #246: a request body of the literal JSON `null` with no + // Content-Type is type-checked against the requestBody schema instead + // of being read as an absent body. POST /v1/pets requires a + // `type: object` body, so a null body fails loudly with a schema type + // error — before the fix the decoded `null` was misreported as an + // empty body. + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.auto_validate_request'] = true; + + $request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null'); + $request->headers->remove('Content-Type'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); + } + #[Test] public function auto_validate_request_true_raises_on_missing_bearer(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 1d01bce..c3736bf 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -144,6 +144,46 @@ public function successful_validation_records_coverage(): void $this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']); } + #[Test] + public function literal_null_response_body_is_type_checked(): void + { + // Issue #246: a response body of the literal JSON `null` is + // type-checked against the schema instead of being read as an absent + // body. GET /v1/pets declares a `type: object` 200 schema, so a null + // body fails with a schema type error. Before the fix the Laravel + // adapter decoded through TestResponse::json(), whose "null decode == + // invalid JSON" heuristic raised a misleading framework failure. + $response = $this->makeTestResponse('null', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function scalar_response_body_is_type_checked(): void + { + // Issue #246: a scalar JSON body (the integer `123`) reaches the + // validator and is type-checked. Before the fix the body extractor's + // `?array` return type raised a TypeError on a non-array decoded + // body; the Laravel and Symfony adapters now behave identically. + $response = $this->makeTestResponse('123', 200); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + #[Test] public function non_json_html_body_passes_as_null_body(): void { diff --git a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php index 4e167b4..3d4bbe6 100644 --- a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php +++ b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -62,6 +63,123 @@ public function validate_flags_missing_required_body(): void $this->assertStringContainsString('Request body is empty', $errors[0]); } + #[Test] + public function validate_flags_present_literal_null_body_against_object_schema_when_optional(): void + { + // Issue #246 — the core silent-pass bug. A request body of the literal + // 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. + $operation = [ + 'requestBody' => [ + 'required' => false, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('must match the type', $errors[0]); + } + + #[Test] + public function validate_flags_present_literal_null_body_against_object_schema_when_required(): void + { + // A present literal `null` against a REQUIRED object body fails with a + // schema type error, not the "Request body is empty" message — the + // body WAS present on the wire, it is simply the wrong type. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($errors); + $this->assertStringContainsString('must match the type', $errors[0]); + $this->assertStringNotContainsString('Request body is empty', $errors[0]); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_31_nullable_schema(): void + { + // OAS 3.1 `type: ["object", "null"]` explicitly permits a null body. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => ['object', 'null']]], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_1, + ); + + $this->assertSame([], $errors); + } + + #[Test] + public function validate_still_treats_plain_null_as_absent_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. + $operation = [ + 'requestBody' => [ + 'required' => false, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object']], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + null, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $errors); + } + #[Test] public function validate_flags_unknown_non_json_content_type(): void { diff --git a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php index 551c833..2942ede 100644 --- a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php +++ b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Studio\OpenApiContractTesting\Internal\PresentJsonNull; use Studio\OpenApiContractTesting\OpenApiVersion; use Studio\OpenApiContractTesting\Validation\Response\ResponseBodyValidator; use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner; @@ -71,6 +72,62 @@ public function validate_flags_empty_body_against_json_schema(): void $this->assertSame('application/json', $result->matchedContentType); } + #[Test] + public function validate_type_checks_present_literal_null_body_against_object_schema(): void + { + // Issue #246: a response body of the literal JSON `null` (the four + // bytes `null` on the wire) is type-checked against the schema, not + // 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". + $content = [ + 'application/json' => ['schema' => ['type' => 'object']], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertNotEmpty($result->errors); + $this->assertStringContainsString('must match the type', $result->errors[0]); + $this->assertStringNotContainsString('Response body is empty', $result->errors[0]); + $this->assertSame('application/json', $result->matchedContentType); + } + + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_31_nullable_schema(): void + { + // OAS 3.1 `type: ["object", "null"]` explicitly permits a null body. + // A present literal `null` validates cleanly against it — the pre-#246 + // "body is empty" short-circuit would have wrongly rejected it. + $content = [ + 'application/json' => ['schema' => ['type' => ['object', 'null']]], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_1, + ); + + $this->assertSame([], $result->errors); + $this->assertSame('application/json', $result->matchedContentType); + } + #[Test] public function validate_accepts_non_json_content_type_when_defined_in_spec(): void { From 178692a1f953883853274a7ffd34efe18b928dc5 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 14:01:54 +0900 Subject: [PATCH 2/2] refactor(adapters): harden body extraction and broaden #246 coverage Addresses review feedback on PR #247 (issue #246 fix). Move the decoded-body `return` inside the `try` block in all three body extractors (`extractRequestBody`, `extractJsonBody`, `extractSymfonyJsonBody`) so the return's dependence on a successful decode is local and explicit, rather than relying on `failOpenApi(): never` to keep `$decoded` defined after the `catch`. Correct the `extractRequestBody` / `extractSymfonyJsonBody` docblocks: JSON is parsed when the Content-Type claims JSON OR is absent, not only when it claims JSON. Document on the `PresentJsonNull` enum that every consumer must unwrap the marker before passing the value to schema conversion or the strict-required walker. Add regression tests for OAS 3.0 `nullable: true` (literal-null body accepted, request + response) and for the literal-null fix on the explicit `Content-Type: application/json` path. No behavior change; full suite (1768 tests), PHPStan and PHP-CS-Fixer all pass. --- src/Internal/PresentJsonNull.php | 8 +++-- src/Laravel/ValidatesOpenApiSchema.php | 18 +++++++---- src/Symfony/OpenApiAssertions.php | 11 ++++--- tests/Unit/ValidatesOpenApiSchemaTest.php | 19 ++++++++++++ .../Request/RequestBodyValidatorTest.php | 30 +++++++++++++++++++ .../Response/ResponseBodyValidatorTest.php | 27 +++++++++++++++++ 6 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/Internal/PresentJsonNull.php b/src/Internal/PresentJsonNull.php index 7d4cf17..82a991b 100644 --- a/src/Internal/PresentJsonNull.php +++ b/src/Internal/PresentJsonNull.php @@ -17,8 +17,12 @@ * in this marker so the request / response body validators type-check it * against the schema instead of short-circuiting as "no body". * - * A single-case enum is used as a value-less singleton: callers compare with - * `$body instanceof PresentJsonNull` (or `=== PresentJsonNull::Body`). + * A single-case enum is used as a value-less singleton: callers detect it + * with `$body instanceof PresentJsonNull` (an explicit `=== PresentJsonNull::Body` + * is equivalent). Every code path that reads a decoded body value MUST unwrap + * this marker — treat it as the value `null` — before passing the value on to + * schema conversion or the strict-required walker; the marker itself must + * never reach `opis/json-schema` or user code. * * @internal Not part of the package's public API. Do not use from user code. */ diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 66f8652..9232fcd 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -1126,8 +1126,9 @@ private function resolveBoolConfig(string $key): bool /** * Extract the request body in the shape OpenApiRequestValidator expects. * Mirrors {@see self::extractJsonBody()} for the request side: parse JSON - * only when the Content-Type claims it, stay `null` on empty or non-JSON - * bodies so the validator decides whether the spec required one. + * 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. * * Issue #246: a non-empty body that decodes to the literal JSON `null` * yields a {@see PresentJsonNull} marker so the validator type-checks the @@ -1144,17 +1145,20 @@ private function extractRequestBody(Request $request, string $contentType): mixe return null; } + // The return lives inside the try so its dependence on a successful + // decode is local and explicit: failOpenApi() is `: never`, so the + // catch cannot fall through to a use of an undefined $decoded. try { /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi( 'Request body could not be parsed as JSON: ' . $e->getMessage() . ($contentType === '' ? ' (no Content-Type header was present on the request)' : ''), ); } - - return $decoded ?? PresentJsonNull::Body; } /** @@ -1183,16 +1187,18 @@ private function extractJsonBody(string $content, string $contentType): mixed return null; } + // See extractRequestBody(): the return is inside the try so its + // dependence on a successful decode is local and explicit. try { /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi( 'Response body could not be parsed as JSON: ' . $e->getMessage() . ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''), ); } - - return $decoded ?? PresentJsonNull::Body; } } diff --git a/src/Symfony/OpenApiAssertions.php b/src/Symfony/OpenApiAssertions.php index f75b501..349d615 100644 --- a/src/Symfony/OpenApiAssertions.php +++ b/src/Symfony/OpenApiAssertions.php @@ -330,8 +330,8 @@ 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, stay `null` on empty or non-JSON bodies so the validator - * decides whether the spec required one. + * claims JSON (or is absent), stay `null` on empty or non-JSON bodies 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 @@ -352,9 +352,14 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st return null; } + // The return is inside the try so its dependence on a successful + // decode is local and explicit: failOpenApi() is `: never`, so the + // catch cannot fall through to a use of an undefined $decoded. try { /** @var mixed $decoded */ $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + return $decoded ?? PresentJsonNull::Body; } catch (JsonException $e) { $this->failOpenApi(sprintf( '%s body could not be parsed as JSON: %s%s', @@ -365,8 +370,6 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st : '', )); } - - return $decoded ?? PresentJsonNull::Body; } /** Like Assert::fail() but with vendor frames stripped from the trace. */ diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index c3736bf..5e17394 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -165,6 +165,25 @@ public function literal_null_response_body_is_type_checked(): void ); } + #[Test] + public function literal_null_response_body_with_json_content_type_is_type_checked(): void + { + // Issue #246: the literal-null fix applies on the explicit-Content-Type + // path too, not only when the header is absent. A response with + // `Content-Type: application/json` and a `null` body is type-checked + // against GET /v1/pets' `type: object` 200 schema and fails. + $response = $this->makeTestResponse('null', 200, ['Content-Type' => 'application/json']); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('must match the type'); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + #[Test] public function scalar_response_body_is_type_checked(): void { diff --git a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php index 3d4bbe6..a7877d5 100644 --- a/tests/Unit/Validation/Request/RequestBodyValidatorTest.php +++ b/tests/Unit/Validation/Request/RequestBodyValidatorTest.php @@ -151,6 +151,36 @@ public function validate_accepts_present_literal_null_body_against_oas_31_nullab $this->assertSame([], $errors); } + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_30_nullable_schema(): void + { + // OAS 3.0 expresses a nullable body with `nullable: true`; + // OpenApiSchemaConverter lowers it to a `["object", "null"]` type + // array for Draft 07. A present literal `null` validates cleanly + // against it — distinct conversion branch from the OAS 3.1 type-array + // form covered above. + $operation = [ + 'requestBody' => [ + 'required' => true, + 'content' => [ + 'application/json' => ['schema' => ['type' => 'object', 'nullable' => true]], + ], + ], + ]; + + $errors = $this->validator->validate( + 'spec', + 'POST', + '/pets', + $operation, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $errors); + } + #[Test] public function validate_still_treats_plain_null_as_absent_body(): void { diff --git a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php index 2942ede..0cb54df 100644 --- a/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php +++ b/tests/Unit/Validation/Response/ResponseBodyValidatorTest.php @@ -128,6 +128,33 @@ public function validate_accepts_present_literal_null_body_against_oas_31_nullab $this->assertSame('application/json', $result->matchedContentType); } + #[Test] + public function validate_accepts_present_literal_null_body_against_oas_30_nullable_schema(): void + { + // OAS 3.0 expresses a nullable body with `nullable: true`; + // OpenApiSchemaConverter lowers it to a `["object", "null"]` type + // array for Draft 07. A present literal `null` must validate cleanly + // against it — distinct conversion branch from the OAS 3.1 type-array + // form covered above. + $content = [ + 'application/json' => ['schema' => ['type' => 'object', 'nullable' => true]], + ]; + + $result = $this->validator->validate( + 'spec', + 'GET', + '/pets', + 200, + $content, + PresentJsonNull::Body, + 'application/json', + OpenApiVersion::V3_0, + ); + + $this->assertSame([], $result->errors); + $this->assertSame('application/json', $result->matchedContentType); + } + #[Test] public function validate_accepts_non_json_content_type_when_defined_in_spec(): void {