Skip to content

Commit 84cd399

Browse files
authored
Merge pull request #21 from studio-design/fix/non-json-content-type-regression
Bugfix: スペックにJSON系コンテンツタイプがないレスポンスで failure になるリグレッション修正
2 parents f1d0d29 + ce7a905 commit 84cd399

6 files changed

Lines changed: 128 additions & 19 deletions

File tree

src/Laravel/ValidatesOpenApiSchema.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ protected function assertResponseMatchesOpenApiSchema(
6161
$this->extractJsonBody($response, $content, $contentType),
6262
);
6363

64+
// Record coverage for any matched endpoint, including those where body
65+
// validation was skipped (e.g. non-JSON content types). "Covered" means
66+
// the endpoint was exercised in a test, not that its body was validated.
6467
if ($result->matchedPath() !== null) {
6568
OpenApiCoverageTracker::record(
6669
$specName,
@@ -69,6 +72,10 @@ protected function assertResponseMatchesOpenApiSchema(
6972
);
7073
}
7174

75+
// This guard catches the case where the spec defines a JSON content type
76+
// but the actual response has a non-JSON Content-Type header. The validator
77+
// itself skips validation for specs that define *only* non-JSON content
78+
// types (returning success), so this guard only fires for the mismatch case.
7279
if (!$result->isValid() && $hasNonJsonContentType) {
7380
$this->fail(
7481
"OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n"

src/OpenApiResponseValidator.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
use Opis\JsonSchema\Validator;
1111

1212
use function array_keys;
13-
use function implode;
1413
use function json_decode;
1514
use function json_encode;
1615
use function str_ends_with;
@@ -69,12 +68,11 @@ public function validate(
6968
$content = $responseSpec['content'];
7069
$jsonContentType = $this->findJsonContentType($content);
7170

71+
// If no JSON-compatible content type is defined, skip body validation.
72+
// This validator only handles JSON schemas; non-JSON types (e.g. text/html,
73+
// application/xml) are outside its scope.
7274
if ($jsonContentType === null) {
73-
$definedTypes = array_keys($content);
74-
75-
return OpenApiValidationResult::failure([
76-
"No JSON-compatible content type found for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: " . implode(', ', $definedTypes),
77-
]);
75+
return OpenApiValidationResult::success($matchedPath);
7876
}
7977

8078
if (!isset($content[$jsonContentType]['schema'])) {

tests/Integration/ResponseValidationTest.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ public function full_pipeline_v30_validate_and_track_coverage(): void
6464

6565
// Check coverage
6666
$coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
67-
$this->assertSame(5, $coverage['total']);
67+
$this->assertSame(6, $coverage['total']);
6868
$this->assertSame(2, $coverage['coveredCount']);
6969
$this->assertContains('GET /v1/pets', $coverage['covered']);
7070
$this->assertContains('POST /v1/pets', $coverage['covered']);
7171
$this->assertContains('GET /v1/health', $coverage['uncovered']);
72+
$this->assertContains('GET /v1/logout', $coverage['uncovered']);
7273
$this->assertContains('DELETE /v1/pets/{petId}', $coverage['uncovered']);
7374
$this->assertContains('GET /v1/pets/{petId}', $coverage['uncovered']);
7475
}
@@ -94,6 +95,27 @@ public function full_pipeline_v31_validate_and_track_coverage(): void
9495
$this->assertSame(1, $coverage['coveredCount']);
9596
}
9697

98+
#[Test]
99+
public function non_json_endpoint_skips_validation_and_records_coverage(): void
100+
{
101+
$result = $this->validator->validate(
102+
'petstore-3.0',
103+
'GET',
104+
'/v1/logout',
105+
200,
106+
'<html><body>Logged out</body></html>',
107+
);
108+
$this->assertTrue($result->isValid());
109+
110+
if ($result->matchedPath() !== null) {
111+
OpenApiCoverageTracker::record('petstore-3.0', 'GET', $result->matchedPath());
112+
}
113+
114+
$coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
115+
$this->assertSame(1, $coverage['coveredCount']);
116+
$this->assertContains('GET /v1/logout', $coverage['covered']);
117+
}
118+
97119
#[Test]
98120
public function invalid_response_produces_descriptive_errors(): void
99121
{

tests/Unit/OpenApiCoverageTrackerTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,21 @@ public function compute_coverage_returns_correct_stats(): void
6767
$result = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
6868

6969
// See tests/fixtures/specs/petstore-3.0.json for the full endpoint list
70-
$this->assertSame(5, $result['total']);
70+
$this->assertSame(6, $result['total']);
7171
$this->assertSame(2, $result['coveredCount']);
7272
$this->assertCount(2, $result['covered']);
73-
$this->assertCount(3, $result['uncovered']);
73+
$this->assertCount(4, $result['uncovered']);
7474
}
7575

7676
#[Test]
7777
public function compute_coverage_with_no_coverage(): void
7878
{
7979
$result = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
8080

81-
$this->assertSame(5, $result['total']);
81+
$this->assertSame(6, $result['total']);
8282
$this->assertSame(0, $result['coveredCount']);
8383
$this->assertCount(0, $result['covered']);
84-
$this->assertCount(5, $result['uncovered']);
84+
$this->assertCount(6, $result['uncovered']);
8585
}
8686

8787
#[Test]

tests/Unit/OpenApiResponseValidatorTest.php

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ public function v30_problem_json_empty_body_fails(): void
211211
}
212212

213213
#[Test]
214-
public function v30_content_without_json_compatible_type_fails(): void
214+
public function v30_non_json_content_type_skips_validation(): void
215215
{
216216
$result = $this->validator->validate(
217217
'petstore-3.0',
@@ -221,9 +221,8 @@ public function v30_content_without_json_compatible_type_fails(): void
221221
'<error>Unsupported</error>',
222222
);
223223

224-
$this->assertFalse($result->isValid());
225-
$this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]);
226-
$this->assertStringContainsString('application/xml', $result->errors()[0]);
224+
$this->assertTrue($result->isValid());
225+
$this->assertSame('/v1/pets', $result->matchedPath());
227226
}
228227

229228
#[Test]
@@ -260,6 +259,51 @@ public function v30_json_content_type_without_schema_skips_validation(): void
260259
$this->assertSame('/v1/pets', $result->matchedPath());
261260
}
262261

262+
#[Test]
263+
public function v30_text_html_only_content_type_skips_validation(): void
264+
{
265+
$result = $this->validator->validate(
266+
'petstore-3.0',
267+
'GET',
268+
'/v1/logout',
269+
200,
270+
'<html><body>Logged out</body></html>',
271+
);
272+
273+
$this->assertTrue($result->isValid());
274+
$this->assertSame('/v1/logout', $result->matchedPath());
275+
}
276+
277+
#[Test]
278+
public function v30_mixed_json_and_non_json_content_types_validates_json_schema(): void
279+
{
280+
$result = $this->validator->validate(
281+
'petstore-3.0',
282+
'POST',
283+
'/v1/pets',
284+
409,
285+
['error' => 'Pet already exists'],
286+
);
287+
288+
$this->assertTrue($result->isValid());
289+
$this->assertSame('/v1/pets', $result->matchedPath());
290+
}
291+
292+
#[Test]
293+
public function v30_mixed_content_types_with_invalid_json_body_fails(): void
294+
{
295+
$result = $this->validator->validate(
296+
'petstore-3.0',
297+
'POST',
298+
'/v1/pets',
299+
409,
300+
['wrong_key' => 'value'],
301+
);
302+
303+
$this->assertFalse($result->isValid());
304+
$this->assertNotEmpty($result->errors());
305+
}
306+
263307
// ========================================
264308
// OAS 3.1 tests
265309
// ========================================
@@ -324,7 +368,7 @@ public function v31_problem_json_valid_response_passes(): void
324368
}
325369

326370
#[Test]
327-
public function v31_content_without_json_compatible_type_fails(): void
371+
public function v31_non_json_content_type_skips_validation(): void
328372
{
329373
$result = $this->validator->validate(
330374
'petstore-3.1',
@@ -334,9 +378,8 @@ public function v31_content_without_json_compatible_type_fails(): void
334378
'<error>Unsupported</error>',
335379
);
336380

337-
$this->assertFalse($result->isValid());
338-
$this->assertStringContainsString('No JSON-compatible content type found', $result->errors()[0]);
339-
$this->assertStringContainsString('application/xml', $result->errors()[0]);
381+
$this->assertTrue($result->isValid());
382+
$this->assertSame('/v1/pets', $result->matchedPath());
340383
}
341384

342385
#[Test]

tests/fixtures/specs/petstore-3.0.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@
130130
}
131131
}
132132
},
133+
"409": {
134+
"description": "Conflict",
135+
"content": {
136+
"text/html": {
137+
"schema": {
138+
"type": "string"
139+
}
140+
},
141+
"application/json": {
142+
"schema": {
143+
"type": "object",
144+
"required": ["error"],
145+
"properties": {
146+
"error": {
147+
"type": "string"
148+
}
149+
}
150+
}
151+
}
152+
}
153+
},
133154
"415": {
134155
"description": "Unsupported media type",
135156
"content": {
@@ -143,6 +164,24 @@
143164
}
144165
}
145166
},
167+
"/v1/logout": {
168+
"get": {
169+
"summary": "Logout page",
170+
"operationId": "logout",
171+
"responses": {
172+
"200": {
173+
"description": "Logout confirmation page",
174+
"content": {
175+
"text/html": {
176+
"schema": {
177+
"type": "string"
178+
}
179+
}
180+
}
181+
}
182+
}
183+
}
184+
},
146185
"/v1/health": {
147186
"get": {
148187
"summary": "Health check",

0 commit comments

Comments
 (0)