From a8688dc59ab69060e5413e5ac2f15430bd96e40f Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Wed, 15 Jul 2026 19:17:59 +0300 Subject: [PATCH] feat: support relative URI resolution in $dynamicRef (#2928) (#2945) * 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 * 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. * fix: remove duplicate XML doc and use Uri.TryCreate for relative URI resolution Addresses Copilot review feedback on #2945. --- .../Reader/V31/OpenApiSchemaDeserializer.cs | 5 +- .../Services/OpenApiWorkspace.cs | 16 ++++- .../V31Tests/OpenApiDynamicRefTests.cs | 66 ++++++++++++++++++- .../OpenApiSchema/schemaWithConstNull.json | 22 ------- .../mutualTlsSecurityScheme.yaml | 2 - 5 files changed, 82 insertions(+), 29 deletions(-) delete mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json delete mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/mutualTlsSecurityScheme.yaml 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/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index ff26d92c0..723562a74 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -671,7 +671,8 @@ private void RegisterAnchor(OpenApiDocument document, string anchorName, IOpenAp /// /// Resolves a $dynamicRef value against the workspace's anchor registries. /// For fragment-only refs (#node), resolves against the host document. - /// For URI refs (absolute), finds the external document and resolves there. + /// 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) { @@ -682,7 +683,18 @@ 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 + && Uri.TryCreate(hostDocument.BaseUri, docUri, out var resolved)) + docUri = resolved.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/Samples/OpenApiSchema/schemaWithConstNull.json b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json deleted file mode 100644 index 586d2f726..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/schemaWithConstNull.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "status": { - "type": "string", - "const": null - }, - "user": { - "type": "object", - "properties": { - "role": { - "type": "string", - "const": null - } - }, - "required": [ "role" ] - } - }, - "required": [ "status" ] -} - diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/mutualTlsSecurityScheme.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/mutualTlsSecurityScheme.yaml deleted file mode 100644 index 72b4e9ae8..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSecurityScheme/mutualTlsSecurityScheme.yaml +++ /dev/null @@ -1,2 +0,0 @@ -type: mutualTLS -description: Sample Description