From 573f8bf531a0881f5a9f57588ed66a38a6699dd5 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Mon, 29 Jun 2026 12:17:26 +0300 Subject: [PATCH] feat(schema): resolve $dynamicRef against $dynamicAnchor via OpenApiSchemaReference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements document-scoped $dynamicRef resolution per JSON Schema 2020-12 §8.2.3.2. Bare $dynamicRef schemas (no $ref) now deserialize as OpenApiSchemaReference whose Target resolves via per-document $dynamicAnchor and $anchor registries in OpenApiWorkspace. Resolution order in Target: 1. $dynamicAnchor index (single candidate → resolved automatically) 2. $anchor fallback when zero $dynamicAnchor candidates exist (per §8.2.3.2) 3. null when ambiguous (multiple candidates need dynamic-scope tracking) Anchor registries are populated by recursively walking the entire document tree: component schemas, reusable component definitions (parameters, responses, request bodies, headers, callbacks, path items, media types), inline schemas (paths, operations, webhooks), and all nested subschema locations ($defs, properties, items, allOf, if/then/else, etc.). Public APIs for consumers tracking dynamic scope: - GetDynamicAnchorCandidates(doc, anchorName): returns all candidate schemas - ResolveDynamicAnchorInContext(contextSchema, anchorName): resolves against a specific schema's $defs for context-dependent resolution Other changes: - Deserializer (V31/V32): detect bare $dynamicRef, create OpenApiSchemaReference with IsDynamicRefOnly, parse siblings via ApplySchemaMetadata - JsonSchemaReference: add IsDynamicRefOnly flag, implement IOpenApiSchemaMissingProperties, override SerializeAsV31/V32 for dynamic-only refs - JsonNodeHelper: GetDynamicReferencePointer, ExtractDynamicAnchorName, IsFragmentOnlyDynamicRef - Siblings preserved via ApplySchemaMetadata and surfaced through existing Reference-first property getters Fixes #2911. --- .../Models/JsonSchemaReference.cs | 46 +- .../References/OpenApiSchemaReference.cs | 37 + src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 5 + .../Reader/JsonNodeHelper.cs | 33 + .../Reader/V31/OpenApiSchemaDeserializer.cs | 20 + .../Reader/V32/OpenApiSchemaDeserializer.cs | 20 + .../Services/OpenApiWorkspace.cs | 341 +++++ .../V31Tests/OpenApiDynamicRefTests.cs | 1270 +++++++++++++++++ .../V32Tests/OpenApiDynamicRefTests.cs | 771 ++++++++++ 9 files changed, 2542 insertions(+), 1 deletion(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs 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 visited) + { + if (!visited.Add(pathItem)) return; + + if (pathItem.Parameters is not null) + foreach (var parameter in pathItem.Parameters) + if (parameter is not null) + RegisterParameterAnchors(document, parameter); + + if (pathItem.Operations is not null) + foreach (var op in pathItem.Operations.Values) + if (op is not null) + RegisterOperationAnchors(document, op, visited); + } + + private void RegisterCallbackAnchors(OpenApiDocument document, IOpenApiCallback callback, HashSet visited) + { + if (!visited.Add(callback)) return; + + if (callback.PathItems is not null) + foreach (var pathItem in callback.PathItems.Values) + if (pathItem is not null) + RegisterPathItemAnchors(document, pathItem, visited); + } + + private void RegisterOperationAnchors(OpenApiDocument document, OpenApiOperation op, HashSet visited) + { + if (!visited.Add(op)) return; + + if (op.Parameters is not null) + foreach (var parameter in op.Parameters) + if (parameter is not null) + RegisterParameterAnchors(document, parameter); + + if (op.RequestBody is not null) + RegisterRequestBodyAnchors(document, op.RequestBody); + + if (op.Responses is not null) + foreach (var response in op.Responses.Values) + if (response is not null) + RegisterResponseAnchors(document, response); + + if (op.Callbacks is not null) + foreach (var callback in op.Callbacks.Values) + if (callback is not null) + RegisterCallbackAnchors(document, callback, visited); + } + + private void RegisterParameterAnchors(OpenApiDocument document, IOpenApiParameter parameter) + { + if (parameter.Schema is not null) + RegisterAnchors(document, parameter.Schema); + RegisterMediaTypeSchemas(document, parameter.Content); + } + + private void RegisterRequestBodyAnchors(OpenApiDocument document, IOpenApiRequestBody requestBody) + => RegisterMediaTypeSchemas(document, requestBody.Content); + + private void RegisterResponseAnchors(OpenApiDocument document, IOpenApiResponse response) + { + RegisterMediaTypeSchemas(document, response.Content); + if (response.Headers is not null) + foreach (var header in response.Headers.Values) + if (header is not null) + RegisterHeaderAnchors(document, header); + } + + private void RegisterHeaderAnchors(OpenApiDocument document, IOpenApiHeader header) + { + if (header.Schema is not null) + RegisterAnchors(document, header.Schema); + RegisterMediaTypeSchemas(document, header.Content); + } + + private void RegisterMediaTypeSchemas(OpenApiDocument document, IDictionary? content) + { + if (content is null) return; + foreach (var mediaType in content.Values) + { + if (mediaType is null) continue; + if (mediaType.Schema is not null) + RegisterAnchors(document, mediaType.Schema); + if (mediaType.ItemSchema is not null) + RegisterAnchors(document, mediaType.ItemSchema); + } } private static string getBaseUri(OpenApiDocument openApiDocument) @@ -288,6 +446,189 @@ internal bool RegisterComponent(string location, T component) return false; } + /// + /// Registers all $dynamicAnchor and $anchor 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 RegisterAnchors(OpenApiDocument document, IOpenApiSchema schema) + => RegisterAnchorsRecursive(document, schema, new HashSet()); + + private void RegisterAnchorsRecursive(OpenApiDocument document, IOpenApiSchema? schema, HashSet visited) + { + if (schema is null || !visited.Add(schema)) return; + + // For reference holders, read authored $dynamicAnchor and $anchor siblings from the + // reference object itself — never from the resolved target. Reading IOpenApiSchema.DynamicAnchor + // or .Anchor on a reference falls through to Target, which would duplicate the entry under a + // different object and make single-candidate resolution look ambiguous. + if (schema is OpenApiSchemaReference osr) + { + if (osr.Reference.DynamicAnchor is string dynAnchor && dynAnchor.Length > 0) + RegisterAnchor(document, dynAnchor, schema, isDynamic: true); + if (osr.Reference.Anchor is string plainAnchor && plainAnchor.Length > 0) + RegisterAnchor(document, plainAnchor, schema, isDynamic: false); + } + else + { + if (schema.DynamicAnchor is string dynAnchor && dynAnchor.Length > 0) + RegisterAnchor(document, dynAnchor, schema, isDynamic: true); + if (schema is IOpenApiSchemaMissingProperties mp && mp.Anchor is string plainAnchor && plainAnchor.Length > 0) + RegisterAnchor(document, plainAnchor, schema, isDynamic: false); + } + + // 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 entries. + var children = schema is OpenApiSchemaReference r ? EnumerateChildren(r.Reference) : EnumerateChildren(schema); + foreach (var child in children) + RegisterAnchorsRecursive(document, child, visited); + } + + /// + /// Enumerates all child schemas from an IOpenApiSchema, including properties from + /// IOpenApiSchemaMissingProperties (Contains, If/Then/Else, etc.). + /// + 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) + foreach (var c in EnumerateMissingPropertiesChildren(mp)) + yield return c; + } + + /// + /// Enumerates child schemas from a JsonSchemaReference's own properties (not the resolved Target). + /// Needed because JsonSchemaReference is not an IOpenApiSchema — reading via the interface + /// on OpenApiSchemaReference would delegate to Target and cross document boundaries. + /// + 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; + // JsonSchemaReference implements IOpenApiSchemaMissingProperties + foreach (var c in EnumerateMissingPropertiesChildren(r)) + yield return c; + } + + private static IEnumerable EnumerateMissingPropertiesChildren(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 void RegisterAnchor(OpenApiDocument document, string anchorName, IOpenApiSchema schema, bool isDynamic) + { + var registry = isDynamic ? _dynamicAnchorRegistryByDocument : _anchorRegistryByDocument; + if (!registry.TryGetValue(document, out var anchors)) + { + anchors = new(StringComparer.Ordinal); + registry[document] = anchors; + } + if (!anchors.TryGetValue(anchorName, out var list)) + { + list = []; + anchors[anchorName] = list; + } + if (!list.Contains(schema)) + list.Add(schema); + } + + /// + /// Resolves a plain $anchor by name within the scope of . + /// Used as the fallback when $dynamicAnchor resolution finds zero candidates, per JSON Schema 2020-12 §8.2.3.2. + /// Returns the schema when exactly one candidate exists; returns null for zero or multiple. + /// + internal IOpenApiSchema? ResolveAnchor(OpenApiDocument hostDocument, string anchorName) + { + if (_anchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) && + anchors.TryGetValue(anchorName, out var candidates)) + return candidates.Count == 1 ? candidates[0] : null; + return null; + } + + /// + /// Returns all schemas in the document that declare a $dynamicAnchor matching + /// . A single candidate resolves directly; zero candidates + /// means no dynamic anchor exists (callers may fall back to plain $anchor); multiple + /// candidates indicate an ambiguous anchor whose resolution requires dynamic-scope evaluation + /// that this library does not perform. + /// + /// The document whose anchors to search. + /// The bare anchor name (without leading #). + /// All candidate schemas, or an empty list if none. + public IReadOnlyList GetDynamicAnchorCandidates(OpenApiDocument hostDocument, string anchorName) + { + if (_dynamicAnchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) && + anchors.TryGetValue(anchorName, out var candidates)) + return candidates; + return []; + } + + /// + /// Resolves a $dynamicAnchor within the context of a specific schema's $defs. + /// This is a context-aware lookup: given the schema that serves as the dynamic-scope entry + /// point (e.g. a response body schema), checks whether it or its $defs entries declare + /// a matching $dynamicAnchor. + /// Consumers that track dynamic scope (e.g. code generators processing an endpoint) call this + /// with their entry-point schema to resolve context-dependent $dynamicRef values. + /// + /// The schema to search (e.g. the response body schema that + /// provides the $defs binding). + /// The bare anchor name (without leading #). + /// The matching schema, or null if not declared in this context. + public static IOpenApiSchema? ResolveDynamicAnchorInContext(IOpenApiSchema? contextSchema, string anchorName) + { + if (contextSchema is null || string.IsNullOrEmpty(anchorName)) return null; + + if (contextSchema.DynamicAnchor is string a && a.Equals(anchorName, StringComparison.Ordinal)) + return contextSchema; + + if (contextSchema.Definitions is not null) + foreach (var def in contextSchema.Definitions.Values) + if (def.DynamicAnchor is string da && da.Equals(anchorName, StringComparison.Ordinal)) + return def; + + 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..e911c9e72 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs @@ -0,0 +1,1270 @@ +using System.IO; +using System.Linq; +using System.Net.Http; +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); + } + + [Fact] + public async Task InlineDynamicAnchorInResponseIsRegistered() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /endpoint-a: + get: + responses: + '200': + description: A + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeA' + $ref: '#/components/schemas/Paged' + /endpoint-b: + get: + responses: + '200': + description: B + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/TypeB' + $ref: '#/components/schemas/Paged' + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + TypeA: + type: object + properties: + name: + type: string + TypeB: + type: object + properties: + title: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidates = doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType"); + Assert.Equal(2, candidates.Count); + + var paged = doc.Components.Schemas["Paged"]; + var itemsSchema = paged.Properties["content"].Items; + var itemsRef = Assert.IsType(itemsSchema); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.Null(itemsRef.Target); + + var endpointASchema = doc.Paths["/endpoint-a"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedA = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointASchema, "itemType"); + Assert.NotNull(resolvedA); + var resolvedARef = Assert.IsType(resolvedA); + Assert.NotNull(resolvedARef.Target); + Assert.Same(doc.Components.Schemas["TypeA"], resolvedARef.Target); + + var endpointBSchema = doc.Paths["/endpoint-b"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + var resolvedB = OpenApiWorkspace.ResolveDynamicAnchorInContext(endpointBSchema, "itemType"); + Assert.NotNull(resolvedB); + var resolvedBRef = Assert.IsType(resolvedB); + Assert.NotNull(resolvedBRef.Target); + Assert.Same(doc.Components.Schemas["TypeB"], resolvedBRef.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableResponseIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested under components/responses must be + // registered just like one declared inline under paths, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + ItemType: + type: object + properties: + name: + type: string + responses: + ListResponse: + description: A paged list + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/ItemType' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + // The anchor is reachable only via components/responses; before the fix it was never indexed. + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.NotNull(itemsRef.Target); + + var resolved = Assert.IsType(itemsRef.Target); + Assert.Same(doc.Components.Schemas["ItemType"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableParameterIsRegistered() + { + // A $dynamicAnchor declared inside a reusable parameter's schema must be registered. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Filter: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + parameters: + filterParam: + name: filter + in: query + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Filter' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var filter = doc.Components.Schemas["Filter"]; + var childRef = Assert.IsType(filter.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableHeaderIsRegistered() + { + // A $dynamicAnchor declared inside a reusable header's schema (components/headers) must + // be registered. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Cursor: + type: object + properties: + next: + $dynamicRef: '#page' + Page: + type: object + properties: + index: + type: integer + headers: + XCursor: + description: Cursor header + schema: + $defs: + page: + $dynamicAnchor: page + $ref: '#/components/schemas/Page' + $ref: '#/components/schemas/Cursor' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "page")); + + var cursor = doc.Components.Schemas["Cursor"]; + var nextRef = Assert.IsType(cursor.Properties["next"]); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + + var resolved = Assert.IsType(nextRef.Target); + Assert.Same(doc.Components.Schemas["Page"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInResponseHeaderSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a response header's schema must be registered. This + // exercises the response.Headers leaf, which (like parameter.Content, header.Content and + // mediaType.ItemSchema) is a schema-bearing location that the anchor walk must cover. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /items: + get: + responses: + '200': + description: OK + headers: + X-Page: + description: Paging header + schema: + $defs: + meta: + $dynamicAnchor: meta + type: object + properties: + total: + type: integer + components: + schemas: + Body: + type: object + properties: + paging: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidate = Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "meta")); + + var body = doc.Components.Schemas["Body"]; + var pagingRef = Assert.IsType(body.Properties["paging"]); + Assert.True(pagingRef.Reference.IsDynamicRefOnly); + Assert.NotNull(pagingRef.Target); + Assert.Same(candidate, pagingRef.Target); + } + + [Fact] + public async Task ResolveDynamicAnchorInContextReturnsNullForNoMatch() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Foo: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + var foo = doc.Components.Schemas["Foo"]; + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(foo, "nonexistent"); + Assert.Null(result); + } + + [Fact] + public async Task DynamicRefFallsBackToPlainAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $anchor: meta + type: object + properties: + value: + type: string + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var target = doc.Components.Schemas["Target"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(target, reference.Target); + } + + [Fact] + public async Task DynamicAnchorTakesPrecedenceOverPlainAnchor() + { + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicTarget: + $dynamicAnchor: meta + type: object + properties: + dynamic: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var dynamicTarget = doc.Components.Schemas["DynamicTarget"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.NotNull(reference.Target); + Assert.Same(dynamicTarget, reference.Target); + } + + [Fact] + public async Task DynamicRefDoesNotFallBackToPlainAnchorWhenDynamicAnchorIsAmbiguous() + { + // Two $dynamicAnchor: meta declarations make the dynamic anchor ambiguous. Per §8.2.3.2, + // the spec requires the outermost $dynamicAnchor, which this library cannot compute, so + // Target must be null. The presence of a $anchor: meta must NOT trigger the plain-anchor + // fallback, since that would silently resolve to a different, incorrect target. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicA: + $dynamicAnchor: meta + type: object + properties: + a: + type: boolean + DynamicB: + $dynamicAnchor: meta + type: object + properties: + b: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorInInlineCallbackIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested in an inline operation callback must be + // registered, matching the treatment of reusable (components/callbacks) callbacks. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: + /register: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + callbacks: + onUpdate: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task CyclicCallbackDoesNotStackOverflowAndStillRegistersAnchors() + { + // A callback that references itself (via $ref) creates a structural cycle + // pathItem -> operation -> callback -> pathItem. The anchor walk must terminate and still + // register anchors declared inside the cycle. + var yaml = + """ + openapi: 3.1.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + callbacks: + Loop: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + callbacks: + self: + $ref: '#/components/callbacks/Loop' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } +} 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..c5b6bb8c2 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs @@ -0,0 +1,771 @@ +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); + } + + [Fact] + public async Task DynamicRefFallsBackToPlainAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $anchor: meta + type: object + properties: + value: + type: string + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var target = doc.Components.Schemas["Target"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(target, reference.Target); + } + + [Fact] + public async Task DynamicAnchorTakesPrecedenceOverPlainAnchor() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicTarget: + $dynamicAnchor: meta + type: object + properties: + dynamic: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var dynamicTarget = doc.Components.Schemas["DynamicTarget"]; + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.NotNull(reference.Target); + Assert.Same(dynamicTarget, reference.Target); + } + + [Fact] + public async Task DynamicRefDoesNotFallBackToPlainAnchorWhenDynamicAnchorIsAmbiguous() + { + // Two $dynamicAnchor: meta declarations make the dynamic anchor ambiguous. Per §8.2.3.2, + // the spec requires the outermost $dynamicAnchor, which this library cannot compute, so + // Target must be null. The presence of a $anchor: meta must NOT trigger the plain-anchor + // fallback, since that would silently resolve to a different, incorrect target. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + DynamicA: + $dynamicAnchor: meta + type: object + properties: + a: + type: boolean + DynamicB: + $dynamicAnchor: meta + type: object + properties: + b: + type: boolean + PlainTarget: + $anchor: meta + type: object + properties: + plain: + type: boolean + Referencer: + type: object + properties: + ref: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referencer = doc.Components.Schemas["Referencer"]; + var refSchema = referencer.Properties["ref"]; + + var reference = Assert.IsType(refSchema); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableResponseIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested under components/responses must be + // registered just like one declared inline under paths, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Paged: + type: object + properties: + content: + type: array + items: + $dynamicRef: '#itemType' + ItemType: + type: object + properties: + name: + type: string + responses: + ListResponse: + description: A paged list + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/ItemType' + $ref: '#/components/schemas/Paged' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "itemType")); + + var paged = doc.Components.Schemas["Paged"]; + var itemsRef = Assert.IsType(paged.Properties["content"].Items); + Assert.True(itemsRef.Reference.IsDynamicRefOnly); + Assert.NotNull(itemsRef.Target); + + var resolved = Assert.IsType(itemsRef.Target); + Assert.Same(doc.Components.Schemas["ItemType"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableParameterIsRegistered() + { + // A $dynamicAnchor declared inside a reusable parameter's schema must be registered. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Filter: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + parameters: + filterParam: + name: filter + in: query + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Filter' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var filter = doc.Components.Schemas["Filter"]; + var childRef = Assert.IsType(filter.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableHeaderIsRegistered() + { + // A $dynamicAnchor declared inside a reusable header's schema (components/headers) must + // be registered. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Cursor: + type: object + properties: + next: + $dynamicRef: '#page' + Page: + type: object + properties: + index: + type: integer + headers: + XCursor: + description: Cursor header + schema: + $defs: + page: + $dynamicAnchor: page + $ref: '#/components/schemas/Page' + $ref: '#/components/schemas/Cursor' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "page")); + + var cursor = doc.Components.Schemas["Cursor"]; + var nextRef = Assert.IsType(cursor.Properties["next"]); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + + var resolved = Assert.IsType(nextRef.Target); + Assert.Same(doc.Components.Schemas["Page"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInResponseHeaderSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a response header's schema must be registered. This + // exercises the response.Headers leaf, which (like parameter.Content and header.Content) + // is a schema-bearing location that the anchor walk must cover. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: + /items: + get: + responses: + '200': + description: OK + headers: + X-Page: + description: Paging header + schema: + $defs: + meta: + $dynamicAnchor: meta + type: object + properties: + total: + type: integer + components: + schemas: + Body: + type: object + properties: + paging: + $dynamicRef: '#meta' + """; + + var doc = await LoadDocumentAsync(yaml); + + var candidate = Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "meta")); + + var body = doc.Components.Schemas["Body"]; + var pagingRef = Assert.IsType(body.Properties["paging"]); + Assert.True(pagingRef.Reference.IsDynamicRefOnly); + Assert.NotNull(pagingRef.Target); + Assert.Same(candidate, pagingRef.Target); + } + + [Fact] + public async Task DynamicAnchorInMediaTypeItemSchemaIsRegistered() + { + // A $dynamicAnchor declared inside a media type's itemSchema (an OAS 3.2 schema-bearing + // leaf distinct from `schema`) must be registered, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Stream: + type: object + properties: + first: + $dynamicRef: '#entry' + Entry: + type: object + properties: + id: + type: string + responses: + StreamResponse: + description: A streaming response + content: + application/x-ndjson: + itemSchema: + $defs: + entry: + $dynamicAnchor: entry + $ref: '#/components/schemas/Entry' + $ref: '#/components/schemas/Stream' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "entry")); + + var stream = doc.Components.Schemas["Stream"]; + var firstRef = Assert.IsType(stream.Properties["first"]); + Assert.True(firstRef.Reference.IsDynamicRefOnly); + Assert.NotNull(firstRef.Target); + + var resolved = Assert.IsType(firstRef.Target); + Assert.Same(doc.Components.Schemas["Entry"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInInlineCallbackIsRegistered() + { + // A $dynamicAnchor declared inside a schema nested in an inline operation callback must be + // registered, matching the treatment of reusable (components/callbacks) callbacks. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: + /register: + post: + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + callbacks: + onUpdate: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } + + [Fact] + public async Task DynamicAnchorInReusableMediaTypeIsRegistered() + { + // A $dynamicAnchor declared inside a reusable media type's schema (components/mediaTypes, + // an OAS 3.2 component) must be registered, so a $dynamicRef resolves to it. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Payload: + type: object + properties: + inner: + $dynamicRef: '#widget' + Widget: + type: object + properties: + id: + type: string + mediaTypes: + JsonPayload: + schema: + $defs: + widget: + $dynamicAnchor: widget + $ref: '#/components/schemas/Widget' + $ref: '#/components/schemas/Payload' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "widget")); + + var payload = doc.Components.Schemas["Payload"]; + var innerRef = Assert.IsType(payload.Properties["inner"]); + Assert.True(innerRef.Reference.IsDynamicRefOnly); + Assert.NotNull(innerRef.Target); + + var resolved = Assert.IsType(innerRef.Target); + Assert.Same(doc.Components.Schemas["Widget"], resolved.Target); + } + + [Fact] + public async Task CyclicCallbackDoesNotStackOverflowAndStillRegistersAnchors() + { + // A callback that references itself (via $ref) creates a structural cycle + // pathItem -> operation -> callback -> pathItem. The anchor walk must terminate and still + // register anchors declared inside the cycle. + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Event: + type: object + properties: + child: + $dynamicRef: '#node' + Node: + type: object + properties: + name: + type: string + callbacks: + Loop: + '$request.body#/url': + post: + requestBody: + content: + application/json: + schema: + $defs: + node: + $dynamicAnchor: node + $ref: '#/components/schemas/Node' + $ref: '#/components/schemas/Event' + responses: + '200': + description: OK + callbacks: + self: + $ref: '#/components/callbacks/Loop' + """; + + var doc = await LoadDocumentAsync(yaml); + + Assert.Single(doc.Workspace.GetDynamicAnchorCandidates(doc, "node")); + + var evt = doc.Components.Schemas["Event"]; + var childRef = Assert.IsType(evt.Properties["child"]); + Assert.True(childRef.Reference.IsDynamicRefOnly); + Assert.NotNull(childRef.Target); + + var resolved = Assert.IsType(childRef.Target); + Assert.Same(doc.Components.Schemas["Node"], resolved.Target); + } +}