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); + } +}