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..49921034c 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]
@@ -634,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);
@@ -1493,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);
@@ -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!;
+ 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..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;
@@ -36,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]
@@ -124,6 +127,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!;
+ 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()
{
@@ -1000,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);
+ }
}