Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ var (openApiDocument, _) = await OpenApiDocument.LoadAsync("https://raw.githubus
var outputString = await openApiDocument.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0);
```

External references are loaded only when `OpenApiReaderSettings.LoadExternalRefs` is enabled.
`MaxExternalReferenceDepth` defaults to 64 external document hops (the root is depth zero)
and can be set to a higher positive value for deeper descriptions. It is independent of
JSON/YAML nesting limits. External documents are reused within a load, allowing valid
recursive schemas across files without repeated requests. Exceeding the depth limit or
encountering an external reference-only loop returns a `ReadResult` with a null `Document`
and an error in `Diagnostic.Errors`.

# Validating/Testing OpenAPI descriptions
[Microsoft.OpenApi.Hidi](https://www.nuget.org/packages/Microsoft.OpenApi.Hidi) is a command line tool for validating and transforming OpenAPI descriptions.
The OpenAPI.NET v3-compatible tool is maintained in [OpenAPI.NET.OData's main branch](https://github.com/microsoft/OpenAPI.NET.OData/tree/main/src/Microsoft.OpenApi.Hidi).
Expand Down
17 changes: 17 additions & 0 deletions src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
get => field;
set
{
// TODO: In the next major release, Const should be made a JsonNode.

Check warning on line 129 in src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment.
// See https://github.com/microsoft/OpenAPI.NET/issues/2935 for more information.
WasConstExplicitlySet = true;
field = value;
Expand Down Expand Up @@ -269,7 +269,7 @@
/// A free-form property to include an example of an instance for this schema.
/// Follow <see href="https://spec.openapis.org/oas/v3.1.1.html#schema-object">OpenAPI Schema Object definition</see>.
/// </summary>
[Obsolete("Use Examples instead.")]

Check warning on line 272 in src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

View workflow job for this annotation

GitHub Actions / Build

Do not forget to remove this deprecated code someday.
public JsonNode? Example { get; set; }

/// <summary>
Expand Down Expand Up @@ -361,6 +361,23 @@
&& ReferenceV3 is string refV3
&& !refV3.StartsWith("#/components/", StringComparison.OrdinalIgnoreCase);

internal bool IsReferenceOnly =>
Description is null && Title is null && Default is null && Deprecated is null &&
ReadOnly is null && WriteOnly is null && Examples is null && Extensions is not { Count: > 0 } &&
SchemaId is null && Schema is null && Comment is null && Vocabulary is null &&
DynamicRef is null && DynamicAnchor is null && Definitions is null && Anchor is null &&
ExclusiveMaximum is null && ExclusiveMinimum is null && SchemaType is null && !WasConstExplicitlySet &&
Format is null && Maximum is null && Minimum is null && MaxLength is null && MinLength is null &&
Pattern is null && MultipleOf is null && AllOf is null && OneOf is null && AnyOf is null &&
Not is null && Required is null && Items is null && MaxItems is null && MinItems is null &&
UniqueItems is null && Contains is null && MaxContains is null && MinContains is null &&
Properties is null && PatternProperties is null && MaxProperties is null && MinProperties is null &&
AdditionalPropertiesAllowed is null && AdditionalProperties is null && Discriminator is null &&
Example is null && Enum is null && UnevaluatedProperties is null && UnevaluatedPropertiesSchema is null &&
ExternalDocs is null && Xml is null && UnrecognizedKeywords is not { Count: > 0 } &&
DependentRequired is null && ContentEncoding is null && ContentMediaType is null && ContentSchema is null &&
PropertyNames is null && DependentSchemas is null && If is null && Then is null && Else is null;

/// <summary>
/// Parameterless constructor
/// </summary>
Expand Down Expand Up @@ -622,12 +639,12 @@
}

/// <inheritdoc/>
[Obsolete("Use ApplySchemaMetadata instead.")]

Check warning on line 642 in src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

View workflow job for this annotation

GitHub Actions / Build

Do not forget to remove this deprecated code someday.
#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member
protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject)
#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member
{
//TODO remove this method in next major release

Check warning on line 647 in src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment.
// no-op: we're using ApplySchemaMetadata
}

Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.OpenApi/Models/OpenApiDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@
/// Serialize <see cref="OpenApiDocument"/> to OpenAPI object V2.0.
/// </summary>
public void SerializeAsV2(IOpenApiWriter writer)
{

Check warning on line 315 in src/Microsoft.OpenApi/Models/OpenApiDocument.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.
Utils.CheckArgumentNull(writer);

writer.WriteStartObject();
Expand Down Expand Up @@ -661,7 +661,7 @@
relativePath = $"#{OpenApiConstants.ComponentsSegment}{reference.Type.GetDisplayName()}/{id}";
}

Uri? externalResourceUri = useExternal ? Workspace?.GetDocumentId(reference.ExternalResource) : null;
Uri? externalResourceUri = useExternal ? Workspace?.GetDocumentId(reference.ExternalResource, BaseUri) : null;

uriLocation = useExternal && externalResourceUri is not null
? externalResourceUri.AbsoluteUri + relativePath
Expand Down
2 changes: 2 additions & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.OpenApi.Reader.OpenApiReaderSettings.MaxExternalReferenceDepth.get -> int
Microsoft.OpenApi.Reader.OpenApiReaderSettings.MaxExternalReferenceDepth.set -> void
78 changes: 40 additions & 38 deletions src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,14 @@
/// <param name="settings"> The OpenApi reader settings.</param>
/// <param name="token">The cancellation token</param>
/// <returns></returns>
public static async Task<ReadResult> LoadAsync(string url, OpenApiReaderSettings? settings = null, CancellationToken token = default)

Check warning on line 76 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View workflow job for this annotation

GitHub Actions / Build

All 'LoadAsync' method overloads should be adjacent.

Check warning on line 76 in src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

All 'LoadAsync' method overloads should be adjacent.

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AaGx1BHg4vO06bDcplmo&open=AaGx1BHg4vO06bDcplmo&pullRequest=3164
{
settings ??= DefaultReaderSettings.Value;
var (stream, format) = await RetrieveStreamAndFormatAsync(url, settings, token).ConfigureAwait(false);
using (stream)
{
return await LoadAsync(stream, format, settings, token).ConfigureAwait(false);
Uri.TryCreate(url, UriKind.Absolute, out var sourceUri);
return await LoadStreamAsync(stream, format, settings, sourceUri, token).ConfigureAwait(false);
}
}

Expand Down Expand Up @@ -112,40 +113,46 @@
/// <param name="cancellationToken">Propagates notification that operations should be cancelled.</param>
/// <param name="format">The Open API format</param>
/// <returns></returns>
public static async Task<ReadResult> LoadAsync(Stream input, string? format = null, OpenApiReaderSettings? settings = null, CancellationToken cancellationToken = default)
public static Task<ReadResult> LoadAsync(Stream input, string? format = null, OpenApiReaderSettings? settings = null, CancellationToken cancellationToken = default)
=> LoadStreamAsync(input, format, settings ?? new OpenApiReaderSettings(), null, cancellationToken);

private static async Task<ReadResult> LoadStreamAsync(Stream input, string? format, OpenApiReaderSettings settings, Uri? sourceUri, CancellationToken cancellationToken)
Comment thread
gavinbarron marked this conversation as resolved.
Resolved
Comment thread
gavinbarron marked this conversation as resolved.
Resolved
{
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(input);
#else
if (input is null) throw new ArgumentNullException(nameof(input));
#endif
settings ??= new OpenApiReaderSettings();

sourceUri = input is FileStream sourceFile ? new Uri(sourceFile.Name) : sourceUri;
Stream? preparedStream = null;

Check notice

Code scanning / CodeQL

Missed 'using' opportunity Note

This variable is manually
disposed
in a
finally block
- consider a C# using statement as a preferable resource management technique.
if (format is null)
try
{
(preparedStream, format) = await PrepareStreamForReadingAsync(input, format, cancellationToken).ConfigureAwait(false);
}
if (format is null)
{
(preparedStream, format) = await PrepareStreamForReadingAsync(input, format, cancellationToken).ConfigureAwait(false);
}

// Use StreamReader to process the prepared stream (buffered for YAML, direct for JSON)
var result = await InternalLoadAsync(preparedStream ?? input, format, settings, cancellationToken).ConfigureAwait(false);
if (!settings.LeaveStreamOpen)
return await InternalLoadAsync(preparedStream ?? input, format, settings, sourceUri, cancellationToken).ConfigureAwait(false);
}
finally
{
if (!settings.LeaveStreamOpen)
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
await input.DisposeAsync().ConfigureAwait(false);
await input.DisposeAsync().ConfigureAwait(false);
#else
input.Dispose();
input.Dispose();
#endif
}
if (preparedStream is not null && preparedStream != input)
{
}
if (preparedStream is not null && preparedStream != input)
{
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
await preparedStream.DisposeAsync().ConfigureAwait(false);
await preparedStream.DisposeAsync().ConfigureAwait(false);
#else
preparedStream.Dispose();
preparedStream.Dispose();
#endif
}
}
return result;
}

/// <summary>
Expand Down Expand Up @@ -240,40 +247,35 @@

private static readonly Lazy<OpenApiReaderSettings> DefaultReaderSettings = new(() => new OpenApiReaderSettings());

private static async Task<ReadResult> InternalLoadAsync(Stream input, string format, OpenApiReaderSettings settings, CancellationToken cancellationToken = default)
internal static Task<ReadResult> ReadDocumentAsync(Stream input, string format, Uri location, OpenApiReaderSettings settings, CancellationToken cancellationToken)
=> settings.GetReader(format).ReadAsync(input, location, settings, cancellationToken);

private static async Task<ReadResult> InternalLoadAsync(Stream input, string format, OpenApiReaderSettings settings, Uri? sourceUri, CancellationToken cancellationToken = default)
{
settings ??= DefaultReaderSettings.Value;
var reader = settings.GetReader(format);
var location =
(input is FileStream fileStream ? new Uri(fileStream.Name) : null) ??
var maxExternalReferenceDepth = settings.MaxExternalReferenceDepth;
var location = (sourceUri?.IsFile == true ? sourceUri : null) ??
settings.BaseUrl ??
sourceUri ??
new Uri(OpenApiConstants.BaseRegistryUri);

var readResult = await reader.ReadAsync(input, location, settings, cancellationToken).ConfigureAwait(false);
var readResult = await ReadDocumentAsync(input, format, location, settings, cancellationToken).ConfigureAwait(false);

if (settings.LoadExternalRefs)
if (settings.LoadExternalRefs && readResult.Document is { } document)
{
var diagnosticExternalRefs = await LoadExternalRefsAsync(readResult.Document, settings, format, cancellationToken).ConfigureAwait(false);
// Merge diagnostics of external reference
if (diagnosticExternalRefs != null)
var workspace = document.Workspace ?? new OpenApiWorkspace();
var loader = new OpenApiWorkspaceLoader(workspace,
settings.CustomExternalLoader ?? new DefaultStreamLoader(settings.HttpClient), settings, maxExternalReferenceDepth);
readResult.Diagnostic ??= new();
if (!await loader.LoadAsync(document, format, readResult.Diagnostic, sourceUri, cancellationToken).ConfigureAwait(false))
{
readResult.Diagnostic?.Errors.AddRange(diagnosticExternalRefs.Errors);
readResult.Diagnostic?.Warnings.AddRange(diagnosticExternalRefs.Warnings);
readResult.Document = null;
}
}

return readResult;
}

private static async Task<OpenApiDiagnostic> LoadExternalRefsAsync(OpenApiDocument? document, OpenApiReaderSettings settings, string? format = null, CancellationToken token = default)
{
// Load this document into the workspace
var streamLoader = new DefaultStreamLoader(settings.HttpClient);
var workspace = document?.Workspace ?? new OpenApiWorkspace();
var workspaceLoader = new OpenApiWorkspaceLoader(workspace, settings.CustomExternalLoader ?? streamLoader, settings);
return await workspaceLoader.LoadAsync(new BaseOpenApiReference() { ExternalResource = "/" }, document, format ?? OpenApiConstants.Json, null, token).ConfigureAwait(false);
}

private static ReadResult InternalLoad(MemoryStream input, string format, OpenApiReaderSettings settings)
{
settings ??= DefaultReaderSettings.Value;
Expand Down
15 changes: 15 additions & 0 deletions src/Microsoft.OpenApi/Reader/OpenApiReaderSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ public Dictionary<string, IOpenApiReader> Readers
/// </summary>
public bool LoadExternalRefs { get; set; } = false;

private int _maxExternalReferenceDepth = 64;

/// <summary>
/// Maximum number of external document hops loaded along a reference path. Defaults to 64.
/// The root document has depth zero. This does not limit JSON/YAML nesting or internal references.
/// When exceeded, loading returns a null document and an error diagnostic.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">The value is not positive.</exception>
public int MaxExternalReferenceDepth
{
get => _maxExternalReferenceDepth;
set => _maxExternalReferenceDepth = value > 0 ? value :
throw new ArgumentOutOfRangeException(nameof(value), "External reference depth must be greater than zero.");
}

/// <summary>
/// Dictionary of parsers for converting extensions into strongly typed classes
/// </summary>
Expand Down
15 changes: 10 additions & 5 deletions src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@
/// <inheritdoc/>
public async Task<Stream> LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
{
var absoluteUri = baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri) switch
{
true => new Uri(Path.Combine(Directory.GetCurrentDirectory(), uri.ToString())),
_ => new Uri(baseUrl, uri),
};
var absoluteUri = ResolveUri(baseUrl, uri);

return absoluteUri.Scheme switch
{
Expand All @@ -46,5 +42,14 @@
_ => throw new ArgumentException("Unsupported scheme"),
};
}

internal static Uri ResolveUri(Uri baseUrl, Uri uri)
{
var resolved = uri.IsAbsoluteUri ? uri :
baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri) ?

Check warning on line 49 in src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs

View workflow job for this annotation

GitHub Actions / Build

Extract this nested ternary operation into an independent statement.
new Uri(new Uri(Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar), uri) :
new Uri(baseUrl, uri);

Check warning on line 51 in src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AaGx1A-A4vO06bDcplmj&open=AaGx1A-A4vO06bDcplmj&pullRequest=3164
return new UriBuilder(resolved) { Fragment = string.Empty }.Uri;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

using System.Collections.Generic;
using System.Runtime.CompilerServices;

namespace Microsoft.OpenApi.Reader
{
Expand All @@ -11,6 +12,8 @@
internal class OpenApiRemoteReferenceCollector : OpenApiVisitorBase
{
private readonly Dictionary<string, BaseOpenApiReference> _references = new();
private readonly HashSet<IOpenApiSchema> _schemas = new(SchemaIdentityComparer.Instance);
internal List<IOpenApiReferenceHolder> ReferenceHolders { get; } = new();

/// <summary>
/// List of all external references collected from OpenApiDocument
Expand All @@ -26,16 +29,64 @@
/// <inheritdoc/>
public override void Visit(IOpenApiReferenceHolder referenceHolder)
{
var reference = referenceHolder switch
if (referenceHolder is IOpenApiSchema schema)
{
Visit(schema);
return;
}

AddReference(referenceHolder);
}

/// <inheritdoc/>
public override void Visit(IOpenApiSchema schema)
{
var pending = new Stack<IOpenApiSchema>();
pending.Push(schema);
while (pending.Count > 0)
{
var current = pending.Pop();
if (!_schemas.Add(current))
{
continue;
}

IEnumerable<IOpenApiSchema> children;
if (current is OpenApiSchemaReference reference)
{
AddReference(reference);
children = OpenApiWorkspace.EnumerateChildren(reference.Reference);
}
else
{
children = OpenApiWorkspace.EnumerateChildren(current);
}
foreach (var child in children)

Check warning on line 64 in src/Microsoft.OpenApi/Reader/Services/OpenApiRemoteReferenceCollector.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AaGy5TiOJUuDXEged8rn&open=AaGy5TiOJUuDXEged8rn&pullRequest=3164
{
if (child is not null)
{
pending.Push(child);
}
}
Comment on lines +64 to +70
Comment on lines +64 to +70
}
}

private void AddReference(IOpenApiReferenceHolder referenceHolder)
{
ReferenceHolders.Add(referenceHolder);
var reference = GetReference(referenceHolder);
AddExternalReferences(reference);
}

internal static BaseOpenApiReference GetReference(IOpenApiReferenceHolder referenceHolder)
=> referenceHolder switch
{
IOpenApiReferenceHolder<OpenApiReferenceWithDescriptionAndSummary> { Reference: OpenApiReferenceWithDescriptionAndSummary withSummary } => withSummary,
IOpenApiReferenceHolder<OpenApiReferenceWithDescription> { Reference: OpenApiReferenceWithDescription withDescription } => withDescription,
IOpenApiReferenceHolder<JsonSchemaReference> { Reference: JsonSchemaReference jsonSchemaReference } => jsonSchemaReference,
IOpenApiReferenceHolder<BaseOpenApiReference> { Reference: BaseOpenApiReference baseReference } => baseReference,
_ => throw new OpenApiException($"Unsupported reference holder type: {referenceHolder.GetType().FullName}")
};
AddExternalReferences(reference);
}

/// <summary>
/// Collect external references
Expand All @@ -48,5 +99,12 @@
_references.Add(externalResource, reference);
}
}

private sealed class SchemaIdentityComparer : IEqualityComparer<IOpenApiSchema>
{
public static SchemaIdentityComparer Instance { get; } = new();
public bool Equals(IOpenApiSchema? x, IOpenApiSchema? y) => ReferenceEquals(x, y);
public int GetHashCode(IOpenApiSchema obj) => RuntimeHelpers.GetHashCode(obj);
}
}
}
Loading
Loading