From 53a89c0153477b46a4397b2ccac44b4ad3a8f842 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 15:27:36 +0900 Subject: [PATCH 1/2] fix(adapters): align adapter JSON content-type detection with ContentTypeMatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three adapter body-extraction methods (Laravel `extractRequestBody` / `extractJsonBody`, Symfony `extractSymfonyJsonBody`) decided whether to JSON-decode a body with a loose `str_contains(strtolower($contentType), 'json')` check. That treats borderline media types such as `application/jsonsomethingweird` as JSON, so the adapter would attempt a `json_decode()` on a non-JSON body and fail with a misleading "could not be parsed as JSON" parse error. Switch the three methods to `ContentTypeMatcher::isJsonContentType()` (fed through `normalizeMediaType()`) — the same strict check the body validators already use. The adapter and validator now agree on exactly which media types count as JSON: a non-JSON body is left undecoded and reported absent, and the validator's content negotiation surfaces its clean "Content-Type is not defined" diagnostic instead. Standard types (`application/json`, `application/*+json`, `application/json; charset=utf-8`) are unaffected. Closes #251 --- src/Laravel/ValidatesOpenApiSchema.php | 43 +++++++++++++------ src/Symfony/OpenApiAssertions.php | 17 +++++--- tests/Unit/Symfony/OpenApiAssertionsTest.php | 25 +++++++++++ ...esOpenApiSchemaAutoValidateRequestTest.php | 30 +++++++++++++ tests/Unit/ValidatesOpenApiSchemaTest.php | 28 ++++++++++++ 5 files changed, 125 insertions(+), 18 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 340a3c6..16e96eb 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,17 @@ 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, and its content negotiation + * delivers the contract verdict — a loud "Content-Type is not defined" when + * the spec does not declare that media type, acceptance when it does. 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 +1149,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 +1176,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 +1197,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 { From 0fd88880b96a2726234d54ef38ae88a9e432e20f Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 18 May 2026 15:44:31 +0900 Subject: [PATCH 2/2] test(validation): pin the json-substring content-type boundary; correct extractRequestBody docblock Review follow-ups for #251: - ContentTypeMatcherTest: add an explicit negative case pinning that media types which merely contain the substring "json" (text/json, application/jsonp, application/jsonsomethingweird) are not JSON. This is the contract the adapters now delegate to, and text/json / application/jsonp are behavior-change cases relative to the old loose check. - extractRequestBody() docblock: the previous wording promised a "loud" verdict unconditionally. The request-side validator only reports an undeclared media type when the operation declares a requestBody content map, and a declared non-JSON media type is accepted without body-schema validation (the schema engine is JSON-only). Reworded to match. --- src/Laravel/ValidatesOpenApiSchema.php | 8 +++++--- .../Validation/Support/ContentTypeMatcherTest.php | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 16e96eb..997a875 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -1130,9 +1130,11 @@ private function resolveBoolConfig(string $key): bool * 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, and its content negotiation - * delivers the contract verdict — a loud "Content-Type is not defined" when - * the spec does not declare that media type, acceptance when it does. The + * 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). 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 {