diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 09c9c13..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; @@ -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" @@ -105,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 5f569b3..2724e82 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); @@ -45,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; @@ -54,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]; @@ -66,6 +70,32 @@ public function validate( /** @var array> $content */ $content = $responseSpec['content']; + + // 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); + + 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}", + ], $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); // If no JSON-compatible content type is defined, skip body validation. @@ -82,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 */ @@ -121,7 +151,7 @@ public function validate( } } - return OpenApiValidationResult::failure($errors); + return OpenApiValidationResult::failure($errors, $matchedPath); } /** @@ -138,11 +168,51 @@ 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)); + } + + /** + * 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 + { + foreach ($content as $specContentType => $mediaType) { + if (strtolower($specContentType) === $responseContentType) { + return true; + } + } + + 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 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..870ca54 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -304,6 +304,194 @@ 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("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] + 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_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 + { + $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..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("Response has Content-Type 'text/html' but the spec expects a JSON response"); + $this->expectExceptionMessage("Response Content-Type 'text/html' is not defined for"); $this->assertResponseMatchesOpenApiSchema( $response, @@ -242,6 +242,35 @@ 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 + { + $response = $this->makeTestResponse( + 'Conflict', + 409, + ['Content-Type' => 'text/html'], + ); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::POST, + '/v1/pets', + ); + } + protected function openApiSpec(): string { return 'petstore-3.0';