diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
index ccb62435b..99b3631b4 100644
--- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
+++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
@@ -335,6 +335,13 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
///
public IOpenApiSchema? Else { get; set; }
+ ///
+ /// Indicates whether this reference was created from a bare $dynamicRef (no $ref).
+ /// When true, serialization emits $dynamicRef instead of $ref, and Target resolution
+ /// uses the $dynamicAnchor index rather than the $ref URI lookup.
+ ///
+ internal bool IsDynamicRefOnly { get; set; }
+
///
/// Parameterless constructor
///
@@ -407,6 +414,36 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
If = reference.If;
Then = reference.Then;
Else = reference.Else;
+ IsDynamicRefOnly = reference.IsDynamicRefOnly;
+ }
+
+ ///
+ public override void SerializeAsV31(IOpenApiWriter writer)
+ {
+ if (IsDynamicRefOnly)
+ {
+ writer.WriteStartObject();
+ SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV31(w), base.SerializeAdditionalV31Properties);
+ writer.WriteEndObject();
+ }
+ else
+ {
+ base.SerializeAsV31(writer);
+ }
+ }
+ ///
+ public override void SerializeAsV32(IOpenApiWriter writer)
+ {
+ if (IsDynamicRefOnly)
+ {
+ writer.WriteStartObject();
+ SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties);
+ writer.WriteEndObject();
+ }
+ else
+ {
+ base.SerializeAsV32(writer);
+ }
}
///
@@ -419,6 +456,7 @@ protected override void SerializeAdditionalV32Properties(IOpenApiWriter writer)
{
SerializeAdditionalV3XProperties(writer, (w, e) => e.SerializeAsV32(w), base.SerializeAdditionalV32Properties);
}
+
private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action serializeCallback, Action baseSerializer)
{
if (Type != ReferenceType.Schema) throw new InvalidOperationException(
diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs
index 51187591c..45993caf9 100644
--- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs
+++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs
@@ -35,6 +35,39 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema)
{
}
+ ///
+ /// Resolves the target schema. When this reference was created from a bare $dynamicRef,
+ /// resolution uses the $dynamicAnchor index in the workspace instead of the $ref URI lookup.
+ /// Returns null when the dynamic anchor is not found or ambiguous (no $ref fallback for
+ /// dynamic-only references, since the anchor name is not a component path).
+ ///
+ public override IOpenApiSchema? Target
+ {
+ get
+ {
+ if (Reference.IsDynamicRefOnly)
+ {
+ // Dynamic-only references have no $ref to fall back on, so never delegate to
+ // base.Target — that would resolve the anchor name (stored in Reference.Id) as a
+ // component id and return an unrelated schema. ExtractDynamicAnchorName returns
+ // null for an empty $dynamicRef, so the guard below already returns null without
+ // reaching base.Target.
+ var anchorName = Microsoft.OpenApi.Reader.JsonNodeHelper.ExtractDynamicAnchorName(Reference.DynamicRef);
+ // Only fragment-only dynamic refs (#node) resolve against the local document's
+ // $dynamicAnchor index. URI-based refs (https://example.com#node) target another
+ // resource and must not be reduced to a bare anchor name — doing so would let a
+ // local same-named anchor shadow the intended external target.
+ if (!string.IsNullOrEmpty(anchorName)
+ && Microsoft.OpenApi.Reader.JsonNodeHelper.IsFragmentOnlyDynamicRef(Reference.DynamicRef)
+ && Reference.HostDocument is { } hostDocument
+ && hostDocument.Workspace?.ResolveDynamicAnchor(hostDocument, anchorName!) is { } dynamicTarget)
+ return dynamicTarget;
+ return null;
+ }
+ return base.Target;
+ }
+ }
+
///
public string? Description
{
diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
index 7dc5c5811..d5d381a69 100644
--- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
@@ -1 +1,4 @@
#nullable enable
+override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
+override Microsoft.OpenApi.JsonSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
+override Microsoft.OpenApi.OpenApiSchemaReference.Target.get -> Microsoft.OpenApi.IOpenApiSchema?
diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
index 3ccfb2116..35f11642f 100644
--- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
+++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
@@ -167,6 +167,39 @@ public static Dictionary> CreateArrayMap(this JsonNode? no
return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null;
}
+ ///
+ /// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference
+ /// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution.
+ ///
+ public static string? GetDynamicReferencePointer(this JsonObject jsonObject)
+ {
+ if (jsonObject.TryGetPropertyValue("$ref", out _))
+ return null;
+ return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode) ? dynRefNode?.GetScalarValue() : null;
+ }
+
+ ///
+ /// Extracts the bare anchor name from a $dynamicRef value.
+ /// Handles fragment-only (#meta), absolute-URI (https://example.com#meta), and bare (meta) forms.
+ /// Returns null for null/empty input. Returns empty string for bare "#" (root reference).
+ ///
+ public static string? ExtractDynamicAnchorName(string? dynamicRef)
+ {
+ if (string.IsNullOrEmpty(dynamicRef) || dynamicRef is null) return null;
+ var hashIndex = dynamicRef.LastIndexOf('#');
+ return hashIndex >= 0 ? dynamicRef.Substring(hashIndex + 1) : dynamicRef;
+ }
+
+ ///
+ /// Determines whether a $dynamicRef value is a fragment-only reference (e.g. "#node")
+ /// that targets an anchor within the current document, as opposed to an absolute/relative
+ /// URI reference (e.g. "https://example.com/schema#node") that targets another resource.
+ /// Per JSON Schema 2020-12, only fragment-only dynamic refs resolve against the local
+ /// $dynamicAnchor index; URI-based refs require resolving their target resource first.
+ ///
+ public static bool IsFragmentOnlyDynamicRef(string? dynamicRef)
+ => !string.IsNullOrEmpty(dynamicRef) && dynamicRef![0] == '#';
+
public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject)
{
return jsonObject.TryGetPropertyValue("$id", out var idNode) ? idNode?.GetScalarValue() : null;
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index e060537b2..5400024d7 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -444,6 +444,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
var pointer = jsonObject.GetReferencePointer();
+ var dynamicPointer = jsonObject.GetDynamicReferencePointer();
var identifier = jsonObject.GetJsonSchemaIdentifier();
if (pointer != null)
@@ -467,6 +468,25 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
return result;
}
+ if (dynamicPointer != null)
+ {
+ var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
+ var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
+ var referenceMetadata = new OpenApiSchema();
+ jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
+ static (schema, name, value) =>
+ {
+ if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
+ {
+ schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
+ schema.UnrecognizedKeywords[name] = value;
+ }
+ });
+ result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
+ result.Reference.IsDynamicRefOnly = true;
+ return result;
+ }
+
var schema = new OpenApiSchema();
jsonObject.ParseMap(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index 71a422516..76f13f112 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -444,6 +444,7 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
var pointer = jsonObject.GetReferencePointer();
+ var dynamicPointer = jsonObject.GetDynamicReferencePointer();
var identifier = jsonObject.GetJsonSchemaIdentifier();
if (pointer != null)
@@ -467,6 +468,25 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
return result;
}
+ if (dynamicPointer != null)
+ {
+ var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
+ var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
+ var referenceMetadata = new OpenApiSchema();
+ jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
+ static (schema, name, value) =>
+ {
+ if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
+ {
+ schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
+ schema.UnrecognizedKeywords[name] = value;
+ }
+ });
+ result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
+ result.Reference.IsDynamicRefOnly = true;
+ return result;
+ }
+
var schema = new OpenApiSchema();
jsonObject.ParseMap(schema, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
index 4849f1a42..e045f9774 100644
--- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
+++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
@@ -16,6 +16,7 @@ public class OpenApiWorkspace
private readonly Dictionary _documentsIdRegistry = new();
private readonly Dictionary _artifactsRegistry = new();
private readonly Dictionary _IOpenApiReferenceableRegistry = new(new UriWithFragmentEqualityComparer());
+ private readonly Dictionary>> _dynamicAnchorRegistryByDocument = new();
private sealed class UriWithFragmentEqualityComparer : IEqualityComparer
{
@@ -101,6 +102,8 @@ public void RegisterComponents(OpenApiDocument document)
{
RegisterComponent(schemaId, item.Value);
}
+
+ RegisterDynamicAnchors(document, item.Value);
}
}
@@ -288,6 +291,127 @@ internal bool RegisterComponent(string location, T component)
return false;
}
+ ///
+ /// Registers all $dynamicAnchor declarations found anywhere within a schema, including
+ /// nested locations ($defs, properties, items, allOf/anyOf/oneOf, if/then/else, etc.).
+ /// Anchors are scoped to so that two documents in the same
+ /// workspace can each declare the same anchor name without interfering.
+ /// $ref targets are not followed; referenced components are registered independently.
+ ///
+ private void RegisterDynamicAnchors(OpenApiDocument document, IOpenApiSchema schema)
+ => RegisterDynamicAnchorsRecursive(document, schema, new HashSet());
+
+ private void RegisterDynamicAnchorsRecursive(OpenApiDocument document, IOpenApiSchema? schema, HashSet visited)
+ {
+ if (schema is null || !visited.Add(schema)) return;
+
+ // For reference holders, only consider the authored $dynamicAnchor sibling on the reference
+ // itself — never the resolved target's anchor (reading IOpenApiSchema.DynamicAnchor on a
+ // reference falls through to Target, which would duplicate the anchor under a different
+ // object and make it look ambiguous).
+ var anchorName = schema is OpenApiSchemaReference osr ? osr.Reference.DynamicAnchor : schema.DynamicAnchor;
+ if (anchorName is string anchor && anchor.Length > 0)
+ RegisterDynamicAnchor(document, anchor, schema);
+
+ // Walk child schemas. For reference holders, read siblings from the reference object
+ // itself (JsonSchemaReference carries authored siblings like $defs via ApplySchemaMetadata),
+ // NOT from the resolved target — the target is registered independently as its own
+ // component and following it would cross document boundaries and duplicate anchors.
+ var children = schema is OpenApiSchemaReference r ? EnumerateChildren(r.Reference) : EnumerateChildren(schema);
+ foreach (var child in children)
+ RegisterDynamicAnchorsRecursive(document, child, visited);
+ }
+
+ private static IEnumerable EnumerateChildren(IOpenApiSchema s)
+ {
+ if (s.Definitions is not null)
+ foreach (var c in s.Definitions.Values) yield return c;
+ if (s.AllOf is not null)
+ foreach (var c in s.AllOf) yield return c;
+ if (s.OneOf is not null)
+ foreach (var c in s.OneOf) yield return c;
+ if (s.AnyOf is not null)
+ foreach (var c in s.AnyOf) yield return c;
+ if (s.Not is not null) yield return s.Not;
+ if (s.Items is not null) yield return s.Items;
+ if (s.AdditionalProperties is not null) yield return s.AdditionalProperties;
+ if (s.Properties is not null)
+ foreach (var c in s.Properties.Values) yield return c;
+ if (s.PatternProperties is not null)
+ foreach (var c in s.PatternProperties.Values) yield return c;
+ if (s is IOpenApiSchemaMissingProperties mp)
+ {
+ if (mp.Contains is not null) yield return mp.Contains;
+ if (mp.PropertyNames is not null) yield return mp.PropertyNames;
+ if (mp.ContentSchema is not null) yield return mp.ContentSchema;
+ if (mp.UnevaluatedPropertiesSchema is not null) yield return mp.UnevaluatedPropertiesSchema;
+ if (mp.If is not null) yield return mp.If;
+ if (mp.Then is not null) yield return mp.Then;
+ if (mp.Else is not null) yield return mp.Else;
+ if (mp.DependentSchemas is not null)
+ foreach (var c in mp.DependentSchemas.Values) yield return c;
+ }
+ }
+
+ private static IEnumerable EnumerateChildren(JsonSchemaReference r)
+ {
+ if (r.Definitions is not null)
+ foreach (var c in r.Definitions.Values) yield return c;
+ if (r.AllOf is not null)
+ foreach (var c in r.AllOf) yield return c;
+ if (r.OneOf is not null)
+ foreach (var c in r.OneOf) yield return c;
+ if (r.AnyOf is not null)
+ foreach (var c in r.AnyOf) yield return c;
+ if (r.Not is not null) yield return r.Not;
+ if (r.Items is not null) yield return r.Items;
+ if (r.AdditionalProperties is not null) yield return r.AdditionalProperties;
+ if (r.Properties is not null)
+ foreach (var c in r.Properties.Values) yield return c;
+ if (r.PatternProperties is not null)
+ foreach (var c in r.PatternProperties.Values) yield return c;
+ if (r.Contains is not null) yield return r.Contains;
+ if (r.PropertyNames is not null) yield return r.PropertyNames;
+ if (r.ContentSchema is not null) yield return r.ContentSchema;
+ if (r.UnevaluatedPropertiesSchema is not null) yield return r.UnevaluatedPropertiesSchema;
+ if (r.If is not null) yield return r.If;
+ if (r.Then is not null) yield return r.Then;
+ if (r.Else is not null) yield return r.Else;
+ if (r.DependentSchemas is not null)
+ foreach (var c in r.DependentSchemas.Values) yield return c;
+ }
+
+ private void RegisterDynamicAnchor(OpenApiDocument document, string anchorName, IOpenApiSchema schema)
+ {
+ if (!_dynamicAnchorRegistryByDocument.TryGetValue(document, out var anchors))
+ {
+ anchors = new(StringComparer.Ordinal);
+ _dynamicAnchorRegistryByDocument[document] = anchors;
+ }
+ if (!anchors.TryGetValue(anchorName, out var list))
+ {
+ list = [];
+ anchors[anchorName] = list;
+ }
+ if (!list.Contains(schema))
+ list.Add(schema);
+ }
+
+ ///
+ /// Resolves a $dynamicAnchor by name within the scope of .
+ /// Per JSON Schema 2020-12, dynamic scope is bound to a document's evaluation path, not to
+ /// the workspace globally, so two documents may each declare the same anchor name.
+ /// Returns the schema when exactly one candidate exists in that document; returns null when
+ /// there are zero or multiple candidates.
+ ///
+ internal IOpenApiSchema? ResolveDynamicAnchor(OpenApiDocument hostDocument, string anchorName)
+ {
+ if (_dynamicAnchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) &&
+ anchors.TryGetValue(anchorName, out var candidates))
+ return candidates.Count == 1 ? candidates[0] : null;
+ return null;
+ }
+
///
/// Adds a document id to the dictionaries of document locations and their ids.
///
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs
new file mode 100644
index 000000000..65efafa16
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs
@@ -0,0 +1,709 @@
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Microsoft.OpenApi.Reader.V31;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.V31Tests;
+
+public class OpenApiDynamicRefTests
+{
+ private static async Task LoadDocumentAsync(string yaml)
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml));
+ var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
+ return result.Document;
+ }
+
+ [Fact]
+ public void BareDynamicRefDeserializesAsSchemaReference()
+ {
+ var json =
+ """
+ {
+ "$dynamicRef": "#category"
+ }
+ """;
+
+ var hostDocument = new OpenApiDocument();
+ var jsonNode = JsonNode.Parse(json);
+
+ var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new()));
+
+ var reference = Assert.IsType(result);
+ Assert.Equal("#category", reference.Reference.DynamicRef);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ }
+
+ [Fact]
+ public void BareDynamicRefDoesNotEmitRefOnSerialization()
+ {
+ var json =
+ """
+ {
+ "$dynamicRef": "#category"
+ }
+ """;
+
+ var hostDocument = new OpenApiDocument();
+ var jsonNode = JsonNode.Parse(json);
+
+ var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new()));
+
+ var sw = new StringWriter();
+ var writer = new OpenApiJsonWriter(sw);
+ result.SerializeAsV31(writer);
+
+ var output = sw.ToString();
+ Assert.Contains("$dynamicRef", output);
+ Assert.DoesNotContain("$ref", output);
+ }
+
+ [Fact]
+ public async Task DynamicRefResolvesToDynamicAnchorTarget()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ value:
+ type: string
+ children:
+ type: array
+ items:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var tree = doc.Components.Schemas["Tree"];
+ var childrenItems = tree.Properties["children"].Items;
+
+ var reference = Assert.IsType(childrenItems);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ Assert.NotNull(reference.Target);
+ Assert.Same(tree, reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefResolvesViaDefsAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Root:
+ $defs:
+ node:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ value:
+ type: string
+ next:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var root = doc.Components.Schemas["Root"];
+ var nodeDef = root.Definitions["node"];
+ var nextSchema = nodeDef.Properties["next"];
+
+ var reference = Assert.IsType(nextSchema);
+ Assert.NotNull(reference.Target);
+ Assert.Same(nodeDef, reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefResolvesViaNestedPropertyAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ type: object
+ properties:
+ self:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ value:
+ type: string
+ next:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var tree = doc.Components.Schemas["Tree"];
+ var self = tree.Properties["self"];
+
+ var nextSchema = self.Properties["next"];
+ var reference = Assert.IsType(nextSchema);
+ Assert.NotNull(reference.Target);
+ Assert.Same(self, reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefResolvesViaAllOfAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Root:
+ allOf:
+ - $dynamicAnchor: node
+ type: object
+ properties:
+ next:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var root = doc.Components.Schemas["Root"];
+ var branch = root.AllOf[0];
+ var nextSchema = branch.Properties["next"];
+
+ var reference = Assert.IsType(nextSchema);
+ Assert.NotNull(reference.Target);
+ Assert.Same(branch, reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefReturnsNullWhenAnchorIsAmbiguous()
+ {
+ // When a single document declares the same $dynamicAnchor name on more than one subschema,
+ // resolution cannot pick one without dynamic-scope evaluation, so Target returns null.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Root:
+ type: object
+ properties:
+ a:
+ $dynamicAnchor: node
+ type: object
+ b:
+ $dynamicAnchor: node
+ type: object
+ ref:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var root = doc.Components.Schemas["Root"];
+ var reference = Assert.IsType(root.Properties["ref"]);
+ Assert.Null(reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicAnchorRegisteredAcrossAllSubschemaLocations()
+ {
+ // Exercises every subschema location the anchor walk descends into (oneOf, anyOf, not,
+ // items, additionalProperties, patternProperties, contains, propertyNames, contentSchema,
+ // if/then/else, dependentSchemas, unevaluatedPropertiesSchema). Each declares a distinct
+ // $dynamicAnchor name; a $dynamicRef to each confirms the walk reached it.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Root:
+ type: object
+ oneOf:
+ - $dynamicAnchor: one
+ type: object
+ anyOf:
+ - $dynamicAnchor: any
+ type: object
+ allOf:
+ - type: object
+ properties:
+ nested:
+ type: array
+ items:
+ $dynamicAnchor: itm
+ type: string
+ dependentSchemas:
+ dep:
+ $dynamicAnchor: depn
+ type: object
+ not:
+ $dynamicAnchor: notn
+ type: object
+ contains:
+ $dynamicAnchor: cont
+ type: object
+ propertyNames:
+ $dynamicAnchor: pn
+ type: string
+ contentSchema:
+ $dynamicAnchor: cs
+ type: string
+ unevaluatedProperties:
+ $dynamicAnchor: up
+ type: object
+ patternProperties:
+ '^x':
+ $dynamicAnchor: pp
+ type: object
+ properties:
+ child:
+ $dynamicAnchor: ifn
+ type: object
+ additionalProperties:
+ $dynamicAnchor: ap
+ type: object
+ if:
+ $dynamicAnchor: iftop
+ type: object
+ then:
+ $dynamicAnchor: thentop
+ type: object
+ else:
+ $dynamicAnchor: elsetop
+ type: object
+ $defs:
+ consumer:
+ type: object
+ properties:
+ one:
+ $dynamicRef: '#one'
+ any:
+ $dynamicRef: '#any'
+ notn:
+ $dynamicRef: '#notn'
+ cont:
+ $dynamicRef: '#cont'
+ pn:
+ $dynamicRef: '#pn'
+ cs:
+ $dynamicRef: '#cs'
+ up:
+ $dynamicRef: '#up'
+ pp:
+ $dynamicRef: '#pp'
+ ifn:
+ $dynamicRef: '#ifn'
+ itm:
+ $dynamicRef: '#itm'
+ depn:
+ $dynamicRef: '#depn'
+ iftop:
+ $dynamicRef: '#iftop'
+ thentop:
+ $dynamicRef: '#thentop'
+ elsetop:
+ $dynamicRef: '#elsetop'
+ ap:
+ $dynamicRef: '#ap'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+ var root = doc.Components.Schemas["Root"];
+ var consumer = root.Definitions["consumer"].Properties;
+
+ // Each anchor is unique within the document, so every resolution must succeed.
+ foreach (var name in new[] { "one", "any", "notn", "cont", "pn", "cs", "up", "pp", "ifn", "itm", "depn", "iftop", "thentop", "elsetop", "ap" })
+ {
+ var reference = Assert.IsType(consumer[name]);
+ Assert.NotNull(reference.Target);
+ }
+ }
+
+ [Fact]
+ public async Task DynamicAnchorInRefSiblingApplicatorsIsRegistered()
+ {
+ // A $ref schema may carry applicator siblings (allOf/oneOf/anyOf/properties/items/...) whose
+ // subschemas declare $dynamicAnchor. The anchor walk must descend into a reference holder's
+ // own siblings (read from JsonSchemaReference) to register them.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Base:
+ type: object
+ Referencing:
+ $ref: '#/components/schemas/Base'
+ oneOf:
+ - $dynamicAnchor: refOne
+ type: object
+ anyOf:
+ - $dynamicAnchor: refAny
+ type: object
+ properties:
+ child:
+ $dynamicAnchor: refChild
+ type: object
+ patternProperties:
+ '^x':
+ $dynamicAnchor: refPP
+ type: object
+ items:
+ $dynamicAnchor: refItem
+ type: string
+ dependentSchemas:
+ dep:
+ $dynamicAnchor: refDep
+ type: object
+ $defs:
+ consumer:
+ type: object
+ properties:
+ a:
+ $dynamicRef: '#refOne'
+ b:
+ $dynamicRef: '#refAny'
+ c:
+ $dynamicRef: '#refChild'
+ d:
+ $dynamicRef: '#refItem'
+ e:
+ $dynamicRef: '#refPP'
+ f:
+ $dynamicRef: '#refDep'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+ var referencing = doc.Components.Schemas["Referencing"];
+ var consumer = referencing.Definitions["consumer"].Properties;
+
+ // Each $dynamicRef targets an anchor declared in a sibling applicator of the $ref schema.
+ foreach (var reference in consumer.Values.Select(v => Assert.IsType(v)))
+ {
+ Assert.NotNull(reference.Target);
+ }
+ }
+
+ [Fact]
+ public async Task DynamicRefReturnsNullForUnknownAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Foo:
+ type: object
+ properties:
+ bar:
+ $dynamicRef: '#nonexistent'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var foo = doc.Components.Schemas["Foo"];
+ var barSchema = foo.Properties["bar"];
+
+ var reference = Assert.IsType(barSchema);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ Assert.Null(reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefRoundTripsThroughSerialization()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ children:
+ type: array
+ items:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var sw = new StringWriter();
+ var writer = new OpenApiYamlWriter(sw);
+ doc.SerializeAsV31(writer);
+ var serialized = sw.ToString();
+
+ var doc2 = await LoadDocumentAsync(serialized);
+
+ var tree2 = doc2.Components.Schemas["Tree"];
+ var childrenItems2 = tree2.Properties["children"].Items;
+ var reference2 = Assert.IsType(childrenItems2);
+ Assert.True(reference2.Reference.IsDynamicRefOnly);
+ Assert.NotNull(reference2.Target);
+ }
+
+ [Fact]
+ public async Task ExistingRefWithPathStillWorks()
+ {
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Foo:
+ type: string
+ Bar:
+ $ref: '#/components/schemas/Foo'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var bar = doc.Components.Schemas["Bar"];
+ var reference = Assert.IsType(bar);
+ Assert.False(reference.Reference.IsDynamicRefOnly);
+ Assert.NotNull(reference.Target);
+ Assert.Same(doc.Components.Schemas["Foo"], reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefWithSiblingsPreservesSiblings()
+ {
+ // A $dynamicRef alongside structural schema keywords must not drop the siblings. The object
+ // is parsed as a normal OpenApiSchema (preserving maxProperties and properties) rather than
+ // being reduced to a bare reference that loses them.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Foo:
+ type: object
+ properties:
+ bar:
+ $dynamicRef: '#node'
+ maxProperties: 3
+ description: a constrained dynamic ref
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var foo = doc.Components.Schemas["Foo"];
+ var bar = foo.Properties["bar"];
+
+ // Siblings preserved
+ Assert.Equal(3, bar.MaxProperties);
+ Assert.Equal("a constrained dynamic ref", bar.Description);
+ Assert.Equal("#node", bar.DynamicRef);
+ }
+
+ [Fact]
+ public async Task AbsoluteDynamicRefDoesNotResolveToLocalAnchor()
+ {
+ // A URI-based $dynamicRef (https://example.com#node) targets an external resource. It must
+ // not be reduced to the bare anchor name "node" and resolved against a local $dynamicAnchor,
+ // which would return the wrong target.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ next:
+ $dynamicRef: 'https://example.com/external#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var tree = doc.Components.Schemas["Tree"];
+ var next = tree.Properties["next"];
+ var reference = Assert.IsType(next);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ // External target is not loaded in this workspace, so resolution returns null rather than
+ // falling back to the local Tree anchor.
+ Assert.Null(reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicAnchorResolvesPerDocumentInSharedWorkspace()
+ {
+ // Two documents in the same workspace each declare $dynamicAnchor: node (the conventional
+ // name for recursive tree schemas). Per JSON Schema 2020-12, dynamic scope is per-document,
+ // so each $dynamicRef must resolve to its own document's anchor, not return null as ambiguous.
+ var yaml = """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ next:
+ $dynamicRef: '#node'
+ """;
+
+ var docA = await LoadDocumentAsync(yaml);
+ var docB = await LoadDocumentAsync(yaml);
+
+ var workspace = new OpenApiWorkspace();
+ docA.Workspace = workspace;
+ docB.Workspace = workspace;
+ workspace.RegisterComponents(docA);
+ workspace.RegisterComponents(docB);
+
+ var treeA = docA.Components.Schemas["Tree"];
+ var treeB = docB.Components.Schemas["Tree"];
+
+ var refA = Assert.IsType(treeA.Properties["next"]);
+ Assert.NotNull(refA.Target);
+ Assert.Same(treeA, refA.Target);
+
+ var refB = Assert.IsType(treeB.Properties["next"]);
+ Assert.NotNull(refB.Target);
+ Assert.Same(treeB, refB.Target);
+ }
+
+ [Fact]
+ public async Task DynamicAnchorInRefSiblingDefsIsRegistered()
+ {
+ // A $dynamicAnchor declared inside a $ref schema's $defs sibling must be registered, so a
+ // $dynamicRef elsewhere in the document resolves to it. (Main's model carries authored
+ // siblings on JsonSchemaReference, so the anchor walk must descend into them without
+ // following the $ref target.)
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Base:
+ type: object
+ Referencing:
+ $ref: '#/components/schemas/Base'
+ $defs:
+ node:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ next:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var referencing = doc.Components.Schemas["Referencing"];
+ var nodeDef = referencing.Definitions["node"];
+ var next = nodeDef.Properties["next"];
+
+ var reference = Assert.IsType(next);
+ Assert.NotNull(reference.Target);
+ Assert.Same(nodeDef, reference.Target);
+ }
+
+ [Fact]
+ public async Task CreateShallowCopyPreservesValidationSiblings()
+ {
+ // CreateShallowCopy routes through the JsonSchemaReference copy constructor. Validation
+ // keyword siblings carried on a $ref schema (type, minProperties, pattern, allOf, etc.)
+ // must survive the copy, not just the JSON-Schema metadata siblings.
+ var yaml =
+ """
+ openapi: 3.1.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Target:
+ type: object
+ Referencing:
+ $ref: '#/components/schemas/Target'
+ type: object
+ minProperties: 2
+ pattern: '^a'
+ allOf:
+ - type: object
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var referencing = doc.Components.Schemas["Referencing"];
+ var copy = referencing.CreateShallowCopy();
+
+ Assert.IsType(copy);
+ Assert.Equal(JsonSchemaType.Object, copy.Type);
+ Assert.Equal(2, copy.MinProperties);
+ Assert.Equal("^a", copy.Pattern);
+ Assert.NotNull(copy.AllOf);
+ Assert.Single(copy.AllOf);
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs
new file mode 100644
index 000000000..06af2fcc1
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs
@@ -0,0 +1,223 @@
+using System.IO;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Microsoft.OpenApi.Reader.V32;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.V32Tests;
+
+public class OpenApiDynamicRefTests
+{
+ private static async Task LoadDocumentAsync(string yaml)
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml));
+ var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
+ return result.Document;
+ }
+
+ [Fact]
+ public void BareDynamicRefDeserializesAsSchemaReference()
+ {
+ var json =
+ """
+ {
+ "$dynamicRef": "#category"
+ }
+ """;
+
+ var hostDocument = new OpenApiDocument();
+ var jsonNode = JsonNode.Parse(json);
+
+ var result = OpenApiV32Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new()));
+
+ var reference = Assert.IsType(result);
+ Assert.Equal("#category", reference.Reference.DynamicRef);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ }
+
+ [Fact]
+ public void BareDynamicRefDoesNotEmitRefOnSerialization()
+ {
+ var json =
+ """
+ {
+ "$dynamicRef": "#category"
+ }
+ """;
+
+ var hostDocument = new OpenApiDocument();
+ var jsonNode = JsonNode.Parse(json);
+
+ var result = OpenApiV32Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new()));
+
+ var sw = new StringWriter();
+ var writer = new OpenApiJsonWriter(sw);
+ result.SerializeAsV32(writer);
+
+ var output = sw.ToString();
+ Assert.Contains("$dynamicRef", output);
+ Assert.DoesNotContain("$ref", output);
+ }
+
+ [Fact]
+ public async Task DynamicRefResolvesToDynamicAnchorTarget()
+ {
+ var yaml =
+ """
+ openapi: 3.2.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ children:
+ type: array
+ items:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var tree = doc.Components.Schemas["Tree"];
+ var childrenItems = tree.Properties["children"].Items;
+
+ var reference = Assert.IsType(childrenItems);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ Assert.NotNull(reference.Target);
+ Assert.Same(tree, reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefReturnsNullForUnknownAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.2.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Foo:
+ type: object
+ properties:
+ bar:
+ $dynamicRef: '#nonexistent'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var foo = doc.Components.Schemas["Foo"];
+ var barSchema = foo.Properties["bar"];
+
+ var reference = Assert.IsType(barSchema);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ Assert.Null(reference.Target);
+ }
+
+ [Fact]
+ public async Task AbsoluteDynamicRefDoesNotResolveToLocalAnchor()
+ {
+ var yaml =
+ """
+ openapi: 3.2.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ next:
+ $dynamicRef: 'https://example.com/external#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var tree = doc.Components.Schemas["Tree"];
+ var next = tree.Properties["next"];
+ var reference = Assert.IsType(next);
+ Assert.True(reference.Reference.IsDynamicRefOnly);
+ Assert.Null(reference.Target);
+ }
+
+ [Fact]
+ public async Task DynamicRefWithSiblingsPreservesSiblings()
+ {
+ var yaml =
+ """
+ openapi: 3.2.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Foo:
+ type: object
+ properties:
+ bar:
+ $dynamicRef: '#node'
+ maxProperties: 3
+ description: a constrained dynamic ref
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var foo = doc.Components.Schemas["Foo"];
+ var bar = foo.Properties["bar"];
+
+ Assert.Equal(3, bar.MaxProperties);
+ Assert.Equal("a constrained dynamic ref", bar.Description);
+ Assert.Equal("#node", bar.DynamicRef);
+ }
+
+ [Fact]
+ public async Task DynamicRefRoundTripsThroughSerialization()
+ {
+ var yaml =
+ """
+ openapi: 3.2.0
+ info:
+ title: Test
+ version: 1.0.0
+ paths: {}
+ components:
+ schemas:
+ Tree:
+ $dynamicAnchor: node
+ type: object
+ properties:
+ children:
+ type: array
+ items:
+ $dynamicRef: '#node'
+ """;
+
+ var doc = await LoadDocumentAsync(yaml);
+
+ var sw = new StringWriter();
+ var writer = new OpenApiYamlWriter(sw);
+ doc.SerializeAsV32(writer);
+ var serialized = sw.ToString();
+
+ var doc2 = await LoadDocumentAsync(serialized);
+
+ var tree2 = doc2.Components.Schemas["Tree"];
+ var childrenItems2 = tree2.Properties["children"].Items;
+ var reference2 = Assert.IsType(childrenItems2);
+ Assert.True(reference2.Reference.IsDynamicRefOnly);
+ Assert.NotNull(reference2.Target);
+ }
+}