Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,13 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
/// </summary>
public IOpenApiSchema? Else { get; set; }

/// <summary>
/// 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.
/// </summary>
internal bool IsDynamicRefOnly { get; set; }

/// <summary>
/// Parameterless constructor
/// </summary>
Expand Down Expand Up @@ -407,6 +414,36 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
If = reference.If;
Then = reference.Then;
Else = reference.Else;
IsDynamicRefOnly = reference.IsDynamicRefOnly;
}

/// <inheritdoc/>
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);
}
}
/// <inheritdoc/>
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);
}
}

/// <inheritdoc/>
Expand All @@ -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<IOpenApiWriter, IOpenApiSerializable> serializeCallback, Action<IOpenApiWriter> baseSerializer)
{
if (Type != ReferenceType.Schema) throw new InvalidOperationException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema)
{
}

/// <summary>
/// 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).
/// </summary>
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;
}
}

/// <inheritdoc/>
public string? Description
{
Expand Down
3 changes: 3 additions & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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?
33 changes: 33 additions & 0 deletions src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,39 @@ public static Dictionary<string, HashSet<T>> CreateArrayMap<T>(this JsonNode? no
return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null;
}

/// <summary>
/// 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.
/// </summary>
public static string? GetDynamicReferencePointer(this JsonObject jsonObject)
{
if (jsonObject.TryGetPropertyValue("$ref", out _))
return null;
return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode) ? dynRefNode?.GetScalarValue() : null;
}

/// <summary>
/// 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).
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
20 changes: 20 additions & 0 deletions src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string, JsonNode>(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,
Expand Down
20 changes: 20 additions & 0 deletions src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string, JsonNode>(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,
Expand Down
124 changes: 124 additions & 0 deletions src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class OpenApiWorkspace
private readonly Dictionary<string, Uri> _documentsIdRegistry = new();
private readonly Dictionary<Uri, Stream> _artifactsRegistry = new();
private readonly Dictionary<Uri, IOpenApiReferenceable> _IOpenApiReferenceableRegistry = new(new UriWithFragmentEqualityComparer());
private readonly Dictionary<OpenApiDocument, Dictionary<string, List<IOpenApiSchema>>> _dynamicAnchorRegistryByDocument = new();

private sealed class UriWithFragmentEqualityComparer : IEqualityComparer<Uri>
{
Expand Down Expand Up @@ -101,6 +102,8 @@ public void RegisterComponents(OpenApiDocument document)
{
RegisterComponent(schemaId, item.Value);
}

RegisterDynamicAnchors(document, item.Value);
}
}

Expand Down Expand Up @@ -288,6 +291,127 @@ internal bool RegisterComponent<T>(string location, T component)
return false;
}

/// <summary>
/// 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 <paramref name="document"/> 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.
/// </summary>
private void RegisterDynamicAnchors(OpenApiDocument document, IOpenApiSchema schema)
=> RegisterDynamicAnchorsRecursive(document, schema, new HashSet<IOpenApiSchema>());

private void RegisterDynamicAnchorsRecursive(OpenApiDocument document, IOpenApiSchema? schema, HashSet<IOpenApiSchema> 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<IOpenApiSchema> 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<IOpenApiSchema> 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);
}

/// <summary>
/// Resolves a $dynamicAnchor by name within the scope of <paramref name="hostDocument"/>.
/// 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.
/// </summary>
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;
}

/// <summary>
/// Adds a document id to the dictionaries of document locations and their ids.
/// </summary>
Expand Down
Loading