Skip to content

Commit 9ebe1ff

Browse files
committed
fix(schema): register $dynamicAnchor inside $ref sibling schemas
The anchor walk returned early for OpenApiSchemaReference, which skipped siblings authored on the reference itself ($defs, properties, allOf, etc. carried by JsonSchemaReference). A $dynamicAnchor declared inside a $ref schema's $defs sibling was never registered, so a $dynamicRef to it resolved to null. The walk now descends into a reference holder's own siblings read from the JsonSchemaReference object, without following the resolved $ref target (still registered independently as its own component).
1 parent 87abeb1 commit 9ebe1ff

2 files changed

Lines changed: 142 additions & 32 deletions

File tree

src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -313,43 +313,74 @@ private void RegisterDynamicAnchorsRecursive(OpenApiDocument document, IOpenApiS
313313
if (anchorName is string anchor && anchor.Length > 0)
314314
RegisterDynamicAnchor(document, anchor, schema);
315315

316-
// Do not follow $ref targets — they are registered as their own components, and
317-
// following them would cross document boundaries and duplicate anchors.
318-
if (schema is OpenApiSchemaReference) return;
319-
320-
if (schema.Definitions is not null)
321-
foreach (var s in schema.Definitions.Values) RegisterDynamicAnchorsRecursive(document, s, visited);
322-
323-
if (schema.AllOf is not null)
324-
foreach (var s in schema.AllOf) RegisterDynamicAnchorsRecursive(document, s, visited);
325-
if (schema.OneOf is not null)
326-
foreach (var s in schema.OneOf) RegisterDynamicAnchorsRecursive(document, s, visited);
327-
if (schema.AnyOf is not null)
328-
foreach (var s in schema.AnyOf) RegisterDynamicAnchorsRecursive(document, s, visited);
329-
330-
RegisterDynamicAnchorsRecursive(document, schema.Not, visited);
331-
RegisterDynamicAnchorsRecursive(document, schema.Items, visited);
332-
RegisterDynamicAnchorsRecursive(document, schema.AdditionalProperties, visited);
333-
334-
if (schema.Properties is not null)
335-
foreach (var s in schema.Properties.Values) RegisterDynamicAnchorsRecursive(document, s, visited);
336-
if (schema.PatternProperties is not null)
337-
foreach (var s in schema.PatternProperties.Values) RegisterDynamicAnchorsRecursive(document, s, visited);
338-
339-
if (schema is IOpenApiSchemaMissingProperties mp)
316+
// Walk child schemas. For reference holders, read siblings from the reference object
317+
// itself (JsonSchemaReference carries authored siblings like $defs via ApplySchemaMetadata),
318+
// NOT from the resolved target — the target is registered independently as its own
319+
// component and following it would cross document boundaries and duplicate anchors.
320+
var children = schema is OpenApiSchemaReference r ? EnumerateChildren(r.Reference) : EnumerateChildren(schema);
321+
foreach (var child in children)
322+
RegisterDynamicAnchorsRecursive(document, child, visited);
323+
}
324+
325+
private static IEnumerable<IOpenApiSchema> EnumerateChildren(IOpenApiSchema s)
326+
{
327+
if (s.Definitions is not null)
328+
foreach (var c in s.Definitions.Values) yield return c;
329+
if (s.AllOf is not null)
330+
foreach (var c in s.AllOf) yield return c;
331+
if (s.OneOf is not null)
332+
foreach (var c in s.OneOf) yield return c;
333+
if (s.AnyOf is not null)
334+
foreach (var c in s.AnyOf) yield return c;
335+
if (s.Not is not null) yield return s.Not;
336+
if (s.Items is not null) yield return s.Items;
337+
if (s.AdditionalProperties is not null) yield return s.AdditionalProperties;
338+
if (s.Properties is not null)
339+
foreach (var c in s.Properties.Values) yield return c;
340+
if (s.PatternProperties is not null)
341+
foreach (var c in s.PatternProperties.Values) yield return c;
342+
if (s is IOpenApiSchemaMissingProperties mp)
340343
{
341-
RegisterDynamicAnchorsRecursive(document, mp.Contains, visited);
342-
RegisterDynamicAnchorsRecursive(document, mp.PropertyNames, visited);
343-
RegisterDynamicAnchorsRecursive(document, mp.ContentSchema, visited);
344-
RegisterDynamicAnchorsRecursive(document, mp.UnevaluatedPropertiesSchema, visited);
345-
RegisterDynamicAnchorsRecursive(document, mp.If, visited);
346-
RegisterDynamicAnchorsRecursive(document, mp.Then, visited);
347-
RegisterDynamicAnchorsRecursive(document, mp.Else, visited);
344+
if (mp.Contains is not null) yield return mp.Contains;
345+
if (mp.PropertyNames is not null) yield return mp.PropertyNames;
346+
if (mp.ContentSchema is not null) yield return mp.ContentSchema;
347+
if (mp.UnevaluatedPropertiesSchema is not null) yield return mp.UnevaluatedPropertiesSchema;
348+
if (mp.If is not null) yield return mp.If;
349+
if (mp.Then is not null) yield return mp.Then;
350+
if (mp.Else is not null) yield return mp.Else;
348351
if (mp.DependentSchemas is not null)
349-
foreach (var s in mp.DependentSchemas.Values) RegisterDynamicAnchorsRecursive(document, s, visited);
352+
foreach (var c in mp.DependentSchemas.Values) yield return c;
350353
}
351354
}
352355

356+
private static IEnumerable<IOpenApiSchema> EnumerateChildren(JsonSchemaReference r)
357+
{
358+
if (r.Definitions is not null)
359+
foreach (var c in r.Definitions.Values) yield return c;
360+
if (r.AllOf is not null)
361+
foreach (var c in r.AllOf) yield return c;
362+
if (r.OneOf is not null)
363+
foreach (var c in r.OneOf) yield return c;
364+
if (r.AnyOf is not null)
365+
foreach (var c in r.AnyOf) yield return c;
366+
if (r.Not is not null) yield return r.Not;
367+
if (r.Items is not null) yield return r.Items;
368+
if (r.AdditionalProperties is not null) yield return r.AdditionalProperties;
369+
if (r.Properties is not null)
370+
foreach (var c in r.Properties.Values) yield return c;
371+
if (r.PatternProperties is not null)
372+
foreach (var c in r.PatternProperties.Values) yield return c;
373+
if (r.Contains is not null) yield return r.Contains;
374+
if (r.PropertyNames is not null) yield return r.PropertyNames;
375+
if (r.ContentSchema is not null) yield return r.ContentSchema;
376+
if (r.UnevaluatedPropertiesSchema is not null) yield return r.UnevaluatedPropertiesSchema;
377+
if (r.If is not null) yield return r.If;
378+
if (r.Then is not null) yield return r.Then;
379+
if (r.Else is not null) yield return r.Else;
380+
if (r.DependentSchemas is not null)
381+
foreach (var c in r.DependentSchemas.Values) yield return c;
382+
}
383+
353384
private void RegisterDynamicAnchor(OpenApiDocument document, string anchorName, IOpenApiSchema schema)
354385
{
355386
if (!_dynamicAnchorRegistryByDocument.TryGetValue(document, out var anchors))

test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,4 +404,83 @@ public async Task DynamicAnchorResolvesPerDocumentInSharedWorkspace()
404404
Assert.NotNull(refB.Target);
405405
Assert.Same(treeB, refB.Target);
406406
}
407+
408+
[Fact]
409+
public async Task DynamicAnchorInRefSiblingDefsIsRegistered()
410+
{
411+
// A $dynamicAnchor declared inside a $ref schema's $defs sibling must be registered, so a
412+
// $dynamicRef elsewhere in the document resolves to it. (Main's model carries authored
413+
// siblings on JsonSchemaReference, so the anchor walk must descend into them without
414+
// following the $ref target.)
415+
var yaml =
416+
"""
417+
openapi: 3.1.0
418+
info:
419+
title: Test
420+
version: 1.0.0
421+
paths: {}
422+
components:
423+
schemas:
424+
Base:
425+
type: object
426+
Referencing:
427+
$ref: '#/components/schemas/Base'
428+
$defs:
429+
node:
430+
$dynamicAnchor: node
431+
type: object
432+
properties:
433+
next:
434+
$dynamicRef: '#node'
435+
""";
436+
437+
var doc = await LoadDocumentAsync(yaml);
438+
439+
var referencing = doc.Components.Schemas["Referencing"];
440+
var nodeDef = referencing.Definitions["node"];
441+
var next = nodeDef.Properties["next"];
442+
443+
var reference = Assert.IsType<OpenApiSchemaReference>(next);
444+
Assert.NotNull(reference.Target);
445+
Assert.Same(nodeDef, reference.Target);
446+
}
447+
448+
[Fact]
449+
public async Task CreateShallowCopyPreservesValidationSiblings()
450+
{
451+
// CreateShallowCopy routes through the JsonSchemaReference copy constructor. Validation
452+
// keyword siblings carried on a $ref schema (type, minProperties, pattern, allOf, etc.)
453+
// must survive the copy, not just the JSON-Schema metadata siblings.
454+
var yaml =
455+
"""
456+
openapi: 3.1.0
457+
info:
458+
title: Test
459+
version: 1.0.0
460+
paths: {}
461+
components:
462+
schemas:
463+
Target:
464+
type: object
465+
Referencing:
466+
$ref: '#/components/schemas/Target'
467+
type: object
468+
minProperties: 2
469+
pattern: '^a'
470+
allOf:
471+
- type: object
472+
""";
473+
474+
var doc = await LoadDocumentAsync(yaml);
475+
476+
var referencing = doc.Components.Schemas["Referencing"];
477+
var copy = referencing.CreateShallowCopy();
478+
479+
Assert.IsType<OpenApiSchemaReference>(copy);
480+
Assert.Equal(JsonSchemaType.Object, copy.Type);
481+
Assert.Equal(2, copy.MinProperties);
482+
Assert.Equal("^a", copy.Pattern);
483+
Assert.NotNull(copy.AllOf);
484+
Assert.Single(copy.AllOf);
485+
}
407486
}

0 commit comments

Comments
 (0)