From 962d6fa817d4ccb18fb5f0f4c75685d0e567f376 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 10 Mar 2025 22:51:15 +0100 Subject: [PATCH 1/3] Fix: relative references in subdirectory documents are not loading #1674 Use OpenApiDocuments BaseUri as location of the document. This allows to have during loading further documents a base Url for retrieval, which can be combined with a relative Uri to get an absolute. --- .../OpenApiYamlReader.cs | 12 ++-- .../Interfaces/IOpenApiReader.cs | 7 +- .../Interfaces/IOpenApiVersionService.cs | 6 +- .../Interfaces/IStreamLoader.cs | 4 +- .../Models/OpenApiDocument.cs | 13 ++-- .../Reader/OpenApiJsonReader.cs | 12 +++- .../Reader/OpenApiModelFactory.cs | 17 +++-- .../Reader/ParsingContext.cs | 9 +-- .../Reader/Services/DefaultStreamLoader.cs | 7 +- .../Reader/Services/OpenApiWorkspaceLoader.cs | 3 +- .../Reader/V2/OpenApiDocumentDeserializer.cs | 7 +- .../Reader/V2/OpenApiV2VersionService.cs | 4 +- .../Reader/V3/OpenApiDocumentDeserializer.cs | 7 +- .../Reader/V3/OpenApiV3VersionService.cs | 4 +- .../Reader/V31/OpenApiDocumentDeserializer.cs | 7 +- .../Reader/V31/OpenApiV31VersionService.cs | 4 +- .../Services/OpenApiWorkspace.cs | 33 +++++++-- .../OpenApiDiagnosticTests.cs | 2 +- .../OpenApiWorkspaceStreamTests.cs | 69 +++++++++++++++++-- .../V31Tests/OpenApiDocumentTests.cs | 7 +- .../Directory/Pets.yaml | 23 +++++++ .../Directory/PetsPage.yaml | 16 +++++ .../Root.yaml | 16 +++++ .../PublicApi/PublicApi.approved.txt | 20 +++--- 24 files changed, 241 insertions(+), 68 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/Pets.yaml create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/PetsPage.yaml create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Root.yaml diff --git a/src/Microsoft.OpenApi.Readers/OpenApiYamlReader.cs b/src/Microsoft.OpenApi.Readers/OpenApiYamlReader.cs index eba4fd248..32970a626 100644 --- a/src/Microsoft.OpenApi.Readers/OpenApiYamlReader.cs +++ b/src/Microsoft.OpenApi.Readers/OpenApiYamlReader.cs @@ -26,25 +26,27 @@ public class OpenApiYamlReader : IOpenApiReader /// public async Task ReadAsync(Stream input, + Uri location, OpenApiReaderSettings settings, CancellationToken cancellationToken = default) { if (input is null) throw new ArgumentNullException(nameof(input)); if (input is MemoryStream memoryStream) { - return Read(memoryStream, settings); + return Read(memoryStream, location, settings); } else { using var preparedStream = new MemoryStream(); await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false); preparedStream.Position = 0; - return Read(preparedStream, settings); + return Read(preparedStream, location, settings); } } /// public ReadResult Read(MemoryStream input, + Uri location, OpenApiReaderSettings settings) { if (input is null) throw new ArgumentNullException(nameof(input)); @@ -74,13 +76,13 @@ public ReadResult Read(MemoryStream input, }; } - return Read(jsonNode, settings); + return Read(jsonNode, location, settings); } /// - public static ReadResult Read(JsonNode jsonNode, OpenApiReaderSettings settings) + public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSettings settings) { - return _jsonReader.Read(jsonNode, settings); + return _jsonReader.Read(jsonNode, location, settings); } /// diff --git a/src/Microsoft.OpenApi/Interfaces/IOpenApiReader.cs b/src/Microsoft.OpenApi/Interfaces/IOpenApiReader.cs index 3b9c85d2f..5f48d1e11 100644 --- a/src/Microsoft.OpenApi/Interfaces/IOpenApiReader.cs +++ b/src/Microsoft.OpenApi/Interfaces/IOpenApiReader.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -18,18 +19,20 @@ public interface IOpenApiReader /// Async method to reads the stream and parse it into an Open API document. /// /// The stream input. + /// Location of where the document that is getting loaded is saved /// The OpenApi reader settings. /// Propagates notification that an operation should be cancelled. /// - Task ReadAsync(Stream input, OpenApiReaderSettings settings, CancellationToken cancellationToken = default); + Task ReadAsync(Stream input, Uri location, OpenApiReaderSettings settings, CancellationToken cancellationToken = default); /// /// Provides a synchronous method to read the input memory stream and parse it into an Open API document. /// /// + /// Location of where the document that is getting loaded is saved /// /// - ReadResult Read(MemoryStream input, OpenApiReaderSettings settings); + ReadResult Read(MemoryStream input, Uri location, OpenApiReaderSettings settings); /// /// Reads the MemoryStream and parses the fragment of an OpenAPI description into an Open API Element. diff --git a/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs b/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs index 073962a35..ef11c8c3d 100644 --- a/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs +++ b/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Reader.ParseNodes; @@ -34,8 +35,9 @@ internal interface IOpenApiVersionService /// Converts a generic RootNode instance into a strongly typed OpenApiDocument /// /// RootNode containing the information to be converted into an OpenAPI Document + /// Location of where the document that is getting loaded is saved /// Instance of OpenApiDocument populated with data from rootNode - OpenApiDocument LoadDocument(RootNode rootNode); + OpenApiDocument LoadDocument(RootNode rootNode, Uri location); /// /// Gets the description and summary scalar values in a reference object for V3.1 support diff --git a/src/Microsoft.OpenApi/Interfaces/IStreamLoader.cs b/src/Microsoft.OpenApi/Interfaces/IStreamLoader.cs index e6438bac1..d56f6075e 100644 --- a/src/Microsoft.OpenApi/Interfaces/IStreamLoader.cs +++ b/src/Microsoft.OpenApi/Interfaces/IStreamLoader.cs @@ -17,9 +17,11 @@ public interface IStreamLoader /// /// Use Uri to locate data and convert into an input object. /// + /// Base URL of parent to which a relative reference could be loaded. + /// If the is an absolute parameter the value of this parameter will be ignored /// Identifier of some source of an OpenAPI Description /// The cancellation token. /// A data object that can be processed by a reader to generate an - Task LoadAsync(Uri uri, CancellationToken cancellationToken = default); + Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 8d04814cd..49462cc27 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -112,9 +112,9 @@ public ISet? Tags public IDictionary? Metadata { get; set; } /// - /// Implements IBaseDocument + /// Absolute location of the document or a generated placeholder if location is not given /// - public Uri BaseUri { get; } + public Uri BaseUri { get; internal set; } /// /// Parameter-less constructor @@ -533,14 +533,15 @@ private static string ConvertByteArrayToString(byte[] hash) } else { - string relativePath = OpenApiConstants.ComponentsSegment + reference.Type.GetDisplayName() + "/" + reference.Id; + string relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{reference.Id}"; + Uri? externalResourceUri = useExternal ? Workspace?.GetDocumentId(reference.ExternalResource) : null; - uriLocation = useExternal - ? Workspace?.GetDocumentId(reference.ExternalResource)?.OriginalString + relativePath + uriLocation = useExternal && externalResourceUri is not null + ? externalResourceUri.AbsoluteUri + relativePath : BaseUri + relativePath; } - return Workspace?.ResolveReference(uriLocation); + return Workspace?.ResolveReference(new Uri(uriLocation).AbsoluteUri); } /// diff --git a/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs b/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs index bac24a51d..f9123bb0f 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs @@ -25,9 +25,11 @@ public class OpenApiJsonReader : IOpenApiReader /// Reads the memory stream input and parses it into an Open API document. /// /// Memory stream containing OpenAPI description to parse. + /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// public ReadResult Read(MemoryStream input, + Uri location, OpenApiReaderSettings settings) { if (input is null) throw new ArgumentNullException(nameof(input)); @@ -52,16 +54,18 @@ public ReadResult Read(MemoryStream input, }; } - return Read(jsonNode, settings); + return Read(jsonNode, location, settings); } /// /// Parses the JsonNode input into an Open API document. /// /// The JsonNode input. + /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// public ReadResult Read(JsonNode jsonNode, + Uri location, OpenApiReaderSettings settings) { if (jsonNode is null) throw new ArgumentNullException(nameof(jsonNode)); @@ -79,7 +83,7 @@ public ReadResult Read(JsonNode jsonNode, try { // Parse the OpenAPI Document - document = context.Parse(jsonNode); + document = context.Parse(jsonNode, location); document.SetReferenceHostDocument(); } catch (OpenApiException ex) @@ -112,10 +116,12 @@ public ReadResult Read(JsonNode jsonNode, /// Reads the stream input asynchronously and parses it into an Open API document. /// /// Memory stream containing OpenAPI description to parse. + /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// Propagates notifications that operations should be cancelled. /// public async Task ReadAsync(Stream input, + Uri location, OpenApiReaderSettings settings, CancellationToken cancellationToken = default) { @@ -140,7 +146,7 @@ public async Task ReadAsync(Stream input, }; } - return Read(jsonNode, settings); + return Read(jsonNode, location, settings); } /// diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index c30f16777..d54a28135 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -240,7 +240,13 @@ private static async Task InternalLoadAsync(Stream input, string for { settings ??= DefaultReaderSettings.Value; var reader = settings.GetReader(format); - var readResult = await reader.ReadAsync(input, settings, cancellationToken).ConfigureAwait(false); + var location = new Uri(OpenApiConstants.BaseRegistryUri); + if (input is FileStream fileStream) + { + location = new Uri(fileStream.Name); + } + + var readResult = await reader.ReadAsync(input, location, settings, cancellationToken).ConfigureAwait(false); if (settings.LoadExternalRefs) { @@ -259,11 +265,11 @@ private static async Task InternalLoadAsync(Stream input, string for private static async Task LoadExternalRefsAsync(OpenApiDocument document, OpenApiReaderSettings settings, string format = null, CancellationToken token = default) { // Create workspace for all documents to live in. - var baseUrl = settings.BaseUrl ?? new Uri(OpenApiConstants.BaseRegistryUri); - var openApiWorkSpace = new OpenApiWorkspace(baseUrl); + var baseUrl = document.BaseUri; + var openApiWorkSpace = document.Workspace; // Load this root document into the workspace - var streamLoader = new DefaultStreamLoader(settings.BaseUrl, settings.HttpClient); + var streamLoader = new DefaultStreamLoader(settings.HttpClient); var workspaceLoader = new OpenApiWorkspaceLoader(openApiWorkSpace, settings.CustomExternalLoader ?? streamLoader, settings); return await workspaceLoader.LoadAsync(new OpenApiReference() { ExternalResource = "/" }, document, format ?? OpenApiConstants.Json, null, token).ConfigureAwait(false); } @@ -280,8 +286,9 @@ private static ReadResult InternalLoad(MemoryStream input, string format, OpenAp throw new ArgumentException($"Cannot parse the stream: {nameof(input)} is empty or contains no elements."); } + var location = new Uri(OpenApiConstants.BaseRegistryUri); var reader = settings.GetReader(format); - var readResult = reader.Read(input, settings); + var readResult = reader.Read(input, location, settings); return readResult; } diff --git a/src/Microsoft.OpenApi/Reader/ParsingContext.cs b/src/Microsoft.OpenApi/Reader/ParsingContext.cs index 485686e89..5fff32d07 100644 --- a/src/Microsoft.OpenApi/Reader/ParsingContext.cs +++ b/src/Microsoft.OpenApi/Reader/ParsingContext.cs @@ -62,8 +62,9 @@ public ParsingContext(OpenApiDiagnostic diagnostic) /// Initiates the parsing process. Not thread safe and should only be called once on a parsing context /// /// Set of Json nodes to parse. + /// Location of where the document that is getting loaded is saved /// An OpenApiDocument populated based on the passed yamlDocument - public OpenApiDocument Parse(JsonNode jsonNode) + public OpenApiDocument Parse(JsonNode jsonNode, Uri location) { RootNode = new RootNode(this, jsonNode); @@ -75,20 +76,20 @@ public OpenApiDocument Parse(JsonNode jsonNode) { case string version when version.is2_0(): VersionService = new OpenApiV2VersionService(Diagnostic); - doc = VersionService.LoadDocument(RootNode); + doc = VersionService.LoadDocument(RootNode, location); this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi2_0; ValidateRequiredFields(doc, version); break; case string version when version.is3_0(): VersionService = new OpenApiV3VersionService(Diagnostic); - doc = VersionService.LoadDocument(RootNode); + doc = VersionService.LoadDocument(RootNode, location); this.Diagnostic.SpecificationVersion = version.is3_1() ? OpenApiSpecVersion.OpenApi3_1 : OpenApiSpecVersion.OpenApi3_0; ValidateRequiredFields(doc, version); break; case string version when version.is3_1(): VersionService = new OpenApiV31VersionService(Diagnostic); - doc = VersionService.LoadDocument(RootNode); + doc = VersionService.LoadDocument(RootNode, location); this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi3_1; ValidateRequiredFields(doc, version); break; diff --git a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs index ad36e5554..13f68e811 100644 --- a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs +++ b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs @@ -17,22 +17,19 @@ namespace Microsoft.OpenApi.Reader.Services /// public class DefaultStreamLoader : IStreamLoader { - private readonly Uri baseUrl; private readonly HttpClient _httpClient; /// /// The default stream loader /// - /// /// The HttpClient to use to retrieve documents when needed - public DefaultStreamLoader(Uri baseUrl, HttpClient httpClient) + public DefaultStreamLoader(HttpClient httpClient) { - this.baseUrl = baseUrl; _httpClient = Utils.CheckArgumentNull(httpClient); } /// - public async Task LoadAsync(Uri uri, CancellationToken cancellationToken = default) + public async Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) { var absoluteUri = (baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri), baseUrl.IsAbsoluteUri, uri.IsAbsoluteUri) switch { diff --git a/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs b/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs index 32090231f..e7818dd46 100644 --- a/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs +++ b/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs @@ -45,7 +45,8 @@ internal async Task LoadAsync(OpenApiReference reference, // If not already in workspace, load it and process references if (!_workspace.Contains(item.ExternalResource)) { - var input = await _loader.LoadAsync(new(item.ExternalResource, UriKind.RelativeOrAbsolute), cancellationToken).ConfigureAwait(false); + 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) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiDocumentDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiDocumentDeserializer.cs index da4060721..3d20dcaed 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiDocumentDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiDocumentDeserializer.cs @@ -221,9 +221,12 @@ private static string BuildUrl(string scheme, string host, string basePath) return uriBuilder.ToString(); } - public static OpenApiDocument LoadOpenApi(RootNode rootNode) + public static OpenApiDocument LoadOpenApi(RootNode rootNode, Uri location) { - var openApiDoc = new OpenApiDocument(); + var openApiDoc = new OpenApiDocument + { + BaseUri = location + }; var openApiNode = rootNode.GetMap(); diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2VersionService.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2VersionService.cs index c4186bb25..13fcfce1f 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2VersionService.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2VersionService.cs @@ -209,9 +209,9 @@ public OpenApiReference ConvertToOpenApiReference(string reference, ReferenceTyp throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, reference)); } - public OpenApiDocument LoadDocument(RootNode rootNode) + public OpenApiDocument LoadDocument(RootNode rootNode, Uri location) { - return OpenApiV2Deserializer.LoadOpenApi(rootNode); + return OpenApiV2Deserializer.LoadOpenApi(rootNode, location); } public T LoadElement(ParseNode node, OpenApiDocument doc) where T : IOpenApiElement diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiDocumentDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiDocumentDeserializer.cs index 044542d21..87f634d5a 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiDocumentDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiDocumentDeserializer.cs @@ -38,9 +38,12 @@ internal static partial class OpenApiV3Deserializer {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p, n))} }; - public static OpenApiDocument LoadOpenApi(RootNode rootNode) + public static OpenApiDocument LoadOpenApi(RootNode rootNode, Uri location) { - var openApiDoc = new OpenApiDocument(); + var openApiDoc = new OpenApiDocument + { + BaseUri = location + }; var openApiNode = rootNode.GetMap(); ParseMap(openApiNode, openApiDoc, _openApiFixedFields, _openApiPatternFields, openApiDoc); diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3VersionService.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3VersionService.cs index 612c59dfb..da327a987 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3VersionService.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3VersionService.cs @@ -170,9 +170,9 @@ public OpenApiReference ConvertToOpenApiReference( throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, reference)); } - public OpenApiDocument LoadDocument(RootNode rootNode) + public OpenApiDocument LoadDocument(RootNode rootNode, Uri location) { - return OpenApiV3Deserializer.LoadOpenApi(rootNode); + return OpenApiV3Deserializer.LoadOpenApi(rootNode, location); } public T LoadElement(ParseNode node, OpenApiDocument doc) where T : IOpenApiElement diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiDocumentDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiDocumentDeserializer.cs index ae95f58e4..270226389 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiDocumentDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiDocumentDeserializer.cs @@ -36,9 +36,12 @@ internal static partial class OpenApiV31Deserializer {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p, n))} }; - public static OpenApiDocument LoadOpenApi(RootNode rootNode) + public static OpenApiDocument LoadOpenApi(RootNode rootNode, Uri location) { - var openApiDoc = new OpenApiDocument(); + var openApiDoc = new OpenApiDocument + { + BaseUri = location + }; var openApiNode = rootNode.GetMap(); ParseMap(openApiNode, openApiDoc, _openApiFixedFields, _openApiPatternFields, openApiDoc); diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs index bfaa82051..321d8a1fd 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs @@ -153,9 +153,9 @@ public OpenApiReference ConvertToOpenApiReference( throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, reference)); } - public OpenApiDocument LoadDocument(RootNode rootNode) + public OpenApiDocument LoadDocument(RootNode rootNode, Uri location) { - return OpenApiV31Deserializer.LoadOpenApi(rootNode); + return OpenApiV31Deserializer.LoadOpenApi(rootNode, location); } public T LoadElement(ParseNode node, OpenApiDocument doc) where T : IOpenApiElement diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index ec368a6c0..3c6e2ce83 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -18,7 +18,30 @@ public class OpenApiWorkspace { private readonly Dictionary _documentsIdRegistry = new(); private readonly Dictionary _artifactsRegistry = new(); - private readonly Dictionary _IOpenApiReferenceableRegistry = new(); + private readonly Dictionary _IOpenApiReferenceableRegistry = new(new UriWithFragmentEquailityComparer()); + + private class UriWithFragmentEquailityComparer : IEqualityComparer + { + public bool Equals(Uri x, Uri y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.AbsoluteUri == y.AbsoluteUri; + } + + public int GetHashCode(Uri obj) + { + return obj.AbsoluteUri.GetHashCode(); + } + } /// /// The base location from where all relative references are resolved @@ -142,7 +165,7 @@ public void RegisterComponents(OpenApiDocument document) private string getBaseUri(OpenApiDocument openApiDocument) { - return openApiDocument.BaseUri + OpenApiConstants.ComponentsSegment; + return openApiDocument.BaseUri + "#" + OpenApiConstants.ComponentsSegment; } /// @@ -224,12 +247,13 @@ public void AddDocumentId(string key, Uri value) } } +#nullable enable /// /// Retrieves the document id given a key. /// /// /// The document id of the given key. - public Uri GetDocumentId(string key) + public Uri? GetDocumentId(string key) { if (_documentsIdRegistry.TryGetValue(key, out var id)) { @@ -249,7 +273,6 @@ public bool Contains(string location) return _IOpenApiReferenceableRegistry.ContainsKey(key) || _artifactsRegistry.ContainsKey(key); } -#nullable enable /// /// Resolves a reference given a key. /// @@ -260,7 +283,7 @@ public bool Contains(string location) { if (string.IsNullOrEmpty(location)) return default; - var uri = ToLocationUrl(location); + var uri = ToLocationUrl(location); if (_IOpenApiReferenceableRegistry.TryGetValue(uri, out var referenceableValue)) { return (T)referenceableValue; diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs index 8e45891db..c32752c04 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/OpenApiDiagnosticTests.cs @@ -61,7 +61,7 @@ public Stream Load(Uri uri) return null; } - public Task LoadAsync(Uri uri, CancellationToken cancellationToken = default) + public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) { var path = new Uri(new("http://example.org/OpenApiReaderTests/Samples/OpenApiDiagnosticReportMerged/"), uri).AbsolutePath; path = path[1..]; // remove leading slash diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs index 720eade40..9d91413ec 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiWorkspaceTests/OpenApiWorkspaceStreamTests.cs @@ -1,9 +1,12 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Models.Interfaces; +using Microsoft.OpenApi.Models.References; using Microsoft.OpenApi.Reader; using Xunit; @@ -59,14 +62,70 @@ public async Task LoadDocumentWithExternalReferenceShouldLoadBothDocumentsIntoWo result = await OpenApiDocument.LoadAsync("V3Tests/Samples/OpenApiWorkspace/TodoMain.yaml", settings); var externalDocBaseUri = result.Document.Workspace.GetDocumentId("./TodoComponents.yaml"); - var schemasPath = "/components/schemas/"; - var parametersPath = "/components/parameters/"; + var schemasPath = "#/components/schemas/"; + var parametersPath = "#/components/parameters/"; Assert.NotNull(externalDocBaseUri); Assert.True(result.Document.Workspace.Contains(externalDocBaseUri + schemasPath + "todo")); Assert.True(result.Document.Workspace.Contains(externalDocBaseUri + schemasPath + "entity")); Assert.True(result.Document.Workspace.Contains(externalDocBaseUri + parametersPath + "filter")); } + + [Fact] + public async Task LoadDocumentWithExternalReferencesInSubDirectories() + { + var sampleFolderPath = $"V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories"; + var referenceBaseUri = "file://" + Path.GetFullPath(sampleFolderPath); + + // Create a reader that will resolve all references also of documentes located in the non-root directory + var settings = new OpenApiReaderSettings() + { + LoadExternalRefs = true, + BaseUrl = new Uri("file://") + }; + settings.AddYamlReader(); + + // Act + var result = await OpenApiDocument.LoadAsync($"{sampleFolderPath}/Root.yaml", settings); + var document = result.Document; + var workspace = result.Document.Workspace; + + // Assert + Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "PetsPage.yaml")}#/components/schemas/PetsPage")); + Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pets")); + Assert.True(workspace.Contains($"{Path.Combine(referenceBaseUri, "Directory", "Pets.yaml")}#/components/schemas/Pet")); + + var operationResponseSchema = document.Paths["/pets"].Operations[OperationType.Get].Responses["200"].Content["application/json"].Schema; + Assert.IsType(operationResponseSchema); + + var petsSchema = operationResponseSchema.Properties["pets"]; + Assert.IsType(petsSchema); + Assert.Equal(JsonSchemaType.Array, petsSchema.Type); + + var petSchema = petsSchema.Items; + Assert.IsType(petSchema); + + Assert.Equivalent(new OpenApiSchema + { + Required = new HashSet { "id", "name" }, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema + { + Type = JsonSchemaType.Integer, + Format = "int64" + }, + ["name"] = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + ["tag"] = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + }, petSchema); + } } public class MockLoader : IStreamLoader @@ -76,7 +135,7 @@ public Stream Load(Uri uri) return null; } - public Task LoadAsync(Uri uri, CancellationToken cancellationToken = default) + public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) { return Task.FromResult(null); } @@ -89,7 +148,7 @@ public Stream Load(Uri uri) return null; } - public Task LoadAsync(Uri uri, CancellationToken cancellationToken = default) + public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) { var path = new Uri(new("http://example.org/V3Tests/Samples/OpenApiWorkspace/"), uri).AbsolutePath; path = path[1..]; // remove leading slash diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs index 3f08158d4..2ea3766ab 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs @@ -527,7 +527,12 @@ public async Task ExternalDocumentDereferenceToOpenApiDocumentUsingJsonPointerWo var responseSchema = result.Document.Paths["/resource"].Operations[OperationType.Get].Responses["200"].Content["application/json"].Schema; // Assert - result.Document.Workspace.Contains("./externalResource.yaml"); + var externalResourceUri = new Uri( + "file://" + + Path.Combine(Path.GetFullPath(SampleFolderPath), + "externalResource.yaml#/components/schemas/todo")).AbsoluteUri; + + Assert.True(result.Document.Workspace.Contains(externalResourceUri)); Assert.Equal(2, responseSchema.Properties.Count); // reference has been resolved } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/Pets.yaml b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/Pets.yaml new file mode 100644 index 000000000..ddf4c6cc3 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/Pets.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Pet(s) Schema +paths: {} +components: + schemas: + Pets: + type: array + items: + "$ref": "#/components/schemas/Pet" + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/PetsPage.yaml b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/PetsPage.yaml new file mode 100644 index 000000000..139c6d4e1 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Directory/PetsPage.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: AllPets Schema +paths: {} +components: + schemas: + PetsPage: + type: object + properties: + pageNumber: + type: integer + minimum: 0 + maximum: 100 + pets: + "$ref": "./Pets.yaml#/components/schemas/Pets" \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Root.yaml b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Root.yaml new file mode 100644 index 000000000..97d386192 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiWorkspace/ExternalReferencesInSubDirectories/Root.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example using relative references into sub directories +paths: + "/pets": + get: + summary: List all pets + operationId: listPets + responses: + '200': + description: An array of pets + content: + application/json: + schema: + "$ref": "./Directory/PetsPage.yaml#/components/schemas/PetsPage" \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index a143a049d..5011750e2 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -218,8 +218,8 @@ namespace Microsoft.OpenApi.Interfaces } public interface IOpenApiReader { - Microsoft.OpenApi.Reader.ReadResult Read(System.IO.MemoryStream input, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings); - System.Threading.Tasks.Task ReadAsync(System.IO.Stream input, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings, System.Threading.CancellationToken cancellationToken = default); + Microsoft.OpenApi.Reader.ReadResult Read(System.IO.MemoryStream input, System.Uri location, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings); + System.Threading.Tasks.Task ReadAsync(System.IO.Stream input, System.Uri location, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings, System.Threading.CancellationToken cancellationToken = default); T ReadFragment(System.IO.MemoryStream input, Microsoft.OpenApi.OpenApiSpecVersion version, Microsoft.OpenApi.Models.OpenApiDocument openApiDocument, out Microsoft.OpenApi.Reader.OpenApiDiagnostic diagnostic, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings = null) where T : Microsoft.OpenApi.Interfaces.IOpenApiElement; } @@ -247,7 +247,7 @@ namespace Microsoft.OpenApi.Interfaces } public interface IStreamLoader { - System.Threading.Tasks.Task LoadAsync(System.Uri uri, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task LoadAsync(System.Uri baseUrl, System.Uri uri, System.Threading.CancellationToken cancellationToken = default); } } namespace Microsoft.OpenApi @@ -1461,9 +1461,9 @@ namespace Microsoft.OpenApi.Reader public class OpenApiJsonReader : Microsoft.OpenApi.Interfaces.IOpenApiReader { public OpenApiJsonReader() { } - public Microsoft.OpenApi.Reader.ReadResult Read(System.IO.MemoryStream input, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings) { } - public Microsoft.OpenApi.Reader.ReadResult Read(System.Text.Json.Nodes.JsonNode jsonNode, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings) { } - public System.Threading.Tasks.Task ReadAsync(System.IO.Stream input, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings, System.Threading.CancellationToken cancellationToken = default) { } + public Microsoft.OpenApi.Reader.ReadResult Read(System.IO.MemoryStream input, System.Uri location, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings) { } + public Microsoft.OpenApi.Reader.ReadResult Read(System.Text.Json.Nodes.JsonNode jsonNode, System.Uri location, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings) { } + public System.Threading.Tasks.Task ReadAsync(System.IO.Stream input, System.Uri location, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings, System.Threading.CancellationToken cancellationToken = default) { } public T ReadFragment(System.IO.MemoryStream input, Microsoft.OpenApi.OpenApiSpecVersion version, Microsoft.OpenApi.Models.OpenApiDocument openApiDocument, out Microsoft.OpenApi.Reader.OpenApiDiagnostic diagnostic, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings = null) where T : Microsoft.OpenApi.Interfaces.IOpenApiElement { } public T ReadFragment(System.Text.Json.Nodes.JsonNode input, Microsoft.OpenApi.OpenApiSpecVersion version, Microsoft.OpenApi.Models.OpenApiDocument openApiDocument, out Microsoft.OpenApi.Reader.OpenApiDiagnostic diagnostic, Microsoft.OpenApi.Reader.OpenApiReaderSettings settings = null) @@ -1516,7 +1516,7 @@ namespace Microsoft.OpenApi.Reader public void EndObject() { } public T GetFromTempStorage(string key, object scope = null) { } public string GetLocation() { } - public Microsoft.OpenApi.Models.OpenApiDocument Parse(System.Text.Json.Nodes.JsonNode jsonNode) { } + public Microsoft.OpenApi.Models.OpenApiDocument Parse(System.Text.Json.Nodes.JsonNode jsonNode, System.Uri location) { } public T ParseFragment(System.Text.Json.Nodes.JsonNode jsonNode, Microsoft.OpenApi.OpenApiSpecVersion version, Microsoft.OpenApi.Models.OpenApiDocument openApiDocument) where T : Microsoft.OpenApi.Interfaces.IOpenApiElement { } public void PopLoop(string loopid) { } @@ -1543,8 +1543,8 @@ namespace Microsoft.OpenApi.Reader.Services { public class DefaultStreamLoader : Microsoft.OpenApi.Interfaces.IStreamLoader { - public DefaultStreamLoader(System.Uri baseUrl, System.Net.Http.HttpClient httpClient) { } - public System.Threading.Tasks.Task LoadAsync(System.Uri uri, System.Threading.CancellationToken cancellationToken = default) { } + public DefaultStreamLoader(System.Net.Http.HttpClient httpClient) { } + public System.Threading.Tasks.Task LoadAsync(System.Uri baseUrl, System.Uri uri, System.Threading.CancellationToken cancellationToken = default) { } } } namespace Microsoft.OpenApi.Services @@ -1674,7 +1674,7 @@ namespace Microsoft.OpenApi.Services public void AddDocumentId(string key, System.Uri value) { } public int ComponentsCount() { } public bool Contains(string location) { } - public System.Uri GetDocumentId(string key) { } + public System.Uri? GetDocumentId(string key) { } public bool RegisterComponentForDocument(Microsoft.OpenApi.Models.OpenApiDocument openApiDocument, T componentToRegister, string id) { } public void RegisterComponents(Microsoft.OpenApi.Models.OpenApiDocument document) { } public T? ResolveReference(string location) { } From 7c5870faecbbf259e6d5235b5521a1532ba79e8b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 15 Mar 2025 12:10:32 +0100 Subject: [PATCH 2/3] PR feedback: remove unnecessary variable --- src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs index d54a28135..d369e4cfc 100644 --- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs +++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs @@ -264,13 +264,9 @@ private static async Task InternalLoadAsync(Stream input, string for private static async Task LoadExternalRefsAsync(OpenApiDocument document, OpenApiReaderSettings settings, string format = null, CancellationToken token = default) { - // Create workspace for all documents to live in. - var baseUrl = document.BaseUri; - var openApiWorkSpace = document.Workspace; - - // Load this root document into the workspace + // Load this document into the workspace var streamLoader = new DefaultStreamLoader(settings.HttpClient); - var workspaceLoader = new OpenApiWorkspaceLoader(openApiWorkSpace, settings.CustomExternalLoader ?? streamLoader, settings); + var workspaceLoader = new OpenApiWorkspaceLoader(document.Workspace, settings.CustomExternalLoader ?? streamLoader, settings); return await workspaceLoader.LoadAsync(new OpenApiReference() { ExternalResource = "/" }, document, format ?? OpenApiConstants.Json, null, token).ConfigureAwait(false); } From 198f93abdbc0d95efde77d22b9ab3fc8a69be422 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 8 Apr 2025 20:40:13 +0200 Subject: [PATCH 3/3] Fix loading on linux --- .../Reader/Services/DefaultStreamLoader.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs index 13f68e811..374e00f49 100644 --- a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs +++ b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs @@ -31,13 +31,10 @@ public DefaultStreamLoader(HttpClient httpClient) /// public async Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) { - var absoluteUri = (baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri), baseUrl.IsAbsoluteUri, uri.IsAbsoluteUri) switch + var absoluteUri = baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri) switch { - (true, _, _) => new Uri(Path.Combine(Directory.GetCurrentDirectory(), uri.ToString())), - // this overcomes a URI concatenation issue for local paths on linux OSes - (_, true, false) when baseUrl.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase) && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) => - new Uri(Path.Combine(baseUrl.AbsoluteUri, uri.ToString())), - (_, _, _) => new Uri(baseUrl, uri), + true => new Uri(Path.Combine(Directory.GetCurrentDirectory(), uri.ToString())), + _ => new Uri(baseUrl, uri), }; return absoluteUri.Scheme switch