diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 5805edc..cadc64f 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_ends_with; use function strtolower; final class OpenApiResponseValidator @@ -58,19 +60,35 @@ 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']; + $jsonContentType = $this->findJsonContentType($content); + + if ($jsonContentType === null) { + $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 (!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 = $responseSpec['content']['application/json']['schema']; + $schema = $content[$jsonContentType]['schema']; $jsonSchema = OpenApiSchemaConverter::convert($schema, $version); // opis/json-schema requires an object, so encode then decode @@ -107,4 +125,26 @@ public function validate( return OpenApiValidationResult::failure($errors); } + + /** + * 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. + * + * @param array> $content + */ + private function findJsonContentType(array $content): ?string + { + foreach ($content as $contentType => $mediaType) { + $lower = strtolower($contentType); + + if ($lower === 'application/json' || str_ends_with($lower, '+json')) { + return $contentType; + } + } + + return null; + } } diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index e30c792..bb7b275 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -152,6 +152,114 @@ 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_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 + { + $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]); + } + + #[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 // ======================================== @@ -195,6 +303,42 @@ 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_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 e188acb..970c7c4 100644 --- a/tests/fixtures/specs/petstore-3.0.json +++ b/tests/fixtures/specs/petstore-3.0.json @@ -41,6 +41,59 @@ } } } + }, + "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": { + "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 +129,16 @@ } } } + }, + "415": { + "description": "Unsupported media type", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } } } } @@ -137,6 +200,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..a307835 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"] + } + } + } + } + } } } }, @@ -74,6 +99,16 @@ } } } + }, + "415": { + "description": "Unsupported media type", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } } } } @@ -126,6 +161,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"] + } + } + } + } + } } } },