Skip to content
Open
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
46 changes: 45 additions & 1 deletion src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public class JsonSchemaReference : OpenApiReferenceWithDescription
public class JsonSchemaReference : OpenApiReferenceWithDescription, IOpenApiSchemaMissingProperties
{
/// <summary>
/// A default value which by default SHOULD override that of the referenced component.
Expand Down Expand Up @@ -268,6 +268,12 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
/// </summary>
public bool? UnevaluatedProperties { get; set; }

/// <summary>
/// Explicit interface implementation for <see cref="IOpenApiSchemaMissingProperties.UnevaluatedProperties"/>.
/// Returns the nullable value coalesced to true (the JSON Schema default) when unset.
/// </summary>
bool IOpenApiSchemaMissingProperties.UnevaluatedProperties => UnevaluatedProperties ?? true;

/// <summary>
/// Follow <see href="https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties">JSON Schema definition</see>.
/// </summary>
Expand Down Expand Up @@ -335,6 +341,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 +420,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 +462,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,43 @@ private OpenApiSchemaReference(OpenApiSchemaReference schema) : base(schema)
{
}

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

/// <inheritdoc/>
public string? Description
{
Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<Microsoft.OpenApi.IOpenApiSchema!>!
static Microsoft.OpenApi.OpenApiWorkspace.ResolveDynamicAnchorInContext(Microsoft.OpenApi.IOpenApiSchema? contextSchema, string! anchorName) -> 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
Loading