From 9cabf5c5da2f332dd5cc7ce3d00459ead2a156d5 Mon Sep 17 00:00:00 2001
From: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
Date: Tue, 3 Nov 2026 06:17:46 -0500
Subject: [PATCH 1/5] feat(library): resolve JSON Schema dialects when reading
Signed-off-by: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
---
README.md | 15 +
.../Interfaces/IOpenApiVersionService.cs | 9 +
.../Reader/BaseOpenApiVersionService.cs | 11 +
.../Reader/OpenApiJsonReader.cs | 12 +-
.../Reader/OpenApiModelFactory.cs | 49 +++
.../Reader/ParsingContext.cs | 14 +
src/Microsoft.OpenApi/Reader/ReadResult.cs | 7 +-
.../Reader/Services/DefaultStreamLoader.cs | 2 +-
.../Services/JsonSchemaDialectResolver.cs | 162 ++++++++
.../Services/JsonSchemaDialectService.cs | 115 ++++++
.../Reader/V31/OpenApiSchemaDeserializer.cs | 6 +
.../Reader/V31/OpenApiV31VersionService.cs | 6 +
.../Reader/V32/OpenApiSchemaDeserializer.cs | 6 +
.../Reader/V32/OpenApiV32VersionService.cs | 7 +-
.../JsonSchemaDialectDiagnosticTests.cs | 386 ++++++++++++++++++
.../JsonSchemaDialectLoaderTests.cs | 325 +++++++++++++++
.../JsonSchemaDialectTests.cs | 287 +++++++++++++
.../V31Tests/OpenApiDocumentTests.cs | 77 ++++
.../V32Tests/OpenApiDocumentTests.cs | 77 ++++
19 files changed, 1567 insertions(+), 6 deletions(-)
create mode 100644 src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
create mode 100644 src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDiagnosticTests.cs
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectLoaderTests.cs
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectTests.cs
diff --git a/README.md b/README.md
index a4cf4171e..24ca23eda 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,21 @@ 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 loads per chain and 1 MiB per response. Cancellation is passed to the configured loader and stream reads. Failed or unsupported chains retain their diagnostic.
+
+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).
diff --git a/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs b/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs
index 01f728ef1..5e4077cdb 100644
--- a/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs
+++ b/src/Microsoft.OpenApi/Interfaces/IOpenApiVersionService.cs
@@ -31,6 +31,15 @@ internal interface IOpenApiVersionService
/// Instance of OpenApiDocument populated with data from JsonNode
OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, ParsingContext context);
+ ///
+ /// Converts a JSON Schema node into an OpenAPI schema using this version's parsing rules.
+ ///
+ /// The JSON Schema node.
+ /// The host document instance.
+ /// The current parsing context.
+ /// The parsed schema.
+ IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument doc, ParsingContext context);
+
///
/// Gets the description and summary scalar values in a reference object for V3.1 support
///
diff --git a/src/Microsoft.OpenApi/Reader/BaseOpenApiVersionService.cs b/src/Microsoft.OpenApi/Reader/BaseOpenApiVersionService.cs
index fb21d57b3..5b7ec1a4d 100644
--- a/src/Microsoft.OpenApi/Reader/BaseOpenApiVersionService.cs
+++ b/src/Microsoft.OpenApi/Reader/BaseOpenApiVersionService.cs
@@ -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)) &&
diff --git a/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs b/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs
index 6b7a5fb9d..ee95d119d 100644
--- a/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs
+++ b/src/Microsoft.OpenApi/Reader/OpenApiJsonReader.cs
@@ -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
{
@@ -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,
};
}
diff --git a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
index 2abd04873..342757b01 100644
--- a/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
+++ b/src/Microsoft.OpenApi/Reader/OpenApiModelFactory.cs
@@ -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;
@@ -253,6 +255,7 @@ private static async Task 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)
@@ -262,9 +265,55 @@ private static async Task 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(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
+ {
+ 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 LoadExternalRefsAsync(OpenApiDocument? document, OpenApiReaderSettings settings, string? format = null, CancellationToken token = default)
{
// Load this document into the workspace
diff --git a/src/Microsoft.OpenApi/Reader/ParsingContext.cs b/src/Microsoft.OpenApi/Reader/ParsingContext.cs
index 2cf72c074..44c2716d8 100644
--- a/src/Microsoft.OpenApi/Reader/ParsingContext.cs
+++ b/src/Microsoft.OpenApi/Reader/ParsingContext.cs
@@ -29,6 +29,7 @@ public class ParsingContext
internal JsonNode? JsonNode { get; set; }
internal List Tags { get; private set; } = new();
+ internal JsonSchemaDialectService? SchemaDialectService { get; private set; }
///
/// The base url for the document
@@ -85,12 +86,14 @@ public OpenApiDocument Parse(JsonNode jsonNode, Uri location)
break;
case string version when version.is3_1():
VersionService = new OpenApiV31VersionService(Diagnostic);
+ SchemaDialectService = new JsonSchemaDialectService(VersionService, GetJsonSchemaDialect(jsonNode), Diagnostic);
doc = VersionService.LoadDocument(jsonNode, location, this);
this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi3_1;
ValidateRequiredFields(doc, version);
break;
case string version when version.is3_2():
VersionService = new OpenApiV32VersionService(Diagnostic);
+ SchemaDialectService = new JsonSchemaDialectService(VersionService, GetJsonSchemaDialect(jsonNode), Diagnostic);
doc = VersionService.LoadDocument(jsonNode, location, this);
this.Diagnostic.SpecificationVersion = OpenApiSpecVersion.OpenApi3_2;
ValidateRequiredFields(doc, version);
@@ -126,10 +129,12 @@ public OpenApiDocument Parse(JsonNode jsonNode, Uri location)
break;
case OpenApiSpecVersion.OpenApi3_1:
this.VersionService = new OpenApiV31VersionService(Diagnostic);
+ SchemaDialectService = new JsonSchemaDialectService(VersionService, openApiDocument.JsonSchemaDialect, Diagnostic);
element = this.VersionService.LoadElement(jsonNode, openApiDocument, this);
break;
case OpenApiSpecVersion.OpenApi3_2:
this.VersionService = new OpenApiV32VersionService(Diagnostic);
+ SchemaDialectService = new JsonSchemaDialectService(VersionService, openApiDocument.JsonSchemaDialect, Diagnostic);
element = this.VersionService.LoadElement(jsonNode, openApiDocument, this);
break;
}
@@ -155,6 +160,15 @@ private static string GetVersion(JsonNode jsonNode)
return versionNode?.GetScalarValue()?.Replace("\"", string.Empty) ?? throw new OpenApiException("Version node not found.");
}
+ private static Uri? GetJsonSchemaDialect(JsonNode jsonNode)
+ {
+ var dialectNode = new JsonPointer("/jsonSchemaDialect").Find(jsonNode);
+ return dialectNode is JsonValue &&
+ Uri.TryCreate(dialectNode.GetScalarValue(), UriKind.Absolute, out var dialect)
+ ? dialect
+ : null;
+ }
+
///
/// Service providing all Version specific conversion functions
///
diff --git a/src/Microsoft.OpenApi/Reader/ReadResult.cs b/src/Microsoft.OpenApi/Reader/ReadResult.cs
index 7710110dd..6317b4b3c 100644
--- a/src/Microsoft.OpenApi/Reader/ReadResult.cs
+++ b/src/Microsoft.OpenApi/Reader/ReadResult.cs
@@ -1,12 +1,18 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
+using System;
+using System.Collections.Generic;
+
namespace Microsoft.OpenApi.Reader;
///
/// Container object used for returning the result of reading an OpenAPI description.
///
public class ReadResult
{
+ internal IReadOnlyCollection? UnsupportedSchemaDialects { get; set; }
+ internal IReadOnlyCollection<(Uri Dialect, OpenApiError Error)>? UnsupportedSchemaDialectErrors { get; set; }
+
///
/// The parsed OpenApiDocument. Null will be returned if the document could not be parsed.
///
@@ -24,4 +30,3 @@ public void Deconstruct(out OpenApiDocument? document, out OpenApiDiagnostic? di
diagnostic = Diagnostic;
}
}
-
diff --git a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs
index fb6d86bab..2630ef484 100644
--- a/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs
+++ b/src/Microsoft.OpenApi/Reader/Services/DefaultStreamLoader.cs
@@ -28,7 +28,7 @@ public DefaultStreamLoader(HttpClient httpClient)
///
public async Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
{
- var absoluteUri = baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri) switch
+ var absoluteUri = uri.IsAbsoluteUri ? uri : baseUrl.AbsoluteUri.Equals(OpenApiConstants.BaseRegistryUri) switch
{
true => new Uri(Path.Combine(Directory.GetCurrentDirectory(), uri.ToString())),
_ => new Uri(baseUrl, uri),
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
new file mode 100644
index 000000000..6d405affa
--- /dev/null
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Security;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.OpenApi.Reader;
+
+///
+/// Resolves unknown JSON Schema dialects to parsing rules already known by the reader.
+///
+internal sealed class JsonSchemaDialectResolver
+{
+ internal const int MaxResolutionDepth = 16;
+ internal const int MaxMetaSchemaSize = 1024 * 1024;
+
+ private readonly IStreamLoader _loader;
+ private readonly Uri _baseUrl;
+ private readonly Dictionary _metaSchemaDialects = new(StringComparer.Ordinal);
+ private readonly Dictionary _resolvedDialects = new(StringComparer.Ordinal);
+
+ internal JsonSchemaDialectResolver(IStreamLoader loader, Uri baseUrl)
+ {
+ _loader = loader;
+ _baseUrl = baseUrl;
+ }
+
+ internal async Task> ResolveAsync(
+ IEnumerable dialects,
+ CancellationToken cancellationToken)
+ {
+ var result = new Dictionary();
+ foreach (var dialect in dialects)
+ {
+ if (await ResolveAsync(dialect, cancellationToken).ConfigureAwait(false) is { } knownDialect)
+ {
+ result[dialect] = knownDialect;
+ }
+ }
+
+ return result;
+ }
+
+ private async Task ResolveAsync(Uri dialect, CancellationToken cancellationToken)
+ {
+ var visited = new HashSet(StringComparer.Ordinal);
+ var chain = new List();
+ var current = dialect;
+
+ for (var depth = 0; depth < MaxResolutionDepth; depth++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (JsonSchemaDialectService.IsKnownDialect(current))
+ {
+ foreach (var item in chain)
+ {
+ _resolvedDialects[GetDialectKey(item)] = current;
+ }
+ return current;
+ }
+
+ var key = GetDialectKey(current);
+ if (_resolvedDialects.TryGetValue(key, out var resolvedDialect))
+ {
+ return resolvedDialect;
+ }
+
+ if (!visited.Add(key))
+ {
+ return null;
+ }
+
+ chain.Add(current);
+ if (!_metaSchemaDialects.TryGetValue(key, out var parentDialect))
+ {
+ parentDialect = await LoadParentDialectAsync(current, cancellationToken).ConfigureAwait(false);
+ _metaSchemaDialects[key] = parentDialect;
+ }
+
+ if (parentDialect is null)
+ {
+ return null;
+ }
+
+ current = parentDialect;
+ }
+
+ if (JsonSchemaDialectService.IsKnownDialect(current))
+ {
+ foreach (var item in chain)
+ {
+ _resolvedDialects[GetDialectKey(item)] = current;
+ }
+ return current;
+ }
+
+ return null;
+ }
+
+ private async Task LoadParentDialectAsync(Uri dialect, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var input = await _loader.LoadAsync(_baseUrl, dialect, cancellationToken).ConfigureAwait(false);
+ if (input.CanSeek && input.Length - input.Position > MaxMetaSchemaSize)
+ {
+ return null;
+ }
+
+ using var buffer = new MemoryStream();
+ var bytes = new byte[81920];
+ var totalBytesRead = 0;
+ int bytesRead;
+ while ((bytesRead = await input.ReadAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ if (bytesRead > MaxMetaSchemaSize - totalBytesRead)
+ {
+ return null;
+ }
+
+ await buffer.WriteAsync(bytes, 0, bytesRead, cancellationToken).ConfigureAwait(false);
+ totalBytesRead += bytesRead;
+ }
+
+ buffer.Position = 0;
+ var metaSchema = await JsonNode.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (metaSchema is not JsonObject jsonObject ||
+ !jsonObject.TryGetPropertyValue(OpenApiConstants.DollarSchema, out var dialectNode) ||
+ dialectNode is not JsonValue)
+ {
+ return null;
+ }
+
+ return Uri.TryCreate(dialectNode.GetScalarValue(), UriKind.Absolute, out var parentDialect)
+ ? parentDialect
+ : null;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is IOException or HttpRequestException or JsonException or OpenApiException or
+ ArgumentException or InvalidOperationException or SecurityException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static string GetDialectKey(Uri dialect)
+ {
+ var value = dialect.AbsoluteUri;
+ return value.EndsWith("#", StringComparison.Ordinal) ? value.Substring(0, value.Length - 1) : value;
+ }
+}
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
new file mode 100644
index 000000000..96bd53310
--- /dev/null
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.OpenApi.Reader;
+
+///
+/// Selects the version service used to parse JSON Schema dialects.
+///
+internal sealed class JsonSchemaDialectService
+{
+ internal static readonly Uri OpenApiBaseDialect = new("https://spec.openapis.org/oas/3.1/dialect/base");
+ internal static readonly Uri JsonSchemaDraft202012 = new("https://json-schema.org/draft/2020-12/schema");
+
+ private readonly Uri? _documentDialect;
+ private readonly IOpenApiVersionService _fallbackVersionService;
+ private readonly OpenApiDiagnostic _diagnostic;
+ private readonly Dictionary _versionServices = new(StringComparer.Ordinal);
+ private readonly Dictionary _unsupportedDialects = new(StringComparer.Ordinal);
+ private readonly List<(Uri Dialect, OpenApiError Error)> _unsupportedDialectErrors = new();
+ private readonly HashSet _reportedDocumentDialects = new(StringComparer.Ordinal);
+ private readonly Stack _dialectScopes = new();
+
+ internal JsonSchemaDialectService(
+ IOpenApiVersionService versionService,
+ Uri? documentDialect,
+ OpenApiDiagnostic diagnostic)
+ {
+ _fallbackVersionService = versionService;
+ _documentDialect = documentDialect;
+ _diagnostic = diagnostic;
+
+ RegisterDialect(OpenApiBaseDialect, versionService);
+ RegisterDialect(JsonSchemaDraft202012, versionService);
+ }
+
+ internal IReadOnlyCollection UnsupportedDialects => _unsupportedDialects.Values;
+ internal IReadOnlyCollection<(Uri Dialect, OpenApiError Error)> UnsupportedDialectErrors => _unsupportedDialectErrors;
+
+ internal IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
+ {
+ var declaredDialect = GetDeclaredDialect(node);
+ var inheritedDialect = _dialectScopes.Count > 0 ? _dialectScopes.Peek() : null;
+ var effectiveDialect = declaredDialect ?? inheritedDialect ?? _documentDialect ?? OpenApiBaseDialect;
+
+ if (!_versionServices.TryGetValue(GetDialectKey(effectiveDialect), out var versionService))
+ {
+ versionService = _fallbackVersionService;
+ _unsupportedDialects[GetDialectKey(effectiveDialect)] = effectiveDialect;
+
+ // Report the point where an unsupported dialect enters the schema tree. Descendants
+ // inherit that result and do not need to repeat the same diagnostic.
+ if (declaredDialect is not null)
+ {
+ AddUnsupportedDialectError(effectiveDialect, context.GetLocation());
+ }
+ else if (inheritedDialect is null && _documentDialect is not null &&
+ _reportedDocumentDialects.Add(GetDialectKey(effectiveDialect)))
+ {
+ AddUnsupportedDialectError(effectiveDialect, $"#/{OpenApiConstants.JsonSchemaDialect}");
+ }
+ }
+
+ _dialectScopes.Push(effectiveDialect);
+ try
+ {
+ return versionService.LoadSchema(node, hostDocument, context);
+ }
+ finally
+ {
+ _dialectScopes.Pop();
+ }
+ }
+
+ private void AddUnsupportedDialectError(Uri dialect, string pointer)
+ {
+ var error = new OpenApiError(pointer, $"The JSON Schema dialect '{dialect}' is not supported.");
+ _diagnostic.Errors.Add(error);
+ _unsupportedDialectErrors.Add((dialect, error));
+ }
+
+ internal static bool IsKnownDialect(Uri dialect)
+ {
+ var key = GetDialectKey(dialect);
+ return string.Equals(key, GetDialectKey(OpenApiBaseDialect), StringComparison.Ordinal) ||
+ string.Equals(key, GetDialectKey(JsonSchemaDraft202012), StringComparison.Ordinal);
+ }
+
+ private void RegisterDialect(Uri dialect, IOpenApiVersionService versionService)
+ {
+ _versionServices[GetDialectKey(dialect)] = versionService;
+ }
+
+ private static Uri? GetDeclaredDialect(JsonNode node)
+ {
+ if (node is not JsonObject jsonObject ||
+ !jsonObject.TryGetPropertyValue(OpenApiConstants.DollarSchema, out var dialectNode) ||
+ dialectNode is not JsonValue)
+ {
+ return null;
+ }
+
+ var value = dialectNode.GetScalarValue();
+ return Uri.TryCreate(value, UriKind.Absolute, out var dialect) ? dialect : null;
+ }
+
+ private static string GetDialectKey(Uri dialect)
+ {
+ var value = dialect.AbsoluteUri;
+ return value.EndsWith("#", StringComparison.Ordinal) ? value.Substring(0, value.Length - 1) : value;
+ }
+}
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index 52ca0912b..11ae081bd 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -412,6 +412,12 @@ internal static partial class OpenApiV31Deserializer
};
public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
+ {
+ return context.SchemaDialectService?.LoadSchema(node, hostDocument, context) ??
+ LoadSchemaCore(node, hostDocument, context);
+ }
+
+ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
{
// Handle boolean schemas (true/false) for JSON Schema 2020-12 compatibility
if (node is JsonValue jsonValue && jsonValue.TryGetValue(out var boolValue))
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs
index 8bd6530f6..faa597d33 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31VersionService.cs
@@ -60,6 +60,12 @@ public override OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, Pa
{
return OpenApiV31Deserializer.LoadOpenApi(jsonNode, location, context);
}
+
+ public override IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument doc, ParsingContext context)
+ {
+ return OpenApiV31Deserializer.LoadSchemaCore(node, doc, context);
+ }
+
internal override Dictionary> Loaders => _loaders;
}
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index b038fa6e5..ebe119f7e 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -412,6 +412,12 @@ internal static partial class OpenApiV32Deserializer
};
public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
+ {
+ return context.SchemaDialectService?.LoadSchema(node, hostDocument, context) ??
+ LoadSchemaCore(node, hostDocument, context);
+ }
+
+ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
{
// Handle boolean schemas (true/false) for JSON Schema 2020-12 compatibility
if (node is JsonValue jsonValue && jsonValue.TryGetValue(out var boolValue))
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32VersionService.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32VersionService.cs
index 35a869a3a..2ba6ed1b0 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiV32VersionService.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiV32VersionService.cs
@@ -60,8 +60,13 @@ public override OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, Pa
{
return OpenApiV32Deserializer.LoadOpenApi(jsonNode, location, context);
}
+
+ public override IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument doc, ParsingContext context)
+ {
+ return OpenApiV32Deserializer.LoadSchemaCore(node, doc, context);
+ }
+
internal override Dictionary> Loaders => _loaders;
}
}
-
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDiagnosticTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDiagnosticTests.cs
new file mode 100644
index 000000000..fcb66eb8d
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDiagnosticTests.cs
@@ -0,0 +1,386 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectDiagnosticTests
+{
+ private const string CustomDialect = "https://example.com/custom-dialect";
+ private const string Draft202012 = "https://json-schema.org/draft/2020-12/schema";
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectPreservesConfiguredReaderDiagnosticsWithoutDuplicates(string version)
+ {
+ const string format = "json-with-diagnostics";
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ Assert.True(settings.TryAddReader(format, new DiagnosticReader()));
+ using var input = CreateStream($$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "$schema": "{{Draft202012}}",
+ "unexpected": true,
+ "jsonSchemaDialect": "{{CustomDialect}}",
+ "components": {
+ "schemas": {
+ "Item": { "type": "string" }
+ }
+ }
+ }
+ """);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ format,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ Assert.Equal(1, loader.CallCount);
+ Assert.Equal(2, result.Diagnostic.Errors.Count);
+ Assert.Equal(2, result.Diagnostic.Warnings.Count);
+ var readerError = Assert.Single(result.Diagnostic.Errors,
+ error => error.Message == DiagnosticReader.ErrorMessage);
+ Assert.Equal("#/configured-reader-error", readerError.Pointer);
+ var readerWarning = Assert.Single(result.Diagnostic.Warnings,
+ warning => warning.Message == DiagnosticReader.WarningMessage);
+ Assert.Equal("#/configured-reader-warning", readerWarning.Pointer);
+ Assert.Single(result.Diagnostic.Errors,
+ error => error.Message.StartsWith("unexpected is not a valid property at ", StringComparison.Ordinal));
+ Assert.Single(result.Diagnostic.Warnings,
+ warning => warning.Message.StartsWith("$schema is not a valid property at ", StringComparison.Ordinal));
+ Assert.DoesNotContain(result.Diagnostic.Errors,
+ error => error.Message.StartsWith("The JSON Schema dialect ", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectPreservesConfiguredReaderDocumentChanges(string version)
+ {
+ const string format = "json-with-document-changes";
+ const string expectedTitle = "test - configured reader";
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ Assert.True(settings.TryAddReader(format,
+ new DiagnosticReader(result => result.Document.Info.Title = expectedTitle)));
+ using var input = CreateDialectDocument(version);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ format,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ Assert.Equal(expectedTitle, result.Document.Info.Title);
+ Assert.Equal(1, loader.CallCount);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectDoesNotRestoreDocumentRejectedByConfiguredReader(string version)
+ {
+ const string format = "json-with-document-rejection";
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ Assert.True(settings.TryAddReader(format,
+ new DiagnosticReader(result => result.Document = null)));
+ using var input = CreateDialectDocument(version);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ format,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Null(result.Document);
+ Assert.Equal(0, loader.CallCount);
+ var readerError = Assert.Single(result.Diagnostic.Errors,
+ error => error.Message == DiagnosticReader.ErrorMessage);
+ Assert.Equal("#/configured-reader-error", readerError.Pointer);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectInvokesExtensionParserOnce(string version)
+ {
+ var extensionCalls = 0;
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ ExtensionParsers =
+ {
+ {
+ "x-test", (node, _) =>
+ {
+ extensionCalls++;
+ return new JsonNodeExtension(node);
+ }
+ }
+ },
+ };
+ using var input = CreateStream($$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "x-test": true,
+ "jsonSchemaDialect": "{{CustomDialect}}",
+ "components": {
+ "schemas": {
+ "Item": { "type": "string" }
+ }
+ }
+ }
+ """);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(1, loader.CallCount);
+ Assert.Equal(1, extensionCalls);
+ var extension = Assert.IsType(result.Document.Extensions["x-test"]);
+ Assert.True(extension.Node.GetValue());
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectInvokesValidationRuleOnce(string version)
+ {
+ var validationCalls = 0;
+ var rules = new ValidationRuleSet();
+ rules.Add(typeof(OpenApiDocument),
+ new ValidationRule("CountDocumentVisits", (_, _) => validationCalls++));
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ RuleSet = rules,
+ };
+ using var input = CreateDialectDocument(version);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(1, loader.CallCount);
+ Assert.Equal(1, validationCalls);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingDialectRemovesOnlyGeneratedErrorFromReadOnlyDiagnostics(string version)
+ {
+ const string format = "json-with-read-only-diagnostics";
+ OpenApiError customError = null;
+ var loader = new KnownDialectLoader();
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ Assert.True(settings.TryAddReader(format, new DiagnosticReader(result =>
+ {
+ var generatedError = Assert.Single(result.Diagnostic.Errors,
+ error => error.Message == $"The JSON Schema dialect '{CustomDialect}' is not supported.");
+ customError = new OpenApiError(generatedError.Pointer, generatedError.Message);
+ result.Diagnostic.Errors = Array.AsReadOnly(new[] { generatedError, customError });
+ })));
+ using var input = CreateDialectDocument(version);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ format,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ Assert.NotNull(customError);
+ Assert.Same(customError, Assert.Single(result.Diagnostic.Errors));
+ Assert.Equal(1, loader.CallCount);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public void NestedSchemaDialectOverridesInheritedDialect(string version)
+ {
+ var result = OpenApiDocument.Parse(
+ $$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "https://example.com/document-dialect",
+ "components": {
+ "schemas": {
+ "Parent": {
+ "$schema": "{{Draft202012}}",
+ "type": "object",
+ "properties": {
+ "child": {
+ "$schema": "{{CustomDialect}}",
+ "customKeyword": true,
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ """,
+ OpenApiConstants.Json);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/components/schemas/Parent/properties/child", error.Pointer);
+ Assert.Equal($"The JSON Schema dialect '{CustomDialect}' is not supported.", error.Message);
+ var parent = result.Document.Components.Schemas["Parent"];
+ Assert.Equal(new Uri(Draft202012), parent.Schema);
+ var child = parent.Properties["child"];
+ Assert.Equal(new Uri(CustomDialect), child.Schema);
+ Assert.True(child.UnrecognizedKeywords.ContainsKey("customKeyword"));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public void ExplicitSchemaDialectDoesNotLeakIntoFollowingSchemas(string version)
+ {
+ const string documentDialect = "https://example.com/document-dialect";
+ var result = OpenApiDocument.Parse(
+ $$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "{{documentDialect}}",
+ "components": {
+ "schemas": {
+ "First": {
+ "$schema": "{{Draft202012}}",
+ "type": "object",
+ "properties": { "nested": { "type": "string" } }
+ },
+ "Second": { "type": "string" }
+ }
+ }
+ }
+ """,
+ OpenApiConstants.Json);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/jsonSchemaDialect", error.Pointer);
+ Assert.Equal($"The JSON Schema dialect '{documentDialect}' is not supported.", error.Message);
+ Assert.Equal(new Uri(Draft202012), result.Document.Components.Schemas["First"].Schema);
+ Assert.Null(result.Document.Components.Schemas["Second"].Schema);
+ }
+
+ private static MemoryStream CreateStream(string document) => new(Encoding.UTF8.GetBytes(document));
+
+ private static MemoryStream CreateDialectDocument(string version) => CreateStream($$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "{{CustomDialect}}",
+ "components": {
+ "schemas": {
+ "Item": { "type": "string" }
+ }
+ }
+ }
+ """);
+
+ private sealed class KnownDialectLoader : IStreamLoader
+ {
+ internal int CallCount { get; private set; }
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Equal(new Uri(CustomDialect), uri);
+ CallCount++;
+ return Task.FromResult(CreateStream($$"""{ "$schema": "{{Draft202012}}" }"""));
+ }
+ }
+
+ private sealed class DiagnosticReader : IOpenApiReader
+ {
+ internal const string ErrorMessage = "Configured reader error.";
+ internal const string WarningMessage = "Configured reader warning.";
+ private readonly OpenApiJsonReader _reader = new();
+ private readonly Action _onRead;
+
+ internal DiagnosticReader(Action onRead = null)
+ {
+ _onRead = onRead;
+ }
+
+ public async Task ReadAsync(
+ Stream input,
+ Uri location,
+ OpenApiReaderSettings settings,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _reader.ReadAsync(input, location, settings, cancellationToken);
+ return AddDiagnostics(result);
+ }
+
+ public ReadResult Read(MemoryStream input, Uri location, OpenApiReaderSettings settings)
+ {
+ return AddDiagnostics(_reader.Read(input, location, settings));
+ }
+
+ public T ReadFragment(
+ MemoryStream input,
+ OpenApiSpecVersion version,
+ OpenApiDocument openApiDocument,
+ out OpenApiDiagnostic diagnostic,
+ OpenApiReaderSettings settings = null) where T : IOpenApiElement
+ {
+ return _reader.ReadFragment(input, version, openApiDocument, out diagnostic, settings);
+ }
+
+ private ReadResult AddDiagnostics(ReadResult result)
+ {
+ result.Diagnostic.Errors.Add(new OpenApiError("#/configured-reader-error", ErrorMessage));
+ result.Diagnostic.Warnings.Add(new OpenApiError("#/configured-reader-warning", WarningMessage));
+ _onRead?.Invoke(result);
+ return result;
+ }
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectLoaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectLoaderTests.cs
new file mode 100644
index 000000000..a96341971
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectLoaderTests.cs
@@ -0,0 +1,325 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectLoaderTests
+{
+ private const string CustomDialect = "https://example.com/custom-dialect";
+ private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema";
+ private const string MetaSchema = """{ "$schema": "https://json-schema.org/draft/2020-12/schema" }""";
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task MemoryStreamDocumentsResolveDialectsThroughTheDefaultHttpLoader(string version)
+ {
+ var handler = new DialectMessageHandler();
+ using var client = new HttpClient(handler);
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ HttpClient = client,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(new Uri(CustomDialect), Assert.Single(handler.Requests));
+ Assert.Equal(JsonSchemaType.String, result.Document.Components.Schemas["First"].Type);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task DisabledExternalLoadingDoesNotRequestUnknownDialects(string version)
+ {
+ var handler = new DialectMessageHandler();
+ using var client = new HttpClient(handler);
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = false,
+ HttpClient = client,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(handler.Requests);
+ Assert.Equal(
+ $"The JSON Schema dialect '{CustomDialect}' is not supported.",
+ Assert.Single(result.Diagnostic.Errors).Message);
+ }
+
+ [Theory]
+ [InlineData("3.1.1", KnownDialect)]
+ [InlineData("3.1.1", "https://spec.openapis.org/oas/3.1/dialect/base")]
+ [InlineData("3.2.0", KnownDialect)]
+ [InlineData("3.2.0", "https://spec.openapis.org/oas/3.1/dialect/base")]
+ public async Task KnownDialectsDoNotRequireExternalLoading(string version, string dialect)
+ {
+ var handler = new DialectMessageHandler();
+ using var client = new HttpClient(handler);
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ HttpClient = client,
+ };
+ using var input = CreateDocument(version, dialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Empty(handler.Requests);
+ }
+
+ [Theory]
+ [InlineData("3.1.1", 0)]
+ [InlineData("3.1.1", 1)]
+ [InlineData("3.2.0", 0)]
+ [InlineData("3.2.0", 1)]
+ public async Task NonSeekableMetaSchemasRespectTheResponseSizeLimit(string version, int extraBytes)
+ {
+ var length = JsonSchemaDialectResolver.MaxMetaSchemaSize + extraBytes;
+ using var metaSchemaStream = new NonSeekableMetaSchemaStream(length);
+ var loader = new DelegateLoader((_, _) => Task.FromResult(metaSchemaStream));
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ if (extraBytes == 0)
+ {
+ Assert.Empty(result.Diagnostic.Errors);
+ }
+ else
+ {
+ Assert.Equal(
+ $"The JSON Schema dialect '{CustomDialect}' is not supported.",
+ Assert.Single(result.Diagnostic.Errors).Message);
+ }
+ Assert.Equal(1, loader.CallCount);
+ Assert.Equal(length, metaSchemaStream.BytesRead);
+ Assert.True(metaSchemaStream.WasDisposed);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task CancellationDuringMetaSchemaLoadingPropagates(string version)
+ {
+ using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var loader = new DelegateLoader((_, token) =>
+ {
+ cancellationSource.Cancel();
+ return Task.FromCanceled(token);
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ await Assert.ThrowsAnyAsync(() => OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ cancellationSource.Token));
+
+ Assert.Equal(1, loader.CallCount);
+ Assert.True(cancellationSource.IsCancellationRequested);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task CancellationDuringMetaSchemaReadingDisposesTheStream(string version)
+ {
+ using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var metaSchemaStream = new NonSeekableMetaSchemaStream(MetaSchema.Length, cancellationSource);
+ var loader = new DelegateLoader((_, _) => Task.FromResult(metaSchemaStream));
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ await Assert.ThrowsAnyAsync(() => OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ cancellationSource.Token));
+
+ Assert.Equal(1, loader.CallCount);
+ Assert.True(metaSchemaStream.BytesRead > 0);
+ Assert.True(metaSchemaStream.WasDisposed);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ReusingSettingsDoesNotReuseDialectResultsAcrossReads(string version)
+ {
+ var responseCount = 0;
+ var loader = new DelegateLoader((_, _) =>
+ {
+ var response = ++responseCount == 1 ? MetaSchema : "{}";
+ return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(response)));
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var firstInput = CreateDocument(version, CustomDialect);
+ using var secondInput = CreateDocument(version, CustomDialect);
+
+ var firstResult = await OpenApiDocument.LoadAsync(
+ firstInput,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+ var secondResult = await OpenApiDocument.LoadAsync(
+ secondInput,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(firstResult.Diagnostic.Errors);
+ Assert.Equal(
+ $"The JSON Schema dialect '{CustomDialect}' is not supported.",
+ Assert.Single(secondResult.Diagnostic.Errors).Message);
+ Assert.Equal(2, loader.CallCount);
+ }
+
+ private static MemoryStream CreateDocument(string version, string dialect)
+ {
+ var document = $$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "{{dialect}}",
+ "components": {
+ "schemas": {
+ "First": { "type": "string" },
+ "Second": { "type": "object" }
+ }
+ }
+ }
+ """;
+ return new MemoryStream(Encoding.UTF8.GetBytes(document));
+ }
+
+ private sealed class DialectMessageHandler : HttpMessageHandler
+ {
+ internal List Requests { get; } = [];
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Requests.Add(request.RequestUri);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(MetaSchema, Encoding.UTF8, "application/schema+json"),
+ });
+ }
+ }
+
+ private sealed class DelegateLoader(Func> load) : IStreamLoader
+ {
+ internal int CallCount { get; private set; }
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ CallCount++;
+ return load(uri, cancellationToken);
+ }
+ }
+
+ private sealed class NonSeekableMetaSchemaStream(int length, CancellationTokenSource cancelAfterRead = null) : Stream
+ {
+ private static readonly byte[] Prefix = Encoding.UTF8.GetBytes(MetaSchema);
+ private int _position;
+
+ internal int BytesRead { get; private set; }
+ internal bool WasDisposed { get; private set; }
+ public override bool CanRead => !WasDisposed;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var bytesRead = Math.Min(count, length - _position);
+ for (var index = 0; index < bytesRead; index++, _position++)
+ {
+ buffer[offset + index] = _position < Prefix.Length ? Prefix[_position] : (byte)' ';
+ }
+ BytesRead += bytesRead;
+ return bytesRead;
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var bytesRead = Read(buffer, offset, count);
+ if (bytesRead > 0)
+ {
+ cancelAfterRead?.Cancel();
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ return Task.FromResult(bytesRead);
+ }
+
+ public override void Flush() => throw new NotSupportedException();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ WasDisposed = true;
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectTests.cs
new file mode 100644
index 000000000..c50298981
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectTests.cs
@@ -0,0 +1,287 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectTests
+{
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task UnknownDialectIsResolvedThroughItsMetaSchema(string version)
+ {
+ const string customDialect = "https://example.com/dialect";
+ var loader = new DialectLoader(new Dictionary
+ {
+ [customDialect] = """
+ {
+ "$schema": "https://json-schema.org/draft/2020-12/schema"
+ }
+ """,
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, customDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(1, loader.GetCallCount(customDialect));
+ var schema = result.Document.Components.Schemas["First"];
+ Assert.True(schema.UnrecognizedKeywords.ContainsKey("customKeyword"));
+
+ var outputVersion = version.StartsWith("3.2", StringComparison.Ordinal)
+ ? OpenApiSpecVersion.OpenApi3_2
+ : OpenApiSpecVersion.OpenApi3_1;
+ var serialized = JsonNode.Parse(await result.Document.SerializeAsJsonAsync(
+ outputVersion,
+ TestContext.Current.CancellationToken));
+ Assert.Equal(customDialect, serialized[OpenApiConstants.JsonSchemaDialect].GetValue());
+ Assert.True(serialized["components"]["schemas"]["First"][OpenApiConstants.UnrecognizedKeywords]["customKeyword"].GetValue());
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task UnknownDialectIsResolvedForYamlDocuments(string version)
+ {
+ const string customDialect = "https://example.com/dialect";
+ var loader = new DialectLoader(new Dictionary
+ {
+ [customDialect] = """{ "$schema": "https://json-schema.org/draft/2020-12/schema" }""",
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ settings.AddYamlReader();
+ var document = $$"""
+ openapi: {{version}}
+ info:
+ title: test
+ version: 1.0.0
+ jsonSchemaDialect: {{customDialect}}
+ components:
+ schemas:
+ Item:
+ type: object
+ """;
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(document));
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Yaml,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format);
+ Assert.Equal(1, loader.GetCallCount(customDialect));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task SharedDialectChainsAreLoadedOnce(string version)
+ {
+ const string documentDialect = "https://example.com/document-dialect";
+ const string schemaDialect = "https://example.com/schema-dialect";
+ var loader = new DialectLoader(new Dictionary
+ {
+ [documentDialect] = $$"""{ "$schema": "{{schemaDialect}}" }""",
+ [schemaDialect] = """{ "$schema": "https://json-schema.org/draft/2020-12/schema" }""",
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, documentDialect, schemaDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(1, loader.GetCallCount(documentDialect));
+ Assert.Equal(1, loader.GetCallCount(schemaDialect));
+
+ var outputVersion = version.StartsWith("3.2", StringComparison.Ordinal)
+ ? OpenApiSpecVersion.OpenApi3_2
+ : OpenApiSpecVersion.OpenApi3_1;
+ var serialized = JsonNode.Parse(await result.Document.SerializeAsJsonAsync(
+ outputVersion,
+ TestContext.Current.CancellationToken));
+ Assert.Equal(schemaDialect, serialized["components"]["schemas"]["Second"][OpenApiConstants.DollarSchema].GetValue());
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task CyclicDialectMetaSchemasRemainUnsupported(string version)
+ {
+ const string firstDialect = "https://example.com/dialect-a";
+ const string secondDialect = "https://example.com/dialect-b";
+ var loader = new DialectLoader(new Dictionary
+ {
+ [firstDialect] = $$"""{ "$schema": "{{secondDialect}}" }""",
+ [secondDialect] = $$"""{ "$schema": "{{firstDialect}}" }""",
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, firstDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal($"The JSON Schema dialect '{firstDialect}' is not supported.", error.Message);
+ Assert.Equal(1, loader.GetCallCount(firstDialect));
+ Assert.Equal(1, loader.GetCallCount(secondDialect));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task OversizedDialectMetaSchemaRemainsUnsupported(string version)
+ {
+ const string customDialect = "https://example.com/dialect";
+ var loader = new DialectLoader(new Dictionary
+ {
+ [customDialect] = new string(' ', JsonSchemaDialectResolver.MaxMetaSchemaSize + 1),
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, customDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal($"The JSON Schema dialect '{customDialect}' is not supported.", error.Message);
+ Assert.Equal(1, loader.GetCallCount(customDialect));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task DialectResolutionStopsAtTheMaximumDepth(string version)
+ {
+ var documents = new Dictionary(StringComparer.Ordinal);
+ for (var index = 0; index <= JsonSchemaDialectResolver.MaxResolutionDepth; index++)
+ {
+ var current = GetDialectUri(index);
+ var parent = index == JsonSchemaDialectResolver.MaxResolutionDepth
+ ? JsonSchemaDialectService.JsonSchemaDraft202012.AbsoluteUri
+ : GetDialectUri(index + 1);
+ documents[current] = $$"""{ "$schema": "{{parent}}" }""";
+ }
+
+ var loader = new DialectLoader(documents);
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, GetDialectUri(0));
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal(JsonSchemaDialectResolver.MaxResolutionDepth, loader.TotalCallCount);
+ Assert.Equal(0, loader.GetCallCount(GetDialectUri(JsonSchemaDialectResolver.MaxResolutionDepth)));
+ }
+
+ private static MemoryStream CreateDocument(string version, string dialect, string secondSchemaDialect = null)
+ {
+ var schemaDialect = secondSchemaDialect is null ? string.Empty : $"\"$schema\": \"{secondSchemaDialect}\",";
+ var document = $$"""
+ {
+ "openapi": "{{version}}",
+ "info": {
+ "title": "test",
+ "version": "1.0.0"
+ },
+ "jsonSchemaDialect": "{{dialect}}",
+ "components": {
+ "schemas": {
+ "First": {
+ "customKeyword": true,
+ "type": "object"
+ },
+ "Second": {
+ {{schemaDialect}}
+ "type": "string"
+ }
+ }
+ }
+ }
+ """;
+ return new MemoryStream(Encoding.UTF8.GetBytes(document));
+ }
+
+ private static string GetDialectUri(int index) => $"https://example.com/dialect-{index}";
+
+ private sealed class DialectLoader : IStreamLoader
+ {
+ private readonly IReadOnlyDictionary _documents;
+ private readonly Dictionary _callCounts = new(StringComparer.Ordinal);
+
+ internal DialectLoader(IReadOnlyDictionary documents)
+ {
+ _documents = documents;
+ }
+
+ internal int GetCallCount(string uri)
+ {
+ return _callCounts.TryGetValue(uri, out var count) ? count : 0;
+ }
+
+ internal int TotalCallCount { get; private set; }
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var key = uri.AbsoluteUri;
+ _callCounts[key] = GetCallCount(key) + 1;
+ TotalCallCount++;
+ return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(_documents[key])));
+ }
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs
index 412037d52..5ef7f04e3 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs
@@ -51,6 +51,83 @@ public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic()
Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message);
}
+ [Fact]
+ public void DocumentDialectAppliesToSchemasRegardlessOfPropertyOrder()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.1.1
+ info:
+ title: test
+ version: 1.0.0
+ components:
+ schemas:
+ Item:
+ type: object
+ jsonSchemaDialect: https://example.com/dialect
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/jsonSchemaDialect", error.Pointer);
+ Assert.Equal("The JSON Schema dialect 'https://example.com/dialect' is not supported.", error.Message);
+ }
+
+ [Fact]
+ public void SchemaDialectOverridesDocumentDialect()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.1.1
+ info:
+ title: test
+ version: 1.0.0
+ jsonSchemaDialect: https://example.com/dialect
+ components:
+ schemas:
+ Item:
+ $schema: https://json-schema.org/draft/2020-12/schema
+ customKeyword: true
+ type: object
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ var schema = result.Document.Components.Schemas["Item"];
+ Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), schema.Schema);
+ Assert.True(schema.UnrecognizedKeywords.ContainsKey("customKeyword"));
+ }
+
+ [Fact]
+ public void NestedSchemasInheritAnExplicitUnsupportedDialect()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.1.1
+ info:
+ title: test
+ version: 1.0.0
+ components:
+ schemas:
+ Item:
+ $schema: https://example.com/dialect
+ type: object
+ properties:
+ name:
+ customKeyword: value
+ type: string
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/components/schemas/Item", error.Pointer);
+ var propertySchema = result.Document.Components.Schemas["Item"].Properties["name"];
+ Assert.True(propertySchema.UnrecognizedKeywords.ContainsKey("customKeyword"));
+ }
+
[Fact]
public async Task ParseDocumentWithWebhooksShouldSucceed()
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs
index 28e213466..fd80e988c 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDocumentTests.cs
@@ -51,6 +51,83 @@ public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic()
Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message);
}
+ [Fact]
+ public void DocumentDialectAppliesToSchemasRegardlessOfPropertyOrder()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.2.0
+ info:
+ title: test
+ version: 1.0.0
+ components:
+ schemas:
+ Item:
+ type: object
+ jsonSchemaDialect: https://example.com/dialect
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/jsonSchemaDialect", error.Pointer);
+ Assert.Equal("The JSON Schema dialect 'https://example.com/dialect' is not supported.", error.Message);
+ }
+
+ [Fact]
+ public void SchemaDialectOverridesDocumentDialect()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.2.0
+ info:
+ title: test
+ version: 1.0.0
+ jsonSchemaDialect: https://example.com/dialect
+ components:
+ schemas:
+ Item:
+ $schema: https://json-schema.org/draft/2020-12/schema
+ customKeyword: true
+ type: object
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ Assert.Empty(result.Diagnostic.Errors);
+ var schema = result.Document.Components.Schemas["Item"];
+ Assert.Equal(new Uri("https://json-schema.org/draft/2020-12/schema"), schema.Schema);
+ Assert.True(schema.UnrecognizedKeywords.ContainsKey("customKeyword"));
+ }
+
+ [Fact]
+ public void NestedSchemasInheritAnExplicitUnsupportedDialect()
+ {
+ var result = OpenApiDocument.Parse(
+ """
+ openapi: 3.2.0
+ info:
+ title: test
+ version: 1.0.0
+ components:
+ schemas:
+ Item:
+ $schema: https://example.com/dialect
+ type: object
+ properties:
+ name:
+ customKeyword: value
+ type: string
+ """,
+ OpenApiConstants.Yaml,
+ SettingsFixture.ReaderSettings);
+
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/components/schemas/Item", error.Pointer);
+ var propertySchema = result.Document.Components.Schemas["Item"].Properties["name"];
+ Assert.True(propertySchema.UnrecognizedKeywords.ContainsKey("customKeyword"));
+ }
+
[Fact]
public async Task ParseDocumentWithWebhooksShouldSucceed()
{
From 24af207776263f02e4cd88e6e73d4457ed476924 Mon Sep 17 00:00:00 2001
From: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
Date: Tue, 3 Nov 2026 07:51:55 -0500
Subject: [PATCH 2/5] fix(library): preserve dialect fallback data and
diagnostics
Signed-off-by: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
---
.../Reader/JsonNodeHelper.cs | 31 +-
.../Services/JsonSchemaDialectResolver.cs | 30 +-
.../Services/JsonSchemaDialectService.cs | 2 +
.../Reader/V31/OpenApiSchemaDeserializer.cs | 6 +-
.../Reader/V32/OpenApiSchemaDeserializer.cs | 6 +-
...sonSchemaDialectFallbackRegressionTests.cs | 320 ++++++++++++++++++
...sonSchemaDialectResolverRegressionTests.cs | 207 +++++++++++
7 files changed, 590 insertions(+), 12 deletions(-)
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFallbackRegressionTests.cs
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs
diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
index db285f078..16f98f708 100644
--- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
+++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
@@ -248,7 +248,8 @@ public static void ParseMap(
PatternFieldMap patternFieldMap,
OpenApiDocument hostDocument,
ParsingContext context,
- Action? unrecognizedProperty = null)
+ Action? unrecognizedProperty = null,
+ bool preserveInvalidProperties = false)
{
if (JsonObject == null)
{
@@ -257,7 +258,7 @@ public static void ParseMap(
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);
}
}
@@ -269,7 +270,8 @@ private static void ParseField(
PatternFieldMap patternFields,
OpenApiDocument hostDocument,
ParsingContext context,
- Action? unrecognizedProperty)
+ Action? unrecognizedProperty,
+ bool preserveInvalidProperties)
{
if (fixedFields.TryGetValue(name, out var fixedFieldMap))
{
@@ -281,11 +283,26 @@ private static void ParseField(
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
{
@@ -305,11 +322,19 @@ private static void ParseField(
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
{
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
index 6d405affa..46aea78e4 100644
--- a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
@@ -36,7 +36,7 @@ internal async Task> ResolveAsync(
IEnumerable dialects,
CancellationToken cancellationToken)
{
- var result = new Dictionary();
+ var result = new Dictionary(new DialectUriEqualityComparer());
foreach (var dialect in dialects)
{
if (await ResolveAsync(dialect, cancellationToken).ConfigureAwait(false) is { } knownDialect)
@@ -143,13 +143,14 @@ internal async Task> ResolveAsync(
? parentDialect
: null;
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
- throw;
+ return null;
}
catch (Exception exception) when (exception is IOException or HttpRequestException or JsonException or OpenApiException or
ArgumentException or InvalidOperationException or SecurityException or UnauthorizedAccessException)
{
+ cancellationToken.ThrowIfCancellationRequested();
return null;
}
}
@@ -159,4 +160,27 @@ private static string GetDialectKey(Uri dialect)
var value = dialect.AbsoluteUri;
return value.EndsWith("#", StringComparison.Ordinal) ? value.Substring(0, value.Length - 1) : value;
}
+
+ private sealed class DialectUriEqualityComparer : IEqualityComparer
+ {
+ public bool Equals(Uri? x, Uri? y)
+ {
+ if (ReferenceEquals(x, y))
+ {
+ return true;
+ }
+
+ if (x is null || y is null)
+ {
+ return false;
+ }
+
+ return StringComparer.Ordinal.Equals(GetDialectKey(x), GetDialectKey(y));
+ }
+
+ public int GetHashCode(Uri obj)
+ {
+ return StringComparer.Ordinal.GetHashCode(GetDialectKey(obj));
+ }
+ }
}
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
index 96bd53310..a802944af 100644
--- a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectService.cs
@@ -39,6 +39,8 @@ internal JsonSchemaDialectService(
internal IReadOnlyCollection UnsupportedDialects => _unsupportedDialects.Values;
internal IReadOnlyCollection<(Uri Dialect, OpenApiError Error)> UnsupportedDialectErrors => _unsupportedDialectErrors;
+ internal bool IsUsingFallback => _dialectScopes.Count > 0 &&
+ !_versionServices.ContainsKey(GetDialectKey(_dialectScopes.Peek()));
internal IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocument, ParsingContext context)
{
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index 11ae081bd..29153565f 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -452,7 +452,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.SetJsonPointerPath(pointer, nodeLocation);
@@ -476,7 +476,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation);
return result;
@@ -489,7 +489,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
{
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null)
{
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index ebe119f7e..72c3af1d5 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -452,7 +452,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.SetJsonPointerPath(pointer, nodeLocation);
@@ -476,7 +476,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation);
return result;
@@ -489,7 +489,7 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
{
schema.UnrecognizedKeywords ??= new Dictionary(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
- });
+ }, preserveInvalidProperties: context.SchemaDialectService?.IsUsingFallback == true);
if (!string.IsNullOrEmpty(identifier) && hostDocument.Workspace is not null)
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFallbackRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFallbackRegressionTests.cs
new file mode 100644
index 000000000..8f840a574
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFallbackRegressionTests.cs
@@ -0,0 +1,320 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Text.Json.Nodes;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectFallbackRegressionTests
+{
+ private const string UnsupportedDialect = "http://json-schema.org/draft-07/schema#";
+ private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema";
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void UnsupportedDialectPreservesTupleItemsThatTheFallbackCannotParse(string version, string format)
+ {
+ var result = ParseSchema(version, format, UnsupportedDialect,
+ """{ "type": "array", "items": [{ "type": "string" }] }""",
+ """
+ type: array
+ items:
+ - type: string
+ """);
+
+ AssertUnsupportedDialectDiagnostic(result);
+ AssertRawTupleItems(result.Document.Components.Schemas["Parent"], "string");
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void UnsupportedDialectPreservesAnIntegerKeywordThatTheFallbackCannotParse(string version, string format)
+ {
+ var result = ParseSchema(version, format, UnsupportedDialect,
+ """{ "type": "string", "minLength": "custom-length" }""",
+ """
+ type: string
+ minLength: custom-length
+ """);
+
+ AssertUnsupportedDialectDiagnostic(result);
+ var schema = result.Document.Components.Schemas["Parent"];
+ Assert.Null(schema.MinLength);
+ Assert.NotNull(schema.UnrecognizedKeywords);
+ Assert.True(schema.UnrecognizedKeywords.TryGetValue("minLength", out var rawKeyword));
+ Assert.Equal("custom-length", rawKeyword.GetValue());
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void UnsupportedDialectPreservesTheFailingKeywordOnItsOwnNestedSchema(string version, string format)
+ {
+ var result = ParseSchema(version, format, UnsupportedDialect,
+ """
+ {
+ "type": "array",
+ "items": { "type": "string" },
+ "properties": {
+ "child": {
+ "type": "array",
+ "items": [{ "type": "integer" }]
+ }
+ }
+ }
+ """,
+ """
+ type: array
+ items:
+ type: string
+ properties:
+ child:
+ type: array
+ items:
+ - type: integer
+ """);
+
+ AssertUnsupportedDialectDiagnostic(result);
+ var parent = result.Document.Components.Schemas["Parent"];
+ Assert.NotNull(parent.Items);
+ Assert.Equal(JsonSchemaType.String, parent.Items.Type);
+ AssertNoRawKeyword(parent, "items");
+ AssertNoRawKeyword(parent, "properties");
+ AssertRawTupleItems(parent.Properties["child"], "integer");
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void KnownDialectDoesNotPreserveMalformedItemsAsAnUnrecognizedKeyword(string version, string format)
+ {
+ var result = ParseSchema(version, format, KnownDialect,
+ """{ "type": "array", "items": [{ "type": "string" }] }""",
+ """
+ type: array
+ items:
+ - type: string
+ """);
+
+ Assert.NotNull(result.Document);
+ Assert.NotEmpty(result.Diagnostic.Errors);
+ Assert.DoesNotContain(result.Diagnostic.Errors,
+ error => error.Message.StartsWith("The JSON Schema dialect ", StringComparison.Ordinal));
+ var schema = result.Document.Components.Schemas["Parent"];
+ Assert.Null(schema.Items);
+ AssertNoRawKeyword(schema, "items");
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void KnownNestedDialectDoesNotInheritFallbackKeywordPreservation(string version, string format)
+ {
+ var result = ParseSchema(version, format, UnsupportedDialect,
+ $$"""
+ {
+ "type": "object",
+ "items": { "type": "boolean" },
+ "properties": {
+ "child": {
+ "$schema": "{{KnownDialect}}",
+ "type": "array",
+ "items": [{ "type": "string" }]
+ }
+ }
+ }
+ """,
+ $$"""
+ type: object
+ items:
+ type: boolean
+ properties:
+ child:
+ $schema: {{KnownDialect}}
+ type: array
+ items:
+ - type: string
+ """);
+
+ AssertUnsupportedDialectDiagnostic(result);
+ var parent = result.Document.Components.Schemas["Parent"];
+ Assert.Equal(JsonSchemaType.Boolean, parent.Items.Type);
+ AssertNoRawKeyword(parent, "items");
+ AssertNoRawKeyword(parent, "properties");
+ var child = parent.Properties["child"];
+ Assert.Equal(new Uri(KnownDialect), child.Schema);
+ Assert.Null(child.Items);
+ AssertNoRawKeyword(child, "items");
+ Assert.Contains(result.Diagnostic.Errors,
+ error => error.Pointer == "#/components/schemas/Parent/properties/child/items");
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json, "$ref")]
+ [InlineData("3.1.1", OpenApiConstants.Yaml, "$ref")]
+ [InlineData("3.2.0", OpenApiConstants.Json, "$ref")]
+ [InlineData("3.2.0", OpenApiConstants.Yaml, "$ref")]
+ [InlineData("3.1.1", OpenApiConstants.Json, "$dynamicRef")]
+ [InlineData("3.1.1", OpenApiConstants.Yaml, "$dynamicRef")]
+ [InlineData("3.2.0", OpenApiConstants.Json, "$dynamicRef")]
+ [InlineData("3.2.0", OpenApiConstants.Yaml, "$dynamicRef")]
+ public void UnsupportedDialectPreservesFailedItemsOnReferenceMetadata(
+ string version,
+ string format,
+ string referenceKeyword)
+ {
+ var referenceValue = referenceKeyword == "$ref" ? "#/components/schemas/Target" : "#target";
+ var result = ParseSchema(version, format, UnsupportedDialect,
+ $$"""
+ {
+ "{{referenceKeyword}}": "{{referenceValue}}",
+ "type": "array",
+ "items": [{ "type": "string" }]
+ }
+ """,
+ $$"""
+ {{referenceKeyword}}: '{{referenceValue}}'
+ type: array
+ items:
+ - type: string
+ """,
+ includeReferenceTarget: true);
+
+ AssertUnsupportedDialectDiagnostic(result);
+ var schemas = result.Document.Components.Schemas;
+ var reference = Assert.IsType(schemas["Parent"]);
+ Assert.Null(reference.Reference.Items);
+ Assert.NotNull(reference.Reference.UnrecognizedKeywords);
+ Assert.True(reference.Reference.UnrecognizedKeywords.TryGetValue("items", out var rawKeyword));
+ var tupleItems = Assert.IsType(rawKeyword);
+ Assert.Equal("string", Assert.Single(tupleItems)["type"].GetValue());
+ Assert.Same(reference.Reference.UnrecognizedKeywords, reference.UnrecognizedKeywords);
+ var target = schemas["Target"];
+ Assert.Same(target, reference.Target);
+ Assert.Equal(JsonSchemaType.Boolean, target.Items.Type);
+ AssertNoRawKeyword(target, "items");
+ }
+
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public void UnknownParentDoesNotRecoverNumericParseFailureFromKnownChild(string version, string format)
+ {
+ Assert.Throws(() => ParseSchema(version, format, UnsupportedDialect,
+ $$"""
+ {
+ "type": "object",
+ "properties": {
+ "validSibling": { "type": "string" },
+ "child": {
+ "$schema": "{{KnownDialect}}",
+ "type": "string",
+ "maxLength": "bad"
+ }
+ }
+ }
+ """,
+ $$"""
+ type: object
+ properties:
+ validSibling:
+ type: string
+ child:
+ $schema: {{KnownDialect}}
+ type: string
+ maxLength: bad
+ """));
+ }
+
+ private static void AssertUnsupportedDialectDiagnostic(ReadResult result)
+ {
+ Assert.NotNull(result.Document);
+ Assert.Contains(result.Diagnostic.Errors,
+ error => error.Pointer == "#/jsonSchemaDialect" &&
+ error.Message == $"The JSON Schema dialect '{new Uri(UnsupportedDialect)}' is not supported.");
+ }
+
+ private static void AssertRawTupleItems(IOpenApiSchema schema, string itemType)
+ {
+ Assert.Null(schema.Items);
+ Assert.NotNull(schema.UnrecognizedKeywords);
+ Assert.True(schema.UnrecognizedKeywords.TryGetValue("items", out var rawKeyword));
+ var tupleItems = Assert.IsType(rawKeyword);
+ var item = Assert.Single(tupleItems);
+ Assert.Equal(itemType, item["type"].GetValue());
+ }
+
+ private static void AssertNoRawKeyword(IOpenApiSchema schema, string keyword)
+ {
+ Assert.False(schema.UnrecognizedKeywords?.ContainsKey(keyword) ?? false);
+ }
+
+ private static ReadResult ParseSchema(
+ string version,
+ string format,
+ string dialect,
+ string jsonSchema,
+ string yamlSchema,
+ bool includeReferenceTarget = false)
+ {
+ var settings = new OpenApiReaderSettings();
+ string description;
+ if (format == OpenApiConstants.Json)
+ {
+ var target = includeReferenceTarget
+ ? """, "Target": { "$dynamicAnchor": "target", "type": "array", "items": { "type": "boolean" } }"""
+ : string.Empty;
+ description = $$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "{{dialect}}",
+ "components": {
+ "schemas": {
+ "Parent": {{jsonSchema}}{{target}}
+ }
+ }
+ }
+ """;
+ }
+ else
+ {
+ settings.AddYamlReader();
+ var indentedSchema = " " + yamlSchema.Replace("\n", "\n ");
+ var target = includeReferenceTarget
+ ? " Target:\n $dynamicAnchor: target\n type: array\n items:\n type: boolean"
+ : string.Empty;
+ description = $$"""
+ openapi: {{version}}
+ info:
+ title: test
+ version: 1.0.0
+ jsonSchemaDialect: {{dialect}}
+ components:
+ schemas:
+ Parent:
+ {{indentedSchema}}
+ {{target}}
+ """;
+ }
+
+ return OpenApiDocument.Parse(description, format, settings);
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs
new file mode 100644
index 000000000..b0566fd47
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs
@@ -0,0 +1,207 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectResolverRegressionTests
+{
+ private const string CustomDialect = "https://example.com/meta";
+ private const string MetaSchema = """{ "$schema": "https://json-schema.org/draft/2020-12/schema" }""";
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task ResolvingOneFragmentDoesNotResolveAnotherFragment(string version)
+ {
+ var loader = new DelegateLoader((uri, _) => Task.FromResult(CreateStream(
+ uri.AbsoluteUri == CustomDialect + "#a" ? MetaSchema : "{}")));
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect + "#a", CustomDialect + "#b");
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal($"The JSON Schema dialect '{CustomDialect}#b' is not supported.", error.Message);
+ Assert.Equal("#/components/schemas/Second", error.Pointer);
+ Assert.Equal([CustomDialect + "#a", CustomDialect + "#b"], loader.Requests);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task EmptyFragmentsShareResolutionAndTheMetaSchemaCache(string version)
+ {
+ var loader = new DelegateLoader((_, _) => Task.FromResult(CreateStream(MetaSchema)));
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect, CustomDialect + "#");
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ Assert.Empty(result.Diagnostic.Errors);
+ Assert.Equal(CustomDialect, Assert.Single(loader.Requests).TrimEnd('#'));
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task LoaderCancellationWithoutCallerCancellationRetainsUnsupportedDialect(string version)
+ {
+ var loader = new DelegateLoader((_, _) => Task.FromException(
+ new OperationCanceledException("The loader cancelled its own operation.")));
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ AssertUnsupportedDialect(result);
+ Assert.Equal(CustomDialect, Assert.Single(loader.Requests));
+ Assert.False(TestContext.Current.CancellationToken.IsCancellationRequested);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task HttpClientTimeoutRetainsUnsupportedDialect(string version)
+ {
+ var handler = new TimeoutMessageHandler();
+ using var client = new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromMilliseconds(100),
+ };
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ HttpClient = client,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ var result = await OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ TestContext.Current.CancellationToken);
+
+ AssertUnsupportedDialect(result);
+ Assert.Equal(1, handler.CallCount);
+ Assert.True(handler.RequestCancellationToken.IsCancellationRequested);
+ Assert.False(TestContext.Current.CancellationToken.IsCancellationRequested);
+ }
+
+ [Theory]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public async Task CallerCancellationStillPropagates(string version)
+ {
+ using var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var loader = new DelegateLoader((_, token) =>
+ {
+ cancellationSource.Cancel();
+ return Task.FromCanceled(token);
+ });
+ var settings = new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ };
+ using var input = CreateDocument(version, CustomDialect);
+
+ await Assert.ThrowsAnyAsync(() => OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ settings,
+ cancellationSource.Token));
+
+ Assert.Equal(CustomDialect, Assert.Single(loader.Requests));
+ Assert.True(cancellationSource.IsCancellationRequested);
+ }
+
+ private static void AssertUnsupportedDialect(ReadResult result)
+ {
+ Assert.NotNull(result.Document);
+ Assert.Equal(
+ $"The JSON Schema dialect '{CustomDialect}' is not supported.",
+ Assert.Single(result.Diagnostic.Errors).Message);
+ }
+
+ private static MemoryStream CreateDocument(string version, string firstDialect, string secondDialect = null)
+ {
+ var secondSchema = secondDialect is null ? string.Empty : $$"""
+ , "Second": { "$schema": "{{secondDialect}}", "type": "object" }
+ """;
+ return CreateStream($$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "components": {
+ "schemas": {
+ "First": { "$schema": "{{firstDialect}}", "type": "string" }{{secondSchema}}
+ }
+ }
+ }
+ """);
+ }
+
+ private static MemoryStream CreateStream(string content) => new(Encoding.UTF8.GetBytes(content));
+
+ private sealed class DelegateLoader(Func> load) : IStreamLoader
+ {
+ internal List Requests { get; } = [];
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Requests.Add(uri.AbsoluteUri);
+ return load(uri, cancellationToken);
+ }
+ }
+
+ private sealed class TimeoutMessageHandler : HttpMessageHandler
+ {
+ internal int CallCount { get; private set; }
+ internal CancellationToken RequestCancellationToken { get; private set; }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ RequestCancellationToken = cancellationToken;
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ throw new InvalidOperationException("The request should only finish through timeout cancellation.");
+ }
+ }
+}
From 64b112dd1acbd312ba8c02b26e91fa0dd6d51641 Mon Sep 17 00:00:00 2001
From: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
Date: Tue, 3 Nov 2026 11:06:22 -0500
Subject: [PATCH 3/5] fix(library): handle dialect fragments and vocabulary
values
Signed-off-by: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com>
---
README.md | 2 +
.../Reader/JsonNodeHelper.cs | 6 +-
.../Services/JsonSchemaDialectResolver.cs | 163 +++++++++-
.../Reader/V31/OpenApiSchemaDeserializer.cs | 3 +-
.../Reader/V32/OpenApiSchemaDeserializer.cs | 3 +-
...sonSchemaDialectFragmentRegressionTests.cs | 137 +++++++++
...sonSchemaDialectResolverRegressionTests.cs | 10 +-
...nSchemaDialectVocabularyRegressionTests.cs | 286 ++++++++++++++++++
8 files changed, 594 insertions(+), 16 deletions(-)
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFragmentRegressionTests.cs
create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectVocabularyRegressionTests.cs
diff --git a/README.md b/README.md
index 24ca23eda..af57efc5b 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,8 @@ var result = await OpenApiDocument.LoadAsync(stream, OpenApiConstants.Json,
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 loads per chain 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
diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
index 16f98f708..711496f00 100644
--- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
+++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs
@@ -102,7 +102,7 @@ public static Dictionary CreateMap(this JsonNode? node, Func k.key, v => v.value);
}
- public static Dictionary CreateSimpleMap(this JsonNode? node, Func map, ParsingContext context)
+ public static Dictionary CreateSimpleMap(this JsonNode? node, Func map, ParsingContext context, bool reportConversionErrors = false)
{
if (node is not JsonObject jsonMap)
{
@@ -121,6 +121,10 @@ public static Dictionary CreateSimpleMap(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();
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
index 46aea78e4..e55eeb020 100644
--- a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Security;
@@ -132,29 +133,169 @@ internal async Task> ResolveAsync(
buffer.Position = 0;
var metaSchema = await JsonNode.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
- if (metaSchema is not JsonObject jsonObject ||
- !jsonObject.TryGetPropertyValue(OpenApiConstants.DollarSchema, out var dialectNode) ||
- dialectNode is not JsonValue)
- {
- return null;
- }
-
- return Uri.TryCreate(dialectNode.GetScalarValue(), UriKind.Absolute, out var parentDialect)
- ? parentDialect
- : null;
+ return FindParentDialect(metaSchema, dialect, cancellationToken);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch (Exception exception) when (exception is IOException or HttpRequestException or JsonException or OpenApiException or
- ArgumentException or InvalidOperationException or SecurityException or UnauthorizedAccessException)
+ ArgumentException or InvalidOperationException or SecurityException or UnauthorizedAccessException or UriFormatException)
{
cancellationToken.ThrowIfCancellationRequested();
return null;
}
}
+ private static Uri? FindParentDialect(JsonNode? metaSchema, Uri dialect, CancellationToken cancellationToken)
+ {
+ var fragment = dialect.Fragment.Length == 0 ? string.Empty : Uri.UnescapeDataString(dialect.Fragment.Substring(1));
+ var isPointer = fragment.StartsWith("/", StringComparison.Ordinal);
+ var isAnchor = fragment.Length > 0 && !isPointer;
+ if (isAnchor && (!IsAnchorName(fragment) || metaSchema is JsonObject root && HasInvalidIdentifier(root, dialect)))
+ {
+ return null;
+ }
+
+ Uri? result = null;
+ var found = false;
+ foreach (var (schema, pointer, effectiveDialect) in EnumerateSchemas(metaSchema, string.Empty, null, isAnchor, cancellationToken))
+ {
+ var matches = isAnchor
+ ? schema is JsonObject jsonObject &&
+ (HasAnchor(jsonObject, OpenApiConstants.Anchor, fragment) || HasAnchor(jsonObject, OpenApiConstants.DynamicAnchor, fragment))
+ : string.Equals(pointer, fragment, StringComparison.Ordinal);
+ if (!matches)
+ {
+ continue;
+ }
+
+ if (found)
+ {
+ // Plain-name fragments must identify one schema in the current resource.
+ return null;
+ }
+
+ found = true;
+ result = effectiveDialect;
+ if (!isAnchor)
+ {
+ return result;
+ }
+ }
+
+ return result;
+ }
+
+ private static IEnumerable<(JsonNode Schema, string Pointer, Uri? Dialect)> EnumerateSchemas(
+ JsonNode? node,
+ string pointer,
+ Uri? inheritedDialect,
+ bool restrictToResource,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!IsSchema(node))
+ {
+ yield break;
+ }
+
+ var effectiveDialect = inheritedDialect;
+ if (node is JsonObject schema)
+ {
+ // Anchors belong to their nearest resource root, not to enclosing resources.
+ if (restrictToResource && pointer.Length > 0 && schema.TryGetPropertyValue("$id", out var identifier) &&
+ (identifier is not JsonValue idValue || !idValue.TryGetValue(out var id) || !string.IsNullOrEmpty(id)))
+ {
+ yield break;
+ }
+
+ if (schema.TryGetPropertyValue(OpenApiConstants.DollarSchema, out var dialectNode))
+ {
+ effectiveDialect = dialectNode is JsonValue value && value.TryGetValue(out var declaredDialect) &&
+ Uri.TryCreate(declaredDialect, UriKind.Absolute, out var parentDialect) ? parentDialect : null;
+ }
+ }
+
+ yield return (node!, pointer, effectiveDialect);
+ if (node is not JsonObject jsonObject)
+ {
+ yield break;
+ }
+
+ foreach (var (child, childPointer) in EnumerateChildSchemas(jsonObject, pointer))
+ {
+ foreach (var descendant in EnumerateSchemas(child, childPointer, effectiveDialect, restrictToResource, cancellationToken))
+ {
+ yield return descendant;
+ }
+ }
+ }
+
+ private static IEnumerable<(JsonNode Schema, string Pointer)> EnumerateChildSchemas(JsonObject schema, string pointer)
+ {
+ // Only locations with known schema semantics are traversed. Examples and custom
+ // vocabulary payloads cannot safely be interpreted as schemas or anchor declarations.
+ foreach (var property in schema)
+ {
+ var propertyPointer = pointer + "/" + property.Key;
+ switch (property.Key)
+ {
+ case "$defs" or "properties" or "patternProperties" or "dependentSchemas" when property.Value is JsonObject map:
+ foreach (var child in map)
+ {
+ if (IsSchema(child.Value))
+ {
+ yield return (child.Value!, propertyPointer + "/" + OpenApiVisitorBase.EncodeJsonPointerSegment(child.Key));
+ }
+ }
+ break;
+ case "allOf" or "anyOf" or "oneOf" or "prefixItems" when property.Value is JsonArray array:
+ for (var index = 0; index < array.Count; index++)
+ {
+ if (IsSchema(array[index]))
+ {
+ yield return (array[index]!, propertyPointer + "/" + index.ToString(CultureInfo.InvariantCulture));
+ }
+ }
+ break;
+ case "items" or "not" or "contains" or "propertyNames" or "additionalProperties" or
+ "unevaluatedProperties" or "unevaluatedItems" or "contentSchema" or "if" or "then" or "else":
+ if (IsSchema(property.Value))
+ {
+ yield return (property.Value!, propertyPointer);
+ }
+ break;
+ }
+ }
+ }
+
+ private static bool IsSchema(JsonNode? node) => node is JsonObject || node is JsonValue value && value.TryGetValue(out _);
+
+ private static bool HasAnchor(JsonObject schema, string keyword, string anchor) =>
+ schema.TryGetPropertyValue(keyword, out var node) && node is JsonValue value &&
+ value.TryGetValue(out var name) && string.Equals(name, anchor, StringComparison.Ordinal);
+
+ private static bool HasInvalidIdentifier(JsonObject schema, Uri dialect) =>
+ schema.TryGetPropertyValue("$id", out var node) &&
+ (node is not JsonValue value || !value.TryGetValue(out var id) ||
+ !string.IsNullOrEmpty(id) && (!Uri.TryCreate(dialect, id, out var identifier) || identifier.Fragment.Length > 1));
+
+ private static bool IsAnchorName(string name)
+ {
+ for (var index = 0; index < name.Length; index++)
+ {
+ var character = name[index];
+ if (!(character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or '_' ||
+ index > 0 && character is >= '0' and <= '9' or '-' or '.'))
+ {
+ return false;
+ }
+ }
+
+ return name.Length > 0;
+ }
+
private static string GetDialectKey(Uri dialect)
{
var value = dialect.AbsoluteUri;
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index 29153565f..06e962bff 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -34,7 +34,8 @@ internal static partial class OpenApiV31Deserializer
},
{
"$vocabulary",
- (o, n, _, c) => o.Vocabulary = n.CreateSimpleMap(LoadBool, c).ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? false)
+ (o, n, _, c) => o.Vocabulary = n.CreateSimpleMap(LoadBool, c,
+ reportConversionErrors: c.SchemaDialectService?.IsUsingFallback == true).ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? false)
},
{
"$dynamicRef",
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index 72c3af1d5..d21b0ff63 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -34,7 +34,8 @@ internal static partial class OpenApiV32Deserializer
},
{
"$vocabulary",
- (o, n, _, c) => o.Vocabulary = n.CreateSimpleMap(LoadBool, c).ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? false)
+ (o, n, _, c) => o.Vocabulary = n.CreateSimpleMap(LoadBool, c,
+ reportConversionErrors: c.SchemaDialectService?.IsUsingFallback == true).ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? false)
},
{
"$dynamicRef",
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFragmentRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFragmentRegressionTests.cs
new file mode 100644
index 000000000..ff0b8d954
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectFragmentRegressionTests.cs
@@ -0,0 +1,137 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectFragmentRegressionTests
+{
+ private const string MetaSchemaUri = "https://example.com/meta";
+ private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema";
+ private const string UnknownDialect = "https://example.com/unsupported";
+
+ public static IEnumerable
- 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;
}
///
@@ -240,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)
+ ? 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)
{
- return jsonObject.TryGetPropertyValue("$id", out var idNode) ? idNode?.GetScalarValue() : null;
+ value = null;
+ return !node.IsJsonNullSentinel() && node is JsonValue jsonValue && jsonValue.TryGetValue(out value);
}
public static void ParseMap(
diff --git a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
index e55eeb020..4537a1552 100644
--- a/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
+++ b/src/Microsoft.OpenApi/Reader/Services/JsonSchemaDialectResolver.cs
@@ -25,7 +25,6 @@ internal sealed class JsonSchemaDialectResolver
private readonly IStreamLoader _loader;
private readonly Uri _baseUrl;
private readonly Dictionary _metaSchemaDialects = new(StringComparer.Ordinal);
- private readonly Dictionary _resolvedDialects = new(StringComparer.Ordinal);
internal JsonSchemaDialectResolver(IStreamLoader loader, Uri baseUrl)
{
@@ -52,7 +51,6 @@ internal async Task> ResolveAsync(
private async Task ResolveAsync(Uri dialect, CancellationToken cancellationToken)
{
var visited = new HashSet(StringComparer.Ordinal);
- var chain = new List();
var current = dialect;
for (var depth = 0; depth < MaxResolutionDepth; depth++)
@@ -61,25 +59,16 @@ internal async Task> ResolveAsync(
if (JsonSchemaDialectService.IsKnownDialect(current))
{
- foreach (var item in chain)
- {
- _resolvedDialects[GetDialectKey(item)] = current;
- }
return current;
}
var key = GetDialectKey(current);
- if (_resolvedDialects.TryGetValue(key, out var resolvedDialect))
- {
- return resolvedDialect;
- }
-
if (!visited.Add(key))
{
return null;
}
- chain.Add(current);
+ // Cached parent links still count toward this chain's depth limit.
if (!_metaSchemaDialects.TryGetValue(key, out var parentDialect))
{
parentDialect = await LoadParentDialectAsync(current, cancellationToken).ConfigureAwait(false);
@@ -96,10 +85,6 @@ internal async Task> ResolveAsync(
if (JsonSchemaDialectService.IsKnownDialect(current))
{
- foreach (var item in chain)
- {
- _resolvedDialects[GetDialectKey(item)] = current;
- }
return current;
}
diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
index 06e962bff..7c8a3fb3c 100644
--- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
@@ -26,7 +26,7 @@ internal static partial class OpenApiV31Deserializer
},
{
"$id",
- (o, n, _, _) => o.Id = n.GetScalarValue()
+ (o, n, _, c) => o.Id = n.GetScalarValue(requireString: c.SchemaDialectService?.IsUsingFallback == true)
},
{
"$comment",
@@ -39,7 +39,7 @@ internal static partial class OpenApiV31Deserializer
},
{
"$dynamicRef",
- (o, n, _, _) => o.DynamicRef = n.GetScalarValue()
+ (o, n, _, c) => o.DynamicRef = n.GetScalarValue(requireString: c.SchemaDialectService?.IsUsingFallback == true)
},
{
"$dynamicAnchor",
@@ -435,9 +435,10 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
- var pointer = jsonObject.GetReferencePointer();
- var dynamicPointer = jsonObject.GetDynamicReferencePointer();
- var identifier = jsonObject.GetJsonSchemaIdentifier();
+ var preserveInvalidProperties = context.SchemaDialectService?.IsUsingFallback == true;
+ var pointer = jsonObject.GetReferencePointer(context, preserveInvalidProperties);
+ var dynamicPointer = jsonObject.GetDynamicReferencePointer(preserveInvalidProperties);
+ var identifier = jsonObject.GetJsonSchemaIdentifier(preserveInvalidProperties);
if (pointer != null)
{
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
index d21b0ff63..796eea00c 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
@@ -26,7 +26,7 @@ internal static partial class OpenApiV32Deserializer
},
{
"$id",
- (o, n, _, _) => o.Id = n.GetScalarValue()
+ (o, n, _, c) => o.Id = n.GetScalarValue(requireString: c.SchemaDialectService?.IsUsingFallback == true)
},
{
"$comment",
@@ -39,7 +39,7 @@ internal static partial class OpenApiV32Deserializer
},
{
"$dynamicRef",
- (o, n, _, _) => o.DynamicRef = n.GetScalarValue()
+ (o, n, _, c) => o.DynamicRef = n.GetScalarValue(requireString: c.SchemaDialectService?.IsUsingFallback == true)
},
{
"$dynamicAnchor",
@@ -435,9 +435,10 @@ internal static IOpenApiSchema LoadSchemaCore(JsonNode node, OpenApiDocument hos
var jsonObject = node.CheckMapNode(OpenApiConstants.Schema, context);
- var pointer = jsonObject.GetReferencePointer();
- var dynamicPointer = jsonObject.GetDynamicReferencePointer();
- var identifier = jsonObject.GetJsonSchemaIdentifier();
+ var preserveInvalidProperties = context.SchemaDialectService?.IsUsingFallback == true;
+ var pointer = jsonObject.GetReferencePointer(context, preserveInvalidProperties);
+ var dynamicPointer = jsonObject.GetDynamicReferencePointer(preserveInvalidProperties);
+ var identifier = jsonObject.GetJsonSchemaIdentifier(preserveInvalidProperties);
if (pointer != null)
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDepthRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDepthRegressionTests.cs
new file mode 100644
index 000000000..b8ac2d2f7
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDepthRegressionTests.cs
@@ -0,0 +1,248 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectDepthRegressionTests
+{
+ private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema";
+
+ [Theory]
+ [InlineData("3.1.1", false)]
+ [InlineData("3.1.1", true)]
+ [InlineData("3.2.0", false)]
+ [InlineData("3.2.0", true)]
+ public async Task ResolvingASuffixFirstDoesNotBypassTheParentChainsDepthLimit(string version, bool suffixFirst)
+ {
+ var length = JsonSchemaDialectResolver.MaxResolutionDepth + 1;
+ var loader = new DialectLoader(CreateChain("chain", length));
+ using var input = CreateDocument(version, suffixFirst,
+ ("TooDeep", GetDialectUri("chain", 0)),
+ ("AtLimit", GetDialectUri("chain", 1)));
+
+ var result = await LoadAsync(input, loader);
+
+ AssertUnsupportedSchema(result, "TooDeep", GetDialectUri("chain", 0));
+ Assert.Equal(length, loader.TotalCallCount);
+ AssertLoadedOnce(loader, "chain", length);
+ }
+
+ [Theory]
+ [InlineData("3.1.1", 15)]
+ [InlineData("3.1.1", 16)]
+ [InlineData("3.1.1", 17)]
+ [InlineData("3.2.0", 15)]
+ [InlineData("3.2.0", 16)]
+ [InlineData("3.2.0", 17)]
+ public async Task EveryParentLinkCountsAtTheDepthBoundary(string version, int length)
+ {
+ var loader = new DialectLoader(CreateChain("boundary", length));
+ var dialect = GetDialectUri("boundary", 0);
+ using var input = CreateDocument(version, false, ("Item", dialect));
+
+ var result = await LoadAsync(input, loader);
+
+ Assert.NotNull(result.Document);
+ if (length > JsonSchemaDialectResolver.MaxResolutionDepth)
+ {
+ AssertUnsupportedSchema(result, "Item", dialect);
+ }
+ else
+ {
+ Assert.Empty(result.Diagnostic.Errors);
+ }
+
+ var loaded = Math.Min(length, JsonSchemaDialectResolver.MaxResolutionDepth);
+ Assert.Equal(loaded, loader.TotalCallCount);
+ AssertLoadedOnce(loader, "boundary", loaded);
+ if (length > loaded)
+ {
+ Assert.Equal(0, loader.GetCallCount(GetDialectUri("boundary", loaded)));
+ }
+ }
+
+ [Theory]
+ [InlineData("3.1.1", false)]
+ [InlineData("3.1.1", true)]
+ [InlineData("3.2.0", false)]
+ [InlineData("3.2.0", true)]
+ public async Task SharedTailKeepsEachRootsDepthAndIsLoadedOnlyOnce(string version, bool longerFirst)
+ {
+ var tailLength = JsonSchemaDialectResolver.MaxResolutionDepth - 1;
+ var documents = CreateChain("tail", tailLength);
+ var atLimit = GetDialectUri("short", 0);
+ var tooDeep = GetDialectUri("long", 0);
+ var intermediate = GetDialectUri("long", 1);
+ documents[atLimit] = MetaSchema(GetDialectUri("tail", 0));
+ documents[tooDeep] = MetaSchema(intermediate);
+ documents[intermediate] = MetaSchema(GetDialectUri("tail", 0));
+ var loader = new DialectLoader(documents);
+ using var input = CreateDocument(version, longerFirst, ("AtLimit", atLimit), ("TooDeep", tooDeep));
+
+ var result = await LoadAsync(input, loader);
+
+ AssertUnsupportedSchema(result, "TooDeep", tooDeep);
+ Assert.Equal(tailLength + 3, loader.TotalCallCount);
+ AssertLoadedOnce(loader, "tail", tailLength);
+ Assert.Equal(1, loader.GetCallCount(atLimit));
+ Assert.Equal(1, loader.GetCallCount(tooDeep));
+ Assert.Equal(1, loader.GetCallCount(intermediate));
+ }
+
+ [Theory]
+ [InlineData("3.1.1", false)]
+ [InlineData("3.1.1", true)]
+ [InlineData("3.2.0", false)]
+ [InlineData("3.2.0", true)]
+ public async Task SharedCyclicTailRemainsUnsupportedWithoutRepeatedLoads(string version, bool secondFirst)
+ {
+ var first = GetDialectUri("root", 0);
+ var second = GetDialectUri("root", 1);
+ var cycleStart = GetDialectUri("cycle", 0);
+ var cycleEnd = GetDialectUri("cycle", 1);
+ var loader = new DialectLoader(new Dictionary(StringComparer.Ordinal)
+ {
+ [first] = MetaSchema(cycleStart),
+ [second] = MetaSchema(cycleStart),
+ [cycleStart] = MetaSchema(cycleEnd),
+ [cycleEnd] = MetaSchema(cycleStart),
+ });
+ using var input = CreateDocument(version, secondFirst, ("First", first), ("Second", second));
+
+ var result = await LoadAsync(input, loader);
+
+ AssertBothUnsupported(result, first, second);
+ Assert.Equal(4, loader.TotalCallCount);
+ AssertLoadedOnce(loader, "root", 2);
+ AssertLoadedOnce(loader, "cycle", 2);
+ }
+
+ [Theory]
+ [InlineData("3.1.1", false)]
+ [InlineData("3.1.1", true)]
+ [InlineData("3.2.0", false)]
+ [InlineData("3.2.0", true)]
+ public async Task SharedMissingParentRemainsUnsupportedWithoutRepeatedLoads(string version, bool secondFirst)
+ {
+ var first = GetDialectUri("root", 0);
+ var second = GetDialectUri("root", 1);
+ var missing = GetDialectUri("missing", 0);
+ var loader = new DialectLoader(new Dictionary(StringComparer.Ordinal)
+ {
+ [first] = MetaSchema(missing),
+ [second] = MetaSchema(missing),
+ });
+ using var input = CreateDocument(version, secondFirst, ("First", first), ("Second", second));
+
+ var result = await LoadAsync(input, loader);
+
+ AssertBothUnsupported(result, first, second);
+ Assert.Equal(3, loader.TotalCallCount);
+ AssertLoadedOnce(loader, "root", 2);
+ Assert.Equal(1, loader.GetCallCount(missing));
+ }
+
+ private static Task LoadAsync(Stream input, IStreamLoader loader) => OpenApiDocument.LoadAsync(
+ input,
+ OpenApiConstants.Json,
+ new OpenApiReaderSettings
+ {
+ LoadExternalRefs = true,
+ CustomExternalLoader = loader,
+ },
+ TestContext.Current.CancellationToken);
+
+ private static void AssertUnsupportedSchema(ReadResult result, string name, string dialect)
+ {
+ Assert.NotNull(result.Document);
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal($"The JSON Schema dialect '{dialect}' is not supported.", error.Message);
+ Assert.Equal($"#/components/schemas/{name}", error.Pointer);
+ }
+
+ private static void AssertBothUnsupported(ReadResult result, string first, string second)
+ {
+ Assert.NotNull(result.Document);
+ Assert.Equal(2, result.Diagnostic.Errors.Count);
+ Assert.Contains(result.Diagnostic.Errors,
+ error => error.Pointer == "#/components/schemas/First" &&
+ error.Message == $"The JSON Schema dialect '{first}' is not supported.");
+ Assert.Contains(result.Diagnostic.Errors,
+ error => error.Pointer == "#/components/schemas/Second" &&
+ error.Message == $"The JSON Schema dialect '{second}' is not supported.");
+ }
+
+ private static void AssertLoadedOnce(DialectLoader loader, string prefix, int length)
+ {
+ for (var index = 0; index < length; index++)
+ {
+ Assert.Equal(1, loader.GetCallCount(GetDialectUri(prefix, index)));
+ }
+ }
+
+ private static Dictionary CreateChain(string prefix, int length)
+ {
+ var documents = new Dictionary(StringComparer.Ordinal);
+ for (var index = 0; index < length; index++)
+ {
+ var parent = index + 1 == length ? KnownDialect : GetDialectUri(prefix, index + 1);
+ documents[GetDialectUri(prefix, index)] = MetaSchema(parent);
+ }
+ return documents;
+ }
+
+ private static string MetaSchema(string parent) => $$"""{ "$schema": "{{parent}}" }""";
+
+ private static string GetDialectUri(string prefix, int index) => $"https://example.com/{prefix}-{index}";
+
+ private static MemoryStream CreateDocument(string version, bool reverseOrder, params (string Name, string Dialect)[] schemas)
+ {
+ if (reverseOrder)
+ {
+ Array.Reverse(schemas);
+ }
+ var entries = new List();
+ foreach (var (name, dialect) in schemas)
+ {
+ entries.Add($$"""
+ "{{name}}": { "$schema": "{{dialect}}", "type": "object" }
+ """);
+ }
+ return new MemoryStream(Encoding.UTF8.GetBytes($$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "components": { "schemas": { {{string.Join(",", entries)}} } }
+ }
+ """));
+ }
+
+ private sealed class DialectLoader(IReadOnlyDictionary documents) : IStreamLoader
+ {
+ private readonly Dictionary _callCounts = new(StringComparer.Ordinal);
+
+ internal int TotalCallCount { get; private set; }
+
+ internal int GetCallCount(string uri) => _callCounts.TryGetValue(uri, out var count) ? count : 0;
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var key = uri.AbsoluteUri;
+ _callCounts[key] = GetCallCount(key) + 1;
+ TotalCallCount++;
+ return documents.TryGetValue(key, out var document)
+ ? Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(document)))
+ : Task.FromException(new FileNotFoundException("The meta-schema was not found."));
+ }
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceDiagnosticTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceDiagnosticTests.cs
new file mode 100644
index 000000000..9e5231e02
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceDiagnosticTests.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.IO;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectReferenceDiagnosticTests
+{
+ [Theory]
+ [InlineData("3.1.1", OpenApiConstants.Json)]
+ [InlineData("3.1.1", OpenApiConstants.Yaml)]
+ [InlineData("3.2.0", OpenApiConstants.Json)]
+ [InlineData("3.2.0", OpenApiConstants.Yaml)]
+ public async Task ResolvingTheDialectRetainsInvalidReferenceDiagnosticsAndSiblingSchemas(string version, string format)
+ {
+ var document = format == OpenApiConstants.Json
+ ? $$"""
+ {
+ "openapi": "{{version}}",
+ "info": { "title": "test", "version": "1.0.0" },
+ "jsonSchemaDialect": "https://example.com/dialect",
+ "components": { "schemas": {
+ "Before": { "type": "string" },
+ "Item": { "$ref": { "custom": true }, "type": "object" },
+ "After": { "type": "integer" }
+ } }
+ }
+ """
+ : $$"""
+ openapi: {{version}}
+ info:
+ title: test
+ version: 1.0.0
+ jsonSchemaDialect: https://example.com/dialect
+ components:
+ schemas:
+ Before:
+ type: string
+ Item:
+ $ref:
+ custom: true
+ type: object
+ After:
+ type: integer
+ """;
+ var loader = new DialectLoader();
+ var settings = new OpenApiReaderSettings { LoadExternalRefs = true, CustomExternalLoader = loader };
+ settings.AddYamlReader();
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(document));
+
+ var result = await OpenApiDocument.LoadAsync(input, format, settings, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.Document);
+ var schemas = result.Document.Components.Schemas;
+ Assert.NotNull(schemas);
+ Assert.Equal(3, schemas.Count);
+ Assert.Equal(JsonSchemaType.String, schemas["Before"].Type);
+ Assert.Equal(JsonSchemaType.Integer, schemas["After"].Type);
+ var schema = Assert.IsType(schemas["Item"]);
+ Assert.Equal(JsonSchemaType.Object, schema.Type);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse("""{ "custom": true }"""), schema.UnrecognizedKeywords["$ref"]));
+ var error = Assert.Single(result.Diagnostic.Errors);
+ Assert.Equal("#/components/schemas/Item/$ref", error.Pointer);
+ Assert.DoesNotContain("not supported", error.Message);
+ Assert.Equal(1, loader.CallCount);
+ }
+
+ private sealed class DialectLoader : IStreamLoader
+ {
+ internal int CallCount { get; private set; }
+
+ public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Equal("https://example.com/dialect", uri.AbsoluteUri);
+ CallCount++;
+ return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(
+ """{ "$schema": "https://json-schema.org/draft/2020-12/schema" }""")));
+ }
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceKeywordRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceKeywordRegressionTests.cs
new file mode 100644
index 000000000..3fd160268
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceKeywordRegressionTests.cs
@@ -0,0 +1,297 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using Microsoft.OpenApi.Reader;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests;
+
+public class JsonSchemaDialectReferenceKeywordRegressionTests
+{
+ private const string UnsupportedDialect = "https://example.com/custom-dialect";
+ private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema";
+ private const string ItemName = "Item/~";
+ private const string ChildName = "child/~";
+ private static readonly string[] InvalidValues = ["{ \"custom\": true }", "[{ \"custom\": true }]", "true", "42", "null"];
+
+ public static IEnumerable