From bafcd4f17ec0e4f6efebe29e7433ccd3ecf7674d Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 23:18:17 +0900 Subject: [PATCH 1/4] feat(validator): accept response Content-Type for content negotiation Add optional $responseContentType parameter to OpenApiResponseValidator::validate(). When provided, non-JSON content types defined in the spec are skipped (success), undefined types produce a failure, and JSON-compatible types proceed to schema validation. Move the Content-Type mismatch logic from ValidatesOpenApiSchema trait into the validator itself for a single source of truth. Closes #22 --- src/Laravel/ValidatesOpenApiSchema.php | 13 +----- src/OpenApiResponseValidator.php | 60 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 09c9c13..7ed533c 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -50,7 +50,6 @@ protected function assertResponseMatchesOpenApiSchema( } $contentType = $response->headers->get('Content-Type', ''); - $hasNonJsonContentType = $content !== '' && $contentType !== '' && !str_contains(strtolower($contentType), 'json'); $validator = new OpenApiResponseValidator(); $result = $validator->validate( @@ -59,6 +58,7 @@ protected function assertResponseMatchesOpenApiSchema( $resolvedPath, $response->getStatusCode(), $this->extractJsonBody($response, $content, $contentType), + $contentType !== '' ? $contentType : null, ); // Record coverage for any matched endpoint, including those where body @@ -72,17 +72,6 @@ protected function assertResponseMatchesOpenApiSchema( ); } - // This guard catches the case where the spec defines a JSON content type - // but the actual response has a non-JSON Content-Type header. The validator - // itself skips validation for specs that define *only* non-JSON content - // types (returning success), so this guard only fires for the mismatch case. - if (!$result->isValid() && $hasNonJsonContentType) { - $this->fail( - "OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n" - . "Response has Content-Type '{$contentType}' but the spec expects a JSON response.", - ); - } - $this->assertTrue( $result->isValid(), "OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n" diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 5f569b3..9bdc853 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -10,10 +10,13 @@ use Opis\JsonSchema\Validator; use function array_keys; +use function implode; use function json_decode; use function json_encode; use function str_ends_with; +use function strstr; use function strtolower; +use function trim; final class OpenApiResponseValidator { @@ -23,6 +26,7 @@ public function validate( string $requestPath, int $statusCode, mixed $responseBody, + ?string $responseContentType = null, ): OpenApiValidationResult { $spec = OpenApiSpecLoader::load($specName); @@ -66,6 +70,28 @@ public function validate( /** @var array> $content */ $content = $responseSpec['content']; + + // When the actual response Content-Type is provided, use it to select + // the correct media type entry from the spec (content negotiation). + if ($responseContentType !== null) { + $normalizedType = $this->normalizeMediaType($responseContentType); + + if (!$this->isJsonContentType($normalizedType)) { + // Non-JSON response: check if the content type is defined in the spec. + if ($this->isContentTypeInSpec($normalizedType, $content)) { + return OpenApiValidationResult::success($matchedPath); + } + + $defined = implode(', ', array_keys($content)); + + return OpenApiValidationResult::failure([ + "Response Content-Type '{$normalizedType}' is not defined for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: {$defined}", + ]); + } + + // JSON-compatible response: fall through to existing JSON schema validation. + } + $jsonContentType = $this->findJsonContentType($content); // If no JSON-compatible content type is defined, skip body validation. @@ -138,11 +164,43 @@ private function findJsonContentType(array $content): ?string foreach ($content as $contentType => $mediaType) { $lower = strtolower($contentType); - if ($lower === 'application/json' || str_ends_with($lower, '+json')) { + if ($this->isJsonContentType($lower)) { return $contentType; } } return null; } + + /** + * Extract the media type portion before any parameters (e.g. charset), + * and return it lower-cased. + * + * Example: "text/html; charset=utf-8" → "text/html" + */ + private function normalizeMediaType(string $contentType): string + { + $mediaType = strstr($contentType, ';', true); + + return strtolower(trim($mediaType !== false ? $mediaType : $contentType)); + } + + /** + * @param array> $content + */ + private function isContentTypeInSpec(string $responseContentType, array $content): bool + { + foreach ($content as $specContentType => $mediaType) { + if (strtolower($specContentType) === $responseContentType) { + return true; + } + } + + return false; + } + + private function isJsonContentType(string $lowerContentType): bool + { + return $lowerContentType === 'application/json' || str_ends_with($lowerContentType, '+json'); + } } From 016d3096b48f47d2a796a0085d9b36a25caa4749 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 23:19:15 +0900 Subject: [PATCH 2/4] test: add content negotiation and responseContentType parameter tests Add 7 unit tests for OpenApiResponseValidator covering mixed content types, charset handling, undefined types, and backward compatibility. Update ValidatesOpenApiSchemaTest for new error message format. Add integration test for content negotiation with coverage tracking. --- tests/Integration/ResponseValidationTest.php | 22 ++++ tests/Unit/OpenApiResponseValidatorTest.php | 117 +++++++++++++++++++ tests/Unit/ValidatesOpenApiSchemaTest.php | 18 ++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/tests/Integration/ResponseValidationTest.php b/tests/Integration/ResponseValidationTest.php index 2207ba2..75ffc71 100644 --- a/tests/Integration/ResponseValidationTest.php +++ b/tests/Integration/ResponseValidationTest.php @@ -116,6 +116,28 @@ public function non_json_endpoint_skips_validation_and_records_coverage(): void $this->assertContains('GET /v1/logout', $coverage['covered']); } + #[Test] + public function content_negotiation_non_json_response_succeeds_and_records_coverage(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + 'text/html', + ); + $this->assertTrue($result->isValid()); + + if ($result->matchedPath() !== null) { + OpenApiCoverageTracker::record('petstore-3.0', 'POST', $result->matchedPath()); + } + + $coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0'); + $this->assertSame(1, $coverage['coveredCount']); + $this->assertContains('POST /v1/pets', $coverage['covered']); + } + #[Test] public function invalid_response_produces_descriptive_errors(): void { diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index e025956..f8ee436 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -304,6 +304,123 @@ public function v30_mixed_content_types_with_invalid_json_body_fails(): void $this->assertNotEmpty($result->errors()); } + // ======================================== + // OAS 3.0 content negotiation tests (responseContentType parameter) + // ======================================== + + #[Test] + public function v30_mixed_content_type_with_non_json_response_content_type_succeeds(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + 'text/html', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_mixed_content_type_with_json_response_content_type_validates_schema(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['error' => 'Pet already exists'], + 'application/json', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_mixed_content_type_with_json_response_content_type_and_invalid_body_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['wrong_key' => 'value'], + 'application/json', + ); + + $this->assertFalse($result->isValid()); + $this->assertNotEmpty($result->errors()); + } + + #[Test] + public function v30_response_content_type_not_in_spec_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + 'text/plain', + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('not defined', $result->errors()[0]); + $this->assertStringContainsString('text/plain', $result->errors()[0]); + } + + #[Test] + public function v30_response_content_type_with_charset_matches_spec(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + 'text/html; charset=utf-8', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_null_response_content_type_preserves_existing_behavior(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + null, + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Response body is empty', $result->errors()[0]); + } + + #[Test] + public function v30_non_json_only_spec_with_matching_response_content_type_succeeds(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/logout', + 200, + null, + 'text/html', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/logout', $result->matchedPath()); + } + // ======================================== // OAS 3.1 tests // ======================================== diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index f05e352..65ba9dd 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -169,7 +169,7 @@ public function non_json_body_fails_with_content_type_mismatch(): void ); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage("Response has Content-Type 'text/html' but the spec expects a JSON response"); + $this->expectExceptionMessage('not defined'); $this->assertResponseMatchesOpenApiSchema( $response, @@ -242,6 +242,22 @@ public function missing_content_type_header_still_parses_json(): void ); } + #[Test] + public function non_json_content_type_in_spec_with_mixed_content_types_passes(): void + { + $response = $this->makeTestResponse( + 'Conflict', + 409, + ['Content-Type' => 'text/html'], + ); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::POST, + '/v1/pets', + ); + } + protected function openApiSpec(): string { return 'petstore-3.0'; From 0de784b1fcebc054f59c3957d8e7d2024e104d30 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 23:35:14 +0900 Subject: [PATCH 3/4] refactor(validator): address review feedback for content negotiation Allow failure() to preserve matchedPath so coverage tracking works for matched endpoints even on validation failures. Add docblocks to isJsonContentType() and isContentTypeInSpec() with preconditions. Clarify content negotiation comment to document the intentional asymmetry between JSON and non-JSON type matching. Narrow catch(Throwable) to catch(JsonException) in extractJsonBody() to avoid masking unrelated errors. --- src/Laravel/ValidatesOpenApiSchema.php | 4 ++-- src/OpenApiResponseValidator.php | 26 +++++++++++++++++++------- src/OpenApiValidationResult.php | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 7ed533c..533a185 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -5,10 +5,10 @@ namespace Studio\OpenApiContractTesting\Laravel; use Illuminate\Testing\TestResponse; +use JsonException; use Studio\OpenApiContractTesting\HttpMethod; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; -use Throwable; use function is_string; use function str_contains; @@ -94,7 +94,7 @@ private function extractJsonBody(TestResponse $response, string $content, string try { return $response->json(); - } catch (Throwable $e) { + } catch (JsonException $e) { $this->fail( 'Response body could not be parsed as JSON: ' . $e->getMessage() . ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''), diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 9bdc853..2724e82 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -49,7 +49,7 @@ public function validate( if (!isset($pathSpec[$lowerMethod])) { return OpenApiValidationResult::failure([ "Method {$method} not defined for path {$matchedPath} in '{$specName}' spec.", - ]); + ], $matchedPath); } $statusCodeStr = (string) $statusCode; @@ -58,7 +58,7 @@ public function validate( if (!isset($responses[$statusCodeStr])) { return OpenApiValidationResult::failure([ "Status code {$statusCode} not defined for {$method} {$matchedPath} in '{$specName}' spec.", - ]); + ], $matchedPath); } $responseSpec = $responses[$statusCodeStr]; @@ -71,8 +71,9 @@ public function validate( /** @var array> $content */ $content = $responseSpec['content']; - // When the actual response Content-Type is provided, use it to select - // the correct media type entry from the spec (content negotiation). + // When the actual response Content-Type is provided, handle content negotiation: + // non-JSON types are checked for spec presence only, while JSON-compatible types + // fall through to schema validation against the first JSON media type in the spec. if ($responseContentType !== null) { $normalizedType = $this->normalizeMediaType($responseContentType); @@ -86,10 +87,13 @@ public function validate( return OpenApiValidationResult::failure([ "Response Content-Type '{$normalizedType}' is not defined for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: {$defined}", - ]); + ], $matchedPath); } // JSON-compatible response: fall through to existing JSON schema validation. + // JSON types are treated as interchangeable (e.g. application/vnd.api+json + // validates against an application/json spec entry) because the schema is + // the same regardless of the specific JSON media type. } $jsonContentType = $this->findJsonContentType($content); @@ -108,7 +112,7 @@ public function validate( if ($responseBody === null) { return OpenApiValidationResult::failure([ "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.", - ]); + ], $matchedPath); } /** @var array $schema */ @@ -147,7 +151,7 @@ public function validate( } } - return OpenApiValidationResult::failure($errors); + return OpenApiValidationResult::failure($errors, $matchedPath); } /** @@ -186,6 +190,10 @@ private function normalizeMediaType(string $contentType): string } /** + * Check whether the given (already normalised, lower-cased) response content + * type matches any content type key defined in the spec. Spec keys are + * lower-cased before comparison. + * * @param array> $content */ private function isContentTypeInSpec(string $responseContentType, array $content): bool @@ -199,6 +207,10 @@ private function isContentTypeInSpec(string $responseContentType, array $content return false; } + /** + * True for "application/json" or any "+json" structured syntax suffix (RFC 6838). + * Expects a lower-cased media type without parameters. + */ private function isJsonContentType(string $lowerContentType): bool { return $lowerContentType === 'application/json' || str_ends_with($lowerContentType, '+json'); diff --git a/src/OpenApiValidationResult.php b/src/OpenApiValidationResult.php index ef3673e..8d31906 100644 --- a/src/OpenApiValidationResult.php +++ b/src/OpenApiValidationResult.php @@ -23,9 +23,9 @@ public static function success(?string $matchedPath = null): self } /** @param string[] $errors */ - public static function failure(array $errors): self + public static function failure(array $errors, ?string $matchedPath = null): self { - return new self(false, $errors); + return new self(false, $errors, $matchedPath); } public function isValid(): bool From de4e02191a016788970b799bcc7347d66584d6ef Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 23:35:21 +0900 Subject: [PATCH 4/4] test: strengthen assertions and add edge-case content negotiation tests Add tests for JSON Content-Type with charset parameter, vendor +json suffix, and case-insensitive Content-Type matching through the responseContentType parameter. Add trait-level test for JSON response on mixed-content-type endpoint. Strengthen assertion specificity in content type mismatch tests. --- tests/Unit/OpenApiResponseValidatorTest.php | 75 ++++++++++++++++++++- tests/Unit/ValidatesOpenApiSchemaTest.php | 15 ++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index f8ee436..870ca54 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -369,8 +369,10 @@ public function v30_response_content_type_not_in_spec_fails(): void ); $this->assertFalse($result->isValid()); - $this->assertStringContainsString('not defined', $result->errors()[0]); - $this->assertStringContainsString('text/plain', $result->errors()[0]); + $this->assertStringContainsString("Response Content-Type 'text/plain' is not defined for", $result->errors()[0]); + $this->assertStringContainsString('text/html', $result->errors()[0]); + $this->assertStringContainsString('application/json', $result->errors()[0]); + $this->assertSame('/v1/pets', $result->matchedPath()); } #[Test] @@ -389,6 +391,75 @@ public function v30_response_content_type_with_charset_matches_spec(): void $this->assertSame('/v1/pets', $result->matchedPath()); } + #[Test] + public function v30_json_response_content_type_with_charset_validates_schema(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['error' => 'Pet already exists'], + 'application/json; charset=utf-8', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_json_response_content_type_with_charset_and_invalid_body_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + ['wrong_key' => 'value'], + 'application/json; charset=utf-8', + ); + + $this->assertFalse($result->isValid()); + $this->assertNotEmpty($result->errors()); + } + + #[Test] + public function v30_vendor_json_response_content_type_validates_schema(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 400, + [ + 'type' => 'https://example.com/bad-request', + 'title' => 'Bad Request', + 'status' => 400, + 'detail' => 'Invalid query parameter', + ], + 'application/problem+json', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_case_insensitive_response_content_type_matches_spec(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 409, + null, + 'Text/HTML', + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + #[Test] public function v30_null_response_content_type_preserves_existing_behavior(): void { diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 65ba9dd..334dc30 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -169,7 +169,7 @@ public function non_json_body_fails_with_content_type_mismatch(): void ); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('not defined'); + $this->expectExceptionMessage("Response Content-Type 'text/html' is not defined for"); $this->assertResponseMatchesOpenApiSchema( $response, @@ -242,6 +242,19 @@ public function missing_content_type_header_still_parses_json(): void ); } + #[Test] + public function json_content_type_in_spec_with_mixed_content_types_validates_schema(): void + { + $body = (string) json_encode(['error' => 'Pet already exists'], JSON_THROW_ON_ERROR); + $response = $this->makeTestResponse($body, 409, ['Content-Type' => 'application/json']); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::POST, + '/v1/pets', + ); + } + #[Test] public function non_json_content_type_in_spec_with_mixed_content_types_passes(): void {