From e4e38ff314158d133157a252ae92fafac6385e3e Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 12:04:17 +0900 Subject: [PATCH 1/2] fix(laravel): check Content-Type before parsing response as JSON Closes #15 --- phpstan.neon.dist | 8 ++++ src/Laravel/ValidatesOpenApiSchema.php | 18 +++++++- tests/Helpers/CreatesTestResponse.php | 35 ++++++++++++++-- tests/Unit/ValidatesOpenApiSchemaTest.php | 51 +++++++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) 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..55f32be 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -10,6 +10,7 @@ use Studio\OpenApiContractTesting\OpenApiResponseValidator; use function is_string; +use function str_contains; trait ValidatesOpenApiSchema { @@ -52,7 +53,7 @@ protected function assertResponseMatchesOpenApiSchema( $resolvedMethod, $resolvedPath, $response->getStatusCode(), - $content !== '' ? $response->json() : null, + $this->extractJsonBody($response, $content), ); if ($result->matchedPath() !== null) { @@ -69,4 +70,19 @@ protected function assertResponseMatchesOpenApiSchema( . $result->errorMessage(), ); } + + /** @return null|array */ + private function extractJsonBody(TestResponse $response, string $content): ?array + { + if ($content === '') { + return null; + } + + $contentType = $response->headers->get('Content-Type', ''); + if ($contentType !== '' && !str_contains($contentType, 'json')) { + return null; + } + + return $response->json(); + } } diff --git a/tests/Helpers/CreatesTestResponse.php b/tests/Helpers/CreatesTestResponse.php index 06afbb7..83708df 100644 --- a/tests/Helpers/CreatesTestResponse.php +++ b/tests/Helpers/CreatesTestResponse.php @@ -6,15 +6,44 @@ use Illuminate\Testing\TestResponse; +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 array $headers; + + /** @param array $headers */ + public function __construct(array $headers) + { + $this->headers = []; + foreach ($headers as $key => $value) { + $this->headers[strtolower($key)] = $value; + } + } + + 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..837a242 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -143,6 +143,57 @@ 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_with_json_schema_required_fails_gracefully(): void + { + $response = $this->makeTestResponse( + 'OK', + 200, + ['Content-Type' => 'text/html'], + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Response body is empty'); + + $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', + ); + } + protected function openApiSpec(): string { return 'petstore-3.0'; From dcbedc997ee51ae59036908a6b7ac2657483b1ce Mon Sep 17 00:00:00 2001 From: wadakatu Date: Sat, 21 Feb 2026 12:14:33 +0900 Subject: [PATCH 2/2] refactor(laravel): improve error messages and edge case handling per review - Fail with Content-Type mismatch message instead of misleading "body is empty" - Wrap json() in try-catch for clear error on unparseable body - Case-insensitive Content-Type check for HTTP spec compliance - Add tests for charset, vendor JSON types, and missing header - Simplify header bag constructor with array_change_key_case() --- src/Laravel/ValidatesOpenApiSchema.php | 30 ++++++++++--- tests/Helpers/CreatesTestResponse.php | 10 ++--- tests/Unit/ValidatesOpenApiSchemaTest.php | 52 ++++++++++++++++++++++- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 55f32be..da4053e 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -8,9 +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 { @@ -47,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(), - $this->extractJsonBody($response, $content), + $this->extractJsonBody($response, $content, $contentType), ); if ($result->matchedPath() !== null) { @@ -64,6 +69,13 @@ 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" @@ -72,17 +84,25 @@ protected function assertResponseMatchesOpenApiSchema( } /** @return null|array */ - private function extractJsonBody(TestResponse $response, string $content): ?array + private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array { if ($content === '') { return null; } - $contentType = $response->headers->get('Content-Type', ''); - if ($contentType !== '' && !str_contains($contentType, 'json')) { + // 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; } - return $response->json(); + 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 83708df..15f6bbd 100644 --- a/tests/Helpers/CreatesTestResponse.php +++ b/tests/Helpers/CreatesTestResponse.php @@ -4,8 +4,11 @@ namespace Studio\OpenApiContractTesting\Tests\Helpers; +use const CASE_LOWER; + use Illuminate\Testing\TestResponse; +use function array_change_key_case; use function strtolower; trait CreatesTestResponse @@ -17,15 +20,12 @@ private function makeTestResponse(string $content, int $statusCode, array $heade { $headerBag = new class ($headers) { /** @var array */ - private array $headers; + private readonly array $headers; /** @param array $headers */ public function __construct(array $headers) { - $this->headers = []; - foreach ($headers as $key => $value) { - $this->headers[strtolower($key)] = $value; - } + $this->headers = array_change_key_case($headers, CASE_LOWER); } public function get(string $key, ?string $default = null): ?string diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 837a242..f05e352 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -160,7 +160,7 @@ public function non_json_html_body_passes_as_null_body(): void } #[Test] - public function non_json_body_with_json_schema_required_fails_gracefully(): void + public function non_json_body_fails_with_content_type_mismatch(): void { $response = $this->makeTestResponse( 'OK', @@ -169,7 +169,7 @@ public function non_json_body_with_json_schema_required_fails_gracefully(): void ); $this->expectException(AssertionFailedError::class); - $this->expectExceptionMessage('Response body is empty'); + $this->expectExceptionMessage("Response has Content-Type 'text/html' but the spec expects a JSON response"); $this->assertResponseMatchesOpenApiSchema( $response, @@ -194,6 +194,54 @@ public function json_content_type_response_still_validates(): void ); } + #[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';