diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 33debca..419275f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,9 @@ parameters: - message: '#Call to an undefined method Illuminate\\Testing\\TestResponse::#' path: src/Laravel/ValidatesOpenApiSchema.php + - + message: '#Access to an undefined property Illuminate\\Testing\\TestResponse::\$headers#' + path: src/Laravel/ValidatesOpenApiSchema.php - message: '#Function config_path not found#' path: src/Laravel/OpenApiContractTestingServiceProvider.php @@ -25,3 +28,8 @@ parameters: paths: - tests/Unit/ValidatesOpenApiSchemaTest.php - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php + - + message: '#class@anonymous/tests/Helpers/CreatesTestResponse\.php.*no value type specified in iterable type array#' + paths: + - tests/Unit/ValidatesOpenApiSchemaTest.php + - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index fdc5e01..da4053e 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -8,8 +8,11 @@ use Studio\OpenApiContractTesting\HttpMethod; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use Throwable; use function is_string; +use function str_contains; +use function strtolower; trait ValidatesOpenApiSchema { @@ -46,13 +49,16 @@ protected function assertResponseMatchesOpenApiSchema( $this->fail('OpenAPI contract testing requires buffered responses, but getContent() returned false (streamed response?).'); } + $contentType = $response->headers->get('Content-Type', ''); + $hasNonJsonContentType = $content !== '' && $contentType !== '' && !str_contains(strtolower($contentType), 'json'); + $validator = new OpenApiResponseValidator(); $result = $validator->validate( $specName, $resolvedMethod, $resolvedPath, $response->getStatusCode(), - $content !== '' ? $response->json() : null, + $this->extractJsonBody($response, $content, $contentType), ); if ($result->matchedPath() !== null) { @@ -63,10 +69,40 @@ protected function assertResponseMatchesOpenApiSchema( ); } + 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" . $result->errorMessage(), ); } + + /** @return null|array */ + private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array + { + if ($content === '') { + return null; + } + + // Non-JSON Content-Type: return null so the validator can decide + // whether the spec requires a JSON body for this endpoint. + if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) { + return null; + } + + try { + return $response->json(); + } catch (Throwable $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/tests/Helpers/CreatesTestResponse.php b/tests/Helpers/CreatesTestResponse.php index 06afbb7..15f6bbd 100644 --- a/tests/Helpers/CreatesTestResponse.php +++ b/tests/Helpers/CreatesTestResponse.php @@ -4,17 +4,46 @@ namespace Studio\OpenApiContractTesting\Tests\Helpers; +use const CASE_LOWER; + use Illuminate\Testing\TestResponse; +use function array_change_key_case; +use function strtolower; + trait CreatesTestResponse { - private function makeTestResponse(string $content, int $statusCode): TestResponse + /** + * @param array $headers + */ + private function makeTestResponse(string $content, int $statusCode, array $headers = []): TestResponse { - $baseResponse = new class ($content, $statusCode) { + $headerBag = new class ($headers) { + /** @var array */ + private readonly array $headers; + + /** @param array $headers */ + public function __construct(array $headers) + { + $this->headers = array_change_key_case($headers, CASE_LOWER); + } + + public function get(string $key, ?string $default = null): ?string + { + return $this->headers[strtolower($key)] ?? $default; + } + }; + + $baseResponse = new class ($content, $statusCode, $headerBag) { + public readonly object $headers; + public function __construct( private readonly string $content, private readonly int $statusCode, - ) {} + object $headers, + ) { + $this->headers = $headers; + } public function getContent(): string { diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 6038d34..f05e352 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -143,6 +143,105 @@ public function successful_validation_records_coverage(): void $this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']); } + #[Test] + public function non_json_html_body_passes_as_null_body(): void + { + $response = $this->makeTestResponse( + 'Done', + 204, + ['Content-Type' => 'text/html'], + ); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::DELETE, + '/v1/pets/123', + ); + } + + #[Test] + public function non_json_body_fails_with_content_type_mismatch(): void + { + $response = $this->makeTestResponse( + 'OK', + 200, + ['Content-Type' => 'text/html'], + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage("Response has Content-Type 'text/html' but the spec expects a JSON response"); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function json_content_type_response_still_validates(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/json']); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function json_content_type_with_charset_validates(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/json; charset=utf-8']); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function vendor_json_content_type_validates(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/vnd.api+json']); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function missing_content_type_header_still_parses_json(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Rex', 'tag' => 'dog']]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + protected function openApiSpec(): string { return 'petstore-3.0';