diff --git a/README.md b/README.md index 850f392..42b96b3 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 (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: @@ -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', diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 533a185..c32cdc8 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_numeric; 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_numeric($maxErrors) ? (int) $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, ]; diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 2724e82..e06e126 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -5,7 +5,9 @@ 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; @@ -13,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; @@ -20,6 +23,16 @@ 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, @@ -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()) { diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index 870ca54..3fbfc11 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -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; @@ -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 { 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)); + } + } }