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
7 changes: 7 additions & 0 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ protected function assertResponseMatchesOpenApiSchema(
$this->extractJsonBody($response, $content, $contentType),
);

// Record coverage for any matched endpoint, including those where body
// validation was skipped (e.g. non-JSON content types). "Covered" means
// the endpoint was exercised in a test, not that its body was validated.
if ($result->matchedPath() !== null) {
OpenApiCoverageTracker::record(
$specName,
Expand All @@ -69,6 +72,10 @@ 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"
Expand Down
10 changes: 4 additions & 6 deletions src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Opis\JsonSchema\Validator;

use function array_keys;
use function implode;
use function json_decode;
use function json_encode;
use function str_ends_with;
Expand Down Expand Up @@ -69,12 +68,11 @@ public function validate(
$content = $responseSpec['content'];
$jsonContentType = $this->findJsonContentType($content);

// If no JSON-compatible content type is defined, skip body validation.
// This validator only handles JSON schemas; non-JSON types (e.g. text/html,
// application/xml) are outside its scope.
if ($jsonContentType === null) {
$definedTypes = array_keys($content);

return OpenApiValidationResult::failure([
"No JSON-compatible content type found for {$method} {$matchedPath} (status {$statusCode}) in '{$specName}' spec. Defined content types: " . implode(', ', $definedTypes),
]);
return OpenApiValidationResult::success($matchedPath);
}

if (!isset($content[$jsonContentType]['schema'])) {
Expand Down
24 changes: 23 additions & 1 deletion tests/Integration/ResponseValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ public function full_pipeline_v30_validate_and_track_coverage(): void

// Check coverage
$coverage = OpenApiCoverageTracker::computeCoverage('petstore-3.0');
$this->assertSame(5, $coverage['total']);
$this->assertSame(6, $coverage['total']);
$this->assertSame(2, $coverage['coveredCount']);
$this->assertContains('GET /v1/pets', $coverage['covered']);
$this->assertContains('POST /v1/pets', $coverage['covered']);
$this->assertContains('GET /v1/health', $coverage['uncovered']);
$this->assertContains('GET /v1/logout', $coverage['uncovered']);
$this->assertContains('DELETE /v1/pets/{petId}', $coverage['uncovered']);
$this->assertContains('GET /v1/pets/{petId}', $coverage['uncovered']);
}
Expand All @@ -94,6 +95,27 @@ public function full_pipeline_v31_validate_and_track_coverage(): void
$this->assertSame(1, $coverage['coveredCount']);
}

#[Test]
public function non_json_endpoint_skips_validation_and_records_coverage(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'GET',
'/v1/logout',
200,
'<html><body>Logged out</body></html>',
);
$this->assertTrue($result->isValid());

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

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

#[Test]
public function invalid_response_produces_descriptive_errors(): void
{
Expand Down
8 changes: 4 additions & 4 deletions tests/Unit/OpenApiCoverageTrackerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,21 @@ public function compute_coverage_returns_correct_stats(): void
$result = OpenApiCoverageTracker::computeCoverage('petstore-3.0');

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

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

$this->assertSame(5, $result['total']);
$this->assertSame(6, $result['total']);
$this->assertSame(0, $result['coveredCount']);
$this->assertCount(0, $result['covered']);
$this->assertCount(5, $result['uncovered']);
$this->assertCount(6, $result['uncovered']);
}

#[Test]
Expand Down
59 changes: 51 additions & 8 deletions tests/Unit/OpenApiResponseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ public function v30_problem_json_empty_body_fails(): void
}

#[Test]
public function v30_content_without_json_compatible_type_fails(): void
public function v30_non_json_content_type_skips_validation(): void
{
$result = $this->validator->validate(
'petstore-3.0',
Expand All @@ -221,9 +221,8 @@ public function v30_content_without_json_compatible_type_fails(): void
'<error>Unsupported</error>',
);

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

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

#[Test]
public function v30_text_html_only_content_type_skips_validation(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'GET',
'/v1/logout',
200,
'<html><body>Logged out</body></html>',
);

$this->assertTrue($result->isValid());
$this->assertSame('/v1/logout', $result->matchedPath());
}

#[Test]
public function v30_mixed_json_and_non_json_content_types_validates_json_schema(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'POST',
'/v1/pets',
409,
['error' => 'Pet already exists'],
);

$this->assertTrue($result->isValid());
$this->assertSame('/v1/pets', $result->matchedPath());
}

#[Test]
public function v30_mixed_content_types_with_invalid_json_body_fails(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'POST',
'/v1/pets',
409,
['wrong_key' => 'value'],
);

$this->assertFalse($result->isValid());
$this->assertNotEmpty($result->errors());
}

// ========================================
// OAS 3.1 tests
// ========================================
Expand Down Expand Up @@ -324,7 +368,7 @@ public function v31_problem_json_valid_response_passes(): void
}

#[Test]
public function v31_content_without_json_compatible_type_fails(): void
public function v31_non_json_content_type_skips_validation(): void
{
$result = $this->validator->validate(
'petstore-3.1',
Expand All @@ -334,9 +378,8 @@ public function v31_content_without_json_compatible_type_fails(): void
'<error>Unsupported</error>',
);

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

#[Test]
Expand Down
39 changes: 39 additions & 0 deletions tests/fixtures/specs/petstore-3.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/html": {
"schema": {
"type": "string"
}
},
"application/json": {
"schema": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
},
"415": {
"description": "Unsupported media type",
"content": {
Expand All @@ -143,6 +164,24 @@
}
}
},
"/v1/logout": {
"get": {
"summary": "Logout page",
"operationId": "logout",
"responses": {
"200": {
"description": "Logout confirmation page",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/v1/health": {
"get": {
"summary": "Health check",
Expand Down