From ddc224b92190dbfd29ae743b59db6ec7ac789ba1 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Thu, 8 Oct 2026 00:25:07 +0300 Subject: [PATCH] Reduce schema compilation memory for shared roots --- src/Internal/Compilation/DocumentCompiler.php | 32 +++--- .../Reference/JsonPointerResolver.php | 6 +- src/Internal/Schema/SchemaCompiler.php | 98 ++++++++++++++++++- tests/ContractTest.php | 27 +++++ tests/SchemaValidatorTest.php | 53 ++++++++++ 5 files changed, 193 insertions(+), 23 deletions(-) diff --git a/src/Internal/Compilation/DocumentCompiler.php b/src/Internal/Compilation/DocumentCompiler.php index 66fc753..9da6b2c 100644 --- a/src/Internal/Compilation/DocumentCompiler.php +++ b/src/Internal/Compilation/DocumentCompiler.php @@ -147,7 +147,7 @@ public function compile(array $document, ?DocumentGraph $graph = null, int $reso )); } $templates[$templateKey] = $pathString; - $parameters = $this->parameters($pathParameters, $rawParameters, $resolver, $pathString, $method); + $parameters = $this->parameters($pathParameters, $rawParameters, $pathString, $method); $this->assertPathParameters($pathString, $parameters); $servers = $this->servers($raw['servers'] ?? null, $pathServers); $operations[$key] = new Operation( @@ -158,8 +158,8 @@ public function compile(array $document, ?DocumentGraph $graph = null, int $reso parameters: $parameters, requestBody: $dialect === SchemaDialect::OpenApi30 && in_array($method, self::BODYLESS_METHODS_IN_30, strict: true) ? [] - : $this->requestBody($raw['requestBody'] ?? null, $resolver, $where), - responses: $this->resolvedResponses($raw['responses'] ?? null, $resolver, $where), + : $this->requestBody($raw['requestBody'] ?? null, $where), + responses: $this->resolvedResponses($raw['responses'] ?? null, $where), security: array_key_exists('security', $raw) ? $this->securityRequirements($raw['security'], $schemeNames) : $rootSecurity, @@ -207,7 +207,7 @@ public function compile(array $document, ?DocumentGraph $graph = null, int $reso $identities[$key] = true; $where = sprintf('webhook operation %s "%s"', strtoupper($method), $name); $rawParameters = $this->parameterList($raw['parameters'] ?? null, $where); - $parameters = $this->parameters($webhookParameters, $rawParameters, $resolver, $name, $method, container: 'webhooks'); + $parameters = $this->parameters($webhookParameters, $rawParameters, $name, $method, container: 'webhooks'); foreach ($parameters as $parameter) { if ($parameter['in'] === 'path') { // Nothing in a delivery names a webhook but the @@ -222,8 +222,8 @@ public function compile(array $document, ?DocumentGraph $graph = null, int $reso method: strtoupper($method), path: '', parameters: $parameters, - requestBody: $this->requestBody($raw['requestBody'] ?? null, $resolver, $where), - responses: $this->resolvedResponses($raw['responses'] ?? null, $resolver, $where), + requestBody: $this->requestBody($raw['requestBody'] ?? null, $where), + responses: $this->resolvedResponses($raw['responses'] ?? null, $where), security: array_key_exists('security', $raw) ? $this->securityRequirements($raw['security'], $schemeNames) : $rootSecurity, @@ -409,7 +409,6 @@ private function escapePointer(string $value): string private function parameters( array $path, array $operation, - JsonPointerResolver $resolver, string $pathString, string $method, string $container = 'paths', @@ -438,7 +437,7 @@ private function parameters( // dropped whichever declaration was stricter. $declared = []; foreach (array_keys($declarations) as $index) { - foreach ($this->parameter($declarations[$index], $resolver, sprintf('%s/%d', $pointer, $index)) as $key => $parameter) { + foreach ($this->parameter($declarations[$index], sprintf('%s/%d', $pointer, $index)) as $key => $parameter) { if (in_array($key, $declared, strict: true)) { throw new InvalidContract(sprintf( 'Duplicate OpenAPI parameter "%s" in %s of %s', @@ -460,12 +459,11 @@ private function parameters( * @param non-empty-string $specPointer * @return array */ - private function parameter(mixed $raw, JsonPointerResolver $resolver, string $specPointer): array + private function parameter(mixed $raw, string $specPointer): array { if (!is_array($raw)) { throw new InvalidContract('OpenAPI parameter must be an object'); } - $raw = $resolver->resolve($raw); $name = $raw['name'] ?? null; $in = $raw['in'] ?? null; if (!is_string($name) || $name === '' || !is_string($in) || !in_array($in, ['path', 'query', 'header', 'cookie'], strict: true)) { @@ -481,7 +479,7 @@ private function parameter(mixed $raw, JsonPointerResolver $resolver, string $sp } /** @var mixed $schemaValue */ $schemaValue = $raw['schema'] ?? null; - $schema = $this->resolvedSchema($schemaValue, $resolver); + $schema = $this->resolvedSchema($schemaValue); $key = $in . ':' . ($in === 'header' ? strtolower($name) : $name); /** @var mixed $styleValue */ $styleValue = $raw['style'] ?? null; @@ -603,12 +601,12 @@ private function assertSupportedStyle(string $name, string $in, string $style, a * * @return CompiledRequestBody */ - private function requestBody(mixed $value, JsonPointerResolver $resolver, string $where): array + private function requestBody(mixed $value, string $where): array { if ($value === null) { return []; } - $body = $resolver->resolve($this->object($value, sprintf('requestBody of %s', $where))); + $body = $this->object($value, sprintf('requestBody of %s', $where)); $this->assertBoolean($body['required'] ?? null, sprintf('requestBody of %s', $where), 'required'); $this->assertContent($body['content'] ?? null, sprintf('requestBody of %s', $where)); @@ -618,7 +616,7 @@ private function requestBody(mixed $value, JsonPointerResolver $resolver, string } /** @return array */ - private function resolvedSchema(mixed $value, JsonPointerResolver $resolver): array + private function resolvedSchema(mixed $value): array { if ($value === null) { return []; @@ -631,7 +629,7 @@ private function resolvedSchema(mixed $value, JsonPointerResolver $resolver): ar if ($value !== [] && array_is_list($value)) { throw new InvalidContract('OpenAPI parameter schema must be an object'); } - $schema = $resolver->resolve($value, inSchema: true); + $schema = $value; foreach (array_keys($schema) as $key) { if (!is_string($key)) { throw new InvalidContract('OpenAPI schema keys must be strings'); @@ -645,7 +643,7 @@ private function resolvedSchema(mixed $value, JsonPointerResolver $resolver): ar } /** @return CompiledResponses */ - private function resolvedResponses(mixed $value, JsonPointerResolver $resolver, string $where): array + private function resolvedResponses(mixed $value, string $where): array { if (!is_array($value) || $value === []) { throw new InvalidContract('Operation responses must be an object'); @@ -656,7 +654,7 @@ private function resolvedResponses(mixed $value, JsonPointerResolver $resolver, if (!is_array($response)) { throw new InvalidContract(sprintf('Response "%s" must be an object', (string) $key)); } - $resolved = $resolver->resolve($response); + $resolved = $response; $position = sprintf('response "%s" of %s', (string) $key, $where); $this->assertContent($resolved['content'] ?? null, $position); $this->assertHeaders($resolved['headers'] ?? null, $position); diff --git a/src/Internal/Reference/JsonPointerResolver.php b/src/Internal/Reference/JsonPointerResolver.php index 90e2ca8..98ca37c 100644 --- a/src/Internal/Reference/JsonPointerResolver.php +++ b/src/Internal/Reference/JsonPointerResolver.php @@ -329,10 +329,8 @@ private function resolveIn(array $node, string $file, int $referenceDepth, bool return $this->merge($siblings, $resolved, $inSchema); } if (array_key_exists($target, $this->shared)) { - // A Reference Object this document has resolved already — the - // compiler resolves a Path Item and then the Request Body inside - // it again. Nothing is deferred outside a schema, so the reuse is - // the resolved object itself. + // A Reference Object this document has resolved already. Nothing + // is deferred outside a schema, so reuse the resolved object. return $this->merge($siblings, $this->shared[$target], $inSchema); } if (++$referenceDepth > $this->maximumReferenceDepth) { diff --git a/src/Internal/Schema/SchemaCompiler.php b/src/Internal/Schema/SchemaCompiler.php index 4c50790..823adaa 100644 --- a/src/Internal/Schema/SchemaCompiler.php +++ b/src/Internal/Schema/SchemaCompiler.php @@ -14,11 +14,14 @@ * @internal */ -final readonly class SchemaCompiler +final class SchemaCompiler { private const string JSON_SCHEMA_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; private const string OPENAPI_31_BASE_DIALECT = 'https://spec.openapis.org/oas/3.1/dialect/base'; + /** @var array */ + private array $sharedSchemas = []; + /** * Keywords whose value is a single subschema, mapped to whether a boolean * form is legal in every supported dialect. @@ -95,7 +98,98 @@ public function compile(array $schema, SchemaDialect $dialect): \stdClass throw new \LogicException('Compiled schema must be a JSON object'); } - return $object; + [$shared] = $this->shareSchemaGraph($object); + + return $shared; + } + + /** + * Opis caches parsed nodes by object identity. Reusing identical, + * reference-free schema nodes lets separately compiled operation roots + * share parsed subschemas. Nodes containing `$ref` are not shared across + * roots: a local reference is interpreted against the root's definitions. + */ + /** @return array{0: \stdClass, 1: bool} */ + private function shareSchemaGraph(\stdClass $schema): array + { + $referenceFree = !property_exists($schema, '$ref'); + foreach (['additionalProperties', 'items', 'not'] as $keyword) { + /** @var bool|\stdClass|null $member */ + $member = $schema->{$keyword} ?? null; + if ($member instanceof \stdClass) { + [$schema->{$keyword}, $childIsReferenceFree] = $this->shareSchemaNode($member); + $referenceFree = $referenceFree && $childIsReferenceFree; + } + } + foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) { + /** @var mixed $members */ + $members = $schema->{$keyword} ?? null; + if (!is_array($members)) { + continue; + } + /** @var list $schemaMembers */ + $schemaMembers = array_values($members); + foreach ($schemaMembers as $index => $member) { + if (!$member instanceof \stdClass) { + continue; + } + [$members[$index], $childIsReferenceFree] = $this->shareSchemaNode($member); + $referenceFree = $referenceFree && $childIsReferenceFree; + } + $schema->{$keyword} = $members; + } + foreach (['properties', '$defs'] as $keyword) { + /** @var mixed $members */ + $members = $schema->{$keyword} ?? null; + if (!$members instanceof \stdClass) { + continue; + } + [$members, $childrenAreReferenceFree] = $this->shareSchemaMap($members); + $schema->{$keyword} = $members; + $referenceFree = $referenceFree && $childrenAreReferenceFree; + } + + return [$this->internSchema($schema, $referenceFree), $referenceFree]; + } + + /** @return array{0: \stdClass, 1: bool} */ + private function shareSchemaNode(\stdClass $schema): array + { + return $this->shareSchemaGraph($schema); + } + + /** @return array{0: \stdClass, 1: bool} */ + private function shareSchemaMap(\stdClass $schemas): array + { + $referenceFree = true; + foreach (get_object_vars($schemas) as $name => $schema) { + if (!$schema instanceof \stdClass) { + continue; + } + [$schemas->{$name}, $childIsReferenceFree] = $this->shareSchemaNode($schema); + $referenceFree = $referenceFree && $childIsReferenceFree; + } + + return [$schemas, $referenceFree]; + } + + private function internSchema(\stdClass $schema, bool $referenceFree): \stdClass + { + if (!$referenceFree) { + return $schema; + } + + try { + $encoded = json_encode($schema, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $schema; + } + $key = hash('xxh128', $encoded); + if (isset($this->sharedSchemas[$key])) { + return $this->sharedSchemas[$key]; + } + + return $this->sharedSchemas[$key] = $schema; } /** diff --git a/tests/ContractTest.php b/tests/ContractTest.php index fad69bc..670e9bd 100644 --- a/tests/ContractTest.php +++ b/tests/ContractTest.php @@ -781,6 +781,33 @@ public function refusesADocumentOverTheConfiguredResolutionBudget(): void Assert::same(Contract::fromArray($document, new Limits(resolvedNodes: 100))->operations()[0]->path, '/h'); } + /** + * Resolving the Path Item already walks its parameters, request body and + * responses. Re-resolving each of those below used to charge the same + * schema tree several times against the reference-resolution budget. + */ + public function resolvedPathItemChildrenAreNotChargedAgain(): void + { + $properties = []; + for ($index = 0; $index < 80; $index++) { + $properties['field' . $index] = ['type' => 'string']; + } + $reference = ['$ref' => '#/components/schemas/Large']; + $document = [ + 'openapi' => '3.1.0', + 'paths' => ['/h' => ['get' => [ + 'parameters' => [['name' => 'q', 'in' => 'query', 'schema' => $reference]], + 'requestBody' => ['content' => ['application/json' => ['schema' => $reference]]], + 'responses' => ['200' => ['content' => ['application/json' => ['schema' => $reference]]]], + ]]], + 'components' => ['schemas' => ['Large' => ['type' => 'object', 'properties' => $properties]]], + ]; + + $contract = Contract::fromArray($document, new Limits(resolvedNodes: 128)); + + Assert::same(count($contract->operations()), 1); + } + public function countsEveryNodeOfADocumentOnce(): void { Assert::same(DocumentNodes::within(['a' => 1, 'b' => ['c' => 2]], 100), 3); diff --git a/tests/SchemaValidatorTest.php b/tests/SchemaValidatorTest.php index d812941..412185b 100644 --- a/tests/SchemaValidatorTest.php +++ b/tests/SchemaValidatorTest.php @@ -41,6 +41,59 @@ public function validatesEffectiveSchema( Assert::false($validator->isValid($invalid, $schema, $dialect)); } + public function compiledRootsShareRepeatedReferenceFreeSubschemas(): void + { + $compiler = new SchemaCompiler(); + $first = $compiler->compile([ + 'title' => 'first root', + 'properties' => ['status' => ['type' => 'string', 'enum' => ['ready', 'done']]], + ], SchemaDialect::OpenApi31); + $second = $compiler->compile([ + 'title' => 'second root', + 'properties' => ['status' => ['type' => 'string', 'enum' => ['ready', 'done']]], + ], SchemaDialect::OpenApi31); + + Assert::true($first->properties->status === $second->properties->status); + } + + public function compiledRootsKeepLocalReferencesBoundToTheirOwnDefinitions(): void + { + $compiler = new SchemaCompiler(); + $first = $compiler->compile([ + 'properties' => ['id' => ['$ref' => '#/$defs/Value']], + '$defs' => ['Value' => ['type' => 'string']], + ], SchemaDialect::OpenApi31); + $second = $compiler->compile([ + 'properties' => ['id' => ['$ref' => '#/$defs/Value']], + '$defs' => ['Value' => ['type' => 'integer']], + ], SchemaDialect::OpenApi31); + + Assert::false($first->{'$defs'} === $second->{'$defs'}); + } + + public function mixedReferenceGraphsShareOnlyTheSafeSubschemas(): void + { + $compiler = new SchemaCompiler(); + $schema = [ + 'oneOf' => [ + ['type' => 'string'], + ['$ref' => '#/$defs/Value'], + ], + '$defs' => [ + 'Value' => ['type' => 'integer'], + 'Alias' => ['$ref' => '#/$defs/Value'], + ], + ]; + + $first = $compiler->compile($schema, SchemaDialect::OpenApi31); + $second = $compiler->compile($schema, SchemaDialect::OpenApi31); + + Assert::false($first === $second); + Assert::true($first->oneOf[0] === $second->oneOf[0]); + Assert::true($first->{'$defs'}->Value === $second->{'$defs'}->Value); + } + + /** @return iterable, mixed, mixed}> */ public static function dialectProvider(): iterable {