Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
namespace Studio\OpenApiContractTesting\Laravel;

use Illuminate\Testing\TestResponse;
use JsonException;
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Throwable;

use function is_string;
use function str_contains;
Expand Down Expand Up @@ -50,7 +50,6 @@ protected function assertResponseMatchesOpenApiSchema(
}

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

$validator = new OpenApiResponseValidator();
$result = $validator->validate(
Expand All @@ -59,6 +58,7 @@ protected function assertResponseMatchesOpenApiSchema(
$resolvedPath,
$response->getStatusCode(),
$this->extractJsonBody($response, $content, $contentType),
$contentType !== '' ? $contentType : null,
);

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

// This guard catches the case where the spec defines a JSON content type
// but the actual response has a non-JSON Content-Type header. The validator
// itself skips validation for specs that define *only* non-JSON content
// types (returning success), so this guard only fires for the mismatch case.
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"
Expand All @@ -105,7 +94,7 @@ private function extractJsonBody(TestResponse $response, string $content, string

try {
return $response->json();
} catch (Throwable $e) {
} catch (JsonException $e) {
$this->fail(
'Response body could not be parsed as JSON: ' . $e->getMessage()
. ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''),
Expand Down
80 changes: 75 additions & 5 deletions src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
use Opis\JsonSchema\Validator;

use function array_keys;
use function implode;
use function json_decode;
use function json_encode;
use function str_ends_with;
use function strstr;
use function strtolower;
use function trim;

final class OpenApiResponseValidator
{
Expand All @@ -23,6 +26,7 @@ public function validate(
string $requestPath,
int $statusCode,
mixed $responseBody,
?string $responseContentType = null,
): OpenApiValidationResult {
$spec = OpenApiSpecLoader::load($specName);

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

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

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

/** @var array<string, array<string, mixed>> $content */
$content = $responseSpec['content'];

// When the actual response Content-Type is provided, handle content negotiation:
// non-JSON types are checked for spec presence only, while JSON-compatible types
// fall through to schema validation against the first JSON media type in the spec.
if ($responseContentType !== null) {
$normalizedType = $this->normalizeMediaType($responseContentType);

if (!$this->isJsonContentType($normalizedType)) {
// Non-JSON response: check if the content type is defined in the spec.
if ($this->isContentTypeInSpec($normalizedType, $content)) {
return OpenApiValidationResult::success($matchedPath);
}

$defined = implode(', ', array_keys($content));

return OpenApiValidationResult::failure([
"Response Content-Type '{$normalizedType}' is not defined for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: {$defined}",
], $matchedPath);
}

// JSON-compatible response: fall through to existing JSON schema validation.
// JSON types are treated as interchangeable (e.g. application/vnd.api+json
// validates against an application/json spec entry) because the schema is
// the same regardless of the specific JSON media type.
}

$jsonContentType = $this->findJsonContentType($content);

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

/** @var array<string, mixed> $schema */
Expand Down Expand Up @@ -121,7 +151,7 @@ public function validate(
}
}

return OpenApiValidationResult::failure($errors);
return OpenApiValidationResult::failure($errors, $matchedPath);
}

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

if ($lower === 'application/json' || str_ends_with($lower, '+json')) {
if ($this->isJsonContentType($lower)) {
return $contentType;
}
}

return null;
}

/**
* Extract the media type portion before any parameters (e.g. charset),
* and return it lower-cased.
*
* Example: "text/html; charset=utf-8" → "text/html"
*/
private function normalizeMediaType(string $contentType): string
{
$mediaType = strstr($contentType, ';', true);

return strtolower(trim($mediaType !== false ? $mediaType : $contentType));
}

/**
* Check whether the given (already normalised, lower-cased) response content
* type matches any content type key defined in the spec. Spec keys are
* lower-cased before comparison.
*
* @param array<string, array<string, mixed>> $content
*/
private function isContentTypeInSpec(string $responseContentType, array $content): bool
{
foreach ($content as $specContentType => $mediaType) {
if (strtolower($specContentType) === $responseContentType) {
return true;
}
}

return false;
}

/**
* True for "application/json" or any "+json" structured syntax suffix (RFC 6838).
* Expects a lower-cased media type without parameters.
*/
private function isJsonContentType(string $lowerContentType): bool
{
return $lowerContentType === 'application/json' || str_ends_with($lowerContentType, '+json');
}
}
4 changes: 2 additions & 2 deletions src/OpenApiValidationResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public static function success(?string $matchedPath = null): self
}

/** @param string[] $errors */
public static function failure(array $errors): self
public static function failure(array $errors, ?string $matchedPath = null): self
{
return new self(false, $errors);
return new self(false, $errors, $matchedPath);
}

public function isValid(): bool
Expand Down
22 changes: 22 additions & 0 deletions tests/Integration/ResponseValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ public function non_json_endpoint_skips_validation_and_records_coverage(): void
$this->assertContains('GET /v1/logout', $coverage['covered']);
}

#[Test]
public function content_negotiation_non_json_response_succeeds_and_records_coverage(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'POST',
'/v1/pets',
409,
null,
'text/html',
);
$this->assertTrue($result->isValid());

if ($result->matchedPath() !== null) {
OpenApiCoverageTracker::record('petstore-3.0', 'POST', $result->matchedPath());
}

$coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
$this->assertSame(1, $coverage['coveredCount']);
$this->assertContains('POST /v1/pets', $coverage['covered']);
}

#[Test]
public function invalid_response_produces_descriptive_errors(): void
{
Expand Down
Loading