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