diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 340a3c6..997a875 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -28,6 +28,7 @@ use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; use Studio\OpenApiContractTesting\Spec\OpenApiSpecResolver; use Studio\OpenApiContractTesting\Validation\Request\SecuritySchemeIntrospector; +use Studio\OpenApiContractTesting\Validation\Support\ContentTypeMatcher; use Studio\OpenApiContractTesting\Validation\Support\HeaderNormalizer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -44,7 +45,6 @@ use function is_string; use function json_decode; use function sprintf; -use function str_contains; use function strtolower; use function strtoupper; use function trigger_error; @@ -1125,10 +1125,19 @@ 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 (or is absent), report an absent - * body on empty or non-JSON content so the validator decides whether the - * spec required one. + * Mirrors {@see self::extractJsonBody()} for the request side: the body is + * JSON-decoded only when the Content-Type is a JSON media type (or absent); + * an empty body, or a non-JSON Content-Type, yields an absent envelope. + * + * A non-JSON body is left undecoded rather than guessed at: the Content-Type + * is forwarded to the validator separately, which resolves non-JSON media + * types through content negotiation. When the operation declares a + * `requestBody` content map, a media type missing from it is reported as + * "Content-Type is not defined" and one it declares is accepted without + * body-schema validation (the validator's schema engine is JSON-only). The + * JSON-ness test uses {@see ContentTypeMatcher::isJsonContentType()} so this + * adapter and the validator agree on exactly which media types count as + * JSON (issue #251). * * Issues #246 / #248: a non-empty body that decodes to the literal JSON * `null` is returned as a present {@see DecodedBody} carrying `null`, so @@ -1142,7 +1151,12 @@ private function extractRequestBody(Request $request, string $contentType): Deco return DecodedBody::absent(); } - if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { + // Non-JSON Content-Type: leave the body undecoded and report it absent. + // The validator receives the Content-Type separately and decides the + // contract verdict from it (issue #251). + if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType( + ContentTypeMatcher::normalizeMediaType($contentType), + )) { return DecodedBody::absent(); } @@ -1164,10 +1178,12 @@ private function extractRequestBody(Request $request, string $contentType): Deco /** * 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), report an absent body on empty - * or non-JSON content so the validator decides whether the spec required - * one. + * Mirrors {@see self::extractRequestBody()}: the body is JSON-decoded only + * when the Content-Type is a JSON media type (or absent); an empty body, or + * a non-JSON Content-Type, yields an absent envelope. The JSON-ness test + * uses {@see ContentTypeMatcher::isJsonContentType()} so this adapter and + * the validator agree on exactly which media types count as JSON (issue + * #251). * * Issues #246 / #248: decoding goes through `json_decode()` rather than * `TestResponse::json()` so a body of the literal JSON `null` is not @@ -1183,9 +1199,12 @@ private function extractJsonBody(string $content, string $contentType): DecodedB return DecodedBody::absent(); } - // 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')) { + // Non-JSON Content-Type: leave the body undecoded and report it absent. + // The validator receives the Content-Type separately and decides the + // contract verdict from it (issue #251). + if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType( + ContentTypeMatcher::normalizeMediaType($contentType), + )) { return DecodedBody::absent(); } diff --git a/src/Symfony/OpenApiAssertions.php b/src/Symfony/OpenApiAssertions.php index 78c0a55..330376c 100644 --- a/src/Symfony/OpenApiAssertions.php +++ b/src/Symfony/OpenApiAssertions.php @@ -17,6 +17,7 @@ use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader; use Studio\OpenApiContractTesting\Spec\OpenApiSpecResolver; +use Studio\OpenApiContractTesting\Validation\Support\ContentTypeMatcher; use Symfony\Component\BrowserKit\Exception\BadMethodCallException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,7 +26,6 @@ use function array_merge; use function json_decode; use function sprintf; -use function str_contains; use function strtolower; use function strtoupper; use function var_export; @@ -329,9 +329,11 @@ 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), report an absent body on empty or non-JSON - * content so the validator decides whether the spec required one. + * expect. Mirrors the Laravel adapter: the body is JSON-decoded only when + * the Content-Type is a JSON media type (or absent); an empty body, or a + * non-JSON Content-Type, yields an absent envelope. The JSON-ness test uses + * {@see ContentTypeMatcher::isJsonContentType()} so this adapter and the + * validator agree on exactly which media types count as JSON (issue #251). * * Issues #246 / #248: when the raw content is non-empty but decodes to the * literal JSON `null`, a present {@see DecodedBody} carrying `null` is @@ -348,7 +350,12 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st return DecodedBody::absent(); } - if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { + // Non-JSON Content-Type: leave the body undecoded and report it absent. + // The validator receives the Content-Type separately and decides the + // contract verdict from it (issue #251). + if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType( + ContentTypeMatcher::normalizeMediaType($contentType), + )) { return DecodedBody::absent(); } diff --git a/tests/Unit/Symfony/OpenApiAssertionsTest.php b/tests/Unit/Symfony/OpenApiAssertionsTest.php index 63ab497..56a96c2 100644 --- a/tests/Unit/Symfony/OpenApiAssertionsTest.php +++ b/tests/Unit/Symfony/OpenApiAssertionsTest.php @@ -249,6 +249,31 @@ public function non_json_response_body_passes_as_null_body(): void $this->assertResponseMatchesOpenApiSchema($request, $response); } + #[Test] + public function content_type_containing_json_substring_is_not_decoded_as_json(): void + { + // Issue #251: a Content-Type that merely contains the substring "json" + // (e.g. application/jsonsomethingweird) is NOT a JSON media type. The + // adapter defers to ContentTypeMatcher::isJsonContentType() — the same + // strict check the validator uses — so a non-JSON body is left + // undecoded and the validator surfaces its clean "Content-Type is not + // defined" diagnostic instead of a misleading "could not be parsed as + // JSON" parse error. + $request = Request::create('/v1/pets', 'GET'); + $response = new Response( + 'not json at all', + 200, + ['Content-Type' => 'application/jsonsomethingweird'], + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage( + "Response Content-Type 'application/jsonsomethingweird' is not defined", + ); + + $this->assertResponseMatchesOpenApiSchema($request, $response); + } + #[Test] public function json_content_type_with_charset_is_validated(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php index b2cd227..758dc49 100644 --- a/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php @@ -394,6 +394,36 @@ public function malformed_json_body_without_content_type_adds_hint(): void $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); } + #[Test] + public function content_type_containing_json_substring_is_not_decoded_as_json(): void + { + // Issue #251: a Content-Type that merely contains the substring "json" + // (e.g. application/jsonsomethingweird) is NOT a JSON media type. The + // adapter defers to ContentTypeMatcher::isJsonContentType() — the same + // strict check the validator uses — so a non-JSON body is left + // undecoded and the validator surfaces its clean "Content-Type is not + // defined" diagnostic instead of a misleading "could not be parsed as + // JSON" parse error. + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.auto_validate_request'] = true; + + $request = Request::create( + '/v1/pets', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/jsonsomethingweird'], + 'not json at all', + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage( + "Request Content-Type 'application/jsonsomethingweird' is not defined", + ); + + $this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets'); + } + #[Test] public function non_json_content_type_body_is_treated_as_null(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 5e17394..e276805 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -286,6 +286,34 @@ public function vendor_json_content_type_validates(): void ); } + #[Test] + public function content_type_containing_json_substring_is_not_decoded_as_json(): void + { + // Issue #251: a Content-Type that merely contains the substring "json" + // (e.g. application/jsonsomethingweird) is NOT a JSON media type. The + // adapter defers to ContentTypeMatcher::isJsonContentType() — the same + // strict check the validator uses — so a non-JSON body is left + // undecoded and the validator surfaces its clean "Content-Type is not + // defined" diagnostic instead of a misleading "could not be parsed as + // JSON" parse error. + $response = $this->makeTestResponse( + 'not json at all', + 200, + ['Content-Type' => 'application/jsonsomethingweird'], + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage( + "Response Content-Type 'application/jsonsomethingweird' is not defined", + ); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + #[Test] public function missing_content_type_header_still_parses_json(): void { diff --git a/tests/Unit/Validation/Support/ContentTypeMatcherTest.php b/tests/Unit/Validation/Support/ContentTypeMatcherTest.php index 1f8f60f..7056cbc 100644 --- a/tests/Unit/Validation/Support/ContentTypeMatcherTest.php +++ b/tests/Unit/Validation/Support/ContentTypeMatcherTest.php @@ -30,6 +30,19 @@ public function is_json_content_type_rejects_non_json(): void $this->assertFalse(ContentTypeMatcher::isJsonContentType('application/xml')); } + #[Test] + public function is_json_content_type_rejects_media_types_that_merely_contain_json(): void + { + // Issue #251: only an exact `application/json` or a `+json` structured + // syntax suffix counts as JSON. Media types that merely contain the + // substring "json" are NOT JSON — pinning this is what lets the + // framework adapters safely delegate their JSON-decode decision here + // instead of a loose `str_contains($ct, 'json')` check. + $this->assertFalse(ContentTypeMatcher::isJsonContentType('text/json')); + $this->assertFalse(ContentTypeMatcher::isJsonContentType('application/jsonp')); + $this->assertFalse(ContentTypeMatcher::isJsonContentType('application/jsonsomethingweird')); + } + #[Test] public function find_json_content_type_returns_first_json_key(): void {