Skip to content

Commit bb5bbea

Browse files
authored
Merge pull request #19 from studio-design/fix/json-compatible-content-type-validation
Bugfix: application/problem+json など JSON 互換コンテンツタイプのスキーマバリデーション対応
2 parents 4c45dd3 + eccd75d commit bb5bbea

4 files changed

Lines changed: 336 additions & 4 deletions

File tree

src/OpenApiResponseValidator.php

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

1212
use function array_keys;
13+
use function implode;
1314
use function json_decode;
1415
use function json_encode;
16+
use function str_ends_with;
1517
use function strtolower;
1618

1719
final class OpenApiResponseValidator
@@ -58,19 +60,35 @@ public function validate(
5860

5961
$responseSpec = $responses[$statusCodeStr];
6062

61-
// If no JSON content schema is defined for this response, skip body validation
62-
if (!isset($responseSpec['content']['application/json']['schema'])) {
63+
// If no content is defined for this response, skip body validation (e.g. 204 No Content)
64+
if (!isset($responseSpec['content'])) {
65+
return OpenApiValidationResult::success($matchedPath);
66+
}
67+
68+
/** @var array<string, array<string, mixed>> $content */
69+
$content = $responseSpec['content'];
70+
$jsonContentType = $this->findJsonContentType($content);
71+
72+
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+
]);
78+
}
79+
80+
if (!isset($content[$jsonContentType]['schema'])) {
6381
return OpenApiValidationResult::success($matchedPath);
6482
}
6583

6684
if ($responseBody === null) {
6785
return OpenApiValidationResult::failure([
68-
"Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON schema in '{$specName}' spec.",
86+
"Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.",
6987
]);
7088
}
7189

7290
/** @var array<string, mixed> $schema */
73-
$schema = $responseSpec['content']['application/json']['schema'];
91+
$schema = $content[$jsonContentType]['schema'];
7492
$jsonSchema = OpenApiSchemaConverter::convert($schema, $version);
7593

7694
// opis/json-schema requires an object, so encode then decode
@@ -107,4 +125,26 @@ public function validate(
107125

108126
return OpenApiValidationResult::failure($errors);
109127
}
128+
129+
/**
130+
* Find the first JSON-compatible content type from the response spec.
131+
*
132+
* Matches "application/json" exactly and any type with a "+json" structured
133+
* syntax suffix (RFC 6838), such as "application/problem+json" and
134+
* "application/vnd.api+json". Matching is case-insensitive.
135+
*
136+
* @param array<string, array<string, mixed>> $content
137+
*/
138+
private function findJsonContentType(array $content): ?string
139+
{
140+
foreach ($content as $contentType => $mediaType) {
141+
$lower = strtolower($contentType);
142+
143+
if ($lower === 'application/json' || str_ends_with($lower, '+json')) {
144+
return $contentType;
145+
}
146+
}
147+
148+
return null;
149+
}
110150
}

tests/Unit/OpenApiResponseValidatorTest.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,114 @@ public function undefined_status_code_returns_failure(): void
152152
$this->assertStringContainsString('Status code 404 not defined', $result->errors()[0]);
153153
}
154154

155+
// ========================================
156+
// OAS 3.0 JSON-compatible content type tests
157+
// ========================================
158+
159+
#[Test]
160+
public function v30_problem_json_valid_response_passes(): void
161+
{
162+
$result = $this->validator->validate(
163+
'petstore-3.0',
164+
'GET',
165+
'/v1/pets',
166+
400,
167+
[
168+
'type' => 'https://example.com/bad-request',
169+
'title' => 'Bad Request',
170+
'status' => 400,
171+
'detail' => 'Invalid query parameter',
172+
],
173+
);
174+
175+
$this->assertTrue($result->isValid());
176+
$this->assertSame('/v1/pets', $result->matchedPath());
177+
}
178+
179+
#[Test]
180+
public function v30_problem_json_invalid_response_fails(): void
181+
{
182+
$result = $this->validator->validate(
183+
'petstore-3.0',
184+
'GET',
185+
'/v1/pets',
186+
400,
187+
[
188+
'type' => 'https://example.com/bad-request',
189+
'title' => 'Bad Request',
190+
'status' => 'not-an-integer',
191+
],
192+
);
193+
194+
$this->assertFalse($result->isValid());
195+
$this->assertNotEmpty($result->errors());
196+
}
197+
198+
#[Test]
199+
public function v30_problem_json_empty_body_fails(): void
200+
{
201+
$result = $this->validator->validate(
202+
'petstore-3.0',
203+
'GET',
204+
'/v1/pets',
205+
400,
206+
null,
207+
);
208+
209+
$this->assertFalse($result->isValid());
210+
$this->assertStringContainsString('Response body is empty', $result->errors()[0]);
211+
}
212+
213+
#[Test]
214+
public function v30_content_without_json_compatible_type_fails(): void
215+
{
216+
$result = $this->validator->validate(
217+
'petstore-3.0',
218+
'POST',
219+
'/v1/pets',
220+
415,
221+
'<error>Unsupported</error>',
222+
);
223+
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]);
227+
}
228+
229+
#[Test]
230+
public function v30_case_insensitive_content_type_matches(): void
231+
{
232+
$result = $this->validator->validate(
233+
'petstore-3.0',
234+
'GET',
235+
'/v1/pets',
236+
422,
237+
[
238+
'type' => 'https://example.com/validation-error',
239+
'title' => 'Validation Error',
240+
'status' => 422,
241+
],
242+
);
243+
244+
$this->assertTrue($result->isValid());
245+
$this->assertSame('/v1/pets', $result->matchedPath());
246+
}
247+
248+
#[Test]
249+
public function v30_json_content_type_without_schema_skips_validation(): void
250+
{
251+
$result = $this->validator->validate(
252+
'petstore-3.0',
253+
'GET',
254+
'/v1/pets',
255+
500,
256+
['error' => 'something went wrong'],
257+
);
258+
259+
$this->assertTrue($result->isValid());
260+
$this->assertSame('/v1/pets', $result->matchedPath());
261+
}
262+
155263
// ========================================
156264
// OAS 3.1 tests
157265
// ========================================
@@ -195,6 +303,42 @@ public function v31_invalid_response_fails(): void
195303
$this->assertNotEmpty($result->errors());
196304
}
197305

306+
#[Test]
307+
public function v31_problem_json_valid_response_passes(): void
308+
{
309+
$result = $this->validator->validate(
310+
'petstore-3.1',
311+
'GET',
312+
'/v1/pets',
313+
400,
314+
[
315+
'type' => 'https://example.com/bad-request',
316+
'title' => 'Bad Request',
317+
'status' => 400,
318+
'detail' => null,
319+
],
320+
);
321+
322+
$this->assertTrue($result->isValid());
323+
$this->assertSame('/v1/pets', $result->matchedPath());
324+
}
325+
326+
#[Test]
327+
public function v31_content_without_json_compatible_type_fails(): void
328+
{
329+
$result = $this->validator->validate(
330+
'petstore-3.1',
331+
'POST',
332+
'/v1/pets',
333+
415,
334+
'<error>Unsupported</error>',
335+
);
336+
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]);
340+
}
341+
198342
#[Test]
199343
public function v31_no_content_response_passes(): void
200344
{

tests/fixtures/specs/petstore-3.0.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,59 @@
4141
}
4242
}
4343
}
44+
},
45+
"422": {
46+
"description": "Validation error",
47+
"content": {
48+
"Application/Problem+JSON": {
49+
"schema": {
50+
"type": "object",
51+
"required": ["type", "title", "status"],
52+
"properties": {
53+
"type": {
54+
"type": "string"
55+
},
56+
"title": {
57+
"type": "string"
58+
},
59+
"status": {
60+
"type": "integer"
61+
}
62+
}
63+
}
64+
}
65+
}
66+
},
67+
"500": {
68+
"description": "Internal server error",
69+
"content": {
70+
"application/json": {}
71+
}
72+
},
73+
"400": {
74+
"description": "Bad request",
75+
"content": {
76+
"application/problem+json": {
77+
"schema": {
78+
"type": "object",
79+
"required": ["type", "title", "status"],
80+
"properties": {
81+
"type": {
82+
"type": "string"
83+
},
84+
"title": {
85+
"type": "string"
86+
},
87+
"status": {
88+
"type": "integer"
89+
},
90+
"detail": {
91+
"type": "string"
92+
}
93+
}
94+
}
95+
}
96+
}
4497
}
4598
}
4699
},
@@ -76,6 +129,16 @@
76129
}
77130
}
78131
}
132+
},
133+
"415": {
134+
"description": "Unsupported media type",
135+
"content": {
136+
"application/xml": {
137+
"schema": {
138+
"type": "string"
139+
}
140+
}
141+
}
79142
}
80143
}
81144
}
@@ -137,6 +200,31 @@
137200
}
138201
}
139202
}
203+
},
204+
"404": {
205+
"description": "Pet not found",
206+
"content": {
207+
"application/problem+json": {
208+
"schema": {
209+
"type": "object",
210+
"required": ["type", "title", "status"],
211+
"properties": {
212+
"type": {
213+
"type": "string"
214+
},
215+
"title": {
216+
"type": "string"
217+
},
218+
"status": {
219+
"type": "integer"
220+
},
221+
"detail": {
222+
"type": "string"
223+
}
224+
}
225+
}
226+
}
227+
}
140228
}
141229
}
142230
},

0 commit comments

Comments
 (0)