Skip to content
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ This creates `config/openapi-contract-testing.php`:
```php
return [
'default_spec' => '', // e.g., 'front'

// Maximum number of validation errors to report per response.
// 0 = unlimited (reports all errors).
'max_errors' => 20,
];
```

Expand Down Expand Up @@ -140,6 +144,23 @@ $result = $validator->validate(
$this->assertTrue($result->isValid(), $result->errorMessage());
```

#### Controlling the number of validation errors

By default, up to **20** validation errors are reported per response. You can change this via the constructor:

```php
// Report up to 5 errors
$validator = new OpenApiResponseValidator(maxErrors: 5);

// Report all errors (unlimited)
$validator = new OpenApiResponseValidator(maxErrors: 0);

// Stop at first error (pre-v0.x default)
$validator = new OpenApiResponseValidator(maxErrors: 1);
```

For Laravel, set the `max_errors` key in `config/openapi-contract-testing.php`.

## Coverage Report

After running tests, the PHPUnit extension prints a coverage report:
Expand Down Expand Up @@ -211,9 +232,12 @@ The package auto-detects the OAS version from the `openapi` field and handles sc

Main validator class. Validates a response body against the spec.

The constructor accepts a `maxErrors` parameter (default: `20`) that limits how many validation errors the underlying JSON Schema validator collects. Use `0` for unlimited, `1` to stop at the first error.

The optional `responseContentType` parameter enables content negotiation: when provided, non-JSON content types (e.g., `text/html`) are checked for spec presence only, while JSON-compatible types proceed to full schema validation.

```php
$validator = new OpenApiResponseValidator(maxErrors: 20);
$result = $validator->validate(
specName: 'front',
method: 'GET',
Expand Down
6 changes: 5 additions & 1 deletion src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;

use function is_numeric;
use function is_string;
use function str_contains;
use function strtolower;
Expand Down Expand Up @@ -51,7 +52,10 @@ protected function assertResponseMatchesOpenApiSchema(

$contentType = $response->headers->get('Content-Type', '');

$validator = new OpenApiResponseValidator();
$maxErrors = config('openapi-contract-testing.max_errors', 20);
$validator = new OpenApiResponseValidator(
maxErrors: is_numeric($maxErrors) ? (int) $maxErrors : 20,
);
$result = $validator->validate(
$specName,
$resolvedMethod,
Expand Down
4 changes: 4 additions & 0 deletions src/Laravel/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@

return [
'default_spec' => '',

// Maximum number of validation errors to report per response.
// 0 = unlimited (reports all errors).
'max_errors' => 20,
];
19 changes: 18 additions & 1 deletion src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,34 @@
namespace Studio\OpenApiContractTesting;

use const JSON_THROW_ON_ERROR;
use const PHP_INT_MAX;

use InvalidArgumentException;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Validator;

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

final class OpenApiResponseValidator
{
public function __construct(
private readonly int $maxErrors = 20,
) {
if ($this->maxErrors < 0) {
throw new InvalidArgumentException(
sprintf('maxErrors must be 0 (unlimited) or a positive integer, got %d.', $this->maxErrors),
);
}
}

public function validate(
string $specName,
string $method,
Expand Down Expand Up @@ -134,7 +147,11 @@ public function validate(
JSON_THROW_ON_ERROR,
);

$validator = new Validator();
$resolvedMaxErrors = $this->maxErrors === 0 ? PHP_INT_MAX : $this->maxErrors;
$validator = new Validator(
max_errors: $resolvedMaxErrors,
stop_at_first_error: $resolvedMaxErrors === 1,
);
$result = $validator->validate($dataObject, $schemaObject);

if ($result->isValid()) {
Expand Down
139 changes: 139 additions & 0 deletions tests/Unit/OpenApiResponseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@

namespace Studio\OpenApiContractTesting\Tests\Unit;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;

use function array_map;
use function count;
use function range;

class OpenApiResponseValidatorTest extends TestCase
{
private OpenApiResponseValidator $validator;
Expand Down Expand Up @@ -584,6 +589,140 @@ public function v31_no_content_response_passes(): void
$this->assertTrue($result->isValid());
}

// ========================================
// maxErrors tests
// ========================================

#[Test]
public function default_max_errors_reports_multiple_errors(): void
{
$result = $this->validator->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
[
'data' => [
['id' => 'not-an-int', 'name' => 123],
['id' => 'also-not-an-int', 'name' => 456],
],
],
);

$this->assertFalse($result->isValid());
$this->assertGreaterThan(1, count($result->errors()));
}

#[Test]
public function max_errors_caps_reported_errors_to_configured_limit(): void
{
$items = array_map(
static fn(int $i) => ['id' => 'str-' . $i, 'name' => $i],
range(1, 50),
);

$capped = new OpenApiResponseValidator(maxErrors: 5);
$cappedResult = $capped->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
['data' => $items],
);

$unlimited = new OpenApiResponseValidator(maxErrors: 0);
$unlimitedResult = $unlimited->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
['data' => $items],
);

$this->assertFalse($cappedResult->isValid());
$this->assertFalse($unlimitedResult->isValid());
$this->assertLessThan(
count($unlimitedResult->errors()),
count($cappedResult->errors()),
);
}

#[Test]
public function max_errors_one_limits_to_single_error(): void
{
$validator = new OpenApiResponseValidator(maxErrors: 1);
$result = $validator->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
[
'data' => [
['id' => 'not-an-int', 'name' => 123],
['id' => 'also-not-an-int', 'name' => 456],
],
],
);

$this->assertFalse($result->isValid());
$this->assertCount(1, $result->errors());
}

#[Test]
public function max_errors_two_reports_more_than_one_error(): void
{
$items = array_map(
static fn(int $i) => ['id' => 'str-' . $i, 'name' => $i],
range(1, 50),
);

$validator = new OpenApiResponseValidator(maxErrors: 2);
$result = $validator->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
['data' => $items],
);

$this->assertFalse($result->isValid());
$this->assertGreaterThan(1, count($result->errors()));
}

#[Test]
public function max_errors_zero_reports_all_errors(): void
{
$items = array_map(
static fn(int $i) => ['id' => 'str-' . $i, 'name' => $i],
range(1, 50),
);

$validator = new OpenApiResponseValidator(maxErrors: 0);
$result = $validator->validate(
'petstore-3.0',
'GET',
'/v1/pets',
200,
['data' => $items],
);

$this->assertFalse($result->isValid());
$this->assertGreaterThan(20, count($result->errors()));
}

#[Test]
public function negative_max_errors_throws_exception(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('maxErrors must be 0 (unlimited) or a positive integer, got -1.');

new OpenApiResponseValidator(maxErrors: -1);
}

// ========================================
// Strip prefix tests
// ========================================

#[Test]
public function v30_strip_prefixes_applied(): void
{
Expand Down
90 changes: 90 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Tests\Helpers\CreatesTestResponse;

use function array_filter;
use function count;
use function explode;
use function json_encode;
use function str_starts_with;
use function trim;

// Load namespace-level config() mock before the trait resolves the function call.
require_once __DIR__ . '/../Helpers/LaravelConfigMock.php';
Expand Down Expand Up @@ -100,4 +105,89 @@ public function configured_default_spec_validates_successfully(): void
'/v1/pets',
);
}

// ========================================
// max_errors config tests
// ========================================

#[Test]
public function max_errors_config_limits_reported_errors(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0';
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.max_errors'] = 1;

$body = (string) json_encode(
['data' => [['id' => 'bad', 'name' => 123], ['id' => 'bad', 'name' => 456]]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200);

try {
$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
$this->fail('Expected AssertionFailedError was not thrown.');
} catch (AssertionFailedError $e) {
// With max_errors=1, the error message should contain exactly one schema error line.
// The error format is "[path] message", so count lines starting with "[".
$lines = explode("\n", $e->getMessage());
$errorLines = array_filter($lines, static fn(string $line) => str_starts_with(trim($line), '['));
$this->assertCount(1, $errorLines);
}
}

#[Test]
public function string_numeric_max_errors_config_is_cast_to_int(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0';
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.max_errors'] = '1';

$body = (string) json_encode(
['data' => [['id' => 'bad', 'name' => 123], ['id' => 'bad', 'name' => 456]]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200);

try {
$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
$this->fail('Expected AssertionFailedError was not thrown.');
} catch (AssertionFailedError $e) {
$lines = explode("\n", $e->getMessage());
$errorLines = array_filter($lines, static fn(string $line) => str_starts_with(trim($line), '['));
$this->assertCount(1, $errorLines);
}
}

#[Test]
public function non_numeric_max_errors_config_falls_back_to_default(): void
{
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0';
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.max_errors'] = 'not-a-number';

$body = (string) json_encode(
['data' => [['id' => 'bad', 'name' => 123], ['id' => 'bad', 'name' => 456]]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200);

try {
$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
$this->fail('Expected AssertionFailedError was not thrown.');
} catch (AssertionFailedError $e) {
// Falls back to default of 20, so multiple errors should be reported
$lines = explode("\n", $e->getMessage());
$errorLines = array_filter($lines, static fn(string $line) => str_starts_with(trim($line), '['));
$this->assertGreaterThan(1, count($errorLines));
}
}
}