From c2c34bc740fb23a35483ac695650ff005834cc18 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Tue, 17 Mar 2026 18:51:40 +0900 Subject: [PATCH 1/2] refactor: cache PathMatcher, opis Validator, and ErrorFormatter instances Instead of recreating PathMatcher, opis Validator, and ErrorFormatter on every validate() call, cache them at the instance level in OpenApiResponseValidator. Also add a static validator cache in the ValidatesOpenApiSchema trait to avoid reconstructing the validator on every assertion, with resetValidatorCache() for test isolation. --- src/Laravel/ValidatesOpenApiSchema.php | 26 ++++++++++++--- src/OpenApiResponseValidator.php | 32 +++++++++++++------ .../ValidatesOpenApiSchemaAttributeTest.php | 1 + .../ValidatesOpenApiSchemaDefaultSpecTest.php | 1 + tests/Unit/ValidatesOpenApiSchemaTest.php | 1 + 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 9e7dc91..8714e3c 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 = -1; + + public static function resetValidatorCache(): void + { + self::$cachedValidator = null; + self::$cachedMaxErrors = -1; + } 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(); From 8fa937af876cda6b32ce99606b20515873638374 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Tue, 17 Mar 2026 18:55:47 +0900 Subject: [PATCH 2/2] refactor: replace magic sentinel -1 with null for cachedMaxErrors Use ?int with null instead of int with -1 to represent the "no cached value" state, consistent with $cachedValidator. --- 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 8714e3c..186fdd2 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -20,12 +20,12 @@ trait ValidatesOpenApiSchema { use OpenApiSpecResolver; private static ?OpenApiResponseValidator $cachedValidator = null; - private static int $cachedMaxErrors = -1; + private static ?int $cachedMaxErrors = null; public static function resetValidatorCache(): void { self::$cachedValidator = null; - self::$cachedMaxErrors = -1; + self::$cachedMaxErrors = null; } protected function openApiSpec(): string