Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 15 additions & 17 deletions src/Internal/Compilation/DocumentCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -460,12 +459,11 @@ private function parameters(
* @param non-empty-string $specPointer
* @return array<string, CompiledParameter>
*/
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)) {
Expand All @@ -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;
Expand Down Expand Up @@ -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));

Expand All @@ -618,7 +616,7 @@ private function requestBody(mixed $value, JsonPointerResolver $resolver, string
}

/** @return array<string, mixed> */
private function resolvedSchema(mixed $value, JsonPointerResolver $resolver): array
private function resolvedSchema(mixed $value): array
{
if ($value === null) {
return [];
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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);
Expand Down
6 changes: 2 additions & 4 deletions src/Internal/Reference/JsonPointerResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
98 changes: 96 additions & 2 deletions src/Internal/Schema/SchemaCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, \stdClass> */
private array $sharedSchemas = [];

/**
* Keywords whose value is a single subschema, mapped to whether a boolean
* form is legal in every supported dialect.
Expand Down Expand Up @@ -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<bool|\stdClass> $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;
}

/**
Expand Down
27 changes: 27 additions & 0 deletions tests/ContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions tests/SchemaValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{SchemaDialect, array<string, mixed>, mixed, mixed}> */
public static function dialectProvider(): iterable
{
Expand Down
Loading