From f03277f4302cf16b46e1f9dda37735c7e8e122f8 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 12:56:11 +0900 Subject: [PATCH 1/2] fix(validator): support JSON-compatible content types beyond application/json The validator previously only checked for `application/json` when looking up response schemas. This adds a `findJsonSchema()` method that matches any content type containing "json" (e.g. `application/problem+json`, `application/vnd.api+json`). When content is defined but no JSON-compatible type is found, a descriptive failure is now returned instead of silently skipping validation. --- src/OpenApiResponseValidator.php | 39 ++++++++-- tests/Unit/OpenApiResponseValidatorTest.php | 79 +++++++++++++++++++++ tests/fixtures/specs/petstore-3.0.json | 60 ++++++++++++++++ tests/fixtures/specs/petstore-3.1.json | 50 +++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 5805edc..e12415c 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -10,8 +10,10 @@ use Opis\JsonSchema\Validator; use function array_keys; +use function implode; use function json_decode; use function json_encode; +use function str_contains; use function strtolower; final class OpenApiResponseValidator @@ -58,19 +60,29 @@ public function validate( $responseSpec = $responses[$statusCodeStr]; - // If no JSON content schema is defined for this response, skip body validation - if (!isset($responseSpec['content']['application/json']['schema'])) { + // If no content is defined for this response, skip body validation (e.g. 204 No Content) + if (!isset($responseSpec['content'])) { return OpenApiValidationResult::success($matchedPath); } + /** @var array> $content */ + $content = $responseSpec['content']; + $schema = $this->findJsonSchema($content); + + if ($schema === null) { + /** @var string[] $definedTypes */ + $definedTypes = array_keys($content); + + return OpenApiValidationResult::failure([ + "No JSON-compatible content type found for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: " . implode(', ', $definedTypes), + ]); + } + if ($responseBody === null) { return OpenApiValidationResult::failure([ "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON schema in '{$specName}' spec.", ]); } - - /** @var array $schema */ - $schema = $responseSpec['content']['application/json']['schema']; $jsonSchema = OpenApiSchemaConverter::convert($schema, $version); // opis/json-schema requires an object, so encode then decode @@ -107,4 +119,21 @@ public function validate( return OpenApiValidationResult::failure($errors); } + + /** + * @param array> $content + * + * @return null|array + */ + private function findJsonSchema(array $content): ?array + { + foreach ($content as $contentType => $mediaType) { + if (str_contains(strtolower($contentType), 'json') && isset($mediaType['schema'])) { + /** @var array */ + return $mediaType['schema']; + } + } + + return null; + } } diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index e30c792..66c743d 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -152,6 +152,65 @@ public function undefined_status_code_returns_failure(): void $this->assertStringContainsString('Status code 404 not defined', $result->errors()[0]); } + // ======================================== + // OAS 3.0 JSON-compatible content type tests + // ======================================== + + #[Test] + public function v30_problem_json_valid_response_passes(): 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', + ], + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_problem_json_invalid_response_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 400, + [ + 'type' => 'https://example.com/bad-request', + 'title' => 'Bad Request', + 'status' => 'not-an-integer', + ], + ); + + $this->assertFalse($result->isValid()); + $this->assertNotEmpty($result->errors()); + } + + #[Test] + public function v30_content_without_json_compatible_type_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'POST', + '/v1/pets', + 415, + 'Unsupported', + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]); + $this->assertStringContainsString('application/xml', $result->errors()[0]); + } + // ======================================== // OAS 3.1 tests // ======================================== @@ -195,6 +254,26 @@ public function v31_invalid_response_fails(): void $this->assertNotEmpty($result->errors()); } + #[Test] + public function v31_problem_json_valid_response_passes(): void + { + $result = $this->validator->validate( + 'petstore-3.1', + 'GET', + '/v1/pets', + 400, + [ + 'type' => 'https://example.com/bad-request', + 'title' => 'Bad Request', + 'status' => 400, + 'detail' => null, + ], + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + #[Test] public function v31_no_content_response_passes(): void { diff --git a/tests/fixtures/specs/petstore-3.0.json b/tests/fixtures/specs/petstore-3.0.json index e188acb..026d674 100644 --- a/tests/fixtures/specs/petstore-3.0.json +++ b/tests/fixtures/specs/petstore-3.0.json @@ -41,6 +41,31 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "required": ["type", "title", "status"], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + } + } + } + } + } } } }, @@ -76,6 +101,16 @@ } } } + }, + "415": { + "description": "Unsupported media type", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } } } } @@ -137,6 +172,31 @@ } } } + }, + "404": { + "description": "Pet not found", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "required": ["type", "title", "status"], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + } + } + } + } + } } } }, diff --git a/tests/fixtures/specs/petstore-3.1.json b/tests/fixtures/specs/petstore-3.1.json index ed32ba2..a3c2bda 100644 --- a/tests/fixtures/specs/petstore-3.1.json +++ b/tests/fixtures/specs/petstore-3.1.json @@ -40,6 +40,31 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "required": ["type", "title", "status"], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": ["string", "null"] + } + } + } + } + } } } }, @@ -126,6 +151,31 @@ } } } + }, + "404": { + "description": "Pet not found", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "required": ["type", "title", "status"], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": ["string", "null"] + } + } + } + } + } } } }, From eccd75d112944755078231de6aba9f011d384fbe Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 13:05:05 +0900 Subject: [PATCH 2/2] refactor(validator): address review feedback for JSON content type matching - Replace str_contains substring match with RFC 6838 structured syntax suffix matching (=== 'application/json' || str_ends_with('+json')) - Rename findJsonSchema to findJsonContentType for clearer intent - Handle JSON content type without schema key (skip validation, matching prior behavior) instead of producing contradictory error message - Fix error message terminology: "JSON-compatible response schema" - Add PHPDoc describing the matching strategy and RFC reference - Add tests: empty body with problem+json, case-insensitive content type, schema-less JSON content type, OAS 3.1 non-JSON content type failure --- src/OpenApiResponseValidator.php | 33 +++++++---- tests/Unit/OpenApiResponseValidatorTest.php | 65 +++++++++++++++++++++ tests/fixtures/specs/petstore-3.0.json | 28 +++++++++ tests/fixtures/specs/petstore-3.1.json | 10 ++++ 4 files changed, 125 insertions(+), 11 deletions(-) diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index e12415c..cadc64f 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -13,7 +13,7 @@ use function implode; use function json_decode; use function json_encode; -use function str_contains; +use function str_ends_with; use function strtolower; final class OpenApiResponseValidator @@ -67,10 +67,9 @@ public function validate( /** @var array> $content */ $content = $responseSpec['content']; - $schema = $this->findJsonSchema($content); + $jsonContentType = $this->findJsonContentType($content); - if ($schema === null) { - /** @var string[] $definedTypes */ + if ($jsonContentType === null) { $definedTypes = array_keys($content); return OpenApiValidationResult::failure([ @@ -78,11 +77,18 @@ public function validate( ]); } + if (!isset($content[$jsonContentType]['schema'])) { + return OpenApiValidationResult::success($matchedPath); + } + if ($responseBody === null) { return OpenApiValidationResult::failure([ - "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON schema in '{$specName}' spec.", + "Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.", ]); } + + /** @var array $schema */ + $schema = $content[$jsonContentType]['schema']; $jsonSchema = OpenApiSchemaConverter::convert($schema, $version); // opis/json-schema requires an object, so encode then decode @@ -121,16 +127,21 @@ public function validate( } /** - * @param array> $content + * Find the first JSON-compatible content type from the response spec. + * + * Matches "application/json" exactly and any type with a "+json" structured + * syntax suffix (RFC 6838), such as "application/problem+json" and + * "application/vnd.api+json". Matching is case-insensitive. * - * @return null|array + * @param array> $content */ - private function findJsonSchema(array $content): ?array + private function findJsonContentType(array $content): ?string { foreach ($content as $contentType => $mediaType) { - if (str_contains(strtolower($contentType), 'json') && isset($mediaType['schema'])) { - /** @var array */ - return $mediaType['schema']; + $lower = strtolower($contentType); + + if ($lower === 'application/json' || str_ends_with($lower, '+json')) { + return $contentType; } } diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index 66c743d..bb7b275 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -195,6 +195,21 @@ public function v30_problem_json_invalid_response_fails(): void $this->assertNotEmpty($result->errors()); } + #[Test] + public function v30_problem_json_empty_body_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 400, + null, + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Response body is empty', $result->errors()[0]); + } + #[Test] public function v30_content_without_json_compatible_type_fails(): void { @@ -211,6 +226,40 @@ public function v30_content_without_json_compatible_type_fails(): void $this->assertStringContainsString('application/xml', $result->errors()[0]); } + #[Test] + public function v30_case_insensitive_content_type_matches(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 422, + [ + 'type' => 'https://example.com/validation-error', + 'title' => 'Validation Error', + 'status' => 422, + ], + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + + #[Test] + public function v30_json_content_type_without_schema_skips_validation(): void + { + $result = $this->validator->validate( + 'petstore-3.0', + 'GET', + '/v1/pets', + 500, + ['error' => 'something went wrong'], + ); + + $this->assertTrue($result->isValid()); + $this->assertSame('/v1/pets', $result->matchedPath()); + } + // ======================================== // OAS 3.1 tests // ======================================== @@ -274,6 +323,22 @@ public function v31_problem_json_valid_response_passes(): void $this->assertSame('/v1/pets', $result->matchedPath()); } + #[Test] + public function v31_content_without_json_compatible_type_fails(): void + { + $result = $this->validator->validate( + 'petstore-3.1', + 'POST', + '/v1/pets', + 415, + 'Unsupported', + ); + + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]); + $this->assertStringContainsString('application/xml', $result->errors()[0]); + } + #[Test] public function v31_no_content_response_passes(): void { diff --git a/tests/fixtures/specs/petstore-3.0.json b/tests/fixtures/specs/petstore-3.0.json index 026d674..970c7c4 100644 --- a/tests/fixtures/specs/petstore-3.0.json +++ b/tests/fixtures/specs/petstore-3.0.json @@ -42,6 +42,34 @@ } } }, + "422": { + "description": "Validation error", + "content": { + "Application/Problem+JSON": { + "schema": { + "type": "object", + "required": ["type", "title", "status"], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": {} + } + }, "400": { "description": "Bad request", "content": { diff --git a/tests/fixtures/specs/petstore-3.1.json b/tests/fixtures/specs/petstore-3.1.json index a3c2bda..a307835 100644 --- a/tests/fixtures/specs/petstore-3.1.json +++ b/tests/fixtures/specs/petstore-3.1.json @@ -99,6 +99,16 @@ } } } + }, + "415": { + "description": "Unsupported media type", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } } } }