Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ var (openApiDocument, _) = await OpenApiDocument.LoadAsync("https://raw.githubus
var outputString = await openApiDocument.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0);
```

## JSON Schema dialects

For OpenAPI 3.1 and 3.2, the reader selects a schema's dialect from its explicit `$schema`, its enclosing schema's dialect, the document's `jsonSchemaDialect`, or the OpenAPI default, in that order. The OpenAPI base dialect and JSON Schema draft 2020-12 use the existing version-specific schema readers. Unrecognized keywords remain available through `UnrecognizedKeywords`; unsupported dialects produce a diagnostic without discarding the schema.

External dialect resolution is opt-in through asynchronous loading:

```C#
var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Json,
new OpenApiReaderSettings { LoadExternalRefs = true });
```

This also enables ordinary external references. For an unknown dialect, the reader follows its meta-schema's `$schema` chain to a supported dialect using `CustomExternalLoader` or the default HTTP/file loader. Resolution is cached per document read, detects cycles, and is limited to 16 parent links per chain (including cached links) and 1 MiB per response. Cancellation is passed to the configured loader and stream reads. Failed or unsupported chains retain their diagnostic.

Non-empty dialect URI fragments select a JSON Pointer target or a schema named by `$anchor` or `$dynamicAnchor` within the loaded resource. Fragment lookup traverses recognized schema locations only; missing or ambiguous targets and locations defined by unknown custom vocabularies retain the unsupported-dialect diagnostic. Named-anchor lookup stops at nested non-empty `$id` declarations; it does not build an embedded-resource index.

This does not add parsers for other JSON Schema drafts or custom vocabularies. Writing still follows the requested OpenAPI output version rather than dispatching on the schema dialect.

# 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
9 changes: 9 additions & 0 deletions src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ internal interface IOpenApiVersionService
/// <returns>Instance of OpenApiDocument populated with data from JsonNode</returns>
OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, ParsingContext context);

/// <summary>
/// Converts a JSON Schema node into an OpenAPI schema using this version's parsing rules.
/// </summary>
/// <param name="node">The JSON Schema node.</param>
/// <param name="doc">The host document instance.</param>
/// <param name="context">The current parsing context.</param>
/// <returns>The parsed schema.</returns>
IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument doc, ParsingContext context);

/// <summary>
/// Gets the description and summary scalar values in a reference object for V3.1 support
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions src/Microsoft.OpenApi/Reader/BaseOpenApiVersionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ protected BaseOpenApiVersionService(OpenApiDiagnostic diagnostic)
}
return default;
}

public virtual IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument doc, ParsingContext context)
{
if (Loaders.TryGetValue(typeof(OpenApiSchema), out var loader) && loader(node, doc, context) is IOpenApiSchema schema)
{
return schema;
}

throw new InvalidOperationException($"No schema loader is registered for {GetType().Name}.");
}

public virtual string? GetReferenceScalarValues(JsonObject jsonObject, string scalarValue)
{
if (jsonObject.Any(static x => !"$ref".Equals(x.Key, StringComparison.OrdinalIgnoreCase)) &&
Expand Down
117 changes: 108 additions & 9 deletions src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public static Dictionary<string, T> CreateMap<T>(this JsonNode? node, Func<JsonN
return nodes.ToDictionary(k => k.key, v => v.value);
}

public static Dictionary<string, T> CreateSimpleMap<T>(this JsonNode? node, Func<JsonNode, T> map, ParsingContext context)
public static Dictionary<string, T> CreateSimpleMap<T>(this JsonNode? node, Func<JsonNode, T> map, ParsingContext context, bool reportConversionErrors = false)
{
if (node is not JsonObject jsonMap)
{
Expand All @@ -121,6 +121,10 @@ public static Dictionary<string, T> CreateSimpleMap<T>(this JsonNode? node, Func

return (key, value: map(jsonNode));
}
catch (Exception exception) when (reportConversionErrors && exception is FormatException or OverflowException)
{
throw new OpenApiReaderException(exception.Message, exception) { Pointer = context.GetLocation() };
}
finally
{
context.EndObject();
Expand Down Expand Up @@ -161,8 +165,18 @@ public static Dictionary<string, HashSet<T>> CreateArrayMap<T>(this JsonNode? no
return nodes.ToDictionary(kvp => kvp.key, kvp => kvp.values);
}

public static string? GetScalarValue(this JsonNode? node)
public static string? GetScalarValue(this JsonNode? node, bool requireString = false)
{
if (requireString)
{
if (!TryGetStringValue(node, out var stringValue))
{
throw new OpenApiException("Expected string value.");
}

return stringValue;
}

if (node.IsJsonNullSentinel())
{
return null;
Expand All @@ -178,6 +192,47 @@ public static Dictionary<string, HashSet<T>> CreateArrayMap<T>(this JsonNode? no
return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null;
}

public static string? GetReferencePointer(this JsonObject jsonObject, ParsingContext context, bool preserveInvalidProperties = false)
{
if (!preserveInvalidProperties)
{
return jsonObject.GetReferencePointer();
}

if (!jsonObject.TryGetPropertyValue(OpenApiConstants.DollarRef, out var refNode))
{
return null;
}

try
{
var pointer = refNode.GetScalarValue(requireString: true);
if (pointer is not null)
{
ValidateReferencePointerFormat(pointer);
}

return pointer;
}
catch (OpenApiException exception)
{
// $ref is consumed before ParseMap, so recover only this keyword's
// extraction/format failure and let the ordinary map retain its raw value.
try
{
context.StartObject(OpenApiConstants.DollarRef);
exception.Pointer = context.GetLocation();
context.Diagnostic.Errors.Add(new(exception));
}
finally
{
context.EndObject();
}

return null;
}
}

public static void ValidateReferencePointerFormat(string pointer)
{
var hashIndex = pointer.IndexOf('#');
Expand All @@ -194,11 +249,13 @@ public static void ValidateReferencePointerFormat(string pointer)
/// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference
/// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution.
/// </summary>
public static string? GetDynamicReferencePointer(this JsonObject jsonObject)
public static string? GetDynamicReferencePointer(this JsonObject jsonObject, bool preserveInvalidProperties = false)
{
if (jsonObject.TryGetPropertyValue("$ref", out _))
return null;
return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode) ? dynRefNode?.GetScalarValue() : null;
return jsonObject.TryGetPropertyValue("$dynamicRef", out var dynRefNode)
? GetSchemaKeywordValue(dynRefNode, preserveInvalidProperties)
: null;
}

/// <summary>
Expand Down Expand Up @@ -236,9 +293,26 @@ public static bool IsFragmentOnlyDynamicRef(string? dynamicRef)
return hashIndex > 0 ? dynamicRef.Substring(0, hashIndex) : null;
}

public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject)
public static string? GetJsonSchemaIdentifier(this JsonObject jsonObject, bool preserveInvalidProperties = false)
{
return jsonObject.TryGetPropertyValue("$id", out var idNode) ? idNode?.GetScalarValue() : null;
return jsonObject.TryGetPropertyValue("$id", out var idNode)
? GetSchemaKeywordValue(idNode, preserveInvalidProperties)
: null;
}

private static string? GetSchemaKeywordValue(JsonNode? node, bool preserveInvalidProperties)
{
// The fixed-field handler reports invalid values during ParseMap. Do not
// interpret them as a reference or identifier before that handler can run.
return preserveInvalidProperties
? TryGetStringValue(node, out var value) ? value : null
: node?.GetScalarValue();
}

private static bool TryGetStringValue(JsonNode? node, out string? value)
{
value = null;
return !node.IsJsonNullSentinel() && node is JsonValue jsonValue && jsonValue.TryGetValue(out value);
}

public static void ParseMap<T>(
Expand All @@ -248,7 +322,8 @@ public static void ParseMap<T>(
PatternFieldMap<T> patternFieldMap,
OpenApiDocument hostDocument,
ParsingContext context,
Action<T, string, JsonNode>? unrecognizedProperty = null)
Action<T, string, JsonNode>? unrecognizedProperty = null,
bool preserveInvalidProperties = false)
{
if (JsonObject == null)
{
Expand All @@ -257,7 +332,7 @@ public static void ParseMap<T>(

foreach (var propertyNode in JsonObject)
{
ParseField(propertyNode.Key, propertyNode.Value ?? JsonNullSentinel.JsonNull, domainObject, fixedFieldMap, patternFieldMap, hostDocument, context, unrecognizedProperty);
ParseField(propertyNode.Key, propertyNode.Value ?? JsonNullSentinel.JsonNull, domainObject, fixedFieldMap, patternFieldMap, hostDocument, context, unrecognizedProperty, preserveInvalidProperties);
}
}

Expand All @@ -269,7 +344,8 @@ private static void ParseField<T>(
PatternFieldMap<T> patternFields,
OpenApiDocument hostDocument,
ParsingContext context,
Action<T, string, JsonNode>? unrecognizedProperty)
Action<T, string, JsonNode>? unrecognizedProperty,
bool preserveInvalidProperties)
{
if (fixedFields.TryGetValue(name, out var fixedFieldMap))
{
Expand All @@ -281,11 +357,26 @@ private static void ParseField<T>(
catch (OpenApiReaderException ex)
{
context.Diagnostic.Errors.Add(new(ex));
if (preserveInvalidProperties)
{
unrecognizedProperty?.Invoke(parentInstance, name, value);
}
}
catch (OpenApiException ex)
{
ex.Pointer = context.GetLocation();
context.Diagnostic.Errors.Add(new(ex));
if (preserveInvalidProperties)
{
unrecognizedProperty?.Invoke(parentInstance, name, value);
}
}
catch (Exception ex) when (preserveInvalidProperties && unrecognizedProperty is not null && value is JsonValue &&
ex is FormatException or OverflowException)
{
// Scalar conversions belong to this field; nested schemas can use a different dialect.
context.Diagnostic.Errors.Add(new OpenApiError(context.GetLocation(), ex.Message));
unrecognizedProperty(parentInstance, name, value);
}
finally
{
Expand All @@ -305,11 +396,19 @@ private static void ParseField<T>(
catch (OpenApiReaderException ex)
{
context.Diagnostic.Errors.Add(new(ex));
if (preserveInvalidProperties)
{
unrecognizedProperty?.Invoke(parentInstance, name, value);
}
}
catch (OpenApiException ex)
{
ex.Pointer = context.GetLocation();
context.Diagnostic.Errors.Add(new(ex));
if (preserveInvalidProperties)
{
unrecognizedProperty?.Invoke(parentInstance, name, value);
}
}
finally
{
Expand Down
12 changes: 9 additions & 3 deletions src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System;
using System.IO;
using System.Text.Json.Nodes;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using System;

namespace Microsoft.OpenApi.Reader
{
Expand Down Expand Up @@ -108,6 +108,12 @@ public ReadResult Read(JsonNode jsonNode,
{
Document = document,
Diagnostic = diagnostic,
UnsupportedSchemaDialects = settings.LoadExternalRefs
? context.SchemaDialectService?.UnsupportedDialects.ToArray()
: null,
UnsupportedSchemaDialectErrors = settings.LoadExternalRefs
? context.SchemaDialectService?.UnsupportedDialectErrors.ToArray()
: null,
};
}

Expand Down
49 changes: 49 additions & 0 deletions src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// Licensed under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security;
using System.Text;
using System.Threading;
Expand Down Expand Up @@ -253,6 +255,7 @@ private static async Task<ReadResult> InternalLoadAsync(Stream input, string for

if (settings.LoadExternalRefs)
{
await ResolveJsonSchemaDialectsAsync(readResult, location, settings, cancellationToken).ConfigureAwait(false);
var diagnosticExternalRefs = await LoadExternalRefsAsync(readResult.Document, settings, format, cancellationToken).ConfigureAwait(false);
// Merge diagnostics of external reference
if (diagnosticExternalRefs != null)
Expand All @@ -262,9 +265,55 @@ private static async Task<ReadResult> InternalLoadAsync(Stream input, string for
}
}

readResult.UnsupportedSchemaDialects = null;
readResult.UnsupportedSchemaDialectErrors = null;

return readResult;
}

private static async Task ResolveJsonSchemaDialectsAsync(
ReadResult readResult,
Uri location,
OpenApiReaderSettings settings,
CancellationToken cancellationToken)
{
if (readResult.Document is null ||
readResult.Diagnostic is null ||
readResult.UnsupportedSchemaDialectErrors is null ||
readResult.UnsupportedSchemaDialects is not { Count: > 0 } unsupportedDialects)
{
return;
}

var loader = settings.CustomExternalLoader ?? new DefaultStreamLoader(settings.HttpClient);
var resolver = new JsonSchemaDialectResolver(loader, location);
var dialectMappings = await resolver.ResolveAsync(unsupportedDialects, cancellationToken).ConfigureAwait(false);
// Both supported dialects use the current OpenAPI version service, which is also
// the fallback used during parsing. Resolving an alias therefore only removes its
// unsupported-dialect errors; reparsing would rerun callbacks and replace custom readers' results.
// Dialects requiring different parsing rules will need a different resolution approach.
var resolvedErrors = new HashSet<OpenApiError>(readResult.UnsupportedSchemaDialectErrors
.Where(item => dialectMappings.ContainsKey(item.Dialect))
.Select(item => item.Error), DiagnosticReferenceEqualityComparer.Instance);
if (resolvedErrors.Count > 0)
{
readResult.Diagnostic.Errors = readResult.Diagnostic.Errors
.Where(error => !resolvedErrors.Contains(error))
.ToList();
}
}

private sealed class DiagnosticReferenceEqualityComparer : IEqualityComparer<OpenApiError>
{
internal static DiagnosticReferenceEqualityComparer Instance { get; } = new();

public bool Equals(OpenApiError? x, OpenApiError? y)
=> ReferenceEquals(x, y);

public int GetHashCode(OpenApiError obj)
=> RuntimeHelpers.GetHashCode(obj);
}

private static async Task<OpenApiDiagnostic> LoadExternalRefsAsync(OpenApiDocument? document, OpenApiReaderSettings settings, string? format = null, CancellationToken token = default)
{
// Load this document into the workspace
Expand Down
Loading