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 FragmentCases() + { + (string Fragment, string Schema, bool Supported)[] cases = + [ + ("#missing", $$"""{ "$schema": "{{KnownDialect}}" }""", false), + ("#/$defs/missing", $$"""{ "$schema": "{{KnownDialect}}", "$defs": {} }""", false), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": {} } }""", true), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "$anchor": "selected" } } }""", true), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "$dynamicAnchor": "selected" } } }""", true), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "$id": "child", "$schema": "{{UnknownDialect}}" } } }""", false), + ("#/$defs/selected", $$"""{ "$schema": "{{UnknownDialect}}", "$defs": { "selected": { "$id": "child", "$schema": "{{KnownDialect}}" } } }""", true), + ("#selected", $$"""{ "$defs": { "selected": { "$anchor": "selected", "$schema": "{{KnownDialect}}" } } }""", true), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": true } }""", true), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "examples": [{ "$anchor": "selected" }] }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "x-custom": { "$anchor": "selected" } }""", false), + ("#/examples/0", $$"""{ "$schema": "{{KnownDialect}}", "examples": [{ "$schema": "{{KnownDialect}}" }] }""", false), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": "not a schema" } }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "a": { "$anchor": "selected" }, "b": { "$anchor": "selected" } } }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "child": { "$id": "other", "$anchor": "selected" } } }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$anchor": "selected", "$defs": { "child": { "$id": "other", "$anchor": "selected" } } }""", true), + ("#%2F$defs%2Ffoo~1bar~0baz", $$"""{ "$defs": { "foo/bar~baz": { "$schema": "{{KnownDialect}}" } } }""", true), + ("#/$defs/%252F", $$"""{ "$defs": { "%2F": { "$schema": "{{KnownDialect}}" } } }""", true), + ("#/$defs/", $$"""{ "$defs": { "": { "$schema": "{{KnownDialect}}" } } }""", true), + ("#/allOf/01", $$"""{ "$schema": "{{KnownDialect}}", "allOf": [{}, {}] }""", false), + ("#/allOf/-", $$"""{ "$schema": "{{KnownDialect}}", "allOf": [{}] }""", false), + ("#/allOf/9", $$"""{ "$schema": "{{KnownDialect}}", "allOf": [{}] }""", false), + ("#/$defs/selected/type/missing", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "type": "string" } } }""", false), + ("#/$defs/selected", """{ "$schema": 42, "$defs": { "selected": {} } }""", false), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "$schema": 42 } } }""", false), + ("#/$defs/selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "selected": { "$schema": "relative" } } }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "child": { "$id": "", "$anchor": "selected" } } }""", true), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$defs": { "child": { "$id": "#invalid", "$anchor": "selected" } } }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "unevaluatedItems": { "$anchor": "selected" } }""", true), + ("#not%20valid", $$"""{ "$schema": "{{KnownDialect}}", "$anchor": "not valid" }""", false), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$id": "{{MetaSchemaUri}}#", "$anchor": "selected" }""", true), + ("#selected", $$"""{ "$schema": "{{KnownDialect}}", "$id": "#", "$anchor": "selected" }""", true), + ]; + + foreach (var version in new[] { "3.1.1", "3.2.0" }) + { + foreach (var (fragment, schema, supported) in cases) + { + yield return [version, fragment, schema, supported]; + } + } + } + + [Theory] + [MemberData(nameof(FragmentCases))] + public async Task DefaultLoaderResolvesTheSelectedSchemaRatherThanTheResourceRoot( + string version, + string fragment, + string metaSchema, + bool supported) + { + var dialect = MetaSchemaUri + fragment; + var handler = new MetaSchemaHandler(metaSchema); + 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.NotNull(result.Document); + if (supported) + { + Assert.Empty(result.Diagnostic.Errors); + } + else + { + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal($"The JSON Schema dialect '{new Uri(dialect)}' is not supported.", error.Message); + Assert.Equal("#/jsonSchemaDialect", error.Pointer); + } + + Assert.Equal(1, handler.MetaSchemaRequests); + } + + private static MemoryStream CreateDocument(string version, string dialect) => new(Encoding.UTF8.GetBytes($$""" + { + "openapi": "{{version}}", + "info": { "title": "test", "version": "1.0.0" }, + "jsonSchemaDialect": "{{dialect}}", + "components": { "schemas": { "Item": { "type": "string" } } } + } + """)); + + private sealed class MetaSchemaHandler(string metaSchema) : HttpMessageHandler + { + internal int MetaSchemaRequests { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.RequestUri.GetLeftPart(UriPartial.Path) == MetaSchemaUri) + { + MetaSchemaRequests++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(metaSchema, Encoding.UTF8, "application/schema+json"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs index b0566fd47..11dc9d4fb 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectResolverRegressionTests.cs @@ -23,8 +23,14 @@ public class JsonSchemaDialectResolverRegressionTests [InlineData("3.2.0")] public async Task ResolvingOneFragmentDoesNotResolveAnotherFragment(string version) { - var loader = new DelegateLoader((uri, _) => Task.FromResult(CreateStream( - uri.AbsoluteUri == CustomDialect + "#a" ? MetaSchema : "{}"))); + var loader = new DelegateLoader((_, _) => Task.FromResult(CreateStream(""" + { + "$defs": { + "First": { "$anchor": "a", "$schema": "https://json-schema.org/draft/2020-12/schema" }, + "Second": { "$anchor": "b" } + } + } + """))); var settings = new OpenApiReaderSettings { LoadExternalRefs = true, diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectVocabularyRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectVocabularyRegressionTests.cs new file mode 100644 index 000000000..1a1a14ff6 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectVocabularyRegressionTests.cs @@ -0,0 +1,286 @@ +// 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 JsonSchemaDialectVocabularyRegressionTests +{ + private const string UnsupportedDialect = "http://json-schema.org/draft-07/schema#"; + private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema"; + private const string Vocabulary = "urn:example:vocab"; + + [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 UnsupportedDialectPreservesVocabularyThatTheFallbackCannotParse(string version, string format) + { + var result = ParseSchema(version, format, UnsupportedDialect, + """{ "$vocabulary": { "urn:example:vocab": "custom" } }""", + """ + $vocabulary: + urn:example:vocab: custom + """); + + AssertUnsupportedDialectDiagnostic(result); + AssertRawVocabulary(result.Document.Components.Schemas["Parent"]); + Assert.Contains(result.Diagnostic.Errors, + error => error.Pointer == "#/components/schemas/Parent/$vocabulary/urn:example:vocab"); + } + + [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 UnsupportedDialectPreservesTheWholeVocabularyWhenOneEntryCannotBeParsed(string version, string format) + { + var result = ParseSchema(version, format, UnsupportedDialect, + """ + { + "$vocabulary": { + "urn:example:before": true, + "urn:example:vocab": "custom", + "urn:example:after": false + } + } + """, + """ + $vocabulary: + urn:example:before: true + urn:example:vocab: custom + urn:example:after: false + """); + + AssertUnsupportedDialectDiagnostic(result); + var schema = result.Document.Components.Schemas["Parent"]; + Assert.Null(schema.Vocabulary); + AssertRawVocabulary(schema); + var rawVocabulary = Assert.IsType(schema.UnrecognizedKeywords["$vocabulary"]); + Assert.Equal(3, rawVocabulary.Count); + Assert.True(rawVocabulary["urn:example:before"].GetValue()); + Assert.False(rawVocabulary["urn:example:after"].GetValue()); + Assert.Contains(result.Diagnostic.Errors, + error => error.Pointer == "#/components/schemas/Parent/$vocabulary/urn:example:vocab"); + } + + [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 UnsupportedDialectPreservesVocabularyOnItsOwnNestedSchema(string version, string format) + { + var result = ParseSchema(version, format, UnsupportedDialect, + """ + { + "$vocabulary": { "urn:example:vocab": true }, + "properties": { + "child": { "$vocabulary": { "urn:example:vocab": "custom" } } + } + } + """, + """ + $vocabulary: + urn:example:vocab: true + properties: + child: + $vocabulary: + urn:example:vocab: custom + """); + + AssertUnsupportedDialectDiagnostic(result); + var parent = result.Document.Components.Schemas["Parent"]; + Assert.True(parent.Vocabulary[Vocabulary]); + AssertNoRawVocabulary(parent); + Assert.False(parent.UnrecognizedKeywords?.ContainsKey("properties") ?? false); + AssertRawVocabulary(parent.Properties["child"]); + Assert.Contains(result.Diagnostic.Errors, + error => error.Pointer == "#/components/schemas/Parent/properties/child/$vocabulary/urn:example:vocab"); + } + + [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 KnownDialectStillPropagatesVocabularyConversionFailure(string version, string format) + { + Assert.Throws(() => ParseSchema(version, format, KnownDialect, + """{ "$vocabulary": { "urn:example:vocab": "custom" } }""", + """ + $vocabulary: + urn:example:vocab: custom + """)); + } + + [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 UnknownParentDoesNotRecoverVocabularyConversionFailureFromKnownChild(string version, string format) + { + Assert.Throws(() => ParseSchema(version, format, UnsupportedDialect, + $$""" + { + "properties": { + "child": { + "$schema": "{{KnownDialect}}", + "$vocabulary": { "urn:example:vocab": "custom" } + } + } + } + """, + $$""" + properties: + child: + $schema: {{KnownDialect}} + $vocabulary: + urn:example:vocab: custom + """)); + } + + [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 UnsupportedDialectKeepsValidVocabularyTyped(string version, string format) + { + var result = ParseSchema(version, format, UnsupportedDialect, + """{ "$vocabulary": { "urn:example:vocab": false } }""", + """ + $vocabulary: + urn:example:vocab: false + """); + + AssertUnsupportedDialectDiagnostic(result); + var schema = result.Document.Components.Schemas["Parent"]; + Assert.False(schema.Vocabulary[Vocabulary]); + AssertNoRawVocabulary(schema); + Assert.Single(result.Diagnostic.Errors); + } + + [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 UnsupportedDialectPreservesVocabularyOnReferenceMetadata( + string version, + string format, + string referenceKeyword) + { + var referenceValue = referenceKeyword == "$ref" ? "#/components/schemas/Target" : "#target"; + var result = ParseSchema(version, format, UnsupportedDialect, + $$""" + { + "{{referenceKeyword}}": "{{referenceValue}}", + "$vocabulary": { "urn:example:vocab": "custom" } + } + """, + $$""" + {{referenceKeyword}}: '{{referenceValue}}' + $vocabulary: + urn:example:vocab: custom + """, + includeReferenceTarget: true); + + AssertUnsupportedDialectDiagnostic(result); + var schemas = result.Document.Components.Schemas; + var reference = Assert.IsType(schemas["Parent"]); + Assert.NotNull(reference.Reference.UnrecognizedKeywords); + Assert.True(reference.Reference.UnrecognizedKeywords.TryGetValue("$vocabulary", out var rawKeyword)); + var rawVocabulary = Assert.IsType(rawKeyword); + Assert.Equal("custom", rawVocabulary[Vocabulary].GetValue()); + Assert.Same(reference.Reference.UnrecognizedKeywords, reference.UnrecognizedKeywords); + var target = schemas["Target"]; + Assert.Same(target, reference.Target); + Assert.True(target.Vocabulary[Vocabulary]); + AssertNoRawVocabulary(target); + } + + 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 AssertRawVocabulary(IOpenApiSchema schema) + { + Assert.NotNull(schema.UnrecognizedKeywords); + Assert.True(schema.UnrecognizedKeywords.TryGetValue("$vocabulary", out var rawKeyword)); + var rawVocabulary = Assert.IsType(rawKeyword); + Assert.Equal("custom", rawVocabulary[Vocabulary].GetValue()); + } + + private static void AssertNoRawVocabulary(IOpenApiSchema schema) + { + Assert.False(schema.UnrecognizedKeywords?.ContainsKey("$vocabulary") ?? 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", "$vocabulary": { "urn:example:vocab": true } }""" + : 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 $vocabulary:\n urn:example:vocab: true" + : 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); + } +} From 500d6c32538b7f9904ed8bd51df6a8084f9bdc44 Mon Sep 17 00:00:00 2001 From: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:45:42 -0500 Subject: [PATCH 4/5] fix(library): preserve reference data and dialect depth limits Signed-off-by: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com> --- README.md | 2 +- .../Reader/JsonNodeHelper.cs | 80 ++++- .../Services/JsonSchemaDialectResolver.cs | 17 +- .../Reader/V31/OpenApiSchemaDeserializer.cs | 11 +- .../Reader/V32/OpenApiSchemaDeserializer.cs | 11 +- .../JsonSchemaDialectDepthRegressionTests.cs | 248 +++++++++++++++ ...onSchemaDialectReferenceDiagnosticTests.cs | 89 ++++++ ...aDialectReferenceKeywordRegressionTests.cs | 297 ++++++++++++++++++ 8 files changed, 723 insertions(+), 32 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectDepthRegressionTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceDiagnosticTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectReferenceKeywordRegressionTests.cs diff --git a/README.md b/README.md index af57efc5b..9bebdc649 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ 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 also enables ordinary external references. For an unknown dialect, the reader follows its meta-schema's `$schema` chain to a supported dialect using `CustomExternalLoader` or the default HTTP/file loader. Resolution is cached per document read, detects cycles, and is limited to 16 parent links per chain (including cached links) and 1 MiB per response. Cancellation is passed to the configured loader and stream reads. Failed or unsupported chains retain their diagnostic. Non-empty dialect URI fragments select a JSON Pointer target or a schema named by `$anchor` or `$dynamicAnchor` within the loaded resource. Fragment lookup traverses recognized schema locations only; missing or ambiguous targets and locations defined by unknown custom vocabularies retain the unsupported-dialect diagnostic. Named-anchor lookup stops at nested non-empty `$id` declarations; it does not build an embedded-resource index. diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index 711496f00..ddf1bda5e 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -165,8 +165,18 @@ public static Dictionary> CreateArrayMap(this JsonNode? no return nodes.ToDictionary(kvp => kvp.key, kvp => kvp.values); } - public static string? GetScalarValue(this JsonNode? node) + public static string? GetScalarValue(this JsonNode? node, bool requireString = false) { + if (requireString) + { + if (!TryGetStringValue(node, out var stringValue)) + { + throw new OpenApiException("Expected string value."); + } + + return stringValue; + } + if (node.IsJsonNullSentinel()) { return null; @@ -182,6 +192,47 @@ public static Dictionary> CreateArrayMap(this JsonNode? no return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null; } + public static string? GetReferencePointer(this JsonObject jsonObject, ParsingContext context, bool preserveInvalidProperties = false) + { + if (!preserveInvalidProperties) + { + return jsonObject.GetReferencePointer(); + } + + if (!jsonObject.TryGetPropertyValue(OpenApiConstants.DollarRef, out var refNode)) + { + return null; + } + + try + { + var pointer = refNode.GetScalarValue(requireString: true); + if (pointer is not null) + { + ValidateReferencePointerFormat(pointer); + } + + return pointer; + } + catch (OpenApiException exception) + { + // $ref is consumed before ParseMap, so recover only this keyword's + // extraction/format failure and let the ordinary map retain its raw value. + try + { + context.StartObject(OpenApiConstants.DollarRef); + exception.Pointer = context.GetLocation(); + context.Diagnostic.Errors.Add(new(exception)); + } + finally + { + context.EndObject(); + } + + return null; + } + } + public static void ValidateReferencePointerFormat(string pointer) { var hashIndex = pointer.IndexOf('#'); @@ -198,11 +249,13 @@ public static void ValidateReferencePointerFormat(string pointer) /// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference /// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution. /// - 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 MalformedKeywordCases() + { + foreach (var version in new[] { "3.1.1", "3.2.0" }) + foreach (var format in new[] { OpenApiConstants.Json, OpenApiConstants.Yaml }) + foreach (var keyword in new[] { "$ref", "$dynamicRef", "$id" }) + foreach (var value in InvalidValues) + foreach (var scope in new[] { "document", "declared", "inherited" }) + { + yield return [version, format, keyword, value, scope]; + } + } + + [Theory] + [MemberData(nameof(MalformedKeywordCases))] + public void UnsupportedDialectPreservesMalformedReferenceKeywordsAndSiblings( + string version, string format, string keyword, string value, string scope) + { + var result = Parse(version, format, keyword, value, scope); + + var schema = Assert.IsType(GetItem(result, scope)); + Assert.Equal("kept", schema.Description); + Assert.Equal(JsonSchemaType.Object, schema.Type); + Assert.Equal(JsonSchemaType.String, schema.Properties["value"].Type); + AssertRawKeyword(schema, keyword, value); + Assert.Contains(result.Diagnostic.Errors, error => error.Message.StartsWith("The JSON Schema dialect ", StringComparison.Ordinal)); + Assert.Contains(result.Diagnostic.Errors, error => error.Pointer == GetItemPointer(scope) + "/" + keyword); + Assert.Equal(JsonSchemaType.Integer, result.Document.Components.Schemas["Unaffected"].Type); + if (scope == "inherited") + { + Assert.Equal(JsonSchemaType.Boolean, result.Document.Components.Schemas[ItemName].Properties["unchanged"].Type); + } + } + + public static IEnumerable ReferenceMetadataCases() + { + foreach (var version in new[] { "3.1.1", "3.2.0" }) + foreach (var format in new[] { OpenApiConstants.Json, OpenApiConstants.Yaml }) + foreach (var value in InvalidValues) + { + yield return [version, format, "$ref", "$id", value]; + yield return [version, format, "$ref", "$dynamicRef", value]; + yield return [version, format, "$dynamicRef", "$id", value]; + } + } + + [Theory] + [MemberData(nameof(ReferenceMetadataCases))] + public void MalformedReferenceMetadataDoesNotDiscardTheValidReference( + string version, string format, string referenceKeyword, string malformedKeyword, string value) + { + var reference = referenceKeyword == "$ref" ? "#/components/schemas/Target" : "#target"; + var result = Parse(version, format, malformedKeyword, value, "document", + $", \"{referenceKeyword}\": \"{reference}\"", $"\n{referenceKeyword}: '{reference}'"); + + var schema = Assert.IsType(GetItem(result, "document")); + Assert.Same(result.Document.Components.Schemas["Target"], schema.Target); + Assert.Equal("kept", schema.Reference.Description); + Assert.Equal(JsonSchemaType.Object, schema.Reference.SchemaType); + AssertRawKeyword(schema, malformedKeyword, value); + Assert.Contains(result.Diagnostic.Errors, error => error.Pointer == GetItemPointer("document") + "/" + malformedKeyword); + Assert.Equal("target", schema.Target.Description); + Assert.False(schema.Target.UnrecognizedKeywords?.ContainsKey(malformedKeyword) ?? false); + } + + public static IEnumerable ReferencePrecedenceCases() + { + foreach (var version in new[] { "3.1.1", "3.2.0" }) + foreach (var format in new[] { OpenApiConstants.Json, OpenApiConstants.Yaml }) + foreach (var value in InvalidValues) + { + yield return [version, format, value]; + } + } + + [Theory] + [MemberData(nameof(ReferencePrecedenceCases))] + public void MalformedDirectReferenceDoesNotActivateItsDynamicReferenceSibling(string version, string format, string value) + { + var result = Parse(version, format, "$ref", value, "document", + ", \"$dynamicRef\": \"#target\"", "\n$dynamicRef: '#target'"); + + var schema = Assert.IsType(GetItem(result, "document")); + Assert.Equal("#target", schema.DynamicRef); + AssertRawKeyword(schema, "$ref", value); + Assert.Contains(result.Diagnostic.Errors, error => error.Pointer == GetItemPointer("document") + "/$ref"); + } + + [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 UnsupportedDialectPreservesMalformedReferenceFormatBeforeReferenceConstruction(string version, string format) + { + const string reference = "https://example.com/schema#anchor/path"; + var result = Parse(version, format, "$ref", $"\"{reference}\"", "document"); + + var schema = Assert.IsType(GetItem(result, "document")); + Assert.Equal("kept", schema.Description); + AssertRawKeyword(schema, "$ref", $"\"{reference}\""); + Assert.Contains(result.Diagnostic.Errors, error => error.Pointer == GetItemPointer("document") + "/$ref"); + Assert.Equal(JsonSchemaType.Integer, result.Document.Components.Schemas["Unaffected"].Type); + } + + public static IEnumerable KnownDialectCases() + { + foreach (var version in new[] { "3.1.1", "3.2.0" }) + foreach (var format in new[] { OpenApiConstants.Json, OpenApiConstants.Yaml }) + foreach (var keyword in new[] { "$ref", "$dynamicRef", "$id" }) + foreach (var value in new[] { InvalidValues[0], InvalidValues[1] }) + foreach (var scope in new[] { "known", "knownChild" }) + { + yield return [version, format, keyword, value, scope]; + } + } + + [Theory] + [MemberData(nameof(KnownDialectCases))] + public void KnownDialectKeepsItsExistingMalformedKeywordBehavior( + string version, string format, string keyword, string value, string scope) + { + var result = Parse(version, format, keyword, value, scope); + + Assert.Contains(result.Diagnostic.Errors, error => error.Message == "Expected scalar value."); + if (scope == "known") + { + Assert.False(result.Document.Components.Schemas?.ContainsKey(ItemName) ?? false); + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.StartsWith("The JSON Schema dialect ", StringComparison.Ordinal)); + } + else + { + var parent = result.Document.Components.Schemas[ItemName]; + Assert.Null(parent.Properties); + Assert.Equal(JsonSchemaType.Integer, result.Document.Components.Schemas["Unaffected"].Type); + } + } + + [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 ValidReferenceStringsKeepDirectReferencePrecedenceAndIdentifierMetadata(string version, string format) + { + const string identifier = "https://example.com/item"; + var result = Parse(version, format, "$ref", "\"#/components/schemas/Target\"", "document", + $", \"$dynamicRef\": \"#target\", \"$id\": \"{identifier}\"", + $"\n$dynamicRef: '#target'\n$id: '{identifier}'"); + + var schema = Assert.IsType(GetItem(result, "document")); + Assert.Same(result.Document.Components.Schemas["Target"], schema.Target); + Assert.Equal("Target", schema.Reference.Id); + Assert.Equal("#target", schema.Reference.DynamicRef); + Assert.Equal(identifier, schema.Reference.SchemaId); + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Pointer == GetItemPointer("document") + "/$ref"); + } + + [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 IdentifierStringRegistrationDoesNotDiscardFallbackSchema(string version, string format) + { + var result = Parse(version, format, "$id", "\"http://[\"", "document"); + + var schema = Assert.IsType(GetItem(result, "document")); + Assert.Equal("http://[", schema.Id); + Assert.Equal("kept", schema.Description); + Assert.Equal(JsonSchemaType.Integer, result.Document.Components.Schemas["Unaffected"].Type); + } + + private static IOpenApiSchema GetItem(ReadResult result, string scope) + { + Assert.NotNull(result.Document); + var root = result.Document.Components.Schemas[ItemName]; + return scope is "inherited" or "knownChild" ? root.Properties[ChildName] : root; + } + + private static string GetItemPointer(string scope) => + "#/components/schemas/Item~1~0" + (scope is "inherited" or "knownChild" ? "/properties/child~1~0" : string.Empty); + + private static void AssertRawKeyword(IOpenApiSchema schema, string keyword, string value) + { + Assert.NotNull(schema.UnrecognizedKeywords); + Assert.True(schema.UnrecognizedKeywords.TryGetValue(keyword, out var raw)); + if (value == "null") + { + Assert.True(raw.IsJsonNullSentinel()); + } + else + { + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(value), raw)); + } + } + + private static ReadResult Parse( + string version, string format, string keyword, string value, string scope, + string extraJson = "", string extraYaml = "") + { + var schemaDeclaration = scope is "declared" ? UnsupportedDialect : scope is "knownChild" ? KnownDialect : null; + var jsonDeclaration = schemaDeclaration is null ? string.Empty : $"\"$schema\": \"{schemaDeclaration}\","; + var yamlDeclaration = schemaDeclaration is null ? string.Empty : $"$schema: {schemaDeclaration}\n"; + var jsonSchema = $$""" + { {{jsonDeclaration}} "description": "kept", "type": "object", "properties": { "value": { "type": "string" } }, "{{keyword}}": {{value}}{{extraJson}} } + """; + var yamlSchema = $$""" + {{yamlDeclaration}}description: kept + type: object + properties: + value: + type: string + {{keyword}}: {{value}}{{extraYaml}} + """; + if (scope is "inherited" or "knownChild") + { + jsonSchema = $$""" + { "$schema": "{{UnsupportedDialect}}", "type": "object", "properties": { "{{ChildName}}": {{jsonSchema}}, "unchanged": { "type": "boolean" } } } + """; + yamlSchema = $$""" + $schema: {{UnsupportedDialect}} + type: object + properties: + '{{ChildName}}': + {{Indent(yamlSchema, 4)}} + unchanged: + type: boolean + """; + } + + var documentDialect = scope == "document" ? UnsupportedDialect : KnownDialect; + var settings = new OpenApiReaderSettings(); + string document; + if (format == OpenApiConstants.Json) + { + document = $$""" + { + "openapi": "{{version}}", "info": { "title": "test", "version": "1.0.0" }, + "jsonSchemaDialect": "{{documentDialect}}", + "components": { "schemas": { + "{{ItemName}}": {{jsonSchema}}, + "Unaffected": { "type": "integer" }, + "Target": { "$dynamicAnchor": "target", "type": "string", "description": "target" } + } } + } + """; + } + else + { + settings.AddYamlReader(); + document = $$""" + openapi: {{version}} + info: + title: test + version: 1.0.0 + jsonSchemaDialect: {{documentDialect}} + components: + schemas: + '{{ItemName}}': + {{Indent(yamlSchema, 6)}} + Unaffected: + type: integer + Target: + $dynamicAnchor: target + type: string + description: target + """; + } + + return OpenApiDocument.Parse(document, format, settings); + } + + private static string Indent(string value, int spaces) + { + var indentation = new string(' ', spaces); + return indentation + value.Replace("\n", "\n" + indentation); + } +} From 03894f50c78b35aefa8e21c86e21ab7dccd5d1d2 Mon Sep 17 00:00:00 2001 From: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:12:12 -0500 Subject: [PATCH 5/5] fix(library): include array indices in reader diagnostics Signed-off-by: Haseeb Nazir <36381672+iamhaseebn@users.noreply.github.com> --- .../Reader/JsonNodeHelper.cs | 29 +- ...hemaDialectArrayLocationRegressionTests.cs | 297 ++++++++++++++++++ .../Reader/JsonNodeHelperTests.cs | 83 +++++ 3 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectArrayLocationRegressionTests.cs diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index ddf1bda5e..dec92ad1f 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -28,11 +28,30 @@ public static List CreateList(this JsonNode? node, Func() - .Select(n => map(n, hostDocument, context)) - .Where(static i => i != null) - .ToList(); + var list = new List(); + for (var index = 0; index < jsonArray.Count; index++) + { + if (jsonArray[index] is not JsonObject jsonObject) + { + continue; + } + + try + { + context.StartObject(index.ToString(CultureInfo.InvariantCulture)); + var value = map(jsonObject, hostDocument, context); + if (value != null) + { + list.Add(value); + } + } + finally + { + context.EndObject(); + } + } + + return list; } public static List CreateListOfAny(this JsonNode? node, ParsingContext context) diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectArrayLocationRegressionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectArrayLocationRegressionTests.cs new file mode 100644 index 000000000..a6317589d --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiReaderTests/JsonSchemaDialectArrayLocationRegressionTests.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.Linq; +using Microsoft.OpenApi.Reader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.OpenApiReaderTests; + +public class JsonSchemaDialectArrayLocationRegressionTests +{ + private const string UnsupportedDialect = "https://example.com/custom-dialect"; + private const string KnownDialect = "https://json-schema.org/draft/2020-12/schema"; + private const string ItemPointer = "#/components/schemas/Item"; + + public static IEnumerable CompositionCases() + { + foreach (var version in new[] { "3.1.1", "3.2.0" }) + foreach (var format in new[] { OpenApiConstants.Json, OpenApiConstants.Yaml }) + foreach (var keyword in new[] { "allOf", "anyOf", "oneOf" }) + { + yield return [version, format, keyword]; + } + } + + [Theory] + [MemberData(nameof(CompositionCases))] + public void CompositionDeclarationsHaveDistinctArrayElementLocations(string version, string format, string keyword) + { + var result = Parse(version, format, + $$""" + { "{{keyword}}": [ + { "$schema": "{{UnsupportedDialect}}/first", "type": "string" }, + { "$schema": "{{UnsupportedDialect}}/second", "type": "integer" } + ] } + """, + $$""" + {{keyword}}: + - $schema: {{UnsupportedDialect}}/first + type: string + - $schema: {{UnsupportedDialect}}/second + type: integer + """); + + AssertErrorPointers(result, $"{ItemPointer}/{keyword}/0", $"{ItemPointer}/{keyword}/1"); + Assert.All(result.Diagnostic.Errors, error => Assert.StartsWith("The JSON Schema dialect ", error.Message)); + var members = GetComposition(result.Document.Components.Schemas["Item"], keyword); + Assert.Equal(2, members.Count); + Assert.Equal(JsonSchemaType.String, members[0].Type); + Assert.Equal(JsonSchemaType.Integer, members[1].Type); + Assert.Equal(JsonSchemaType.Boolean, result.Document.Components.Schemas["Unaffected"].Type); + } + + [Theory] + [MemberData(nameof(CompositionCases))] + public void SkippedCompositionValuesDoNotCompactDiagnosticIndices(string version, string format, string keyword) + { + var result = Parse(version, format, + $$""" + { "{{keyword}}": [null, true, + { "$schema": "{{UnsupportedDialect}}/first", "type": "string" }, 42, + { "$schema": "{{UnsupportedDialect}}/second", "type": "integer" } + ] } + """, + $$""" + {{keyword}}: + - null + - true + - $schema: {{UnsupportedDialect}}/first + type: string + - 42 + - $schema: {{UnsupportedDialect}}/second + type: integer + """); + + AssertErrorPointers(result, $"{ItemPointer}/{keyword}/2", $"{ItemPointer}/{keyword}/4"); + var members = GetComposition(result.Document.Components.Schemas["Item"], keyword); + Assert.Equal(2, members.Count); + Assert.Equal(JsonSchemaType.String, members[0].Type); + Assert.Equal(JsonSchemaType.Integer, members[1].Type); + } + + [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 NestedCompositionsRestoreTheLocationBeforeFollowingSiblingSchemas(string version, string format) + { + var result = Parse(version, format, + $$""" + { + "allOf": [ + { "anyOf": [ + { "$schema": "{{UnsupportedDialect}}/first", "type": "string" }, + { "$schema": "{{UnsupportedDialect}}/second", "type": "integer" } + ] }, + { "oneOf": [{}, { "$schema": "{{UnsupportedDialect}}/third", "type": "number" }] } + ], + "properties": { "after/~": { "$schema": "{{UnsupportedDialect}}/fourth", "type": "boolean" } } + } + """, + $$""" + allOf: + - anyOf: + - $schema: {{UnsupportedDialect}}/first + type: string + - $schema: {{UnsupportedDialect}}/second + type: integer + - oneOf: + - {} + - $schema: {{UnsupportedDialect}}/third + type: number + properties: + 'after/~': + $schema: {{UnsupportedDialect}}/fourth + type: boolean + """); + + AssertErrorPointers(result, + $"{ItemPointer}/allOf/0/anyOf/0", + $"{ItemPointer}/allOf/0/anyOf/1", + $"{ItemPointer}/allOf/1/oneOf/1", + $"{ItemPointer}/properties/after~1~0"); + var schema = result.Document.Components.Schemas["Item"]; + Assert.Equal(JsonSchemaType.String, schema.AllOf[0].AnyOf[0].Type); + Assert.Equal(JsonSchemaType.Integer, schema.AllOf[0].AnyOf[1].Type); + Assert.Equal(JsonSchemaType.Number, schema.AllOf[1].OneOf[1].Type); + Assert.Equal(JsonSchemaType.Boolean, schema.Properties["after/~"].Type); + Assert.Equal(JsonSchemaType.Boolean, result.Document.Components.Schemas["Unaffected"].Type); + } + + [Theory] + [MemberData(nameof(CompositionCases))] + public void FallbackKeywordDiagnosticsIncludeTheirCompositionElementIndex(string version, string format, string keyword) + { + var result = Parse(version, format, + $$""" + { + "$schema": "{{UnsupportedDialect}}", + "{{keyword}}": [ + { "type": "string", "minLength": "first" }, + { "type": "string", "minLength": "second" } + ], + "properties": { "after/~": { "type": "string", "minLength": "after" } } + } + """, + $$""" + $schema: {{UnsupportedDialect}} + {{keyword}}: + - type: string + minLength: first + - type: string + minLength: second + properties: + 'after/~': + type: string + minLength: after + """); + + AssertErrorPointers(result, ItemPointer, + $"{ItemPointer}/{keyword}/0/minLength", + $"{ItemPointer}/{keyword}/1/minLength", + $"{ItemPointer}/properties/after~1~0/minLength"); + Assert.Single(result.Diagnostic.Errors, + error => error.Message.StartsWith("The JSON Schema dialect ", StringComparison.Ordinal)); + var schema = result.Document.Components.Schemas["Item"]; + var members = GetComposition(schema, keyword); + Assert.Equal(2, members.Count); + Assert.Equal("first", members[0].UnrecognizedKeywords["minLength"].GetValue()); + Assert.Equal("second", members[1].UnrecognizedKeywords["minLength"].GetValue()); + Assert.Equal("after", schema.Properties["after/~"].UnrecognizedKeywords["minLength"].GetValue()); + Assert.Equal(JsonSchemaType.Boolean, result.Document.Components.Schemas["Unaffected"].Type); + } + + [Theory] + [MemberData(nameof(CompositionCases))] + public void ReferenceMetadataDiagnosticsIncludeTheirCompositionElementIndex(string version, string format, string keyword) + { + var result = Parse(version, format, + $$""" + { "{{keyword}}": [ + { "type": "integer" }, + { "$schema": "{{UnsupportedDialect}}", "$ref": "#/components/schemas/Target", "description": "metadata", "minLength": "custom" } + ] } + """, + $$""" + {{keyword}}: + - type: integer + - $schema: {{UnsupportedDialect}} + $ref: '#/components/schemas/Target' + description: metadata + minLength: custom + """); + + AssertErrorPointers(result, $"{ItemPointer}/{keyword}/1", $"{ItemPointer}/{keyword}/1/minLength"); + var members = GetComposition(result.Document.Components.Schemas["Item"], keyword); + var reference = Assert.IsType(members[1]); + Assert.Same(result.Document.Components.Schemas["Target"], reference.Target); + Assert.Equal("metadata", reference.Reference.Description); + Assert.Equal("custom", reference.UnrecognizedKeywords["minLength"].GetValue()); + Assert.Equal("target", reference.Target.Description); + Assert.False(reference.Target.UnrecognizedKeywords?.ContainsKey("minLength") ?? false); + Assert.Equal(JsonSchemaType.Integer, members[0].Type); + } + + [Theory] + [MemberData(nameof(CompositionCases))] + public void KnownDialectCompositionMembersKeepTheirTypedValues(string version, string format, string keyword) + { + var result = Parse(version, format, + $$""" + { "{{keyword}}": [ + { "$schema": "{{KnownDialect}}", "type": "string", "minLength": 2 }, + { "type": "integer", "minimum": 3 } + ] } + """, + $$""" + {{keyword}}: + - $schema: {{KnownDialect}} + type: string + minLength: 2 + - type: integer + minimum: 3 + """); + + Assert.Empty(result.Diagnostic.Errors); + var members = GetComposition(result.Document.Components.Schemas["Item"], keyword); + Assert.Equal(2, members.Count); + Assert.Equal(JsonSchemaType.String, members[0].Type); + Assert.Equal(2, members[0].MinLength); + Assert.Equal(JsonSchemaType.Integer, members[1].Type); + Assert.Equal("3", members[1].Minimum); + } + + private static IList GetComposition(IOpenApiSchema schema, string keyword) => keyword switch + { + "allOf" => schema.AllOf, + "anyOf" => schema.AnyOf, + "oneOf" => schema.OneOf, + _ => throw new ArgumentOutOfRangeException(nameof(keyword)), + }; + + private static void AssertErrorPointers(ReadResult result, params string[] pointers) + { + Assert.NotNull(result.Document); + Assert.Equal(pointers, result.Diagnostic.Errors.Select(error => error.Pointer)); + } + + private static ReadResult Parse(string version, string format, string jsonSchema, string yamlSchema) + { + var settings = new OpenApiReaderSettings(); + string document; + if (format == OpenApiConstants.Json) + { + document = $$""" + { + "openapi": "{{version}}", "info": { "title": "test", "version": "1.0.0" }, + "components": { "schemas": { + "Item": {{jsonSchema}}, + "Unaffected": { "type": "boolean" }, + "Target": { "type": "string", "description": "target" } + } } + } + """; + } + else + { + settings.AddYamlReader(); + document = $$""" + openapi: {{version}} + info: + title: test + version: 1.0.0 + components: + schemas: + Item: + {{Indent(yamlSchema, 6)}} + Unaffected: + type: boolean + Target: + type: string + description: target + """; + } + + return OpenApiDocument.Parse(document, format, settings); + } + + private static string Indent(string value, int spaces) + { + var indentation = new string(' ', spaces); + return indentation + value.Replace("\n", "\n" + indentation); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/JsonNodeHelperTests.cs b/test/Microsoft.OpenApi.Tests/Reader/JsonNodeHelperTests.cs index bcec153fd..48c2d0a79 100644 --- a/test/Microsoft.OpenApi.Tests/Reader/JsonNodeHelperTests.cs +++ b/test/Microsoft.OpenApi.Tests/Reader/JsonNodeHelperTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.Json.Nodes; using Microsoft.OpenApi.Reader; using Xunit; @@ -6,6 +8,87 @@ namespace Microsoft.OpenApi.Tests.Reader; public class JsonNodeHelperTests { + [Fact] + public void CreateListUsesOriginalIndicesAndOmitsNullResults() + { + var array = JsonNode.Parse("""[true, null, {}, "ignored", {}, {}]"""); + var context = new ParsingContext(new()); + var locations = new List(); + context.StartObject("items"); + + var actual = array.CreateList((_, _, c) => + { + var location = c.GetLocation(); + locations.Add(location); + return locations.Count == 2 ? null : location; + }, new OpenApiDocument(), context); + + Assert.Equal(new[] { "#/items/2", "#/items/4", "#/items/5" }, locations); + Assert.Equal(new[] { "#/items/2", "#/items/5" }, actual); + Assert.Equal("#/items", context.GetLocation()); + } + + [Fact] + public void CreateListRestoresContextWhenMapperThrows() + { + var array = JsonNode.Parse("[{}]"); + var context = new ParsingContext(new()); + var expected = new InvalidOperationException("Mapping failed."); + context.StartObject("items"); + string actualLocation = null; + + var actual = Assert.Throws(() => array.CreateList((_, _, c) => + { + actualLocation = c.GetLocation(); + throw expected; + }, new OpenApiDocument(), context)); + + Assert.Same(expected, actual); + Assert.Equal("#/items/0", actualLocation); + Assert.Equal("#/items", context.GetLocation()); + } + + [Fact] + public void CreateListRestoresContextAfterNestedLists() + { + var array = JsonNode.Parse("""[{"children": [{}, {}]}, {"children": [{}]}]"""); + var context = new ParsingContext(new()); + context.StartObject("items"); + + var actual = array.CreateList((node, document, c) => + { + c.StartObject("children"); + try + { + return node["children"].CreateList((_, _, childContext) => childContext.GetLocation(), document, c); + } + finally + { + c.EndObject(); + } + }, new OpenApiDocument(), context); + + Assert.Collection(actual, + first => Assert.Equal(new[] { "#/items/0/children/0", "#/items/0/children/1" }, first), + second => Assert.Equal(new[] { "#/items/1/children/0" }, second)); + Assert.Equal("#/items", context.GetLocation()); + } + + [Theory] + [InlineData("[]")] + [InlineData("[null, true, 1, \"ignored\", []]")] + public void CreateListSkipsNonObjectsWithoutChangingContext(string input) + { + var array = JsonNode.Parse(input); + var context = new ParsingContext(new()); + context.StartObject("items"); + + var actual = array.CreateList((_, _, _) => throw new InvalidOperationException("Mapper must not be called."), new OpenApiDocument(), context); + + Assert.Empty(actual); + Assert.Equal("#/items", context.GetLocation()); + } + [Fact] public void DoesNotFailOnNullValue() {