Skip to content

Commit 06d3b50

Browse files
committed
feat(schema): resolve $dynamicRef against $dynamicAnchor via OpenApiSchemaReference
Implements document-scoped $dynamicRef resolution per JSON Schema 2020-12 §8.2.3.2. Bare $dynamicRef schemas (no $ref) now deserialize as OpenApiSchemaReference whose Target resolves via per-document $dynamicAnchor and $anchor registries in OpenApiWorkspace. Resolution order in Target: 1. $dynamicAnchor index (single candidate → resolved automatically) 2. $anchor fallback when zero $dynamicAnchor candidates exist (per §8.2.3.2) 3. null when ambiguous (multiple candidates need dynamic-scope tracking) Anchor registries are populated by recursively walking the entire document tree: component schemas, reusable component definitions (parameters, responses, request bodies, headers, callbacks, path items, media types), inline schemas (paths, operations, webhooks), and all nested subschema locations ($defs, properties, items, allOf, if/then/else, etc.). Public APIs for consumers tracking dynamic scope: - GetDynamicAnchorCandidates(doc, anchorName): returns all candidate schemas - ResolveDynamicAnchorInContext(contextSchema, anchorName): resolves against a specific schema's $defs for context-dependent resolution Other changes: - Deserializer (V31/V32): detect bare $dynamicRef, create OpenApiSchemaReference with IsDynamicRefOnly, parse siblings via ApplySchemaMetadata - JsonSchemaReference: add IsDynamicRefOnly flag, implement IOpenApiSchemaMissingProperties, override SerializeAsV31/V32 for dynamic-only refs - JsonNodeHelper: GetDynamicReferencePointer, ExtractDynamicAnchorName, IsFragmentOnlyDynamicRef - Siblings preserved via ApplySchemaMetadata and surfaced through existing Reference-first property getters Fixes #2911.
1 parent 2bad4da commit 06d3b50

9 files changed

Lines changed: 2573 additions & 0 deletions

File tree

src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,13 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
335335
/// </summary>
336336
public IOpenApiSchema? Else { get; set; }
337337

338+
/// <summary>
339+
/// Indicates whether this reference was created from a bare $dynamicRef (no $ref).
340+
/// When true, serialization emits $dynamicRef instead of $ref, and Target resolution
341+
/// uses the $dynamicAnchor index rather than the $ref URI lookup.
342+
/// </summary>
343+
internal bool IsDynamicRefOnly { get; set; }
344+
338345
/// <summary>
339346
/// Parameterless constructor
340347
/// </summary>
@@ -407,6 +414,36 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
407414
If = reference.If;
408415
Then = reference.Then;
409416
Else = reference.Else;
417+
IsDynamicRefOnly = reference.IsDynamicRefOnly;
418+
}
419+
420+
/// <inheritdoc/>
421+
public override void SerializeAsV31(IOpenApiWriter writer)
422+
{
423+
if (IsDynamicRefOnly)
424+
{
425+
writer.WriteStartObject();
426+
SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV31(w), base.SerializeAdditionalV31Properties);
427+
writer.WriteEndObject();
428+
}
429+
else
430+
{
431+
base.SerializeAsV31(writer);
432+
}
433+
}
434+
/// <inheritdoc/>
435+
public override void SerializeAsV32(IOpenApiWriter writer)
436+
{
437+
if (IsDynamicRefOnly)
438+
{
439+
writer.WriteStartObject();
440+
SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties);
441+
writer.WriteEndObject();
442+
}
443+
else
444+
{
445+
base.SerializeAsV32(writer);
446+
}
410447
}
411448

412449
/// <inheritdoc/>
@@ -419,6 +456,7 @@ protected override void SerializeAdditionalV32Properties(IOpenApiWriter writer)
419456
{
420457
SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties);
421458
}
459+
422460
private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action<IOpenApiWriter, IOpenApiSerializable> serializeCallback, Action<IOpenApiWriter> baseSerializer)
423461
{
424462
if (Type != ReferenceType.Schema) throw new InvalidOperationException(

src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,43 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema)
3535
{
3636
}
3737

38+
/// <summary>
39+
/// Resolves the target schema. When this reference was created from a bare $dynamicRef,
40+
/// resolution first tries the $dynamicAnchor index, then falls back to $anchor resolution
41+
/// per JSON Schema 2020-12 §8.2.3.2 / §9.2 (dereferencing). The $anchor fallback only fires
42+
/// when there are zero $dynamicAnchor candidates; with multiple candidates the spec requires
43+
/// the outermost dynamic anchor, which cannot be computed without dynamic-scope tracking, so
44+
/// this returns null. Returns null when neither matches.
45+
/// </summary>
46+
public override IOpenApiSchema? Target
47+
{
48+
get
49+
{
50+
if (Reference.IsDynamicRefOnly)
51+
{
52+
var anchorName = Microsoft.OpenApi.Reader.JsonNodeHelper.ExtractDynamicAnchorName(Reference.DynamicRef);
53+
if (!string.IsNullOrEmpty(anchorName)
54+
&& Microsoft.OpenApi.Reader.JsonNodeHelper.IsFragmentOnlyDynamicRef(Reference.DynamicRef)
55+
&& Reference.HostDocument is { } hostDocument
56+
&& hostDocument.Workspace is { } workspace)
57+
{
58+
var candidates = workspace.GetDynamicAnchorCandidates(hostDocument, anchorName!);
59+
if (candidates.Count == 1)
60+
return candidates[0];
61+
// Per §8.2.3.2: when no $dynamicAnchor matches at all, $dynamicRef resolves like $ref
62+
// to the plain-name fragment ($anchor). When multiple $dynamicAnchor candidates exist,
63+
// the spec requires the outermost one, which cannot be computed without dynamic-scope
64+
// tracking, so return null rather than incorrectly falling back to $anchor.
65+
if (candidates.Count == 0
66+
&& workspace.ResolveAnchor(hostDocument, anchorName!) is { } anchorTarget)
67+
return anchorTarget;
68+
}
69+
return null;
70+
}
71+
return base.Target;
72+
}
73+
}
74+
3875
/// <inheritdoc/>
3976
public string? Description
4077
{
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
#nullable enable
2+
override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
3+
override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
4+
override Microsoft.OpenApi.OpenApiSchemaReference.Target.get -> Microsoft.OpenApi.IOpenApiSchema?
5+
Microsoft.OpenApi.OpenApiWorkspace.GetDynamicAnchorCandidates(Microsoft.OpenApi.OpenApiDocument! hostDocument, string! anchorName) -> System.Collections.Generic.IReadOnlyList<Microsoft.OpenApi.IOpenApiSchema!>!
6+
static Microsoft.OpenApi.OpenApiWorkspace.ResolveDynamicAnchorInContext(Microsoft.OpenApi.IOpenApiSchema? contextSchema, string! anchorName) -> Microsoft.OpenApi.IOpenApiSchema?

src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,39 @@ public static Dictionary<string, HashSet<T>> CreateArrayMap<T>(this JsonNode? no
167167
return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null;
168168
}
169169

170+
/// <summary>
171+
/// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference
172+
/// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution.
173+
/// </summary>
174+
public static string? GetDynamicReferencePointer(this JsonObject jsonObject)
175+
{
176+
if (jsonObject.TryGetPropertyValue("$ref", out _))
177+
return null;
178+
return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode) ? dynRefNode?.GetScalarValue() : null;
179+
}
180+
181+
/// <summary>
182+
/// Extracts the bare anchor name from a $dynamicRef value.
183+
/// Handles fragment-only (#meta), absolute-URI (https://example.com#meta), and bare (meta) forms.
184+
/// Returns null for null/empty input. Returns empty string for bare "#" (root reference).
185+
/// </summary>
186+
public static string? ExtractDynamicAnchorName(string? dynamicRef)
187+
{
188+
if (string.IsNullOrEmpty(dynamicRef)) return null;
189+
var hashIndex = dynamicRef!.LastIndexOf('#');
190+
return hashIndex >= 0 ? dynamicRef.Substring(hashIndex + 1) : dynamicRef;
191+
}
192+
193+
/// <summary>
194+
/// Determines whether a $dynamicRef value is a fragment-only reference (e.g. "#node")
195+
/// that targets an anchor within the current document, as opposed to an absolute/relative
196+
/// URI reference (e.g. "https://example.com/schema#node") that targets another resource.
197+
/// Per JSON Schema 2020-12, only fragment-only dynamic refs resolve against the local
198+
/// $dynamicAnchor index; URI-based refs require resolving their target resource first.
199+
/// </summary>
200+
public static bool IsFragmentOnlyDynamicRef(string? dynamicRef)
201+
=> !string.IsNullOrEmpty(dynamicRef) && dynamicRef![0] == '#';
202+
170203
public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject)
171204
{
172205
return jsonObject.TryGetPropertyValue("$id", out var idNode) ? idNode?.GetScalarValue() : null;

src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
444444
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
445445

446446
var pointer = jsonObject.GetReferencePointer();
447+
var dynamicPointer = jsonObject.GetDynamicReferencePointer();
447448
var identifier = jsonObject.GetJsonSchemaIdentifier();
448449

449450
if (pointer != null)
@@ -467,6 +468,27 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
467468
return result;
468469
}
469470

471+
if (dynamicPointer != null)
472+
{
473+
var nodeLocation = context.GetLocation();
474+
var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
475+
var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
476+
var referenceMetadata = new OpenApiSchema();
477+
jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
478+
static (schema, name, value) =>
479+
{
480+
if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
481+
{
482+
schema.UnrecognizedKeywords ??= new Dictionary<string, JsonNode>(StringComparer.Ordinal);
483+
schema.UnrecognizedKeywords[name] = value;
484+
}
485+
});
486+
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
487+
result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation);
488+
result.Reference.IsDynamicRefOnly = true;
489+
return result;
490+
}
491+
470492
var schema = new OpenApiSchema();
471493

472494
jsonObject.ParseMap(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,

src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
444444
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
445445

446446
var pointer = jsonObject.GetReferencePointer();
447+
var dynamicPointer = jsonObject.GetDynamicReferencePointer();
447448
var identifier = jsonObject.GetJsonSchemaIdentifier();
448449

449450
if (pointer != null)
@@ -467,6 +468,27 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
467468
return result;
468469
}
469470

471+
if (dynamicPointer != null)
472+
{
473+
var nodeLocation = context.GetLocation();
474+
var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
475+
var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
476+
var referenceMetadata = new OpenApiSchema();
477+
jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
478+
static (schema, name, value) =>
479+
{
480+
if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
481+
{
482+
schema.UnrecognizedKeywords ??= new Dictionary<string, JsonNode>(StringComparer.Ordinal);
483+
schema.UnrecognizedKeywords[name] = value;
484+
}
485+
});
486+
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
487+
result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation);
488+
result.Reference.IsDynamicRefOnly = true;
489+
return result;
490+
}
491+
470492
var schema = new OpenApiSchema();
471493

472494
jsonObject.ParseMap(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,

0 commit comments

Comments
 (0)