From 25fc0e4d767f922bb41260db7e30009f020ab13b Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:38:44 +0900 Subject: [PATCH 1/8] feat(validator): add maxErrors parameter to limit validation errors Add a `maxErrors` constructor parameter (default: 20, 0 = unlimited via PHP_INT_MAX) and pass it to the opis/json-schema Validator. `stopAtFirstError` is true only when maxErrors is 1. --- src/OpenApiResponseValidator.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 2724e82..88be07d 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -5,6 +5,7 @@ namespace Studio\OpenApiContractTesting; use const JSON_THROW_ON_ERROR; +use const PHP_INT_MAX; use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Validator; @@ -20,6 +21,10 @@ final class OpenApiResponseValidator { + public function __construct( + private readonly int $maxErrors = 20, + ) {} + public function validate( string $specName, string $method, @@ -134,7 +139,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()) { From 874cbdb36eea036df3c8884933e02a961852aa13 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:39:09 +0900 Subject: [PATCH 2/8] feat(laravel): add max_errors config and pass it to validator --- src/Laravel/ValidatesOpenApiSchema.php | 6 +++++- src/Laravel/config.php | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 533a185..e83c0c0 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -10,6 +10,7 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use function is_int; use function is_string; use function str_contains; use function strtolower; @@ -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_int($maxErrors) ? $maxErrors : 20, + ); $result = $validator->validate( $specName, $resolvedMethod, diff --git a/src/Laravel/config.php b/src/Laravel/config.php index d9cf9f4..ec0055d 100644 --- a/src/Laravel/config.php +++ b/src/Laravel/config.php @@ -4,4 +4,8 @@ return [ 'default_spec' => '', + + // Maximum number of validation errors to report per response. + // 0 = unlimited (reports all errors). + 'max_errors' => 20, ]; From 17d89935eed5f52e41d5f94996611b78147a3b6f Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:39:33 +0900 Subject: [PATCH 3/8] test: add maxErrors behavior tests for OpenApiResponseValidator --- tests/Unit/OpenApiResponseValidatorTest.php | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index 870ca54..705a441 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -9,6 +9,8 @@ use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecLoader; +use function count; + class OpenApiResponseValidatorTest extends TestCase { private OpenApiResponseValidator $validator; @@ -584,6 +586,76 @@ 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_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_zero_reports_all_errors(): void + { + $validator = new OpenApiResponseValidator(maxErrors: 0); + $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->assertGreaterThan(1, count($result->errors())); + } + + // ======================================== + // Strip prefix tests + // ======================================== + #[Test] public function v30_strip_prefixes_applied(): void { From e81f39437f7b7ce2f8ddbc5902b287a4510ed2cb Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:39:56 +0900 Subject: [PATCH 4/8] docs(readme): add maxErrors parameter documentation --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 850f392..86b7414 100644 --- a/README.md +++ b/README.md @@ -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, ]; ``` @@ -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 (original behavior) +$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: @@ -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`) controlling how many schema errors are collected before stopping. 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', From c7d877488c79e0eaea0bfaf9082e0076ca270cff Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:47:56 +0900 Subject: [PATCH 5/8] feat(validator): add constructor validation for negative maxErrors Reject negative maxErrors values with InvalidArgumentException to enforce the contract that maxErrors must be 0 (unlimited) or positive. --- src/OpenApiResponseValidator.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 88be07d..e06e126 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -7,6 +7,7 @@ use const JSON_THROW_ON_ERROR; use const PHP_INT_MAX; +use InvalidArgumentException; use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Validator; @@ -14,6 +15,7 @@ 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; @@ -23,7 +25,13 @@ 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, From 6c247f7a09054e12b752e5c654321142e9d2c9ae Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:48:19 +0900 Subject: [PATCH 6/8] fix(laravel): handle string max_errors from environment variables Change is_int() guard to is_numeric() + (int) cast so that string values returned by env() (e.g. "10") are accepted instead of silently falling back to the default. --- src/Laravel/ValidatesOpenApiSchema.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index e83c0c0..c32cdc8 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -10,7 +10,7 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; -use function is_int; +use function is_numeric; use function is_string; use function str_contains; use function strtolower; @@ -54,7 +54,7 @@ protected function assertResponseMatchesOpenApiSchema( $maxErrors = config('openapi-contract-testing.max_errors', 20); $validator = new OpenApiResponseValidator( - maxErrors: is_int($maxErrors) ? $maxErrors : 20, + maxErrors: is_numeric($maxErrors) ? (int) $maxErrors : 20, ); $result = $validator->validate( $specName, From e9b5b48fe89fe385d72225d0efbf0d473747db44 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:48:45 +0900 Subject: [PATCH 7/8] test: strengthen maxErrors assertions and add edge-case tests - Use 50-item payloads to prove truncation works in maxErrors tests - Add capped vs unlimited comparison, boundary, and negative input tests - Add Laravel trait tests for max_errors config, string casting, and fallback --- tests/Unit/OpenApiResponseValidatorTest.php | 81 +++++++++++++++-- .../ValidatesOpenApiSchemaDefaultSpecTest.php | 90 +++++++++++++++++++ 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index 705a441..3fbfc11 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -4,12 +4,15 @@ 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 { @@ -610,6 +613,40 @@ public function default_max_errors_reports_multiple_errors(): void $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 { @@ -631,25 +668,55 @@ public function max_errors_one_limits_to_single_error(): void $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' => [ - ['id' => 'not-an-int', 'name' => 123], - ['id' => 'also-not-an-int', 'name' => 456], - ], - ], + ['data' => $items], ); $this->assertFalse($result->isValid()); - $this->assertGreaterThan(1, count($result->errors())); + $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); } // ======================================== diff --git a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php index a6eb5fc..63ec93c 100644 --- a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php @@ -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'; @@ -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)); + } + } } From c180e2e2d246972e2ceea580f109b351eb0e8e14 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 23 Feb 2026 05:49:35 +0900 Subject: [PATCH 8/8] docs(readme): clarify maxErrors wording for accuracy - Change "original behavior" to "pre-v0.x default" in the maxErrors: 1 code comment to avoid misleading upgrading users - Reword API Reference description from "controlling how many schema errors are collected before stopping" to "that limits how many validation errors the underlying JSON Schema validator collects" --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86b7414..42b96b3 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ $validator = new OpenApiResponseValidator(maxErrors: 5); // Report all errors (unlimited) $validator = new OpenApiResponseValidator(maxErrors: 0); -// Stop at first error (original behavior) +// Stop at first error (pre-v0.x default) $validator = new OpenApiResponseValidator(maxErrors: 1); ``` @@ -232,7 +232,7 @@ 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`) controlling how many schema errors are collected before stopping. Use `0` for unlimited, `1` to stop at the first error. +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.