Skip to content

Commit 5ea36ad

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 beb68f5 commit 5ea36ad

9 files changed

Lines changed: 3058 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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,51 @@ 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. For fragment-only refs (#node), resolves against the
42+
/// local document. For URI refs (https://example.com#node), finds the target document in
43+
/// the workspace and resolves there. The $anchor fallback only fires when there are zero
44+
/// $dynamicAnchor candidates; with multiple candidates the spec requires the outermost
45+
/// dynamic anchor, which cannot be computed without dynamic-scope tracking, so returns null.
46+
/// </summary>
47+
public override IOpenApiSchema? Target
48+
{
49+
get
50+
{
51+
if (Reference.IsDynamicRefOnly)
52+
{
53+
var anchorName = Microsoft.OpenApi.Reader.JsonNodeHelper.ExtractDynamicAnchorName(Reference.DynamicRef);
54+
if (!string.IsNullOrEmpty(anchorName)
55+
&& Reference.HostDocument is { } hostDocument
56+
&& hostDocument.Workspace is { } workspace)
57+
{
58+
// Determine which document to resolve against
59+
OpenApiDocument? targetDoc = hostDocument;
60+
if (!Microsoft.OpenApi.Reader.JsonNodeHelper.IsFragmentOnlyDynamicRef(Reference.DynamicRef))
61+
{
62+
// URI-based ref: find the external document
63+
var docUri = Microsoft.OpenApi.Reader.JsonNodeHelper.ExtractDocumentUri(Reference.DynamicRef);
64+
targetDoc = docUri is not null ? workspace.FindDocumentByBaseUri(docUri) : null;
65+
}
66+
67+
if (targetDoc is not null)
68+
{
69+
var candidates = workspace.GetDynamicAnchorCandidates(targetDoc, anchorName!);
70+
if (candidates.Count == 1)
71+
return candidates[0];
72+
if (candidates.Count == 0
73+
&& workspace.ResolveAnchor(targetDoc, anchorName!) is { } anchorTarget)
74+
return anchorTarget;
75+
}
76+
}
77+
return null;
78+
}
79+
return base.Target;
80+
}
81+
}
82+
3883
/// <inheritdoc/>
3984
public string? Description
4085
{
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,51 @@ 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+
203+
/// <summary>
204+
/// Extracts the document URI from a $dynamicRef value. Returns null for fragment-only
205+
/// refs (e.g. "#node"). For URI refs (e.g. "https://example.com/external#node"), returns
206+
/// the part before the fragment (e.g. "https://example.com/external").
207+
/// </summary>
208+
public static string? ExtractDocumentUri(string? dynamicRef)
209+
{
210+
if (string.IsNullOrEmpty(dynamicRef)) return null;
211+
var hashIndex = dynamicRef!.IndexOf('#');
212+
return hashIndex > 0 ? dynamicRef.Substring(0, hashIndex) : null;
213+
}
214+
170215
public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject)
171216
{
172217
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)