diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
index ccb62435b..8891af8c2 100644
--- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
+++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
@@ -12,7 +12,7 @@ namespace Microsoft.OpenApi;
/// Schema reference information that includes metadata annotations from JSON Schema 2020-12.
/// This class extends OpenApiReference to provide schema-specific metadata override capabilities.
///
-public class JsonSchemaReference : OpenApiReferenceWithDescription
+public class JsonSchemaReference : OpenApiReferenceWithDescription, IOpenApiSchemaMissingProperties
{
///
/// A default value which by default SHOULD override that of the referenced component.
@@ -268,6 +268,12 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
///
public bool? UnevaluatedProperties { get; set; }
+ ///
+ /// Explicit interface implementation for .
+ /// Returns the nullable value coalesced to true (the JSON Schema default) when unset.
+ ///
+ bool IOpenApiSchemaMissingProperties.UnevaluatedProperties => UnevaluatedProperties ?? true;
+
///
/// Follow JSON Schema definition.
///
@@ -335,6 +341,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 +420,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 +462,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..e49cd7f91 100644
--- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs
+++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs
@@ -35,6 +35,43 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema)
{
}
+ ///
+ /// Resolves the target schema. When this reference was created from a bare $dynamicRef,
+ /// resolution first tries the $dynamicAnchor index, then falls back to $anchor resolution
+ /// per JSON Schema 2020-12 §8.2.3.2 / §9.2 (dereferencing). The $anchor fallback only fires
+ /// when there are zero $dynamicAnchor candidates; with multiple candidates the spec requires
+ /// the outermost dynamic anchor, which cannot be computed without dynamic-scope tracking, so
+ /// this returns null. Returns null when neither matches.
+ ///
+ public override IOpenApiSchema? Target
+ {
+ get
+ {
+ if (Reference.IsDynamicRefOnly)
+ {
+ var anchorName = Microsoft.OpenApi.Reader.JsonNodeHelper.ExtractDynamicAnchorName(Reference.DynamicRef);
+ if (!string.IsNullOrEmpty(anchorName)
+ && Microsoft.OpenApi.Reader.JsonNodeHelper.IsFragmentOnlyDynamicRef(Reference.DynamicRef)
+ && Reference.HostDocument is { } hostDocument
+ && hostDocument.Workspace is { } workspace)
+ {
+ var candidates = workspace.GetDynamicAnchorCandidates(hostDocument, anchorName!);
+ if (candidates.Count == 1)
+ return candidates[0];
+ // Per §8.2.3.2: when no $dynamicAnchor matches at all, $dynamicRef resolves like $ref
+ // to the plain-name fragment ($anchor). When multiple $dynamicAnchor candidates exist,
+ // the spec requires the outermost one, which cannot be computed without dynamic-scope
+ // tracking, so return null rather than incorrectly falling back to $anchor.
+ if (candidates.Count == 0
+ && workspace.ResolveAnchor(hostDocument, anchorName!) is { } anchorTarget)
+ return anchorTarget;
+ }
+ 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..9a1d247a1 100644
--- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
@@ -1 +1,6 @@
#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?
+Microsoft.OpenApi.OpenApiWorkspace.GetDynamicAnchorCandidates(Microsoft.OpenApi.OpenApiDocument! hostDocument, string! anchorName) -> System.Collections.Generic.IReadOnlyList!
+static Microsoft.OpenApi.OpenApiWorkspace.ResolveDynamicAnchorInContext(Microsoft.OpenApi.IOpenApiSchema? contextSchema, string! anchorName) -> 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..54ec7da71 100644
--- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
+++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
@@ -16,6 +16,8 @@ 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 readonly Dictionary>> _anchorRegistryByDocument = new();
private sealed class UriWithFragmentEqualityComparer : IEqualityComparer
{
@@ -101,6 +103,8 @@ public void RegisterComponents(OpenApiDocument document)
{
RegisterComponent(schemaId, item.Value);
}
+
+ RegisterAnchors(document, item.Value);
}
}
@@ -213,6 +217,160 @@ public void RegisterComponents(OpenApiDocument document)
RegisterComponent(location, item.Value);
}
}
+
+ RegisterComponentAnchors(document);
+ RegisterInlineAnchors(document);
+ }
+
+ ///
+ /// Registers $dynamicAnchor and $anchor declarations from schemas nested inside reusable
+ /// components (parameters, responses, requestBodies, headers, callbacks, pathItems, mediaTypes).
+ /// Uses the same per-element helpers as so anchors are
+ /// discovered regardless of whether their containing definition is inline or reusable.
+ ///
+ private void RegisterComponentAnchors(OpenApiDocument document)
+ {
+ var components = document.Components;
+ if (components is null) return;
+
+ if (components.Parameters is not null)
+ foreach (var parameter in components.Parameters.Values)
+ if (parameter is not null)
+ RegisterParameterAnchors(document, parameter);
+
+ if (components.Responses is not null)
+ foreach (var response in components.Responses.Values)
+ if (response is not null)
+ RegisterResponseAnchors(document, response);
+
+ if (components.RequestBodies is not null)
+ foreach (var requestBody in components.RequestBodies.Values)
+ if (requestBody is not null)
+ RegisterRequestBodyAnchors(document, requestBody);
+
+ if (components.Headers is not null)
+ foreach (var header in components.Headers.Values)
+ if (header is not null)
+ RegisterHeaderAnchors(document, header);
+
+ if (components.Callbacks is not null)
+ foreach (var callback in components.Callbacks.Values)
+ if (callback is not null)
+ RegisterCallbackAnchors(document, callback, new());
+
+ if (components.PathItems is not null)
+ foreach (var pathItem in components.PathItems.Values)
+ if (pathItem is not null)
+ RegisterPathItemAnchors(document, pathItem, new());
+
+ RegisterMediaTypeSchemas(document, components.MediaTypes);
+ }
+
+ ///
+ /// Registers $dynamicAnchor and $anchor declarations from inline (non-component) schemas:
+ /// paths and webhooks.
+ ///
+ private void RegisterInlineAnchors(OpenApiDocument document)
+ {
+ if (document.Paths is not null)
+ foreach (var pathItem in document.Paths.Values)
+ if (pathItem is not null)
+ RegisterPathItemAnchors(document, pathItem, new());
+
+ if (document.Webhooks is not null)
+ foreach (var pathItem in document.Webhooks.Values)
+ if (pathItem is not null)
+ RegisterPathItemAnchors(document, pathItem, new());
+ }
+
+ // The structural walk (pathItem -> operation -> callback -> pathItem) can cycle through
+ // self- or mutually-referential callbacks, so each of these guards against re-entry via the
+ // shared `visited` set, mirroring RegisterAnchorsRecursive's schema-cycle guard.
+ private void RegisterPathItemAnchors(OpenApiDocument document, IOpenApiPathItem pathItem, HashSet