diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 9e7dc91..186fdd2 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -19,6 +19,14 @@ trait ValidatesOpenApiSchema { use OpenApiSpecResolver; + private static ?OpenApiResponseValidator $cachedValidator = null; + private static ?int $cachedMaxErrors = null; + + public static function resetValidatorCache(): void + { + self::$cachedValidator = null; + self::$cachedMaxErrors = null; + } protected function openApiSpec(): string { @@ -61,10 +69,7 @@ protected function assertResponseMatchesOpenApiSchema( $contentType = $response->headers->get('Content-Type', ''); - $maxErrors = config('openapi-contract-testing.max_errors', 20); - $validator = new OpenApiResponseValidator( - maxErrors: is_numeric($maxErrors) ? (int) $maxErrors : 20, - ); + $validator = self::getOrCreateValidator(); $result = $validator->validate( $specName, $resolvedMethod, @@ -92,6 +97,19 @@ protected function assertResponseMatchesOpenApiSchema( ); } + private static function getOrCreateValidator(): OpenApiResponseValidator + { + $maxErrors = config('openapi-contract-testing.max_errors', 20); + $resolvedMaxErrors = is_numeric($maxErrors) ? (int) $maxErrors : 20; + + if (self::$cachedValidator === null || self::$cachedMaxErrors !== $resolvedMaxErrors) { + self::$cachedValidator = new OpenApiResponseValidator(maxErrors: $resolvedMaxErrors); + self::$cachedMaxErrors = $resolvedMaxErrors; + } + + return self::$cachedValidator; + } + /** @return null|array */ private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array { diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index 3c7c3e7..73ba2df 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -23,6 +23,11 @@ final class OpenApiResponseValidator { + /** @var array */ + private array $pathMatchers = []; + private Validator $opisValidator; + private ErrorFormatter $errorFormatter; + public function __construct( private readonly int $maxErrors = 20, ) { @@ -31,6 +36,13 @@ public function __construct( sprintf('maxErrors must be 0 (unlimited) or a positive integer, got %d.', $this->maxErrors), ); } + + $resolvedMaxErrors = $this->maxErrors === 0 ? PHP_INT_MAX : $this->maxErrors; + $this->opisValidator = new Validator( + max_errors: $resolvedMaxErrors, + stop_at_first_error: $resolvedMaxErrors === 1, + ); + $this->errorFormatter = new ErrorFormatter(); } public function validate( @@ -47,7 +59,7 @@ public function validate( /** @var string[] $specPaths */ $specPaths = array_keys($spec['paths'] ?? []); - $matcher = new OpenApiPathMatcher($specPaths, OpenApiSpecLoader::getStripPrefixes()); + $matcher = $this->getPathMatcher($specName, $specPaths); $matchedPath = $matcher->match($requestPath); if ($matchedPath === null) { @@ -135,19 +147,13 @@ public function validate( $schemaObject = self::toObject($jsonSchema); $dataObject = self::toObject($responseBody); - $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); + $result = $this->opisValidator->validate($dataObject, $schemaObject); if ($result->isValid()) { return OpenApiValidationResult::success($matchedPath); } - $formatter = new ErrorFormatter(); - $formattedErrors = $formatter->format($result->error()); + $formattedErrors = $this->errorFormatter->format($result->error()); $errors = []; foreach ($formattedErrors as $path => $messages) { @@ -187,6 +193,14 @@ private static function toObject(mixed $value): mixed return $object; } + /** + * @param string[] $specPaths + */ + private function getPathMatcher(string $specName, array $specPaths): OpenApiPathMatcher + { + return $this->pathMatchers[$specName] ??= new OpenApiPathMatcher($specPaths, OpenApiSpecLoader::getStripPrefixes()); + } + /** * Find the first JSON-compatible content type from the response spec. * diff --git a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php index dc08d2c..8abcf8b 100644 --- a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php @@ -33,6 +33,7 @@ protected function setUp(): void protected function tearDown(): void { + self::resetValidatorCache(); OpenApiSpecLoader::reset(); OpenApiCoverageTracker::reset(); parent::tearDown(); diff --git a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php index d12d6b7..1cd3e76 100644 --- a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php @@ -41,6 +41,7 @@ protected function setUp(): void protected function tearDown(): void { + self::resetValidatorCache(); unset($GLOBALS['__openapi_testing_config']); OpenApiSpecLoader::reset(); OpenApiCoverageTracker::reset(); diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 334dc30..96512ab 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -32,6 +32,7 @@ protected function setUp(): void protected function tearDown(): void { + self::resetValidatorCache(); OpenApiSpecLoader::reset(); OpenApiCoverageTracker::reset(); parent::tearDown();