From a13fbc2f0100177f9f0c7c40aaccd00a269a29b3 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 30 Oct 2026 00:59:11 +0530 Subject: [PATCH 1/3] fix(v31): recognize external resource in schema ref without fragment (#3148) Fix GetReferenceIdAndExternalResource in OpenApiV31Deserializer and OpenApiV32Deserializer so that references to external documents without a fragment (e.g., schemas/uuid.schema.json or an absolute URL) correctly populate ExternalResource and mark IsExternal = true. Signed-off-by: Hardik Kaurani --- .../Reader/V31/OpenApiV31Deserializer.cs | 2 +- .../Reader/V32/OpenApiV32Deserializer.cs | 2 +- ...OpenApiSchemaReferenceDeserializerTests.cs | 26 +++++++++++++++++++ ...OpenApiSchemaReferenceDeserializerTests.cs | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs index 617ee9ced..76e1de571 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs @@ -165,7 +165,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; - if (isExternalResource && pointer.Contains('#')) + if (isExternalResource) { externalResource = pointer.Split('#')[0].TrimEnd('#'); } diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs index 5efed4657..bf43941f2 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32Deserializer.cs @@ -165,7 +165,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; - if (isExternalResource && pointer.Contains('#')) + if (isExternalResource) { externalResource = pointer.Split('#')[0].TrimEnd('#'); } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs index 782ae6e45..5b05cc32a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs @@ -57,4 +57,30 @@ public void ShouldDeserializeSchemaReferenceAnnotations() Assert.Equal("example reference value", resultReference.Examples[0]?.ToString()); Assert.NotNull(resultReference.Target); } + + [Theory] + [InlineData("schemas/uuid.schema.json", "schemas/uuid.schema.json", "schemas/uuid.schema.json")] + [InlineData("./schemas/uuid.schema.json", "./schemas/uuid.schema.json", "./schemas/uuid.schema.json")] + [InlineData("uuid.schema.json", "uuid.schema.json", "uuid.schema.json")] + [InlineData("https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json")] + public void ShouldDeserializeExternalSchemaReferenceWithoutFragment(string refPointer, string expectedId, string expectedExternalResource) + { + var json = $$""" + { + "$ref": "{{refPointer}}" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV31Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + Assert.NotNull(result); + var resultReference = Assert.IsType(result); + + Assert.True(resultReference.Reference.IsExternal); + Assert.Equal(expectedId, resultReference.Reference.Id); + Assert.Equal(expectedExternalResource, resultReference.Reference.ExternalResource); + } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs index 100e72b4e..fe7b4d5e8 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs @@ -57,5 +57,31 @@ public void ShouldDeserializeSchemaReferenceAnnotations() Assert.Equal("example reference value", resultReference.Examples[0]?.ToString()); Assert.NotNull(resultReference.Target); } + + [Theory] + [InlineData("schemas/uuid.schema.json", "schemas/uuid.schema.json", "schemas/uuid.schema.json")] + [InlineData("./schemas/uuid.schema.json", "./schemas/uuid.schema.json", "./schemas/uuid.schema.json")] + [InlineData("uuid.schema.json", "uuid.schema.json", "uuid.schema.json")] + [InlineData("https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json", "https://example.com/schemas/resource.json")] + public void ShouldDeserializeExternalSchemaReferenceWithoutFragment(string refPointer, string expectedId, string expectedExternalResource) + { + var json = $$""" + { + "$ref": "{{refPointer}}" + } + """; + + var hostDocument = new OpenApiDocument(); + var jsonNode = JsonNode.Parse(json); + + var result = OpenApiV32Deserializer.LoadSchema(jsonNode, hostDocument, new ParsingContext(new())); + + Assert.NotNull(result); + var resultReference = Assert.IsType(result); + + Assert.True(resultReference.Reference.IsExternal); + Assert.Equal(expectedId, resultReference.Reference.Id); + Assert.Equal(expectedExternalResource, resultReference.Reference.ExternalResource); + } } From 8003bbf4ece8e89c6bc44369cb0fb783bf6c3464 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 30 Oct 2026 01:01:56 +0530 Subject: [PATCH 2/3] fix(models): resolve relative reference URIs safely against BaseUri (#3148) Update OpenApiDocument.ResolveReference and OpenApiWorkspace.ToLocationUrl to resolve relative references safely against BaseUri or external document URIs using Uri.TryCreate, preventing unhandled UriFormatException and ArgumentNullException when resolving relative references without fragments. Signed-off-by: Hardik Kaurani --- .../Models/OpenApiDocument.cs | 31 +++++++++----- .../Services/OpenApiWorkspace.cs | 10 ++++- .../V31Tests/RelativeReferenceTests.cs | 34 ++++++++++++++++ .../Models/OpenApiDocumentTests.cs | 40 +++++++++++++++++++ .../Workspaces/OpenApiWorkspaceTests.cs | 15 +++++++ 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 03086600d..7203ca414 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -630,7 +630,7 @@ private static string ConvertByteArrayToString(byte[] hash) string uriLocation; var id = reference.Id; - if (!string.IsNullOrEmpty(id) && id!.Contains('/')) // this means its a URL reference + if (!string.IsNullOrEmpty(id) && (id!.Contains('/') || (reference.IsExternal && reference.ExternalResource == id))) // this means its a URL or standalone external reference { uriLocation = id!; } @@ -668,17 +668,26 @@ private static string ConvertByteArrayToString(byte[] hash) : BaseUri + relativePath; } - var absoluteUri = -#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER - uriLocation.StartsWith('#') -#else - uriLocation.StartsWith("#", StringComparison.OrdinalIgnoreCase) -#endif - switch + Uri? resolvedUri = null; + if (Uri.TryCreate(uriLocation, UriKind.Absolute, out var absUri)) { - true => new Uri(BaseUri, uriLocation).AbsoluteUri, - false => new Uri(uriLocation).AbsoluteUri - }; + resolvedUri = absUri; + } + else if (useExternal && reference.ExternalResource is not null && Workspace?.GetDocumentId(reference.ExternalResource) is Uri extDocUri) + { + resolvedUri = extDocUri; + } + else if (BaseUri is not null && Uri.TryCreate(BaseUri, uriLocation, out var relCombinedUri)) + { + resolvedUri = relCombinedUri; + } + + if (resolvedUri is null) + { + return null; + } + + var absoluteUri = resolvedUri.AbsoluteUri; if (reference.Type is ReferenceType.Schema && absoluteUri.Contains('#') && parentSchema is not null) { diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index 8872a8983..c9708339e 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -949,10 +949,16 @@ public bool Contains(string location) private Uri? ToLocationUrl(string location) { - if (BaseUrl is not null) + if (Uri.TryCreate(location, UriKind.Absolute, out var absoluteUri)) { - return new(BaseUrl, location); + return absoluteUri; } + + if (BaseUrl is not null && Uri.TryCreate(BaseUrl, location, out var relativeUri)) + { + return relativeUri; + } + return null; } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index d829c4d5a..2bed9c518 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -747,5 +747,39 @@ public void ExitsEarlyOnCyclicalReferences() Assert.Equal("#/components/schemas/Category", parentSchemaRef.Reference.ReferenceV3); Assert.NotNull(parentSchemaRef.Target); } + + [Fact] + public void ParseDocumentWithRelativeReferenceWithoutFragmentDoesNotThrowUriFormatException() + { + var openApi = """ + { + "openapi": "3.1.1", + "info": { "title": "Repro", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Request": { + "type": "object", + "properties": { "attemptId": { "$ref": "schemas/uuid.schema.json" } } + } + } + } + } + """; + + var reader = new OpenApiStringReader(); + var doc = reader.Read(openApi, out var diagnostic); + + Assert.NotNull(doc); + Assert.Empty(diagnostic.Errors); + + var requestSchema = Assert.IsType(doc.Components.Schemas["Request"]); + var attemptIdRef = Assert.IsType(requestSchema.Properties["attemptId"]); + Assert.True(attemptIdRef.Reference.IsExternal); + Assert.Equal("schemas/uuid.schema.json", attemptIdRef.Reference.ExternalResource); + Assert.Equal("schemas/uuid.schema.json", attemptIdRef.Reference.Id); + + Assert.Null(attemptIdRef.Target); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs index 8b4629664..b9e066c60 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs @@ -2697,5 +2697,45 @@ public void GetOperationById_ThrowsOnNullOrEmptyId() Assert.Throws(() => doc.GetOperationById(null!)); Assert.Throws(() => doc.GetOperationById(string.Empty)); } + + [Theory] + [InlineData("schemas/uuid.schema.json")] + [InlineData("./schemas/uuid.schema.json")] + [InlineData("uuid.schema.json")] + public void ResolveReference_RelativeReferenceWithoutFragmentDoesNotThrowUriFormatException(string referenceId) + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + BaseUri = new Uri("https://example.com/api/openapi.json"), + }; + + var schemaRef = new OpenApiSchemaReference(referenceId, doc, referenceId); + + var result = doc.ResolveReferenceTo(schemaRef.Reference, null); + Assert.Null(result); + Assert.Null(schemaRef.Target); + } + + [Fact] + public void ResolveReference_RelativeReferenceWithoutFragmentResolvesAgainstWorkspace() + { + var workspace = new OpenApiWorkspace(new Uri("https://example.com/api/")); + var targetSchema = new OpenApiSchema { Type = JsonSchemaType.String, Description = "UUID schema" }; + workspace.RegisterComponent("https://example.com/api/schemas/uuid.schema.json", targetSchema); + + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + BaseUri = new Uri("https://example.com/api/openapi.json"), + Workspace = workspace, + }; + + var schemaRef = new OpenApiSchemaReference("schemas/uuid.schema.json", doc, "schemas/uuid.schema.json"); + + var result = doc.ResolveReferenceTo(schemaRef.Reference, null); + Assert.Same(targetSchema, result); + Assert.Same(targetSchema, schemaRef.Target); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs index 8bb018e9f..8f97f2c5a 100644 --- a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs @@ -172,6 +172,21 @@ private static OpenApiDocument CreateCommonDocument() return doc; } + + [Fact] + public void OpenApiWorkspacesCanResolveAbsoluteUriReferences() + { + var workspace = new OpenApiWorkspace(); + var targetSchema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "Schema at absolute URI" + }; + workspace.RegisterComponent("https://example.com/schemas/uuid.schema.json", targetSchema); + + var resolved = workspace.ResolveReference("https://example.com/schemas/uuid.schema.json"); + Assert.Same(targetSchema, resolved); + } } public static class OpenApiFactoryExtensions From d0eeafadf5b15cc873e09410955f223fe13a9f18 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sat, 31 Oct 2026 01:46:55 +0530 Subject: [PATCH 3/3] fix(models): resolve review findings for external refs, discriminator mappings, and workspace URLs (#3148) - Preserve component fragments in OpenApiDocument.ResolveReference when external resource and component names collide (e.g. common.json#/components/schemas/common.json). - Preserve authored fragmentless relative pointers in BaseOpenApiReference.SetJsonPointerPath and GetExternalReferenceV3 to maintain exact round trips during serialization. - Preserve bare schema names in OpenApiDiscriminatorDeserializer.LoadMapping so local schemas in discriminator mapping and defaultMapping resolve properly. - Resolve root-relative locations against BaseUrl in OpenApiWorkspace.ToLocationUrl before fallback to absolute URI parsing. - Catch loader errors gracefully in OpenApiWorkspaceLoader to report diagnostics without aborting document resolution. - Add regression unit tests for colliding external references, discriminator bare schema mappings, workspace root-relative URI resolution, and round-trip serialization. Signed-off-by: Hardik Kaurani --- .../Models/BaseOpenApiReference.cs | 10 +++- .../Models/OpenApiDocument.cs | 15 +++-- .../Reader/Services/OpenApiWorkspaceLoader.cs | 27 +++++---- .../V31/OpenApiDiscriminatorDeserializer.cs | 22 +++++++- .../V32/OpenApiDiscriminatorDeserializer.cs | 22 +++++++- .../Services/OpenApiWorkspace.cs | 10 ++-- .../V31Tests/OpenApiComponentsTests.cs | 4 +- .../V31Tests/OpenApiDiscriminatorTests.cs | 56 ++++++++++++++++++- ...OpenApiSchemaReferenceDeserializerTests.cs | 1 + .../V31Tests/RelativeReferenceTests.cs | 13 ++++- .../V32Tests/OpenApiComponentsTests.cs | 2 +- .../V32Tests/OpenApiDiscriminatorTests.cs | 56 ++++++++++++++++++- ...OpenApiSchemaReferenceDeserializerTests.cs | 1 + .../Models/OpenApiDocumentTests.cs | 25 ++++++++- .../Workspaces/OpenApiWorkspaceTests.cs | 17 +++++- 15 files changed, 247 insertions(+), 34 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs b/src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs index d32080890..ba1e7135e 100644 --- a/src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs +++ b/src/Microsoft.OpenApi/Models/BaseOpenApiReference.cs @@ -254,6 +254,11 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) return Id; } + if (string.Equals(ExternalResource, Id, StringComparison.Ordinal) && !Id.Contains("#")) + { + return ExternalResource; + } + return ExternalResource + "#/components/" + Type.GetDisplayName() + "/"+ Id; } @@ -329,9 +334,8 @@ internal void SetJsonPointerPath(string pointer, string nodeLocation) ReferenceV3 = ResolveRelativePointer(nodeLocation, pointer); } - // Absolute reference or anchor (e.g. "#/components/schemas/..." or full URL) - else if ((pointer.Contains('#') || pointer.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - && !string.Equals(ReferenceV3, pointer, StringComparison.OrdinalIgnoreCase)) + // Absolute reference, anchor, or relative pointer (e.g. "#/components/schemas/...", full URL, or "schemas/uuid.schema.json") + else if (!string.Equals(ReferenceV3, pointer, StringComparison.OrdinalIgnoreCase)) { ReferenceV3 = pointer; } diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 7203ca414..a0213e25b 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -630,14 +630,21 @@ private static string ConvertByteArrayToString(byte[] hash) string uriLocation; var id = reference.Id; - if (!string.IsNullOrEmpty(id) && (id!.Contains('/') || (reference.IsExternal && reference.ExternalResource == id))) // this means its a URL or standalone external reference + var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty; + var isStandaloneExternal = reference.IsExternal && + (!string.IsNullOrEmpty(referenceV3) ? !referenceV3.Contains("#") : (id is not null && !id.Contains("#"))); + + if (isStandaloneExternal) + { + uriLocation = id ?? reference.ExternalResource ?? string.Empty; + } + else if (!string.IsNullOrEmpty(id) && id!.Contains("/")) { uriLocation = id!; } else { string relativePath; - var referenceV3 = !string.IsNullOrEmpty(reference.ReferenceV3) ? reference.ReferenceV3! : string.Empty; if (reference.Type is ReferenceType.Schema && useExternal && TryGetPlainNameFragment(referenceV3, out var plainNameFragment)) { @@ -689,7 +696,7 @@ private static string ConvertByteArrayToString(byte[] hash) var absoluteUri = resolvedUri.AbsoluteUri; - if (reference.Type is ReferenceType.Schema && absoluteUri.Contains('#') && parentSchema is not null) + if (reference.Type is ReferenceType.Schema && absoluteUri.Contains("#") && parentSchema is not null) { return Workspace?.ResolveJsonSchemaReference(absoluteUri, parentSchema); } diff --git a/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs b/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs index e793aa985..29914ddac 100644 --- a/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs +++ b/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -45,18 +45,25 @@ internal async Task LoadAsync(BaseOpenApiReference reference, // If not already in workspace, load it and process references if (item.ExternalResource is not null && !_workspace.Contains(item.ExternalResource)) { - var uri = new Uri(item.ExternalResource, UriKind.RelativeOrAbsolute); - var input = await _loader.LoadAsync(item.HostDocument!.BaseUri, uri, cancellationToken).ConfigureAwait(false); - var result = await OpenApiDocument.LoadAsync(input, format, _readerSettings, cancellationToken).ConfigureAwait(false); - // Merge diagnostics - if (result.Diagnostic != null) + try { - diagnostic.AppendDiagnostic(result.Diagnostic, item.ExternalResource); + var uri = new Uri(item.ExternalResource, UriKind.RelativeOrAbsolute); + var input = await _loader.LoadAsync(item.HostDocument!.BaseUri, uri, cancellationToken).ConfigureAwait(false); + var result = await OpenApiDocument.LoadAsync(input, format, _readerSettings, cancellationToken).ConfigureAwait(false); + // Merge diagnostics + if (result.Diagnostic != null) + { + diagnostic.AppendDiagnostic(result.Diagnostic, item.ExternalResource); + } + if (result.Document != null) + { + var loadDiagnostic = await LoadAsync(item, result.Document, format, diagnostic, cancellationToken).ConfigureAwait(false); + diagnostic = loadDiagnostic; + } } - if (result.Document != null) + catch (Exception ex) { - var loadDiagnostic = await LoadAsync(item, result.Document, format, diagnostic, cancellationToken).ConfigureAwait(false); - diagnostic = loadDiagnostic; + diagnostic.Errors.Add(new OpenApiError(string.Empty, ex.Message)); } } } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiDiscriminatorDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiDiscriminatorDeserializer.cs index 38452fda2..8208053ba 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiDiscriminatorDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiDiscriminatorDeserializer.cs @@ -56,8 +56,28 @@ public static OpenApiDiscriminator LoadDiscriminator(JsonNode node, OpenApiDocum public static OpenApiSchemaReference LoadMapping(JsonNode node, OpenApiDocument hostDocument, ParsingContext context) { var pointer = node.GetScalarValue() ?? throw new InvalidOperationException("Could not get a pointer reference"); + if (IsBareSchemaName(pointer)) + { + var schemaRef = new OpenApiSchemaReference(pointer, hostDocument); + schemaRef.Reference.SetJsonPointerPath(pointer, context.GetLocation()); + return schemaRef; + } + var reference = GetReferenceIdAndExternalResource(pointer); - return new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); + var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); + result.Reference.SetJsonPointerPath(pointer, context.GetLocation()); + return result; + } + + private static bool IsBareSchemaName(string pointer) + { + return !pointer.StartsWith("#", StringComparison.OrdinalIgnoreCase) + && !pointer.Contains("/") + && !pointer.Contains("#") + && !pointer.Contains(":") + && !pointer.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !pointer.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) + && !pointer.EndsWith(".yml", StringComparison.OrdinalIgnoreCase); } } } diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiDiscriminatorDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiDiscriminatorDeserializer.cs index a3aec0c53..0ed9c3bf8 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiDiscriminatorDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiDiscriminatorDeserializer.cs @@ -51,8 +51,28 @@ public static OpenApiDiscriminator LoadDiscriminator(JsonNode node, OpenApiDocum public static OpenApiSchemaReference LoadMapping(JsonNode node, OpenApiDocument hostDocument, ParsingContext context) { var pointer = node.GetScalarValue() ?? throw new InvalidOperationException("Could not get a pointer reference"); + if (IsBareSchemaName(pointer)) + { + var schemaRef = new OpenApiSchemaReference(pointer, hostDocument); + schemaRef.Reference.SetJsonPointerPath(pointer, context.GetLocation()); + return schemaRef; + } + var reference = GetReferenceIdAndExternalResource(pointer); - return new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); + var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); + result.Reference.SetJsonPointerPath(pointer, context.GetLocation()); + return result; + } + + private static bool IsBareSchemaName(string pointer) + { + return !pointer.StartsWith("#", StringComparison.OrdinalIgnoreCase) + && !pointer.Contains("/") + && !pointer.Contains("#") + && !pointer.Contains(":") + && !pointer.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !pointer.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) + && !pointer.EndsWith(".yml", StringComparison.OrdinalIgnoreCase); } } } diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index c9708339e..7c58d476a 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -949,14 +949,14 @@ public bool Contains(string location) private Uri? ToLocationUrl(string location) { - if (Uri.TryCreate(location, UriKind.Absolute, out var absoluteUri)) + if (BaseUrl is not null && Uri.TryCreate(BaseUrl, location, out var relativeUri)) { - return absoluteUri; + return relativeUri; } - if (BaseUrl is not null && Uri.TryCreate(BaseUrl, location, out var relativeUri)) + if (Uri.TryCreate(location, UriKind.Absolute, out var absoluteUri)) { - return relativeUri; + return absoluteUri; } return null; diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiComponentsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiComponentsTests.cs index 01f7d97a8..7c6c7ba6c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiComponentsTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiComponentsTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using Microsoft.OpenApi.Reader; @@ -14,7 +14,7 @@ public class OpenApiComponentsTests [InlineData("A:\\Dir\\File.json#/components/schemas/ExternalAbsWindowsPathModel", "ExternalAbsWindowsPathModel", "A:\\Dir\\File.json")] [InlineData("/Dir/File.json#/components/schemas/ExternalAbsUnixPathModel", "ExternalAbsUnixPathModel", "/Dir/File.json")] [InlineData("https://host.lan:1234/path/to/file/resource.json#/components/schemas/ExternalHttpsModel", "ExternalHttpsModel", "https://host.lan:1234/path/to/file/resource.json")] - [InlineData("File.json", "File.json", null)] + [InlineData("File.json", "File.json", "File.json")] public void ParseExternalSchemaReferenceShouldSucceed(string reference, string referenceId, string externalResource) { var input = $@"{{ diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs index 854afad9e..800bacabc 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDiscriminatorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -42,5 +42,59 @@ public async Task ParseBasicDiscriminatorShouldSucceed() DefaultMapping = new OpenApiSchemaReference("Animal", openApiDocument) }, discriminator); } + + [Fact] + public void ParseDiscriminatorWithBareSchemaNameMappingResolvesLocalTarget() + { + var openApi = """ + { + "openapi": "3.1.1", + "info": { "title": "Repro", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Dog": { + "type": "object", + "properties": { "bark": { "type": "boolean" } } + }, + "Pet": { + "type": "object", + "required": [ "petType" ], + "properties": { + "petType": { "type": "string" } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "Dog" + }, + "x-oas-default-mapping": "Dog" + } + } + } + } + } + """; + + var readResult = OpenApiDocument.Parse(openApi, "json"); + var doc = readResult.Document; + Assert.NotNull(doc); + Assert.Empty(readResult.Diagnostic.Errors); + + var petSchema = Assert.IsType(doc.Components.Schemas["Pet"]); + Assert.NotNull(petSchema.Discriminator); + + var dogMapping = petSchema.Discriminator.Mapping["dog"]; + Assert.False(dogMapping.Reference.IsExternal); + Assert.Null(dogMapping.Reference.ExternalResource); + Assert.Equal("Dog", dogMapping.Reference.Id); + + var dogSchema = Assert.IsType(doc.Components.Schemas["Dog"]); + Assert.Same(dogSchema, dogMapping.Target); + + Assert.NotNull(petSchema.Discriminator.DefaultMapping); + Assert.False(petSchema.Discriminator.DefaultMapping.Reference.IsExternal); + Assert.Same(dogSchema, petSchema.Discriminator.DefaultMapping.Target); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs index 5b05cc32a..8010092c4 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaReferenceDeserializerTests.cs @@ -82,5 +82,6 @@ public void ShouldDeserializeExternalSchemaReferenceWithoutFragment(string refPo Assert.True(resultReference.Reference.IsExternal); Assert.Equal(expectedId, resultReference.Reference.Id); Assert.Equal(expectedExternalResource, resultReference.Reference.ExternalResource); + Assert.Equal(refPointer, resultReference.Reference.ReferenceV3); } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index 2bed9c518..fb6d8bf4a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Net.Http; @@ -767,8 +767,9 @@ public void ParseDocumentWithRelativeReferenceWithoutFragmentDoesNotThrowUriForm } """; - var reader = new OpenApiStringReader(); - var doc = reader.Read(openApi, out var diagnostic); + var readResult = OpenApiDocument.Parse(openApi, "json"); + var doc = readResult.Document; + var diagnostic = readResult.Diagnostic; Assert.NotNull(doc); Assert.Empty(diagnostic.Errors); @@ -780,6 +781,12 @@ public void ParseDocumentWithRelativeReferenceWithoutFragmentDoesNotThrowUriForm Assert.Equal("schemas/uuid.schema.json", attemptIdRef.Reference.Id); Assert.Null(attemptIdRef.Target); + + using var writer = new StringWriter(); + doc.SerializeAsV31(new OpenApiJsonWriter(writer)); + var serialized = writer.ToString(); + Assert.Contains("\"$ref\": \"schemas/uuid.schema.json\"", serialized); + Assert.DoesNotContain("#/components/schemas/schemas/uuid.schema.json", serialized); } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiComponentsTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiComponentsTests.cs index da82e35e7..2128454f9 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiComponentsTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiComponentsTests.cs @@ -14,7 +14,7 @@ public class OpenApiComponentsTests [InlineData("A:\\Dir\\File.json#/components/schemas/ExternalAbsWindowsPathModel", "ExternalAbsWindowsPathModel", "A:\\Dir\\File.json")] [InlineData("/Dir/File.json#/components/schemas/ExternalAbsUnixPathModel", "ExternalAbsUnixPathModel", "/Dir/File.json")] [InlineData("https://host.lan:1234/path/to/file/resource.json#/components/schemas/ExternalHttpsModel", "ExternalHttpsModel", "https://host.lan:1234/path/to/file/resource.json")] - [InlineData("File.json", "File.json", null)] + [InlineData("File.json", "File.json", "File.json")] public void ParseExternalSchemaReferenceShouldSucceed(string reference, string referenceId, string externalResource) { var input = $@"{{ diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs index c571b0ce6..7761281a0 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDiscriminatorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System.Collections.Generic; @@ -42,5 +42,59 @@ public async Task ParseBasicDiscriminatorShouldSucceed() DefaultMapping = new OpenApiSchemaReference("Animal", openApiDocument) }, discriminator); } + + [Fact] + public void ParseDiscriminatorWithBareSchemaNameMappingResolvesLocalTarget() + { + var openApi = """ + { + "openapi": "3.2.0", + "info": { "title": "Repro", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Dog": { + "type": "object", + "properties": { "bark": { "type": "boolean" } } + }, + "Pet": { + "type": "object", + "required": [ "petType" ], + "properties": { + "petType": { "type": "string" } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "Dog" + }, + "defaultMapping": "Dog" + } + } + } + } + } + """; + + var readResult = OpenApiDocument.Parse(openApi, "json"); + var doc = readResult.Document; + Assert.NotNull(doc); + Assert.Empty(readResult.Diagnostic.Errors); + + var petSchema = Assert.IsType(doc.Components.Schemas["Pet"]); + Assert.NotNull(petSchema.Discriminator); + + var dogMapping = petSchema.Discriminator.Mapping["dog"]; + Assert.False(dogMapping.Reference.IsExternal); + Assert.Null(dogMapping.Reference.ExternalResource); + Assert.Equal("Dog", dogMapping.Reference.Id); + + var dogSchema = Assert.IsType(doc.Components.Schemas["Dog"]); + Assert.Same(dogSchema, dogMapping.Target); + + Assert.NotNull(petSchema.Discriminator.DefaultMapping); + Assert.False(petSchema.Discriminator.DefaultMapping.Reference.IsExternal); + Assert.Same(dogSchema, petSchema.Discriminator.DefaultMapping.Target); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs index fe7b4d5e8..63777e626 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaReferenceDeserializerTests.cs @@ -82,6 +82,7 @@ public void ShouldDeserializeExternalSchemaReferenceWithoutFragment(string refPo Assert.True(resultReference.Reference.IsExternal); Assert.Equal(expectedId, resultReference.Reference.Id); Assert.Equal(expectedExternalResource, resultReference.Reference.ExternalResource); + Assert.Equal(refPointer, resultReference.Reference.ReferenceV3); } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs index b9e066c60..f332cd8c3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -2737,5 +2737,28 @@ public void ResolveReference_RelativeReferenceWithoutFragmentResolvesAgainstWork Assert.Same(targetSchema, result); Assert.Same(targetSchema, schemaRef.Target); } + + [Fact] + public void ResolveReference_ExternalComponentCollidingWithDocumentNameResolvesComponent() + { + var workspace = new OpenApiWorkspace(new Uri("https://example.com/api/")); + var targetSchema = new OpenApiSchema { Type = JsonSchemaType.String, Description = "Component named common.json" }; + workspace.AddDocumentId("common.json", new Uri("https://example.com/api/common.json")); + workspace.RegisterComponent("https://example.com/api/common.json#/components/schemas/common.json", targetSchema); + + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + BaseUri = new Uri("https://example.com/api/openapi.json"), + Workspace = workspace, + }; + + var schemaRef = new OpenApiSchemaReference("common.json", doc, "common.json"); + schemaRef.Reference.SetJsonPointerPath("common.json#/components/schemas/common.json", "#"); + + var result = doc.ResolveReferenceTo(schemaRef.Reference, null); + Assert.Same(targetSchema, result); + Assert.Same(targetSchema, schemaRef.Target); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs index 8f97f2c5a..4b67dc13b 100644 --- a/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Workspaces/OpenApiWorkspaceTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -187,6 +187,21 @@ public void OpenApiWorkspacesCanResolveAbsoluteUriReferences() var resolved = workspace.ResolveReference("https://example.com/schemas/uuid.schema.json"); Assert.Same(targetSchema, resolved); } + + [Fact] + public void OpenApiWorkspacesResolvesRootRelativeUriAgainstBaseUrl() + { + var workspace = new OpenApiWorkspace(new Uri("https://example.com/base/")); + var targetSchema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "Schema at root-relative URI" + }; + workspace.RegisterComponent("https://example.com/schemas/common.json", targetSchema); + + var resolved = workspace.ResolveReference("/schemas/common.json"); + Assert.Same(targetSchema, resolved); + } } public static class OpenApiFactoryExtensions