From 91b4b6a6a31c55565d915d25c1e5b1a54816c92f Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 11 Jul 2026 19:19:37 +0300 Subject: [PATCH 1/2] feat: support relative URI resolution in $dynamicRef (#2928) Set ExternalResource on non-fragment $dynamicRef references so the workspace loader can discover and fetch external documents. Resolve relative URIs against hostDocument.BaseUri in Target before calling FindDocumentByBaseUri. Tests: - Fragment-only: ExternalResource is null - Absolute URI: ExternalResource is set, resolves across documents - Relative URI: ExternalResource is set, resolves against BaseUri across documents - V31 and V32 coverage --- .../Reader/V31/OpenApiSchemaDeserializer.cs | 5 +- .../Reader/V32/OpenApiSchemaDeserializer.cs | 5 +- .../Services/OpenApiWorkspace.cs | 17 ++++- .../V31Tests/OpenApiDynamicRefTests.cs | 62 +++++++++++++++++++ .../V32Tests/OpenApiDynamicRefTests.cs | 61 ++++++++++++++++++ 5 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index e1300935f..d09df2101 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -455,7 +455,10 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum { var nodeLocation = context.GetLocation(); var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer); - var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument); + string? externalResource = JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicPointer) + ? null + : JsonNodeHelper.ExtractDocumentUri(dynamicPointer); + var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument, externalResource); var referenceMetadata = new OpenApiSchema(); jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, static (schema, name, value) => diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index a902e557e..c7f1d50b2 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -455,7 +455,10 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum { var nodeLocation = context.GetLocation(); var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer); - var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument); + string? externalResource = JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicPointer) + ? null + : JsonNodeHelper.ExtractDocumentUri(dynamicPointer); + var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument, externalResource); var referenceMetadata = new OpenApiSchema(); jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context, static (schema, name, value) => diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index fac4da696..476a4628f 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -689,6 +689,12 @@ private void RegisterAnchor(OpenApiDocument document, string anchorName, IOpenAp /// For fragment-only refs (#node), resolves against the host document. /// For URI refs (absolute), finds the external document and resolves there. /// + /// + /// Resolves a $dynamicRef value against the workspace's anchor registries. + /// For fragment-only refs (#node), resolves against the host document. + /// For URI refs (absolute or relative), finds the external document and resolves there. + /// Relative URIs are resolved against the host document's BaseUri. + /// internal IOpenApiSchema? ResolveDynamicRef(OpenApiDocument hostDocument, string dynamicRef) { var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicRef); @@ -698,7 +704,16 @@ private void RegisterAnchor(OpenApiDocument document, string anchorName, IOpenAp if (!JsonNodeHelper.IsFragmentOnlyDynamicRef(dynamicRef)) { var docUri = JsonNodeHelper.ExtractDocumentUri(dynamicRef); - targetDoc = docUri is not null ? FindDocumentByBaseUri(docUri) : null; + if (docUri is not null) + { + if (!Uri.IsWellFormedUriString(docUri, UriKind.Absolute) && hostDocument.BaseUri is not null) + docUri = new Uri(hostDocument.BaseUri, docUri).AbsoluteUri; + targetDoc = FindDocumentByBaseUri(docUri); + } + else + { + targetDoc = null; + } } if (targetDoc is null) return null; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs index 4b8f7580a..e159183a6 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs @@ -38,6 +38,7 @@ public void BareDynamicRefDeserializesAsSchemaReference() var reference = Assert.IsType(result); Assert.Equal("#category", reference.Reference.DynamicRef); Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Reference.ExternalResource); } [Fact] @@ -1501,6 +1502,67 @@ public async Task ExternalDynamicRefResolvesToPlainAnchorInExternalDocument() var next = tree.Properties["next"]; var reference = Assert.IsType(next); Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("https://example.com/external", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + + [Fact] + public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() + { + var yamlA = + """ + openapi: 3.1.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'external.yaml#node' + """; + + var yamlB = + """ + openapi: 3.1.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main.yaml"); + docB.BaseUri = new("https://example.com/external.yaml"); + + var workspace = docA.Workspace ?? new OpenApiWorkspace(); + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("external.yaml", reference.Reference.ExternalResource); Assert.NotNull(reference.Target); Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs index b2f8103e1..5aea202dd 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs @@ -36,6 +36,7 @@ public void BareDynamicRefDeserializesAsSchemaReference() var reference = Assert.IsType(result); Assert.Equal("#category", reference.Reference.DynamicRef); Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Null(reference.Reference.ExternalResource); } [Fact] @@ -124,6 +125,66 @@ public async Task DynamicRefReturnsNullForUnknownAnchor() Assert.Null(reference.Target); } + [Fact] + public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() + { + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'external.yaml#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main.yaml"); + docB.BaseUri = new("https://example.com/external.yaml"); + + var workspace = docA.Workspace ?? new OpenApiWorkspace(); + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("external.yaml", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } + [Fact] public async Task AbsoluteDynamicRefDoesNotResolveToLocalAnchor() { From d50ac83ba4bb6df2dd96f7379c7d10e8c8a49547 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Tue, 14 Jul 2026 17:50:41 +0300 Subject: [PATCH 2/2] test: mirror V31 dynamic ref tests to V32 and fix workspace fallback Adds 17 missing V32 test mirrors for full parity with V31 coverage. Replaces dead '?? new OpenApiWorkspace()' fallback with null-forgiving '!' in both V31 and V32 cross-document tests. --- .../V31Tests/OpenApiDynamicRefTests.cs | 6 +- .../V32Tests/OpenApiDynamicRefTests.cs | 802 +++++++++++++++++- 2 files changed, 804 insertions(+), 4 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs index e159183a6..49921034c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs @@ -635,7 +635,7 @@ public async Task AbsoluteDynamicRefResolvesAcrossDocuments() docA.BaseUri = new("https://example.com/main"); docB.BaseUri = new("https://example.com/external"); - var workspace = docA.Workspace ?? new OpenApiWorkspace(); + var workspace = docA.Workspace!; workspace.RegisterComponents(docA); workspace.RegisterComponents(docB); @@ -1494,7 +1494,7 @@ public async Task ExternalDynamicRefResolvesToPlainAnchorInExternalDocument() docA.BaseUri = new("https://example.com/main"); docB.BaseUri = new("https://example.com/external"); - var workspace = docA.Workspace ?? new OpenApiWorkspace(); + var workspace = docA.Workspace!; workspace.RegisterComponents(docA); workspace.RegisterComponents(docB); @@ -1554,7 +1554,7 @@ public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() docA.BaseUri = new("https://example.com/main.yaml"); docB.BaseUri = new("https://example.com/external.yaml"); - var workspace = docA.Workspace ?? new OpenApiWorkspace(); + var workspace = docA.Workspace!; workspace.RegisterComponents(docA); workspace.RegisterComponents(docB); diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs index 5aea202dd..e35b501dc 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Net.Http; using System.Text; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -172,7 +174,7 @@ public async Task ExternalDynamicRefWithRelativeUriResolvesAcrossDocuments() docA.BaseUri = new("https://example.com/main.yaml"); docB.BaseUri = new("https://example.com/external.yaml"); - var workspace = docA.Workspace ?? new OpenApiWorkspace(); + var workspace = docA.Workspace!; workspace.RegisterComponents(docA); workspace.RegisterComponents(docB); @@ -1061,4 +1063,802 @@ public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReference }; Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(refWithDefs, "node")); } + + [Fact] + public async Task DynamicRefResolvesViaDefsAnchor() + { + var yaml = + """ + openapi: 3.2.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.2.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.2.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.2.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.2.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.2.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 ExistingRefWithPathStillWorks() + { + var yaml = + """ + openapi: 3.2.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 AbsoluteDynamicRefResolvesToLocalAnchorWhenExternalNotLoaded() + { + // A URI-based $dynamicRef (https://example.com/external#node) targets an external resource. + // When the external document is not loaded in the workspace, resolution returns null + // rather than falling back to a local same-named anchor. + 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 AbsoluteDynamicRefResolvesAcrossDocuments() + { + // Document A references an anchor in Document B via a URI-based $dynamicRef. + // Both documents are in the same workspace. + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $dynamicAnchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], 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.2.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.2.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.2.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.2.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 ResolveDynamicAnchorInContextReturnsNullForNoMatch() + { + var yaml = + """ + openapi: 3.2.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 ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceHolders() + { + var yaml = + """ + openapi: 3.2.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + Target: + $defs: + node: + $dynamicAnchor: node + type: object + properties: + name: + type: string + Referrer: + $ref: '#/components/schemas/Target' + """; + + var doc = await LoadDocumentAsync(yaml); + + var referrer = doc.Components.Schemas["Referrer"]; + var refSchema = Assert.IsType(referrer); + + var result = OpenApiWorkspace.ResolveDynamicAnchorInContext(refSchema, "node"); + Assert.Null(result); + } + + [Fact] + public void DynamicRefResolvesInObjectModelBuiltDocument() + { + var doc = new OpenApiDocument(); + var tree = new OpenApiSchema + { + DynamicAnchor = "node", + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["next"] = new OpenApiSchemaReference("node", doc) + { + Reference = { DynamicRef = "#node" } + } + } + }; + doc.AddComponent("Tree", tree); + doc.Workspace = new OpenApiWorkspace(); + doc.Workspace.RegisterComponents(doc); + + var nextRef = Assert.IsType(tree.Properties["next"]); + nextRef.Reference.SetJsonPointerPath("#node", "#/components/schemas/Tree/properties/next"); + Assert.True(nextRef.Reference.IsDynamicRefOnly); + Assert.NotNull(nextRef.Target); + Assert.Same(tree, nextRef.Target); + } + + [Fact] + public async Task ExternalDynamicRefResolvesToPlainAnchorInExternalDocument() + { + // Document B has only $anchor (no $dynamicAnchor). FindDocumentByBaseUri must search + // both registries so the $anchor fallback works for URI-based $dynamicRef. + var yamlA = + """ + openapi: 3.2.0 + info: + title: Doc A + version: 1.0.0 + paths: {} + components: + schemas: + Tree: + type: object + properties: + next: + $dynamicRef: 'https://example.com/external#node' + """; + + var yamlB = + """ + openapi: 3.2.0 + info: + title: Doc B + version: 1.0.0 + paths: {} + components: + schemas: + ExternalNode: + $anchor: node + type: object + properties: + value: + type: string + """; + + using var streamA = new MemoryStream(Encoding.UTF8.GetBytes(yamlA)); + using var streamB = new MemoryStream(Encoding.UTF8.GetBytes(yamlB)); + var resultA = await OpenApiDocument.LoadAsync(streamA, "yaml", SettingsFixture.ReaderSettings); + var resultB = await OpenApiDocument.LoadAsync(streamB, "yaml", SettingsFixture.ReaderSettings); + + var docA = resultA.Document; + var docB = resultB.Document; + + docA.BaseUri = new("https://example.com/main"); + docB.BaseUri = new("https://example.com/external"); + + var workspace = docA.Workspace!; + workspace.RegisterComponents(docA); + workspace.RegisterComponents(docB); + + var tree = docA.Components.Schemas["Tree"]; + var next = tree.Properties["next"]; + var reference = Assert.IsType(next); + Assert.True(reference.Reference.IsDynamicRefOnly); + Assert.Equal("https://example.com/external", reference.Reference.ExternalResource); + Assert.NotNull(reference.Target); + Assert.Same(docB.Components.Schemas["ExternalNode"], reference.Target); + } }