Skip to content

Commit bafcd4f

Browse files
committed
feat(validator): accept response Content-Type for content negotiation
Add optional $responseContentType parameter to OpenApiResponseValidator::validate(). When provided, non-JSON content types defined in the spec are skipped (success), undefined types produce a failure, and JSON-compatible types proceed to schema validation. Move the Content-Type mismatch logic from ValidatesOpenApiSchema trait into the validator itself for a single source of truth. Closes #22
1 parent 84cd399 commit bafcd4f

2 files changed

Lines changed: 60 additions & 13 deletions

File tree

src/Laravel/ValidatesOpenApiSchema.php

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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"

src/OpenApiResponseValidator.php

Lines changed: 59 additions & 1 deletion
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

@@ -66,6 +70,28 @@ 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, use it to select
75+
// the correct media type entry from the spec (content negotiation).
76+
if ($responseContentType !== null) {
77+
$normalizedType = $this->normalizeMediaType($responseContentType);
78+
79+
if (!$this->isJsonContentType($normalizedType)) {
80+
// Non-JSON response: check if the content type is defined in the spec.
81+
if ($this->isContentTypeInSpec($normalizedType, $content)) {
82+
return OpenApiValidationResult::success($matchedPath);
83+
}
84+
85+
$defined = implode(', ', array_keys($content));
86+
87+
return OpenApiValidationResult::failure([
88+
"Response Content-Type '{$normalizedType}' is not defined for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: {$defined}",
89+
]);
90+
}
91+
92+
// JSON-compatible response: fall through to existing JSON schema validation.
93+
}
94+
6995
$jsonContentType = $this->findJsonContentType($content);
7096

7197
// If no JSON-compatible content type is defined, skip body validation.
@@ -138,11 +164,43 @@ private function findJsonContentType(array $content): ?string
138164
foreach ($content as $contentType => $mediaType) {
139165
$lower = strtolower($contentType);
140166

141-
if ($lower === 'application/json' || str_ends_with($lower, '+json')) {
167+
if ($this->isJsonContentType($lower)) {
142168
return $contentType;
143169
}
144170
}
145171

146172
return null;
147173
}
174+
175+
/**
176+
* Extract the media type portion before any parameters (e.g. charset),
177+
* and return it lower-cased.
178+
*
179+
* Example: "text/html; charset=utf-8" → "text/html"
180+
*/
181+
private function normalizeMediaType(string $contentType): string
182+
{
183+
$mediaType = strstr($contentType, ';', true);
184+
185+
return strtolower(trim($mediaType !== false ? $mediaType : $contentType));
186+
}
187+
188+
/**
189+
* @param array<string, array<string, mixed>> $content
190+
*/
191+
private function isContentTypeInSpec(string $responseContentType, array $content): bool
192+
{
193+
foreach ($content as $specContentType => $mediaType) {
194+
if (strtolower($specContentType) === $responseContentType) {
195+
return true;
196+
}
197+
}
198+
199+
return false;
200+
}
201+
202+
private function isJsonContentType(string $lowerContentType): bool
203+
{
204+
return $lowerContentType === 'application/json' || str_ends_with($lowerContentType, '+json');
205+
}
148206
}

0 commit comments

Comments
 (0)