Skip to content

Commit f89ef5c

Browse files
authored
Merge pull request #23 from studio-design/feat/content-negotiation-support
New Feature: バリデータにレスポンスの Content-Type を渡してコンテントネゴシエーションに対応する
2 parents 84cd399 + de4e021 commit f89ef5c

6 files changed

Lines changed: 320 additions & 22 deletions

src/Laravel/ValidatesOpenApiSchema.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
namespace Studio\OpenApiContractTesting\Laravel;
66

77
use Illuminate\Testing\TestResponse;
8+
use JsonException;
89
use Studio\OpenApiContractTesting\HttpMethod;
910
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
1011
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
11-
use Throwable;
1212

1313
use function is_string;
1414
use function str_contains;
@@ -50,7 +50,6 @@ protected function assertResponseMatchesOpenApiSchema(
5050
}
5151

5252
$contentType = $response->headers->get('Content-Type', '');
53-
$hasNonJsonContentType = $content !== '' && $contentType !== '' && !str_contains(strtolower($contentType), 'json');
5453

5554
$validator = new OpenApiResponseValidator();
5655
$result = $validator->validate(
@@ -59,6 +58,7 @@ protected function assertResponseMatchesOpenApiSchema(
5958
$resolvedPath,
6059
$response->getStatusCode(),
6160
$this->extractJsonBody($response, $content, $contentType),
61+
$contentType !== '' ? $contentType : null,
6262
);
6363

6464
// Record coverage for any matched endpoint, including those where body
@@ -72,17 +72,6 @@ protected function assertResponseMatchesOpenApiSchema(
7272
);
7373
}
7474

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.
79-
if (!$result->isValid() && $hasNonJsonContentType) {
80-
$this->fail(
81-
"OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n"
82-
. "Response has Content-Type '{$contentType}' but the spec expects a JSON response.",
83-
);
84-
}
85-
8675
$this->assertTrue(
8776
$result->isValid(),
8877
"OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n"
@@ -105,7 +94,7 @@ private function extractJsonBody(TestResponse $response, string $content, string
10594

10695
try {
10796
return $response->json();
108-
} catch (Throwable $e) {
97+
} catch (JsonException $e) {
10998
$this->fail(
11099
'Response body could not be parsed as JSON: ' . $e->getMessage()
111100
. ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''),

src/OpenApiResponseValidator.php

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
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;
1516
use function str_ends_with;
17+
use function strstr;
1618
use function strtolower;
19+
use function trim;
1720

1821
final class OpenApiResponseValidator
1922
{
@@ -23,6 +26,7 @@ public function validate(
2326
string $requestPath,
2427
int $statusCode,
2528
mixed $responseBody,
29+
?string $responseContentType = null,
2630
): OpenApiValidationResult {
2731
$spec = OpenApiSpecLoader::load($specName);
2832

@@ -45,7 +49,7 @@ public function validate(
4549
if (!isset($pathSpec[$lowerMethod])) {
4650
return OpenApiValidationResult::failure([
4751
"Method {$method} not defined for path {$matchedPath} in '{$specName}' spec.",
48-
]);
52+
], $matchedPath);
4953
}
5054

5155
$statusCodeStr = (string) $statusCode;
@@ -54,7 +58,7 @@ public function validate(
5458
if (!isset($responses[$statusCodeStr])) {
5559
return OpenApiValidationResult::failure([
5660
"Status code {$statusCode} not defined for {$method} {$matchedPath} in '{$specName}' spec.",
57-
]);
61+
], $matchedPath);
5862
}
5963

6064
$responseSpec = $responses[$statusCodeStr];
@@ -66,6 +70,32 @@ public function validate(
6670

6771
/** @var array<string, array<string, mixed>> $content */
6872
$content = $responseSpec['content'];
73+
74+
// When the actual response Content-Type is provided, handle content negotiation:
75+
// non-JSON types are checked for spec presence only, while JSON-compatible types
76+
// fall through to schema validation against the first JSON media type in the spec.
77+
if ($responseContentType !== null) {
78+
$normalizedType = $this->normalizeMediaType($responseContentType);
79+
80+
if (!$this->isJsonContentType($normalizedType)) {
81+
// Non-JSON response: check if the content type is defined in the spec.
82+
if ($this->isContentTypeInSpec($normalizedType, $content)) {
83+
return OpenApiValidationResult::success($matchedPath);
84+
}
85+
86+
$defined = implode(', ', array_keys($content));
87+
88+
return OpenApiValidationResult::failure([
89+
"Response Content-Type '{$normalizedType}' is not defined for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: {$defined}",
90+
], $matchedPath);
91+
}
92+
93+
// JSON-compatible response: fall through to existing JSON schema validation.
94+
// JSON types are treated as interchangeable (e.g. application/vnd.api+json
95+
// validates against an application/json spec entry) because the schema is
96+
// the same regardless of the specific JSON media type.
97+
}
98+
6999
$jsonContentType = $this->findJsonContentType($content);
70100

71101
// If no JSON-compatible content type is defined, skip body validation.
@@ -82,7 +112,7 @@ public function validate(
82112
if ($responseBody === null) {
83113
return OpenApiValidationResult::failure([
84114
"Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.",
85-
]);
115+
], $matchedPath);
86116
}
87117

88118
/** @var array<string, mixed> $schema */
@@ -121,7 +151,7 @@ public function validate(
121151
}
122152
}
123153

124-
return OpenApiValidationResult::failure($errors);
154+
return OpenApiValidationResult::failure($errors, $matchedPath);
125155
}
126156

127157
/**
@@ -138,11 +168,51 @@ private function findJsonContentType(array $content): ?string
138168
foreach ($content as $contentType => $mediaType) {
139169
$lower = strtolower($contentType);
140170

141-
if ($lower === 'application/json' || str_ends_with($lower, '+json')) {
171+
if ($this->isJsonContentType($lower)) {
142172
return $contentType;
143173
}
144174
}
145175

146176
return null;
147177
}
178+
179+
/**
180+
* Extract the media type portion before any parameters (e.g. charset),
181+
* and return it lower-cased.
182+
*
183+
* Example: "text/html; charset=utf-8" → "text/html"
184+
*/
185+
private function normalizeMediaType(string $contentType): string
186+
{
187+
$mediaType = strstr($contentType, ';', true);
188+
189+
return strtolower(trim($mediaType !== false ? $mediaType : $contentType));
190+
}
191+
192+
/**
193+
* Check whether the given (already normalised, lower-cased) response content
194+
* type matches any content type key defined in the spec. Spec keys are
195+
* lower-cased before comparison.
196+
*
197+
* @param array<string, array<string, mixed>> $content
198+
*/
199+
private function isContentTypeInSpec(string $responseContentType, array $content): bool
200+
{
201+
foreach ($content as $specContentType => $mediaType) {
202+
if (strtolower($specContentType) === $responseContentType) {
203+
return true;
204+
}
205+
}
206+
207+
return false;
208+
}
209+
210+
/**
211+
* True for "application/json" or any "+json" structured syntax suffix (RFC 6838).
212+
* Expects a lower-cased media type without parameters.
213+
*/
214+
private function isJsonContentType(string $lowerContentType): bool
215+
{
216+
return $lowerContentType === 'application/json' || str_ends_with($lowerContentType, '+json');
217+
}
148218
}

src/OpenApiValidationResult.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ public static function success(?string $matchedPath = null): self
2323
}
2424

2525
/** @param string[] $errors */
26-
public static function failure(array $errors): self
26+
public static function failure(array $errors, ?string $matchedPath = null): self
2727
{
28-
return new self(false, $errors);
28+
return new self(false, $errors, $matchedPath);
2929
}
3030

3131
public function isValid(): bool

tests/Integration/ResponseValidationTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,28 @@ public function non_json_endpoint_skips_validation_and_records_coverage(): void
116116
$this->assertContains('GET /v1/logout', $coverage['covered']);
117117
}
118118

119+
#[Test]
120+
public function content_negotiation_non_json_response_succeeds_and_records_coverage(): void
121+
{
122+
$result = $this->validator->validate(
123+
'petstore-3.0',
124+
'POST',
125+
'/v1/pets',
126+
409,
127+
null,
128+
'text/html',
129+
);
130+
$this->assertTrue($result->isValid());
131+
132+
if ($result->matchedPath() !== null) {
133+
OpenApiCoverageTracker::record('petstore-3.0', 'POST', $result->matchedPath());
134+
}
135+
136+
$coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
137+
$this->assertSame(1, $coverage['coveredCount']);
138+
$this->assertContains('POST /v1/pets', $coverage['covered']);
139+
}
140+
119141
#[Test]
120142
public function invalid_response_produces_descriptive_errors(): void
121143
{

0 commit comments

Comments
 (0)