From 25fb628c9ece3c18b869e8a5bc09592e5b488961 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 09:53:00 -0400 Subject: [PATCH 01/22] test(coverage): exclude test assembly from merged reports Exclude the Microsoft.OpenApi.Tests assembly from coverage collection and add focused OpenApiYamlReader tests for non-memory streams and fragment parsing so the published merged report reflects product code coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiYamlReaderTests.cs | 111 ++++++++++++++++++ .../Properties/AssemblyInfo.cs | 3 + 2 files changed, 114 insertions(+) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs new file mode 100644 index 000000000..6d66430da --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +public class OpenApiYamlReaderTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.yaml"); + + [Fact] + public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams() + { + var reader = new OpenApiYamlReader(); + await using var stream = new NonMemoryStream(CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """)); + + var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None); + + Assert.NotNull(result.Document); + Assert.Equal("Sample API", result.Document.Info.Title); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReadThrowsWhenYamlDoesNotContainADocument() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(string.Empty); + + var exception = Assert.Throws(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings)); + + Assert.Equal("No documents found in the YAML stream.", exception.Message); + } + + [Fact] + public void ReadFragmentParsesSchemaFragments() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream( + """ + type: string + description: A reusable schema + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.NotNull(schema); + Assert.Empty(diagnostic.Errors); + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("A reusable schema", schema.Description); + } + + [Fact] + public void ReadThrowsWhenSettingsIsNull() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream("openapi: 3.0.1"); + + Assert.Throws(() => reader.Read(stream, DocumentLocation, null!)); + } + + private static MemoryStream CreateStream(string yaml) + { + return new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + } + + private sealed class NonMemoryStream(Stream innerStream) : Stream + { + public override bool CanRead => innerStream.CanRead; + public override bool CanSeek => innerStream.CanSeek; + public override bool CanWrite => innerStream.CanWrite; + public override long Length => innerStream.Length; + public override long Position + { + get => innerStream.Position; + set => innerStream.Position = value; + } + + public override void Flush() => innerStream.Flush(); + public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + public override void SetLength(long value) => innerStream.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count); + public override ValueTask DisposeAsync() => innerStream.DisposeAsync(); + protected override void Dispose(bool disposing) + { + if (disposing) + { + innerStream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs b/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs index 62f4c0e52..c6c6211fb 100644 --- a/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs +++ b/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs @@ -1,3 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] From b8ad0226bc71028792d0b66ac78647d28870ce59 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 09:59:24 -0400 Subject: [PATCH 02/22] test(coverage): add reference and reader edge tests Add focused tests for CopyReferences, OpenApiSecurityRequirement deserialization, and small uncovered exception/helper types without broad fixture duplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiSecurityRequirementTests.cs | 71 ++++++++++++ .../Exceptions/OpenApiWriterExceptionTests.cs | 27 +++++ .../Models/OpenApiReferenceErrorTests.cs | 37 +++++++ .../Reader/AnyListFieldMapParameterTests.cs | 33 ++++++ .../Services/CopyReferencesTests.cs | 103 ++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs new file mode 100644 index 000000000..b2113d2a7 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Reader.V2; +using Microsoft.OpenApi.YamlReader; +using SharpYaml.Serialization; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V2Tests; + +[Collection("DefaultSettings")] +public class OpenApiSecurityRequirementTests +{ + [Fact] + public void LoadSecurityRequirementResolvesScopesForKnownSchemes() + { + var node = LoadYamlNode( + """ + petstore_auth: + - write:pets + - read:pets + """); + var hostDocument = new OpenApiDocument + { + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary + { + ["petstore_auth"] = new OpenApiSecurityScheme { Type = SecuritySchemeType.OAuth2 } + } + } + }; + var context = new ParsingContext(new OpenApiDiagnostic()); + + var requirement = OpenApiV2Deserializer.LoadSecurityRequirement(node, hostDocument, context); + + var resolvedScheme = Assert.Single(requirement); + Assert.Equal("petstore_auth", resolvedScheme.Key.Reference.Id); + Assert.Equal(["write:pets", "read:pets"], resolvedScheme.Value); + Assert.Empty(context.Diagnostic.Errors); + } + + [Fact] + public void LoadSecurityRequirementCreatesUnresolvedReferenceWhenSchemeIsMissing() + { + var node = LoadYamlNode( + """ + petstore_auth: + - write:pets + """); + var hostDocument = new OpenApiDocument(); + var context = new ParsingContext(new OpenApiDiagnostic()); + + var requirement = OpenApiV2Deserializer.LoadSecurityRequirement(node, hostDocument, context); + + var unresolvedScheme = Assert.Single(requirement); + Assert.Equal("petstore_auth", unresolvedScheme.Key.Reference.Id); + Assert.True(unresolvedScheme.Key.UnresolvedReference); + Assert.Equal(["write:pets"], unresolvedScheme.Value); + Assert.Empty(context.Diagnostic.Errors); + } + + private static JsonNode LoadYamlNode(string yaml) + { + using var reader = new StringReader(yaml); + var yamlStream = new YamlStream(); + yamlStream.Load(reader); + return yamlStream.Documents[0].RootNode.ToJsonNode(); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs b/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs new file mode 100644 index 000000000..9829af020 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs @@ -0,0 +1,27 @@ +using System; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Exceptions; + +public class OpenApiWriterExceptionTests +{ + [Fact] + public void DefaultConstructorUsesTheGenericWriterMessage() + { + var exception = new OpenApiWriterException(); + + Assert.Equal(SRResource.OpenApiWriterExceptionGenericError, exception.Message); + Assert.Null(exception.InnerException); + } + + [Fact] + public void ConstructorPreservesMessageAndInnerException() + { + var innerException = new InvalidOperationException("boom"); + + var exception = new OpenApiWriterException("writer failed", innerException); + + Assert.Equal("writer failed", exception.Message); + Assert.Same(innerException, exception.InnerException); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs new file mode 100644 index 000000000..1e0ecd2e8 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models; + +public class OpenApiReferenceErrorTests +{ + [Fact] + public void ConstructorCopiesMessageAndPointerFromException() + { + var exception = new OpenApiException("Reference could not be resolved") + { + Pointer = "#/components/schemas/Pet" + }; + + var error = new OpenApiReferenceError(exception); + + Assert.Equal(exception.Message, error.Message); + Assert.Equal(exception.Pointer, error.Pointer); + Assert.Null(error.Reference); + } + + [Fact] + public void ConstructorStoresTheReferenceThatFailedResolution() + { + var reference = new BaseOpenApiReference + { + Id = "Pet", + Type = ReferenceType.Schema + }; + + var error = new OpenApiReferenceError(reference, "Missing component"); + + Assert.Equal("Missing component", error.Message); + Assert.Equal(string.Empty, error.Pointer); + Assert.Same(reference, error.Reference); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs b/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs new file mode 100644 index 000000000..30c48741f --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Xunit; +using Microsoft.OpenApi.Reader; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class AnyListFieldMapParameterTests +{ + [Fact] + public void ConstructorStoresPropertyDelegates() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Array }; + var values = new List { JsonValue.Create("value")! }; + var owner = new TestOwner { Schema = schema }; + var parameter = new AnyListFieldMapParameter( + static current => current.Values, + static (current, currentValues) => current.Values = currentValues, + static current => current.Schema); + + parameter.PropertySetter(owner, values); + + Assert.Same(values, parameter.PropertyGetter(owner)); + Assert.NotNull(parameter.SchemaGetter); + Assert.Same(schema, parameter.SchemaGetter(owner)); + } + + private sealed class TestOwner + { + public List Values { get; set; } = []; + public OpenApiSchema Schema { get; set; } = new(); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs b/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs new file mode 100644 index 000000000..f612c226a --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Services; + +public class CopyReferencesTests +{ + [Fact] + public void VisitCopiesResolvedReferenceTargetsIntoMatchingComponentCollections() + { + var callback = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("{$request.body#/callbackUrl}")] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "ok" } + } + } + } + } + } + }; + var link = new OpenApiLink { OperationId = "getUser" }; + var requestBody = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType() + } + }; + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + Name = "api-key", + In = ParameterLocation.Header + }; + + var source = new OpenApiDocument + { + Components = new OpenApiComponents + { + Callbacks = new Dictionary { ["callback"] = callback }, + Links = new Dictionary { ["link"] = link }, + RequestBodies = new Dictionary { ["body"] = requestBody }, + SecuritySchemes = new Dictionary { ["scheme"] = securityScheme } + } + }; + source.RegisterComponents(); + var target = new OpenApiDocument(); + var visitor = new CopyReferences(target); + + visitor.Visit((IOpenApiReferenceHolder)new OpenApiCallbackReference("callback", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiLinkReference("link", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiRequestBodyReference("body", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiSecuritySchemeReference("scheme", source)); + + Assert.Same(callback, Assert.Single(visitor.Components.Callbacks).Value); + Assert.Same(link, Assert.Single(visitor.Components.Links).Value); + Assert.Same(requestBody, Assert.Single(visitor.Components.RequestBodies).Value); + Assert.Same(securityScheme, Assert.Single(visitor.Components.SecuritySchemes).Value); + + Assert.NotNull(target.Components); + Assert.NotNull(target.Components.Callbacks); + Assert.NotNull(target.Components.Links); + Assert.NotNull(target.Components.RequestBodies); + Assert.NotNull(target.Components.SecuritySchemes); + } + + [Fact] + public void VisitCopiesSchemaTargetsOnceAndIgnoresMissingReferences() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Object }; + var source = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary { ["Pet"] = schema } + } + }; + source.RegisterComponents(); + var visitor = new CopyReferences(new OpenApiDocument()); + + visitor.Visit((IOpenApiReferenceHolder)new OpenApiLinkReference("missing", source)); + + var schemaReference = new OpenApiSchemaReference("Pet", source); + visitor.Visit((IOpenApiSchema)schemaReference); + visitor.Visit((IOpenApiSchema)schemaReference); + + Assert.Null(visitor.Components.Links); + var copiedSchema = Assert.Single(visitor.Components.Schemas); + Assert.Equal("Pet", copiedSchema.Key); + Assert.Same(schema, copiedSchema.Value); + } +} From b65bbe573b4971525f953255775f72806ba036a5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 10:19:16 -0400 Subject: [PATCH 03/22] test(coverage): add reader and walker edge tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiSpecVersionHelperTests.cs | 31 ++ .../Services/OpenApiFilterServiceTests.cs | 66 ++++ .../StatsVisitorTests.cs | 110 +++++++ .../Utilities/SettingsUtilitiesTests.cs | 44 +++ .../V2Tests/OpenApiDocumentFixupTests.cs | 106 +++++++ .../Reader/BaseOpenApiVersionServiceTests.cs | 76 +++++ .../Reader/OpenApiJsonReaderTests.cs | 105 +++++++ .../Walkers/OpenApiWalkerRichDocumentTests.cs | 289 ++++++++++++++++++ 8 files changed, 827 insertions(+) create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs diff --git a/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs new file mode 100644 index 000000000..da7da8e7a --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs @@ -0,0 +1,31 @@ +#nullable enable +using System; +using Microsoft.OpenApi.Hidi; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class OpenApiSpecVersionHelperTests +{ + [Theory] + [InlineData("2.0", OpenApiSpecVersion.OpenApi2_0)] + [InlineData("3.0", OpenApiSpecVersion.OpenApi3_0)] + [InlineData("3.1", OpenApiSpecVersion.OpenApi3_1)] + [InlineData("3.2", OpenApiSpecVersion.OpenApi3_2)] + [InlineData("4.0", OpenApiSpecVersion.OpenApi3_2)] + public void TryParseOpenApiSpecVersionReturnsExpectedVersion(string version, OpenApiSpecVersion expectedVersion) + { + var result = OpenApiSpecVersionHelper.TryParseOpenApiSpecVersion(version); + + Assert.Equal(expectedVersion, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("abc")] + public void TryParseOpenApiSpecVersionThrowsForInvalidValues(string? version) + { + Assert.Throws(() => OpenApiSpecVersionHelper.TryParseOpenApiSpecVersion(version!)); + } +} diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs index 483deaf25..8f8aa0c8e 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs +++ b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs @@ -221,6 +221,72 @@ public void ThrowsInvalidOperationExceptionInCreatePredicateWhenInvalidArguments Assert.Equal("Cannot specify both operationIds and tags at the same time.", message2); } + [Fact] + public void ThrowsInvalidOperationExceptionWhenRequestUrlsAreCombinedWithOtherFilters() + { + var requestUrls = new Dictionary> + { + ["/users"] = ["GET"] + }; + + var message = Assert.Throws(() => + OpenApiFilterService.CreatePredicate("users.user.ListUser", null, requestUrls, _openApiDocumentMock)).Message; + + Assert.Equal("Cannot filter by Postman collection and either operationIds and tags at the same time.", message); + } + + [Fact] + public void ThrowsWhenPredicateDoesNotMatchAnyPath() + { + var source = new OpenApiDocument + { + Info = new() { Title = "Test", Version = "1.0" }, + Paths = new() + { + ["/test"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "getTest" } + } + } + } + }; + + var subset = OpenApiFilterService.CreateFilteredDocument(source, static (_, _, _) => false); + + Assert.Empty(subset.Paths); + } + + [Fact] + public void CreatePredicateMatchesAbsoluteUrlsWhenSourceHasNoServers() + { + var source = new OpenApiDocument + { + Info = new() { Title = "Test", Version = "v1" }, + Paths = new() + { + ["/users"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "listUsers" } + } + } + } + }; + var requestUrls = new Dictionary> + { + ["https://graph.contoso.com/users"] = ["GET"] + }; + + var predicate = OpenApiFilterService.CreatePredicate(requestUrls: requestUrls, source: source); + var subset = OpenApiFilterService.CreateFilteredDocument(source, predicate); + + Assert.Single(subset.Paths); + Assert.True(subset.Paths.ContainsKey("/users")); + } + [Fact] public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly() { diff --git a/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs new file mode 100644 index 000000000..763540269 --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class StatsVisitorTests +{ + [Fact] + public void GetStatisticsReportReflectsVisitedElements() + { + var document = new OpenApiDocument + { + Paths = new() + { + ["/pets"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Post] = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "expand", + In = ParameterLocation.Query, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + } + ], + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Headers = new Dictionary + { + ["x-rate-limit"] = new OpenApiHeader + { + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }, + Links = new Dictionary + { + ["next"] = new OpenApiLink() + } + } + }, + Callbacks = new Dictionary + { + ["onData"] = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("$request.body#/callbackUrl")] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Post] = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["202"] = new OpenApiResponse { Description = "Accepted" } + } + } + } + } + } + } + } + } + } + } + } + }; + + var visitor = new StatsVisitor(); + new OpenApiWalker(visitor).Walk(document); + var report = visitor.GetStatisticsReport(); + + Assert.Equal(2, visitor.PathItemCount); + Assert.Equal(2, visitor.OperationCount); + Assert.Equal(1, visitor.ParameterCount); + Assert.Equal(1, visitor.RequestBodyCount); + Assert.Equal(2, visitor.ResponseCount); + Assert.Equal(1, visitor.LinkCount); + Assert.Equal(1, visitor.CallbackCount); + Assert.Equal(4, visitor.SchemaCount); + Assert.Contains("Path Items: 2", report, StringComparison.Ordinal); + Assert.Contains("Callbacks: 1", report, StringComparison.Ordinal); + Assert.Contains("Schemas: 4", report, StringComparison.Ordinal); + } +} diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs new file mode 100644 index 000000000..aa31367ae --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.OpenApi.Hidi.Utilities; +using Microsoft.OpenApi.OData; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class SettingsUtilitiesTests +{ + [Fact] + public void GetOpenApiConvertSettingsThrowsWhenConfigurationIsNull() + { + Assert.Throws(() => SettingsUtilities.GetOpenApiConvertSettings(null!, null)); + } + + [Fact] + public void GetOpenApiConvertSettingsUsesMetadataVersionWhenSectionIsMissing() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + + var settings = SettingsUtilities.GetOpenApiConvertSettings(configuration, "2.1"); + + Assert.Equal("2.1", settings.SemVerVersion); + } + + [Fact] + public void GetOpenApiConvertSettingsBindsConfiguredValuesOverMetadataVersion() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{nameof(OpenApiConvertSettings)}:{nameof(OpenApiConvertSettings.SemVerVersion)}"] = "3.0", + [$"{nameof(OpenApiConvertSettings)}:{nameof(OpenApiConvertSettings.EnablePagination)}"] = bool.TrueString + }) + .Build(); + + var settings = SettingsUtilities.GetOpenApiConvertSettings(configuration, "2.1"); + + Assert.Equal("3.0", settings.SemVerVersion); + Assert.True(settings.EnablePagination); + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs new file mode 100644 index 000000000..2fe466848 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V2Tests; + +public class OpenApiDocumentFixupTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/swagger.yaml"); + + [Fact] + public void ReadCreatesServersFromHostBasePathAndSchemes() + { + var result = ReadDocument( + """ + swagger: "2.0" + info: + title: Sample API + version: "1.0" + host: api.contoso.com:443 + basePath: /v1/ + schemes: + - https + paths: {} + """); + + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + var server = Assert.Single(result.Document.Servers); + Assert.Equal("https://api.contoso.com/v1", server.Url); + } + + [Theory] + [InlineData("https://api.contoso.com")] + [InlineData("api contoso com")] + public void ReadAddsDiagnosticErrorWhenHostIsInvalid(string host) + { + var result = ReadDocument( + $$""" + swagger: "2.0" + info: + title: Sample API + version: "1.0" + host: {{host}} + paths: {} + """); + + Assert.NotNull(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Contains("Invalid host", error.Message, StringComparison.Ordinal); + Assert.Empty(result.Document.Servers); + } + + [Fact] + public void ReadMovesReferencedBodyParametersToRequestBodies() + { + var result = ReadDocument( + """ + swagger: "2.0" + info: + title: Sample API + version: "1.0" + paths: + /pets: + post: + parameters: + - $ref: '#/parameters/PetBody' + responses: + '200': + description: ok + parameters: + PetBody: + name: pet + in: body + required: true + schema: + type: object + properties: + name: + type: string + """); + + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + var requestBody = Assert.IsType(result.Document.Paths["/pets"].Operations[HttpMethod.Post].RequestBody); + Assert.Equal("PetBody", requestBody.Reference.Id); + Assert.NotNull(result.Document.Components); + var componentRequestBody = Assert.IsType(result.Document.Components.RequestBodies["PetBody"]); + Assert.True(componentRequestBody.Required); + Assert.Contains("application/json", componentRequestBody.Content.Keys); + Assert.Empty(result.Document.Paths["/pets"].Operations[HttpMethod.Post].Parameters); + } + + private static ReadResult ReadDocument(string yaml) + { + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + + return new OpenApiYamlReader().Read(stream, DocumentLocation, settings); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs b/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs new file mode 100644 index 000000000..7542bd6f8 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs @@ -0,0 +1,76 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Reader; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class BaseOpenApiVersionServiceTests +{ + [Fact] + public void LoadElementReturnsLoadedElementWhenACompatibleLoaderExists() + { + var service = new TestVersionService(new Dictionary> + { + [typeof(OpenApiInfo)] = static (_, _, _) => new OpenApiInfo { Title = "Sample" } + }); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.NotNull(info); + Assert.Equal("Sample", info.Title); + } + + [Fact] + public void LoadElementReturnsNullWhenLoaderReturnsADifferentType() + { + var service = new TestVersionService(new Dictionary> + { + [typeof(OpenApiInfo)] = static (_, _, _) => new OpenApiContact { Name = "Contoso" } + }); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.Null(info); + } + + [Fact] + public void LoadElementReturnsNullWhenNoLoaderIsRegistered() + { + var service = new TestVersionService([]); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.Null(info); + } + + [Theory] + [InlineData("""{"$ref":"#/components/schemas/Pet"}""", "description", null)] + [InlineData("""{"$ref":"#/components/schemas/Pet","description":"A pet"}""", "description", "A pet")] + [InlineData("""{"$ref":"#/components/schemas/Pet","summary":"ignored"}""", "description", null)] + public void GetReferenceScalarValuesReturnsExpectedScalarValue(string json, string scalarValue, string? expectedValue) + { + var service = new TestVersionService([]); + var jsonObject = JsonNode.Parse(json)!.AsObject(); + + var value = service.GetReferenceScalarValues(jsonObject, scalarValue); + + Assert.Equal(expectedValue, value); + } + + private sealed class TestVersionService(Dictionary> loaders) + : BaseOpenApiVersionService(new OpenApiDiagnostic()) + { + internal override Dictionary> Loaders { get; } = loaders; + + public override OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, ParsingContext context) + { + return new OpenApiDocument + { + BaseUri = location + }; + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs new file mode 100644 index 000000000..8b0fac733 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs @@ -0,0 +1,105 @@ +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 Microsoft.OpenApi.Validations; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class OpenApiJsonReaderTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.json"); + + [Fact] + public void ReadReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("{"); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings()); + + Assert.Null(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Json, result.Diagnostic.Format); + Assert.Contains("Expected depth to be zero at the end of the JSON payload.", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsyncReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + await using var stream = CreateStream("{"); + + var result = await reader.ReadAsync(stream, DocumentLocation, new OpenApiReaderSettings(), CancellationToken.None); + + Assert.Null(result.Document); + Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Json, result.Diagnostic.Format); + } + + [Fact] + public void ReadValidatesParsedDocumentAgainstConfiguredRules() + { + var ruleSet = new ValidationRuleSet(); + ruleSet.Add(typeof(OpenApiDocument), new ValidationRule("AlwaysFail", static (context, _) => context.CreateError("rule", "Document failed validation."))); + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("""{"openapi":"3.0.1","info":{"title":"Sample","version":"1.0.0"},"paths":{}}"""); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings { RuleSet = ruleSet }); + + Assert.NotNull(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("#/", error.Pointer); + Assert.Equal("Document failed validation.", error.Message); + } + + [Fact] + public void ReadReturnsDiagnosticWhenRootNodeCannotBeParsedAsDocument() + { + var reader = new OpenApiJsonReader(); + + var result = reader.Read(JsonNode.Parse("[]")!, DocumentLocation, new OpenApiReaderSettings()); + + Assert.Null(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Contains("Expected scalar value.", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenJsonNodeCannotBeParsedAsSchema() + { + var reader = new OpenApiJsonReader(); + var input = JsonNode.Parse("[]")!; + + var schema = reader.ReadFragment( + input, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + var error = Assert.Single(diagnostic.Errors); + Assert.Contains("schema must be a map/object", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("{"); + + var schema = reader.ReadFragment(stream, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + } + + private static MemoryStream CreateStream(string json) + { + return new MemoryStream(Encoding.UTF8.GetBytes(json)); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs new file mode 100644 index 000000000..846119182 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Walkers; + +public class OpenApiWalkerRichDocumentTests +{ + [Fact] + public void WalkTraversesRichDocumentsAcrossComponentsWebhooksAndExtensions() + { + var document = CreateDocument(); + var visitor = new RichWalkerVisitor(); + var walker = new OpenApiWalker(visitor); + + walker.Walk(document); + + Assert.Contains("#/servers/0/variables/tenant", visitor.Locations); + Assert.Contains("#/paths/~1pets/post/callbacks/onData/$request.body#~1callbackUrl/post/responses/202", visitor.Locations); + Assert.Contains("#/webhooks/petCreated/post/requestBody/content/application~1json/schema", visitor.Locations); + Assert.Contains("#/components/requestBodies/PetBody/content/application~1json/schema/properties/name", visitor.Locations); + Assert.Contains("#/components/headers/RateLimit/examples/detailed", visitor.Locations); + Assert.Contains("#/components/links/NextPage/server", visitor.Locations); + Assert.Contains("#/components/examples/PetExample", visitor.Locations); + Assert.Contains("#/externalDocs", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/tags/0", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/tags/1", visitor.Locations); + Assert.Contains("#/paths/~1pets/post/x-operation", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/requestBody", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/responses/200/headers/x-rate-limit", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/responses/200/content/application~1json", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/security/0", visitor.Locations); + } + + private static OpenApiDocument CreateDocument() + { + var document = new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = "Pets", + Version = "1.0.0", + Contact = new OpenApiContact { Name = "Contoso" }, + License = new OpenApiLicense { Name = "MIT" } + }, + Servers = + [ + new OpenApiServer + { + Url = "https://{tenant}.contoso.com", + Variables = new Dictionary + { + ["tenant"] = new() + { + Default = "prod" + } + } + } + ], + ExternalDocs = new OpenApiExternalDocs + { + Url = new Uri("https://contoso.test/docs") + }, + Tags = new HashSet + { + new() { Name = "pets" }, + new() { Name = "store" } + }, + Paths = new OpenApiPaths + { + ["/pets"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + RequestBody = new OpenApiRequestBodyReference("PetBody"), + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Headers = new Dictionary + { + ["x-rate-limit"] = new OpenApiHeaderReference("RateLimit") + }, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaTypeReference("PetMediaType") + } + } + }, + Callbacks = new Dictionary + { + ["onData"] = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("$request.body#/callbackUrl")] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + Responses = new OpenApiResponses + { + ["202"] = new OpenApiResponse + { + Description = "Accepted" + } + } + } + } + } + } + } + }, + Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("oauth")] = ["pets.read"] + } + ], + Extensions = new Dictionary + { + ["x-operation"] = new JsonNodeExtension("tracked") + } + } + } + } + }, + Webhooks = new Dictionary + { + ["petCreated"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + }, + Responses = new OpenApiResponses + { + ["204"] = new OpenApiResponse + { + Description = "No content" + } + } + } + } + } + }, + Components = new OpenApiComponents + { + RequestBodies = new Dictionary + { + ["PetBody"] = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + Not = new OpenApiSchema { Type = JsonSchemaType.Boolean }, + AllOf = [new OpenApiSchemaReference("Pet")], + AnyOf = [new OpenApiSchemaReference("Pet")], + OneOf = [new OpenApiSchemaReference("Cat")], + Discriminator = new OpenApiDiscriminator + { + PropertyName = "kind", + Mapping = new Dictionary + { + ["cat"] = new("Cat") + } + } + } + } + } + } + }, + Headers = new Dictionary + { + ["RateLimit"] = new OpenApiHeader + { + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer }, + Example = JsonValue.Create(100), + Examples = new Dictionary + { + ["detailed"] = new OpenApiExample + { + Value = JsonValue.Create(200) + } + } + } + }, + Links = new Dictionary + { + ["NextPage"] = new OpenApiLink + { + Server = new OpenApiServer + { + Url = "https://next.contoso.com" + } + } + }, + Examples = new Dictionary + { + ["PetExample"] = new OpenApiExample + { + Value = JsonNode.Parse("""{"name":"Fluffy"}""") + } + }, + MediaTypes = new Dictionary + { + ["PetMediaType"] = new OpenApiMediaTypeReference("PetMediaType") + }, + SecuritySchemes = new Dictionary + { + ["oauth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2 + } + }, + Schemas = new Dictionary + { + ["Pet"] = new OpenApiSchema + { + Type = JsonSchemaType.Object + }, + ["Cat"] = new OpenApiSchema + { + Type = JsonSchemaType.Object + } + } + } + }; + + document.Paths["/pets"].Operations[HttpMethod.Post].Tags = new HashSet + { + new("pets", document), + new("store", document) + }; + + return document; + } + + private sealed class RichWalkerVisitor : OpenApiVisitorBase + { + public List Locations { get; } = []; + + public override void Visit(OpenApiExternalDocs externalDocs) => Locations.Add(PathString); + public override void Visit(OpenApiServer server) => Locations.Add(PathString); + public override void Visit(OpenApiServerVariable serverVariable) => Locations.Add(PathString); + public override void Visit(IOpenApiRequestBody requestBody) => Locations.Add(PathString); + public override void Visit(IOpenApiResponse response) => Locations.Add(PathString); + public override void Visit(IOpenApiMediaType mediaType) => Locations.Add(PathString); + public override void Visit(IOpenApiSchema schema) => Locations.Add(PathString); + public override void Visit(IOpenApiCallback callback) => Locations.Add(PathString); + public override void Visit(IOpenApiLink link) => Locations.Add(PathString); + public override void Visit(IOpenApiExample example) => Locations.Add(PathString); + public override void Visit(IOpenApiExtension extension) => Locations.Add(PathString); + public override void Visit(JsonNode node) => Locations.Add(PathString); + public override void Visit(OpenApiTag tag) => Locations.Add(PathString); + public override void Visit(OpenApiTagReference tag) => Locations.Add(PathString); + public override void Visit(IOpenApiReferenceHolder referenceable) => Locations.Add("referenceAt: " + PathString); + } +} From 7a443c298fb87346338095b5df86f6608fb4d582 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 07:28:06 -0400 Subject: [PATCH 04/22] fix(library): avoid false circular refs for external schema re-exports Fixes #2872. The false positive came from workspace schema registration preferring item.Value.Id for component aliases. For OpenApiSchemaReference values, reading Id eagerly dereferenced the external target during registration, which triggered "Circular reference detected while resolving schema" for a root schema re-export plus a direct external reference. This faulty behavior traces back to PR #1826, which introduced the item.Value.Id ?? fallback to support JSON Schema identifier-based resolution. Under JSON Schema 2020-12, JSON Pointer still identifies a lexical location in the containing document, but $id establishes the canonical schema resource URI. This change preserves explicit $id alias registration for concrete schemas while always registering component keys for schema references so workspace registration does not resolve refs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/OpenApiWorkspace.cs | 7 +- .../V31Tests/RelativeReferenceTests.cs | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs index 22128adc2..4849f1a42 100644 --- a/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs +++ b/src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs @@ -94,8 +94,13 @@ public void RegisterComponents(OpenApiDocument document) foreach (var item in document.Components.Schemas) { if (item.Value == null) continue; - location = item.Value.Id ?? baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; + location = baseUri + ReferenceType.Schema.GetDisplayName() + ComponentSegmentSeparator + item.Key; RegisterComponent(location, item.Value); + + if (item.Value is not OpenApiSchemaReference && item.Value.Id is string schemaId && schemaId.Length > 0) + { + RegisterComponent(schemaId, item.Value); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs index a8b103e21..070fff68c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/RelativeReferenceTests.cs @@ -203,6 +203,75 @@ public async Task ParseLocalReferenceToJsonSchemaResourceWorks() Assert.Equal(JsonSchemaType.Object | JsonSchemaType.Null, schema.Type); } + [Fact] + public async Task ParseExternalSchemaReferencedDirectlyAndReExportedAtRootWorks() + { + var tempDirectory = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + + var rootPath = Path.Join(tempDirectory, "root.yaml"); + var sharedPath = Path.Join(tempDirectory, "shared.yaml"); + + await File.WriteAllTextAsync(rootPath, + @"openapi: 3.1.0 +info: + title: T + version: 1.0.0 +paths: + /a: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + meta: + $ref: './shared.yaml#/Leaf' +components: + schemas: + Leaf: + $ref: './shared.yaml#/Leaf' +"); + + await File.WriteAllTextAsync(sharedPath, + @"Leaf: + type: object + properties: + x: + type: string + y: + type: integer +"); + + try + { + var settings = new OpenApiReaderSettings + { + LoadExternalRefs = true, + BaseUrl = new Uri(rootPath), + }; + settings.AddYamlReader(); + + var result = await OpenApiDocument.LoadAsync(rootPath, settings); + var responseSchema = result.Document.Paths["/a"].Operations[HttpMethod.Get].Responses["200"].Content["application/json"].Schema; + var metaSchema = responseSchema.Properties["meta"]; + var leafSchema = result.Document.Components.Schemas["Leaf"]; + + Assert.NotNull(result.Document); + Assert.DoesNotContain(result.Diagnostic.Errors, error => error.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.DoesNotContain(result.Diagnostic.Warnings, warning => warning.Message.Contains("Circular reference detected while resolving schema", StringComparison.Ordinal)); + Assert.IsType(metaSchema); + Assert.IsType(leafSchema); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public void ResolveSubSchema_ShouldTraverseKnownKeywords() { From 82f84e072d9f6b4b5907e9435212fbfc8f82d9c7 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:08:14 -0400 Subject: [PATCH 05/22] feat(library): add missing json schema properties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Interfaces/IOpenApiSchema.cs | 2 +- .../IOpenApiSchemaMissingProperties.cs | 86 ++++++++ ...IOpenApiSchemaWithUnevaluatedProperties.cs | 3 + .../Models/OpenApiConstants.cs | 99 ++++++++- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 92 +++++++- .../References/OpenApiSchemaReference.cs | 24 ++- src/Microsoft.OpenApi/PublicAPI.Shipped.txt | 2 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 58 +++++ .../Reader/V3/OpenApiSchemaDeserializer.cs | 72 +++++++ .../Reader/V31/OpenApiSchemaDeserializer.cs | 36 ++++ .../Reader/V32/OpenApiSchemaDeserializer.cs | 36 ++++ .../V31Tests/OpenApiSchemaTests.cs | 44 ++++ .../V32Tests/OpenApiSchemaTests.cs | 45 +++- .../V3Tests/OpenApiSchemaTests.cs | 46 ++++ .../Models/OpenApiSchemaTests.cs | 203 ++++++++++++++++-- .../References/OpenApiSchemaReferenceTests.cs | 45 ++++ 16 files changed, 862 insertions(+), 31 deletions(-) create mode 100644 src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs index 6d43a087a..7cb85f100 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchema.cs @@ -260,7 +260,7 @@ public interface IOpenApiSchema : IOpenApiDescribedElement, IOpenApiReadOnlyExte /// /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties - /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaWithUnevaluatedProperties) is null. + /// Only serialized when false and UnevaluatedPropertiesSchema (from IOpenApiSchemaMissingProperties) is null. /// /// /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs new file mode 100644 index 000000000..be2d6fbb1 --- /dev/null +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; + +namespace Microsoft.OpenApi; + +/// +/// Compatibility interface for schema properties that cannot be added to +/// in the current major version without a breaking change. +/// This interface provides access to those properties in contexts where callers need a typed model surface. +/// +/// +/// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. +/// +public interface IOpenApiSchemaMissingProperties +{ + /// + /// $anchor - identifies a plain-name location-independent fragment within the schema resource. + /// + public string? Anchor { get; } + + /// + /// Indicates whether unevaluated properties are allowed. When false, no unevaluated properties are permitted. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// Only serialized when false and is null. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalPropertiesAllowed for binary compatibility reasons. + /// In the next major version, this will be renamed to UnevaluatedPropertiesAllowed. + /// TODO: Rename to UnevaluatedPropertiesAllowed in the next major version. + /// + public bool UnevaluatedProperties { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties + /// This is a schema that unevaluated properties must validate against. + /// When serialized, this takes precedence over the boolean property. + /// + /// + /// NOTE: This property differs from the naming pattern of AdditionalProperties/AdditionalPropertiesAllowed + /// for binary compatibility reasons. In the next major version: + /// - This property will be renamed to UnevaluatedProperties + /// - The current boolean UnevaluatedProperties property will be renamed to UnevaluatedPropertiesAllowed + /// + /// TODO: Rename this property to UnevaluatedProperties in the next major version. + /// + public IOpenApiSchema? UnevaluatedPropertiesSchema { get; } + + /// + /// contentEncoding - identifies the encoding of string content. + /// + public string? ContentEncoding { get; } + + /// + /// contentMediaType - identifies the media type of string content. + /// + public string? ContentMediaType { get; } + + /// + /// contentSchema - provides a schema that describes the decoded string content. + /// + public IOpenApiSchema? ContentSchema { get; } + + /// + /// propertyNames - provides a schema that validates property names. + /// + public IOpenApiSchema? PropertyNames { get; } + + /// + /// dependentSchemas - maps property names to schemas that are applied when that property is present. + /// + public IDictionary? DependentSchemas { get; } + + /// + /// if - applies a conditional schema that determines whether or should be evaluated. + /// + public IOpenApiSchema? If { get; } + + /// + /// then - applies when evaluates successfully. + /// + public IOpenApiSchema? Then { get; } + + /// + /// else - applies when does not evaluate successfully. + /// + public IOpenApiSchema? Else { get; } +} diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs index 3379a0837..6fe7e9f9a 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithUnevaluatedProperties.cs @@ -1,3 +1,5 @@ +using System; + namespace Microsoft.OpenApi; /// @@ -13,6 +15,7 @@ namespace Microsoft.OpenApi; /// /// TODO: Remove this interface in the next major version and merge its content into IOpenApiSchema. /// +[Obsolete("Use IOpenApiSchemaMissingProperties instead.")] public interface IOpenApiSchemaWithUnevaluatedProperties { /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index a54758002..07ad81923 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -90,6 +90,11 @@ public static class OpenApiConstants /// public const string Vocabulary = "$vocabulary"; + /// + /// Field: Anchor + /// + public const string Anchor = "$anchor"; + /// /// Field: DynamicRef /// @@ -131,9 +136,14 @@ public static class OpenApiConstants public const string UnevaluatedProperties = "unevaluatedProperties"; /// - /// Extension: x-jsonschema-unevaluatedProperties + /// Extension: x-oai-unevaluatedProperties + /// + public const string UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; + + /// + /// Legacy extension: x-jsonschema-unevaluatedProperties /// - public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; + public const string LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; /// /// Field: Version @@ -535,11 +545,51 @@ public static class OpenApiConstants /// public const string PatternProperties = "patternProperties"; + /// + /// Field: PropertyNames + /// + public const string PropertyNames = "propertyNames"; + /// /// Extension: x-jsonschema-patternProperties /// public const string PatternPropertiesExtension = "x-jsonschema-patternProperties"; + /// + /// Field: DependentSchemas + /// + public const string DependentSchemas = "dependentSchemas"; + + /// + /// Field: If + /// + public const string If = "if"; + + /// + /// Field: Then + /// + public const string Then = "then"; + + /// + /// Field: Else + /// + public const string Else = "else"; + + /// + /// Field: ContentEncoding + /// + public const string ContentEncoding = "contentEncoding"; + + /// + /// Field: ContentMediaType + /// + public const string ContentMediaType = "contentMediaType"; + + /// + /// Field: ContentSchema + /// + public const string ContentSchema = "contentSchema"; + /// /// Field: AdditionalProperties /// @@ -790,6 +840,51 @@ public static class OpenApiConstants /// public const string DependentRequired = "dependentRequired"; + /// + /// Extension: x-oai-$anchor + /// + public const string AnchorExtension = "x-oai-$anchor"; + + /// + /// Extension: x-oai-propertyNames + /// + public const string PropertyNamesExtension = "x-oai-propertyNames"; + + /// + /// Extension: x-oai-dependentSchemas + /// + public const string DependentSchemasExtension = "x-oai-dependentSchemas"; + + /// + /// Extension: x-oai-if + /// + public const string IfExtension = "x-oai-if"; + + /// + /// Extension: x-oai-then + /// + public const string ThenExtension = "x-oai-then"; + + /// + /// Extension: x-oai-else + /// + public const string ElseExtension = "x-oai-else"; + + /// + /// Extension: x-oai-contentEncoding + /// + public const string ContentEncodingExtension = "x-oai-contentEncoding"; + + /// + /// Extension: x-oai-contentMediaType + /// + public const string ContentMediaTypeExtension = "x-oai-contentMediaType"; + + /// + /// Extension: x-oai-contentSchema + /// + public const string ContentSchemaExtension = "x-oai-contentSchema"; + #region V2.0 /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 40f24bdd0..e94481caa 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -9,6 +9,7 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// The Schema Object allows the definition of input and output data types. /// @@ -18,7 +19,7 @@ namespace Microsoft.OpenApi /// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty /// for "true" behavior, or create a schema with only set to an empty schema for "false" behavior. /// - public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer + public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer { /// public string? Title { get; set; } @@ -44,6 +45,9 @@ public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaW /// public IDictionary? Definitions { get; set; } + /// + public string? Anchor { get; set; } + private string? _exclusiveMaximum; /// public string? ExclusiveMaximum @@ -243,6 +247,30 @@ public string? Minimum /// public IOpenApiSchema? UnevaluatedPropertiesSchema { get; set; } + /// + public string? ContentEncoding { get; set; } + + /// + public string? ContentMediaType { get; set; } + + /// + public IOpenApiSchema? ContentSchema { get; set; } + + /// + public IOpenApiSchema? PropertyNames { get; set; } + + /// + public IDictionary? DependentSchemas { get; set; } + + /// + public IOpenApiSchema? If { get; set; } + + /// + public IOpenApiSchema? Then { get; set; } + + /// + public IOpenApiSchema? Else { get; set; } + /// public OpenApiExternalDocs? ExternalDocs { get; set; } @@ -282,13 +310,32 @@ internal OpenApiSchema(IOpenApiSchema schema) Schema = schema.Schema ?? Schema; Comment = schema.Comment ?? Comment; Vocabulary = schema.Vocabulary != null ? new Dictionary(schema.Vocabulary) : null; + if (schema is IOpenApiSchemaMissingProperties { Anchor: not null } missingPropertiesWithAnchor) + { + Anchor = missingPropertiesWithAnchor.Anchor; + } DynamicAnchor = schema.DynamicAnchor ?? DynamicAnchor; DynamicRef = schema.DynamicRef ?? DynamicRef; Definitions = schema.Definitions != null ? new Dictionary(schema.Definitions) : null; - UnevaluatedProperties = schema.UnevaluatedProperties; - if (schema is IOpenApiSchemaWithUnevaluatedProperties { UnevaluatedPropertiesSchema: { } unevaluatedSchema }) + if (schema is IOpenApiSchemaMissingProperties missingProperties) { - UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + UnevaluatedProperties = missingProperties.UnevaluatedProperties; + if (missingProperties.UnevaluatedPropertiesSchema is { } unevaluatedSchema) + { + UnevaluatedPropertiesSchema = unevaluatedSchema.CreateShallowCopy(); + } + ContentEncoding = missingProperties.ContentEncoding ?? ContentEncoding; + ContentMediaType = missingProperties.ContentMediaType ?? ContentMediaType; + ContentSchema = missingProperties.ContentSchema?.CreateShallowCopy(); + PropertyNames = missingProperties.PropertyNames?.CreateShallowCopy(); + DependentSchemas = missingProperties.DependentSchemas != null ? new Dictionary(missingProperties.DependentSchemas) : null; + If = missingProperties.If?.CreateShallowCopy(); + Then = missingProperties.Then?.CreateShallowCopy(); + Else = missingProperties.Else?.CreateShallowCopy(); + } + else + { + UnevaluatedProperties = schema.UnevaluatedProperties; } ExclusiveMaximum = schema.ExclusiveMaximum ?? ExclusiveMaximum; ExclusiveMinimum = schema.ExclusiveMinimum ?? ExclusiveMinimum; @@ -557,18 +604,22 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null). if (!Type.HasValue || (Type.Value & JsonSchemaType.Object) != 0) { + var unevaluatedPropertiesExtensionName = version == OpenApiSpecVersion.OpenApi3_0 + ? OpenApiConstants.UnevaluatedPropertiesExtension + : OpenApiConstants.LegacyUnevaluatedPropertiesExtension; + // Write UnevaluatedPropertiesSchema as extension if present if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.UnevaluatedPropertiesExtension, + unevaluatedPropertiesExtensionName, UnevaluatedPropertiesSchema, callback); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); + writer.WritePropertyName(unevaluatedPropertiesExtensionName); writer.WriteValue(false); } } @@ -578,6 +629,8 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version { writer.WriteOptionalMap(OpenApiConstants.PatternPropertiesExtension, PatternProperties, callback); } + + WriteV3CompatibilityKeywords(writer, callback); } // extensions @@ -607,6 +660,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteProperty(OpenApiConstants.Const, Const); writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + writer.WriteProperty(OpenApiConstants.Anchor, Anchor); writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); @@ -630,6 +684,27 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); + writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.If, If, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, (w, s) => s.SerializeAsV31(w)); + } + + private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) + { + writer.WriteProperty(OpenApiConstants.AnchorExtension, Anchor); + writer.WriteProperty(OpenApiConstants.ContentEncodingExtension, ContentEncoding); + writer.WriteProperty(OpenApiConstants.ContentMediaTypeExtension, ContentMediaType); + writer.WriteOptionalObject(OpenApiConstants.ContentSchemaExtension, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNamesExtension, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemasExtension, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.IfExtension, If, callback); + writer.WriteOptionalObject(OpenApiConstants.ThenExtension, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.ElseExtension, Else, callback); } internal void WriteAsItemsProperties(IOpenApiWriter writer) @@ -799,6 +874,7 @@ private void SerializeAsV2( // oneOf (Not Supported in V2) - Write the first schema only as an allOf. writer.WriteOptionalCollection(OpenApiConstants.AllOf, OneOf?.Take(1), (w, s) => s.SerializeAsV2(w)); } + #pragma warning restore CS0618 } // properties @@ -855,14 +931,14 @@ private void SerializeAsV2( if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.UnevaluatedPropertiesExtension, + OpenApiConstants.LegacyUnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, (w, s) => s.SerializeAsV2(w)); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); + writer.WritePropertyName(OpenApiConstants.LegacyUnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 67eb79645..570e395c8 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -7,10 +7,11 @@ namespace Microsoft.OpenApi { +#pragma warning disable CS0618 /// /// Schema reference object /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaMissingProperties, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible { /// @@ -62,6 +63,8 @@ public string? Title /// public IDictionary? Definitions { get => Target?.Definitions; } /// + public string? Anchor { get => (Target as IOpenApiSchemaMissingProperties)?.Anchor; } + /// public string? ExclusiveMaximum { get => Target?.ExclusiveMaximum; } /// public string? ExclusiveMinimum { get => Target?.ExclusiveMinimum; } @@ -146,7 +149,23 @@ public IList? Examples /// public bool UnevaluatedProperties { get => Target?.UnevaluatedProperties ?? true; } /// - public IOpenApiSchema? UnevaluatedPropertiesSchema { get => (Target as IOpenApiSchemaWithUnevaluatedProperties)?.UnevaluatedPropertiesSchema; } + public IOpenApiSchema? UnevaluatedPropertiesSchema { get => (Target as IOpenApiSchemaMissingProperties)?.UnevaluatedPropertiesSchema; } + /// + public string? ContentEncoding { get => (Target as IOpenApiSchemaMissingProperties)?.ContentEncoding; } + /// + public string? ContentMediaType { get => (Target as IOpenApiSchemaMissingProperties)?.ContentMediaType; } + /// + public IOpenApiSchema? ContentSchema { get => (Target as IOpenApiSchemaMissingProperties)?.ContentSchema; } + /// + public IOpenApiSchema? PropertyNames { get => (Target as IOpenApiSchemaMissingProperties)?.PropertyNames; } + /// + public IDictionary? DependentSchemas { get => (Target as IOpenApiSchemaMissingProperties)?.DependentSchemas; } + /// + public IOpenApiSchema? If { get => (Target as IOpenApiSchemaMissingProperties)?.If; } + /// + public IOpenApiSchema? Then { get => (Target as IOpenApiSchemaMissingProperties)?.Then; } + /// + public IOpenApiSchema? Else { get => (Target as IOpenApiSchemaMissingProperties)?.Else; } /// public OpenApiExternalDocs? ExternalDocs { get => Target?.ExternalDocs; } /// @@ -226,5 +245,6 @@ protected override JsonSchemaReference CopyReference(JsonSchemaReference sourceR { return new JsonSchemaReference(sourceReference); } + #pragma warning restore CS0618 } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt index 4424e5862..4c197c380 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt @@ -2022,7 +2022,7 @@ virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV3(Microsoft.OpenApi.IOpenApiWri virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void const Microsoft.OpenApi.OpenApiConstants.OAuth2MetadataUrl = "oauth2MetadataUrl" -> string! -const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! Microsoft.OpenApi.IOAuth2MetadataProvider Microsoft.OpenApi.IOAuth2MetadataProvider.OAuth2MetadataUrl.get -> System.Uri? Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..2c1dde11f 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,59 @@ #nullable enable +const Microsoft.OpenApi.OpenApiConstants.Anchor = "$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-oai-$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncoding = "contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-oai-contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaType = "contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-oai-contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-oai-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-oai-dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! +const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-oai-else" -> string! +const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! +const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-oai-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-oai-propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! +const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-oai-then" -> string! +Microsoft.OpenApi.IOpenApiSchemaMissingProperties +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Anchor.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentEncoding.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentMediaType.get -> string? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedProperties.get -> bool +Microsoft.OpenApi.IOpenApiSchemaMissingProperties.UnevaluatedPropertiesSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchema.Anchor.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentEncoding.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchema.ContentMediaType.set -> void +Microsoft.OpenApi.OpenApiSchema.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.ContentSchema.set -> void +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchema.DependentSchemas.set -> void +Microsoft.OpenApi.OpenApiSchema.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Else.set -> void +Microsoft.OpenApi.OpenApiSchema.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.If.set -> void +Microsoft.OpenApi.OpenApiSchema.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.PropertyNames.set -> void +Microsoft.OpenApi.OpenApiSchema.Then.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Then.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Anchor.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentEncoding.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentMediaType.get -> string? +Microsoft.OpenApi.OpenApiSchemaReference.ContentSchema.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.DependentSchemas.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.OpenApiSchemaReference.Else.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.If.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.PropertyNames.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.Then.get -> Microsoft.OpenApi.IOpenApiSchema? diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 0ad8e747c..9fdd69702 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -282,6 +282,78 @@ internal static partial class OpenApiV3Deserializer OpenApiConstants.PatternPropertiesExtension, (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + OpenApiConstants.UnevaluatedPropertiesExtension, + (o, n, t, c) => + { + if (n is JsonValue) + { + var value = n.GetScalarValue(); + if (value is not null) + { + o.UnevaluatedProperties = bool.Parse(value); + } + } + else + { + o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); + } + } + }, + { + OpenApiConstants.LegacyUnevaluatedPropertiesExtension, + (o, n, t, c) => + { + if (n is JsonValue) + { + var value = n.GetScalarValue(); + if (value is not null) + { + o.UnevaluatedProperties = bool.Parse(value); + } + } + else + { + o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); + } + } + }, + { + OpenApiConstants.AnchorExtension, + (o, n, _, _) => o.Anchor = n.GetScalarValue() + }, + { + OpenApiConstants.ContentEncodingExtension, + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + OpenApiConstants.ContentMediaTypeExtension, + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + OpenApiConstants.ContentSchemaExtension, + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.PropertyNamesExtension, + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.DependentSchemasExtension, + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + OpenApiConstants.IfExtension, + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ThenExtension, + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + OpenApiConstants.ElseExtension, + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 14deab765..f4b98234f 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -43,6 +43,10 @@ internal static partial class OpenApiV31Deserializer { "$defs", (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) + }, + { + "$anchor", + (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { "multipleOf", @@ -164,6 +168,18 @@ internal static partial class OpenApiV31Deserializer } } }, + { + "contentEncoding", + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + "contentMediaType", + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + "contentSchema", + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, { "maxProperties", (o, n, _, _) => @@ -249,6 +265,10 @@ internal static partial class OpenApiV31Deserializer "patternProperties", (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + "propertyNames", + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, { "additionalProperties", (o, n, doc, c) => { @@ -356,6 +376,22 @@ internal static partial class OpenApiV31Deserializer o.DependentRequired = n.CreateArrayMap((n2, _) => n2.GetScalarValue()!, doc, c); } }, + { + "dependentSchemas", + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + "if", + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + "then", + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + "else", + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index f0e07724f..dca4f339c 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -43,6 +43,10 @@ internal static partial class OpenApiV32Deserializer { "$defs", (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) + }, + { + "$anchor", + (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { "multipleOf", @@ -164,6 +168,18 @@ internal static partial class OpenApiV32Deserializer } } }, + { + "contentEncoding", + (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() + }, + { + "contentMediaType", + (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() + }, + { + "contentSchema", + (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) + }, { "maxProperties", (o, n, _, _) => @@ -249,6 +265,10 @@ internal static partial class OpenApiV32Deserializer "patternProperties", (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, + { + "propertyNames", + (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) + }, { "additionalProperties", (o, n, doc, c) => { @@ -356,6 +376,22 @@ internal static partial class OpenApiV32Deserializer o.DependentRequired = n.CreateArrayMap((n2, _) => n2.GetScalarValue()!, doc, c); } }, + { + "dependentSchemas", + (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) + }, + { + "if", + (o, n, doc, c) => o.If = LoadSchema(n, doc, c) + }, + { + "then", + (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) + }, + { + "else", + (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) + }, }; private static readonly PatternFieldMap _openApiSchemaPatternFields = new() diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index ec66dcbb9..e112eb1ae 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -857,6 +857,50 @@ public void ParseSchemaWithoutUnevaluatedPropertiesDefaultsToTrue() Assert.True(actual.UnevaluatedProperties); // Explicitly verify the default } + [Fact] + public void ParseSchemaWithMissingJsonSchemaProperties() + { + var schema = @"{ + ""$anchor"": ""root"", + ""contentEncoding"": ""base64"", + ""contentMediaType"": ""application/jwt"", + ""contentSchema"": { + ""type"": ""array"" + }, + ""propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""if"": { + ""required"": [""token""] + }, + ""then"": { + ""minProperties"": 1 + }, + ""else"": { + ""maxProperties"": 0 + } +}"; + + var actual = OpenApiModelFactory.Parse(schema, OpenApiSpecVersion.OpenApi3_1, new(), out _); + var missingProperties = Assert.IsAssignableFrom(actual); + + Assert.Equal("root", missingProperties.Anchor); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData("{}")] [InlineData("true")] diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index 621cd156c..804117d5c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -708,6 +708,50 @@ public void ParseSchemaWithUnevaluatedPropertiesComplexSchema() Assert.Equivalent(expected, actual); } + [Fact] + public void ParseSchemaWithMissingJsonSchemaProperties() + { + var schema = @"{ + ""$anchor"": ""root"", + ""contentEncoding"": ""base64"", + ""contentMediaType"": ""application/jwt"", + ""contentSchema"": { + ""type"": ""array"" + }, + ""propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""if"": { + ""required"": [""token""] + }, + ""then"": { + ""minProperties"": 1 + }, + ""else"": { + ""maxProperties"": 0 + } +}"; + + var actual = OpenApiModelFactory.Parse(schema, OpenApiSpecVersion.OpenApi3_2, new(), out _); + var missingProperties = Assert.IsAssignableFrom(actual); + + Assert.Equal("root", missingProperties.Anchor); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData("{}")] [InlineData("true")] @@ -738,4 +782,3 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema() } } } - diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 9b0f0b94b..110e2c342 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -178,6 +178,52 @@ public void ParseDictionarySchemaShouldSucceed() } } + [Fact] + public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() + { + var schemaJson = @"{ + ""x-oai-$anchor"": ""root"", + ""x-oai-unevaluatedProperties"": false, + ""x-oai-contentEncoding"": ""base64"", + ""x-oai-contentMediaType"": ""application/jwt"", + ""x-oai-contentSchema"": { + ""type"": ""array"" + }, + ""x-oai-propertyNames"": { + ""pattern"": ""^[a-z]+$"" + }, + ""x-oai-dependentSchemas"": { + ""token"": { + ""type"": ""string"" + } + }, + ""x-oai-if"": { + ""required"": [""token""] + }, + ""x-oai-then"": { + ""minProperties"": 1 + }, + ""x-oai-else"": { + ""maxProperties"": 0 + } +}"; + + var schema = OpenApiModelFactory.Parse(schemaJson, OpenApiSpecVersion.OpenApi3_0, new(), out _, "json", SettingsFixture.ReaderSettings); + var missingProperties = Assert.IsAssignableFrom(schema); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Fact] public void ParseBasicSchemaWithExampleShouldSucceed() { diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 40034eded..06ade24a1 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -523,6 +523,48 @@ public void OpenApiSchemaCopyConstructorWithUnevaluatedPropertiesSchemaSucceeds( Assert.Equal(100, baseSchema.UnevaluatedPropertiesSchema.MaxLength); } + [Fact] + public void OpenApiSchemaCopyConstructorWithMissingPropertiesSucceeds() + { + var baseSchema = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + UnevaluatedPropertiesSchema = new OpenApiSchema { Type = JsonSchemaType.String }, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + var actualMissingProperties = Assert.IsAssignableFrom(actualSchema); + + Assert.Equal("root", actualMissingProperties.Anchor); + Assert.False(actualMissingProperties.UnevaluatedProperties); + Assert.NotNull(actualMissingProperties.UnevaluatedPropertiesSchema); + Assert.Equal("base64", actualMissingProperties.ContentEncoding); + Assert.Equal("application/jwt", actualMissingProperties.ContentMediaType); + Assert.NotNull(actualMissingProperties.ContentSchema); + Assert.NotNull(actualMissingProperties.PropertyNames); + Assert.NotNull(actualMissingProperties.DependentSchemas); + Assert.NotNull(actualMissingProperties.If); + Assert.NotNull(actualMissingProperties.Then); + Assert.NotNull(actualMissingProperties.Else); + Assert.NotSame(baseSchema.ContentSchema, actualMissingProperties.ContentSchema); + Assert.NotSame(baseSchema.PropertyNames, actualMissingProperties.PropertyNames); + Assert.NotSame(baseSchema.If, actualMissingProperties.If); + Assert.NotSame(baseSchema.Then, actualMissingProperties.Then); + Assert.NotSame(baseSchema.Else, actualMissingProperties.Else); + } + public static TheoryData SchemaExamples() { return new() @@ -1256,32 +1298,38 @@ public async Task SerializeUnevaluatedPropertiesSchemaTakesPrecedenceOverBoolean Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Theory] - [InlineData(OpenApiSpecVersion.OpenApi2_0)] - [InlineData(OpenApiSpecVersion.OpenApi3_0)] - public async Task SerializeUnevaluatedPropertiesAsExtensionInEarlierVersions(OpenApiSpecVersion version) + [Fact] + public async Task SerializeUnevaluatedPropertiesAsExtensionInV2() { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; - // Given - UnevaluatedProperties should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedProperties = false }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } - [Theory] - [InlineData(OpenApiSpecVersion.OpenApi2_0)] - [InlineData(OpenApiSpecVersion.OpenApi3_0)] - public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersions(OpenApiSpecVersion version) + [Fact] + public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() + { + var expected = @"{ ""x-oai-unevaluatedProperties"": false }"; + var schema = new OpenApiSchema + { + UnevaluatedProperties = false + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV2() { var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; - // Given - UnevaluatedPropertiesSchema should be emitted as extension in versions < 3.1 var schema = new OpenApiSchema { UnevaluatedPropertiesSchema = new OpenApiSchema @@ -1290,10 +1338,25 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInEarlierVersio } }; - // When - var actual = await schema.SerializeAsJsonAsync(version); + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); + } + + [Fact] + public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() + { + var expected = @"{ ""x-oai-unevaluatedProperties"": { ""type"": ""string"" } }"; + var schema = new OpenApiSchema + { + UnevaluatedPropertiesSchema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + }; + + var actual = await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); - // Then Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } @@ -1316,6 +1379,114 @@ public async Task SerializeUnevaluatedPropertiesTrueNotEmittedInEarlierVersions( Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), JsonNode.Parse(actual))); } + [Fact] + public async Task SerializeMissingPropertiesEmitsJsonSchemaKeywordsInV31() + { + var expected = JsonNode.Parse(""" + { + "$anchor": "root", + "contentEncoding": "base64", + "contentMediaType": "application/jwt", + "contentSchema": { + "type": "array" + }, + "propertyNames": { + "pattern": "^[a-z]+$" + }, + "dependentSchemas": { + "token": { + "type": "string" + } + }, + "if": { + "required": [ + "token" + ] + }, + "then": { + "minProperties": 1 + }, + "else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + + [Fact] + public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() + { + var expected = JsonNode.Parse(""" + { + "x-oai-$anchor": "root", + "x-oai-contentEncoding": "base64", + "x-oai-contentMediaType": "application/jwt", + "x-oai-contentSchema": { + "type": "array" + }, + "x-oai-propertyNames": { + "pattern": "^[a-z]+$" + }, + "x-oai-dependentSchemas": { + "token": { + "type": "string" + } + }, + "x-oai-if": { + "required": [ + "token" + ] + }, + "x-oai-then": { + "minProperties": 1 + }, + "x-oai-else": { + "maxProperties": 0 + } + } + """); + + var schema = new OpenApiSchema + { + Anchor = "root", + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + }; + + var actual = JsonNode.Parse(await schema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0)); + + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + [Theory] [InlineData(JsonSchemaType.Array, "array")] [InlineData(JsonSchemaType.String, "string")] diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs index 488488518..ebf8423d1 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs @@ -119,6 +119,51 @@ public void SchemaReferenceWithoutAnnotationsShouldFallbackToTarget() Assert.Equal("target example", schemaReference.Examples.First()?.GetValue()); } + [Fact] + public void SchemaReferenceExposesMissingPropertiesFromTarget() + { + var workingDocument = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + const string referenceId = "targetSchema"; + workingDocument.Components.Schemas = new Dictionary + { + [referenceId] = new OpenApiSchema + { + Anchor = "root", + UnevaluatedProperties = false, + ContentEncoding = "base64", + ContentMediaType = "application/jwt", + ContentSchema = new OpenApiSchema { Type = JsonSchemaType.Array }, + PropertyNames = new OpenApiSchema { Pattern = "^[a-z]+$" }, + DependentSchemas = new Dictionary + { + ["token"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + If = new OpenApiSchema { Required = new HashSet { "token" } }, + Then = new OpenApiSchema { MinProperties = 1 }, + Else = new OpenApiSchema { MaxProperties = 0 } + } + }; + workingDocument.Workspace.RegisterComponents(workingDocument); + + var schemaReference = new OpenApiSchemaReference(referenceId, workingDocument); + var missingProperties = Assert.IsAssignableFrom(schemaReference); + + Assert.Equal("root", missingProperties.Anchor); + Assert.False(missingProperties.UnevaluatedProperties); + Assert.Equal("base64", missingProperties.ContentEncoding); + Assert.Equal("application/jwt", missingProperties.ContentMediaType); + Assert.Equal(JsonSchemaType.Array, missingProperties.ContentSchema?.Type); + Assert.Equal("^[a-z]+$", missingProperties.PropertyNames?.Pattern); + Assert.Equal(JsonSchemaType.String, missingProperties.DependentSchemas?["token"].Type); + Assert.NotNull(missingProperties.If?.Required); + Assert.Contains("token", missingProperties.If.Required); + Assert.Equal(1, missingProperties.Then?.MinProperties); + Assert.Equal(0, missingProperties.Else?.MaxProperties); + } + [Theory] [InlineData(true)] [InlineData(false)] From 6e22ec6948509d2e256932ee55f1781a544cb53f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:09:17 -0400 Subject: [PATCH 06/22] fix(library): use version-specific schema keyword callbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 22 ++++++------- .../Mocks/OpenApiSchemaSerializationTests.cs | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index e94481caa..4f44bb504 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -451,7 +451,7 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version if (version >= OpenApiSpecVersion.OpenApi3_1) { - WriteJsonSchemaKeywords(writer); + WriteJsonSchemaKeywords(writer, callback); } // title @@ -652,14 +652,14 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) SerializeAsV2(writer: writer, parentRequiredProperties: new HashSet(), propertyName: null); } - internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) + internal void WriteJsonSchemaKeywords(IOpenApiWriter writer, Action callback) { writer.WriteProperty(OpenApiConstants.Id, Id); writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString()); writer.WriteProperty(OpenApiConstants.Comment, Comment); writer.WriteProperty(OpenApiConstants.Const, Const); writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); - writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, callback); writer.WriteProperty(OpenApiConstants.Anchor, Anchor); writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); @@ -674,7 +674,7 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalObject( OpenApiConstants.UnevaluatedProperties, UnevaluatedPropertiesSchema, - (w, s) => s.SerializeAsV31(w)); + callback); } else if (!UnevaluatedProperties) { @@ -682,16 +682,16 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) } } writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); - writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, callback); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); writer.WriteProperty(OpenApiConstants.ContentEncoding, ContentEncoding); writer.WriteProperty(OpenApiConstants.ContentMediaType, ContentMediaType); - writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.If, If, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.Then, Then, (w, s) => s.SerializeAsV31(w)); - writer.WriteOptionalObject(OpenApiConstants.Else, Else, (w, s) => s.SerializeAsV31(w)); + writer.WriteOptionalObject(OpenApiConstants.ContentSchema, ContentSchema, callback); + writer.WriteOptionalObject(OpenApiConstants.PropertyNames, PropertyNames, callback); + writer.WriteOptionalMap(OpenApiConstants.DependentSchemas, DependentSchemas, callback); + writer.WriteOptionalObject(OpenApiConstants.If, If, callback); + writer.WriteOptionalObject(OpenApiConstants.Then, Then, callback); + writer.WriteOptionalObject(OpenApiConstants.Else, Else, callback); } private void WriteV3CompatibilityKeywords(IOpenApiWriter writer, Action callback) diff --git a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs index 8a402fb74..2d9c5f766 100644 --- a/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs +++ b/test/Microsoft.OpenApi.Tests/Mocks/OpenApiSchemaSerializationTests.cs @@ -45,5 +45,37 @@ public void SerializeAsV3_DoesNotCallV31OrV2Serialization() _xmlMock.Verify(c => c.SerializeAsV2(It.IsAny()), Times.Never, "V2 method should not be called"); _xmlMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never, "V31 method should not be called"); } + + [Fact] + public void SerializeAsV31_UsesV31CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV31(writer); + + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } + + [Fact] + public void SerializeAsV32_UsesV32CallbackForJsonSchemaKeywords() + { + using var stringWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(stringWriter); + var childSchemaMock = new Mock { CallBase = true }; + childSchemaMock.Object.Type = JsonSchemaType.String; + _schema.ContentSchema = childSchemaMock.Object; + + _schema.SerializeAsV32(writer); + + childSchemaMock.Verify(c => c.SerializeAsV32(It.IsAny()), Times.AtLeastOnce); + childSchemaMock.Verify(c => c.SerializeAsV31(It.IsAny()), Times.Never); + childSchemaMock.Verify(c => c.SerializeAsV3(It.IsAny()), Times.Never); + } } } From c62769a6fac6ae354bf5554d6d2e4648e917b99a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:17:09 -0400 Subject: [PATCH 07/22] docs(library): add json schema spec links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Interfaces/IOpenApiSchemaMissingProperties.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs index be2d6fbb1..a144f751e 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaMissingProperties.cs @@ -14,6 +14,7 @@ public interface IOpenApiSchemaMissingProperties { /// /// $anchor - identifies a plain-name location-independent fragment within the schema resource. + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-anchor /// public string? Anchor { get; } @@ -45,41 +46,49 @@ public interface IOpenApiSchemaMissingProperties public IOpenApiSchema? UnevaluatedPropertiesSchema { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentencoding /// contentEncoding - identifies the encoding of string content. /// public string? ContentEncoding { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentmediatype /// contentMediaType - identifies the media type of string content. /// public string? ContentMediaType { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation#name-contentschema /// contentSchema - provides a schema that describes the decoded string content. /// public IOpenApiSchema? ContentSchema { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-propertynames /// propertyNames - provides a schema that validates property names. /// public IOpenApiSchema? PropertyNames { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-dependentschemas /// dependentSchemas - maps property names to schemas that are applied when that property is present. /// public IDictionary? DependentSchemas { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-if /// if - applies a conditional schema that determines whether or should be evaluated. /// public IOpenApiSchema? If { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-then /// then - applies when evaluates successfully. /// public IOpenApiSchema? Then { get; } /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-else /// else - applies when does not evaluate successfully. /// public IOpenApiSchema? Else { get; } From eb1891a8d77915add1cdd88949b40aa43cd525b8 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:22:22 -0400 Subject: [PATCH 08/22] fix(library): use x-jsonschema schema extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 44 +++++++++---------- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 4 +- src/Microsoft.OpenApi/PublicAPI.Shipped.txt | 2 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 20 ++++----- .../V3Tests/OpenApiSchemaTests.cs | 20 ++++----- .../Models/OpenApiSchemaTests.cs | 22 +++++----- 6 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 07ad81923..e66287fb2 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -136,14 +136,14 @@ public static class OpenApiConstants public const string UnevaluatedProperties = "unevaluatedProperties"; /// - /// Extension: x-oai-unevaluatedProperties + /// Extension: x-jsonschema-unevaluatedProperties /// - public const string UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; + public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; /// - /// Legacy extension: x-jsonschema-unevaluatedProperties + /// Legacy extension: x-oai-unevaluatedProperties /// - public const string LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; + public const string LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; /// /// Field: Version @@ -841,49 +841,49 @@ public static class OpenApiConstants public const string DependentRequired = "dependentRequired"; /// - /// Extension: x-oai-$anchor + /// Extension: x-jsonschema-$anchor /// - public const string AnchorExtension = "x-oai-$anchor"; + public const string AnchorExtension = "x-jsonschema-$anchor"; /// - /// Extension: x-oai-propertyNames + /// Extension: x-jsonschema-propertyNames /// - public const string PropertyNamesExtension = "x-oai-propertyNames"; + public const string PropertyNamesExtension = "x-jsonschema-propertyNames"; /// - /// Extension: x-oai-dependentSchemas + /// Extension: x-jsonschema-dependentSchemas /// - public const string DependentSchemasExtension = "x-oai-dependentSchemas"; + public const string DependentSchemasExtension = "x-jsonschema-dependentSchemas"; /// - /// Extension: x-oai-if + /// Extension: x-jsonschema-if /// - public const string IfExtension = "x-oai-if"; + public const string IfExtension = "x-jsonschema-if"; /// - /// Extension: x-oai-then + /// Extension: x-jsonschema-then /// - public const string ThenExtension = "x-oai-then"; + public const string ThenExtension = "x-jsonschema-then"; /// - /// Extension: x-oai-else + /// Extension: x-jsonschema-else /// - public const string ElseExtension = "x-oai-else"; + public const string ElseExtension = "x-jsonschema-else"; /// - /// Extension: x-oai-contentEncoding + /// Extension: x-jsonschema-contentEncoding /// - public const string ContentEncodingExtension = "x-oai-contentEncoding"; + public const string ContentEncodingExtension = "x-jsonschema-contentEncoding"; /// - /// Extension: x-oai-contentMediaType + /// Extension: x-jsonschema-contentMediaType /// - public const string ContentMediaTypeExtension = "x-oai-contentMediaType"; + public const string ContentMediaTypeExtension = "x-jsonschema-contentMediaType"; /// - /// Extension: x-oai-contentSchema + /// Extension: x-jsonschema-contentSchema /// - public const string ContentSchemaExtension = "x-oai-contentSchema"; + public const string ContentSchemaExtension = "x-jsonschema-contentSchema"; #region V2.0 diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 4f44bb504..ac21dc7b9 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -931,14 +931,14 @@ private void SerializeAsV2( if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - OpenApiConstants.LegacyUnevaluatedPropertiesExtension, + OpenApiConstants.UnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, (w, s) => s.SerializeAsV2(w)); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(OpenApiConstants.LegacyUnevaluatedPropertiesExtension); + writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt index 4c197c380..4424e5862 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Shipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Shipped.txt @@ -2022,7 +2022,7 @@ virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV3(Microsoft.OpenApi.IOpenApiWri virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV31(Microsoft.OpenApi.IOpenApiWriter! writer) -> void virtual Microsoft.OpenApi.OpenApiXml.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void const Microsoft.OpenApi.OpenApiConstants.OAuth2MetadataUrl = "oauth2MetadataUrl" -> string! -const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! Microsoft.OpenApi.IOAuth2MetadataProvider Microsoft.OpenApi.IOAuth2MetadataProvider.OAuth2MetadataUrl.get -> System.Uri? Microsoft.OpenApi.IOpenApiSchemaWithUnevaluatedProperties diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 2c1dde11f..cde35ac89 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1,23 +1,23 @@ #nullable enable const Microsoft.OpenApi.OpenApiConstants.Anchor = "$anchor" -> string! -const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-oai-$anchor" -> string! +const Microsoft.OpenApi.OpenApiConstants.AnchorExtension = "x-jsonschema-$anchor" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentEncoding = "contentEncoding" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-oai-contentEncoding" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentEncodingExtension = "x-jsonschema-contentEncoding" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentMediaType = "contentMediaType" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-oai-contentMediaType" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentMediaTypeExtension = "x-jsonschema-contentMediaType" -> string! const Microsoft.OpenApi.OpenApiConstants.ContentSchema = "contentSchema" -> string! -const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-oai-contentSchema" -> string! +const Microsoft.OpenApi.OpenApiConstants.ContentSchemaExtension = "x-jsonschema-contentSchema" -> string! const Microsoft.OpenApi.OpenApiConstants.DependentSchemas = "dependentSchemas" -> string! -const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-oai-dependentSchemas" -> string! +const Microsoft.OpenApi.OpenApiConstants.DependentSchemasExtension = "x-jsonschema-dependentSchemas" -> string! const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! -const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-oai-else" -> string! +const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! -const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-oai-if" -> string! -const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties" -> string! +const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! +const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! -const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-oai-propertyNames" -> string! +const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! -const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-oai-then" -> string! +const Microsoft.OpenApi.OpenApiConstants.ThenExtension = "x-jsonschema-then" -> string! Microsoft.OpenApi.IOpenApiSchemaMissingProperties Microsoft.OpenApi.IOpenApiSchemaMissingProperties.Anchor.get -> string? Microsoft.OpenApi.IOpenApiSchemaMissingProperties.ContentEncoding.get -> string? diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs index 110e2c342..df2f0d6eb 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiSchemaTests.cs @@ -182,28 +182,28 @@ public void ParseDictionarySchemaShouldSucceed() public void ParseSchemaWithOaiCompatibilityKeywordsShouldSucceed() { var schemaJson = @"{ - ""x-oai-$anchor"": ""root"", - ""x-oai-unevaluatedProperties"": false, - ""x-oai-contentEncoding"": ""base64"", - ""x-oai-contentMediaType"": ""application/jwt"", - ""x-oai-contentSchema"": { + ""x-jsonschema-$anchor"": ""root"", + ""x-jsonschema-unevaluatedProperties"": false, + ""x-jsonschema-contentEncoding"": ""base64"", + ""x-jsonschema-contentMediaType"": ""application/jwt"", + ""x-jsonschema-contentSchema"": { ""type"": ""array"" }, - ""x-oai-propertyNames"": { + ""x-jsonschema-propertyNames"": { ""pattern"": ""^[a-z]+$"" }, - ""x-oai-dependentSchemas"": { + ""x-jsonschema-dependentSchemas"": { ""token"": { ""type"": ""string"" } }, - ""x-oai-if"": { + ""x-jsonschema-if"": { ""required"": [""token""] }, - ""x-oai-then"": { + ""x-jsonschema-then"": { ""minProperties"": 1 }, - ""x-oai-else"": { + ""x-jsonschema-else"": { ""maxProperties"": 0 } }"; diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 06ade24a1..ccb9d7109 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -1315,7 +1315,7 @@ public async Task SerializeUnevaluatedPropertiesAsExtensionInV2() [Fact] public async Task SerializeUnevaluatedPropertiesAsExtensionInV3() { - var expected = @"{ ""x-oai-unevaluatedProperties"": false }"; + var expected = @"{ ""x-jsonschema-unevaluatedProperties"": false }"; var schema = new OpenApiSchema { UnevaluatedProperties = false @@ -1346,7 +1346,7 @@ public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV2() [Fact] public async Task SerializeUnevaluatedPropertiesSchemaAsExtensionInV3() { - var expected = @"{ ""x-oai-unevaluatedProperties"": { ""type"": ""string"" } }"; + var expected = @"{ ""x-jsonschema-unevaluatedProperties"": { ""type"": ""string"" } }"; var schema = new OpenApiSchema { UnevaluatedPropertiesSchema = new OpenApiSchema @@ -1438,29 +1438,29 @@ public async Task SerializeMissingPropertiesEmitsOaiExtensionsInV3() { var expected = JsonNode.Parse(""" { - "x-oai-$anchor": "root", - "x-oai-contentEncoding": "base64", - "x-oai-contentMediaType": "application/jwt", - "x-oai-contentSchema": { + "x-jsonschema-$anchor": "root", + "x-jsonschema-contentEncoding": "base64", + "x-jsonschema-contentMediaType": "application/jwt", + "x-jsonschema-contentSchema": { "type": "array" }, - "x-oai-propertyNames": { + "x-jsonschema-propertyNames": { "pattern": "^[a-z]+$" }, - "x-oai-dependentSchemas": { + "x-jsonschema-dependentSchemas": { "token": { "type": "string" } }, - "x-oai-if": { + "x-jsonschema-if": { "required": [ "token" ] }, - "x-oai-then": { + "x-jsonschema-then": { "minProperties": 1 }, - "x-oai-else": { + "x-jsonschema-else": { "maxProperties": 0 } } From cf54bb3e2746c0f6c7a60fde5bcc7fa1139dd8b6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:26:35 -0400 Subject: [PATCH 09/22] fix(library): remove unshipped schema extension fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/OpenApiConstants.cs | 5 ----- src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 8 ++------ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 1 - .../Reader/V3/OpenApiSchemaDeserializer.cs | 18 ------------------ 4 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index e66287fb2..80ef1ae59 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -140,11 +140,6 @@ public static class OpenApiConstants /// public const string UnevaluatedPropertiesExtension = "x-jsonschema-unevaluatedProperties"; - /// - /// Legacy extension: x-oai-unevaluatedProperties - /// - public const string LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties"; - /// /// Field: Version /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index ac21dc7b9..7dbe09d9f 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -604,22 +604,18 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // Skip when type is explicitly set to a non-object type (array, string, number, integer, boolean, null). if (!Type.HasValue || (Type.Value & JsonSchemaType.Object) != 0) { - var unevaluatedPropertiesExtensionName = version == OpenApiSpecVersion.OpenApi3_0 - ? OpenApiConstants.UnevaluatedPropertiesExtension - : OpenApiConstants.LegacyUnevaluatedPropertiesExtension; - // Write UnevaluatedPropertiesSchema as extension if present if (UnevaluatedPropertiesSchema is not null) { writer.WriteOptionalObject( - unevaluatedPropertiesExtensionName, + OpenApiConstants.UnevaluatedPropertiesExtension, UnevaluatedPropertiesSchema, callback); } // Write boolean false as extension if explicitly set to false else if (!UnevaluatedProperties) { - writer.WritePropertyName(unevaluatedPropertiesExtensionName); + writer.WritePropertyName(OpenApiConstants.UnevaluatedPropertiesExtension); writer.WriteValue(false); } } diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index cde35ac89..30d861e31 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -13,7 +13,6 @@ const Microsoft.OpenApi.OpenApiConstants.Else = "else" -> string! const Microsoft.OpenApi.OpenApiConstants.ElseExtension = "x-jsonschema-else" -> string! const Microsoft.OpenApi.OpenApiConstants.If = "if" -> string! const Microsoft.OpenApi.OpenApiConstants.IfExtension = "x-jsonschema-if" -> string! -const Microsoft.OpenApi.OpenApiConstants.LegacyUnevaluatedPropertiesExtension = "x-oai-unevaluatedProperties" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNames = "propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.PropertyNamesExtension = "x-jsonschema-propertyNames" -> string! const Microsoft.OpenApi.OpenApiConstants.Then = "then" -> string! diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs index 9fdd69702..12eb631ea 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs @@ -300,24 +300,6 @@ internal static partial class OpenApiV3Deserializer } } }, - { - OpenApiConstants.LegacyUnevaluatedPropertiesExtension, - (o, n, t, c) => - { - if (n is JsonValue) - { - var value = n.GetScalarValue(); - if (value is not null) - { - o.UnevaluatedProperties = bool.Parse(value); - } - } - else - { - o.UnevaluatedPropertiesSchema = LoadSchema(n, t, c); - } - } - }, { OpenApiConstants.AnchorExtension, (o, n, _, _) => o.Anchor = n.GetScalarValue() From 1a974f8dfcd7850c70d80133ceecee08f6671cd7 Mon Sep 17 00:00:00 2001 From: Romain Vergnory Date: Tue, 9 Jun 2026 18:26:47 +0200 Subject: [PATCH 10/22] feat: add contains/minContains/maxContains members --- .../IOpenApiSchemaWithContainsProperties.cs | 31 ++++++ .../Models/OpenApiConstants.cs | 15 +++ src/Microsoft.OpenApi/Models/OpenApiSchema.cs | 26 ++++- .../References/OpenApiSchemaReference.cs | 8 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 16 +++ .../Reader/V31/OpenApiSchemaDeserializer.cs | 26 +++++ .../Reader/V32/OpenApiSchemaDeserializer.cs | 26 +++++ .../V31Tests/OpenApiSchemaTests.cs | 8 +- .../Samples/OpenApiSchema/jsonSchema.json | 7 +- .../V32Tests/OpenApiSchemaTests.cs | 8 +- .../Samples/OpenApiSchema/jsonSchema.json | 7 +- .../Models/OpenApiSchemaTests.cs | 104 ++++++++++++++++++ 12 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs new file mode 100644 index 000000000..2aa91d0d6 --- /dev/null +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiSchemaWithContainsProperties.cs @@ -0,0 +1,31 @@ +namespace Microsoft.OpenApi; + +/// +/// Compatibility interface for the JSON Schema 2020-12 "contains" keywords support. +/// This interface provides access to the Contains, MaxContains and MinContains properties, which were +/// missed in the initial release of the IOpenApiSchema interface. +/// +/// This is a temporary compatibility solution. In the next major version this interface should be +/// merged into IOpenApiSchema. +/// +public interface IOpenApiSchemaWithContainsProperties +{ + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-core#name-contains + /// An array instance is valid against "contains" if at least one of its elements is valid against this schema. + /// Inline or referenced schema MUST be of a Schema Object and not a standard JSON Schema. + /// + IOpenApiSchema? Contains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be less than or equal to this value. + /// + uint? MaxContains { get; } + + /// + /// Follow JSON Schema definition: https://json-schema.org/draft/2020-12/json-schema-validation + /// The number of elements matching the "contains" schema MUST be greater than or equal to this value. + /// + uint? MinContains { get; } +} diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index a54758002..cac501a89 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -485,6 +485,21 @@ public static class OpenApiConstants /// public const string UniqueItems = "uniqueItems"; + /// + /// Field: Contains + /// + public const string Contains = "contains"; + + /// + /// Field: MaxContains + /// + public const string MaxContains = "maxContains"; + + /// + /// Field: MinContains + /// + public const string MinContains = "minContains"; + /// /// Field: MaxProperties /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs index 40f24bdd0..f90449d28 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiSchema.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiSchema.cs @@ -18,7 +18,7 @@ namespace Microsoft.OpenApi /// - Serialization: To produce something functionally equivalent to boolean schemas, create an empty /// for "true" behavior, or create a schema with only set to an empty schema for "false" behavior. /// - public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IMetadataContainer + public class OpenApiSchema : IOpenApiExtensible, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiSchemaWithContainsProperties, IMetadataContainer { /// public string? Title { get; set; } @@ -207,6 +207,15 @@ public string? Minimum /// public bool? UniqueItems { get; set; } + /// + public IOpenApiSchema? Contains { get; set; } + + /// + public uint? MaxContains { get; set; } + + /// + public uint? MinContains { get; set; } + /// public IDictionary? Properties { get; set; } @@ -318,6 +327,12 @@ internal OpenApiSchema(IOpenApiSchema schema) MaxItems = schema.MaxItems ?? MaxItems; MinItems = schema.MinItems ?? MinItems; UniqueItems = schema.UniqueItems ?? UniqueItems; + if (schema is IOpenApiSchemaWithContainsProperties containsSchema) + { + Contains = containsSchema.Contains?.CreateShallowCopy(); + MaxContains = containsSchema.MaxContains ?? MaxContains; + MinContains = containsSchema.MinContains ?? MinContains; + } Properties = schema.Properties != null ? new Dictionary(schema.Properties) : null; PatternProperties = schema.PatternProperties != null ? new Dictionary(schema.PatternProperties) : null; MaxProperties = schema.MaxProperties ?? MaxProperties; @@ -630,6 +645,15 @@ internal void WriteJsonSchemaKeywords(IOpenApiWriter writer) writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (nodeWriter, s) => nodeWriter.WriteAny(s)); writer.WriteOptionalMap(OpenApiConstants.PatternProperties, PatternProperties, (w, s) => s.SerializeAsV31(w)); writer.WriteOptionalMap(OpenApiConstants.DependentRequired, DependentRequired, (w, s) => w.WriteValue(s)); + + // contains + writer.WriteOptionalObject(OpenApiConstants.Contains, Contains, (w, s) => s.SerializeAsV31(w)); + + // maxContains + writer.WriteProperty(OpenApiConstants.MaxContains, MaxContains); + + // minContains + writer.WriteProperty(OpenApiConstants.MinContains, MinContains); } internal void WriteAsItemsProperties(IOpenApiWriter writer) diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 67eb79645..aef65205f 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -10,7 +10,7 @@ namespace Microsoft.OpenApi /// /// Schema reference object /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiSchemaWithContainsProperties, IOpenApiExtensible { /// @@ -120,6 +120,12 @@ public bool WriteOnly /// public bool? UniqueItems { get => Target?.UniqueItems; } /// + public IOpenApiSchema? Contains { get => (Target as IOpenApiSchemaWithContainsProperties)?.Contains; } + /// + public uint? MaxContains { get => (Target as IOpenApiSchemaWithContainsProperties)?.MaxContains; } + /// + public uint? MinContains { get => (Target as IOpenApiSchemaWithContainsProperties)?.MinContains; } + /// public IDictionary? Properties { get => Target?.Properties; } /// public IDictionary? PatternProperties { get => Target?.PatternProperties; } diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..40ac9f0fc 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,17 @@ #nullable enable +const Microsoft.OpenApi.OpenApiConstants.Contains = "contains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MaxContains = "maxContains" -> string! +const Microsoft.OpenApi.OpenApiConstants.MinContains = "minContains" -> string! +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.MaxContains.get -> uint? +Microsoft.OpenApi.IOpenApiSchemaWithContainsProperties.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchema.Contains.set -> void +Microsoft.OpenApi.OpenApiSchema.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MaxContains.set -> void +Microsoft.OpenApi.OpenApiSchema.MinContains.get -> uint? +Microsoft.OpenApi.OpenApiSchema.MinContains.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Contains.get -> Microsoft.OpenApi.IOpenApiSchema? +Microsoft.OpenApi.OpenApiSchemaReference.MaxContains.get -> uint? +Microsoft.OpenApi.OpenApiSchemaReference.MinContains.get -> uint? diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 14deab765..6343ea4e7 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -144,6 +144,32 @@ internal static partial class OpenApiV31Deserializer } } }, + { + "contains", + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + "maxContains", + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + "minContains", + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { "unevaluatedProperties", (o, n, t, c) => diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index f0e07724f..ebcd05255 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -144,6 +144,32 @@ internal static partial class OpenApiV32Deserializer } } }, + { + "contains", + (o, n, doc, c) => o.Contains = LoadSchema(n, doc, c) + }, + { + "maxContains", + (o, n, _, _) => + { + var maxContains = n.GetScalarValue(); + if (maxContains != null) + { + o.MaxContains = uint.Parse(maxContains, CultureInfo.InvariantCulture); + } + } + }, + { + "minContains", + (o, n, _, _) => + { + var minContains = n.GetScalarValue(); + if (minContains != null) + { + o.MinContains = uint.Parse(minContains, CultureInfo.InvariantCulture); + } + } + }, { "unevaluatedProperties", (o, n, t, c) => diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index ec66dcbb9..a6d7970ac 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -45,7 +45,13 @@ public async Task ParseBasicV31SchemaShouldSucceed() Items = new OpenApiSchema { Type = JsonSchemaType.String - } + }, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + MinContains = 1, + MaxContains = 5 }, ["vegetables"] = new OpenApiSchema { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json index 4a16ab4f5..4ee9fc8fa 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiSchema/jsonSchema.json @@ -8,7 +8,12 @@ "type": "array", "items": { "type": "string" - } + }, + "contains": { + "type": "string" + }, + "minContains": 1, + "maxContains": 5 }, "vegetables": { "type": "array" diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index 621cd156c..7d2997c9e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -44,7 +44,13 @@ public async Task ParseBasicV32SchemaShouldSucceed() Items = new OpenApiSchema { Type = JsonSchemaType.String - } + }, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String + }, + MinContains = 1, + MaxContains = 5 }, ["vegetables"] = new OpenApiSchema { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json index dc55b72c2..c14fee6c1 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiSchema/jsonSchema.json @@ -8,7 +8,12 @@ "type": "array", "items": { "type": "string" - } + }, + "contains": { + "type": "string" + }, + "minContains": 1, + "maxContains": 5 }, "vegetables": { "type": "array" diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs index 40034eded..8acc771a2 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiSchemaTests.cs @@ -523,6 +523,41 @@ public void OpenApiSchemaCopyConstructorWithUnevaluatedPropertiesSchemaSucceeds( Assert.Equal(100, baseSchema.UnevaluatedPropertiesSchema.MaxLength); } + [Fact] + public void OpenApiSchemaCopyConstructorWithContainsSucceeds() + { + var baseSchema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema + { + Type = JsonSchemaType.String, + MaxLength = 100 + }, + MinContains = 1, + MaxContains = 5 + }; + + var actualSchema = Assert.IsType(baseSchema.CreateShallowCopy()); + + // Verify scalar properties are copied + Assert.Equal(baseSchema.MinContains, actualSchema.MinContains); + Assert.Equal(baseSchema.MaxContains, actualSchema.MaxContains); + + // Verify schema property is copied + Assert.NotNull(actualSchema.Contains); + Assert.Equal(JsonSchemaType.String, actualSchema.Contains.Type); + Assert.Equal(100, actualSchema.Contains.MaxLength); + + // Verify it's a shallow copy (different object reference) + Assert.NotSame(baseSchema.Contains, actualSchema.Contains); + + // Verify that changing the copy doesn't affect the original + var actualContainsTyped = Assert.IsType(actualSchema.Contains); + actualContainsTyped.MaxLength = 200; + Assert.Equal(100, baseSchema.Contains.MaxLength); + } + public static TheoryData SchemaExamples() { return new() @@ -1164,6 +1199,75 @@ public async Task SerializeOneOfWithNullAndRefAsV3ShouldUseNullableAsync() Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); } + [Fact] + public async Task SerializeContainsKeywordsAsV31Works() + { + // Arrange + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV31(writer); + await writer.FlushAsync(); + + var v31Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV31Schema = + """ + { + "type": "array", + "contains": { + "type": "string" + }, + "maxContains": 5, + "minContains": 1 + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV31Schema), JsonNode.Parse(v31Schema))); + } + + [Fact] + public async Task SerializeContainsKeywordsAsV3DoesNotEmit() + { + // Arrange - contains/minContains/maxContains are JSON Schema 2020-12 keywords and have no equivalent in OpenAPI 3.0 + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Contains = new OpenApiSchema { Type = JsonSchemaType.String }, + MinContains = 1, + MaxContains = 5 + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = false }); + + // Act + schema.SerializeAsV3(writer); + await writer.FlushAsync(); + + var v3Schema = outputStringWriter.GetStringBuilder().ToString(); + + var expectedV3Schema = + """ + { + "type": "array" + } + """; + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedV3Schema), JsonNode.Parse(v3Schema))); + } + // UnevaluatedProperties tests - similar to AdditionalProperties pattern [Fact] public async Task SerializeUnevaluatedPropertiesBooleanDefaultDoesNotEmit() From 9672f95f2622761f88337c5a8804f92285eff2a9 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:42:15 -0400 Subject: [PATCH 11/22] chore(library): use constants for new schema keywords Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Reader/V31/OpenApiSchemaDeserializer.cs | 18 +++++++++--------- .../Reader/V32/OpenApiSchemaDeserializer.cs | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index f4b98234f..2c309d63e 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -45,7 +45,7 @@ internal static partial class OpenApiV31Deserializer (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) }, { - "$anchor", + OpenApiConstants.Anchor, (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { @@ -169,15 +169,15 @@ internal static partial class OpenApiV31Deserializer } }, { - "contentEncoding", + OpenApiConstants.ContentEncoding, (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() }, { - "contentMediaType", + OpenApiConstants.ContentMediaType, (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() }, { - "contentSchema", + OpenApiConstants.ContentSchema, (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) }, { @@ -266,7 +266,7 @@ internal static partial class OpenApiV31Deserializer (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, { - "propertyNames", + OpenApiConstants.PropertyNames, (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) }, { @@ -377,19 +377,19 @@ internal static partial class OpenApiV31Deserializer } }, { - "dependentSchemas", + OpenApiConstants.DependentSchemas, (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) }, { - "if", + OpenApiConstants.If, (o, n, doc, c) => o.If = LoadSchema(n, doc, c) }, { - "then", + OpenApiConstants.Then, (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) }, { - "else", + OpenApiConstants.Else, (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) }, }; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index dca4f339c..03bac2785 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -45,7 +45,7 @@ internal static partial class OpenApiV32Deserializer (o, n, t, c) => o.Definitions = n.CreateMap(LoadSchema, t, c) }, { - "$anchor", + OpenApiConstants.Anchor, (o, n, _, _) => o.Anchor = n.GetScalarValue() }, { @@ -169,15 +169,15 @@ internal static partial class OpenApiV32Deserializer } }, { - "contentEncoding", + OpenApiConstants.ContentEncoding, (o, n, _, _) => o.ContentEncoding = n.GetScalarValue() }, { - "contentMediaType", + OpenApiConstants.ContentMediaType, (o, n, _, _) => o.ContentMediaType = n.GetScalarValue() }, { - "contentSchema", + OpenApiConstants.ContentSchema, (o, n, doc, c) => o.ContentSchema = LoadSchema(n, doc, c) }, { @@ -266,7 +266,7 @@ internal static partial class OpenApiV32Deserializer (o, n, t, c) => o.PatternProperties = n.CreateMap(LoadSchema, t, c) }, { - "propertyNames", + OpenApiConstants.PropertyNames, (o, n, doc, c) => o.PropertyNames = LoadSchema(n, doc, c) }, { @@ -377,19 +377,19 @@ internal static partial class OpenApiV32Deserializer } }, { - "dependentSchemas", + OpenApiConstants.DependentSchemas, (o, n, t, c) => o.DependentSchemas = n.CreateMap(LoadSchema, t, c) }, { - "if", + OpenApiConstants.If, (o, n, doc, c) => o.If = LoadSchema(n, doc, c) }, { - "then", + OpenApiConstants.Then, (o, n, doc, c) => o.Then = LoadSchema(n, doc, c) }, { - "else", + OpenApiConstants.Else, (o, n, doc, c) => o.Else = LoadSchema(n, doc, c) }, }; From 68f9bd2fbf55fe85831b1ccd5cb51ff25920ad75 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 9 Jun 2026 12:59:10 -0400 Subject: [PATCH 12/22] chore(benchmark): refresh performance reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../performance.Descriptions-report-github.md | 16 ++--- .../performance.Descriptions-report.csv | 12 ++-- .../performance.Descriptions-report.html | 14 ++--- .../performance.Descriptions-report.json | 2 +- .../performance.EmptyModels-report-github.md | 60 +++++++++---------- .../performance.EmptyModels-report.csv | 56 ++++++++--------- .../performance.EmptyModels-report.html | 58 +++++++++--------- .../performance.EmptyModels-report.json | 2 +- 8 files changed, 110 insertions(+), 110 deletions(-) diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md index 9b5931f81..1b2eb289d 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report-github.md @@ -10,11 +10,11 @@ Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 ``` -| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | -|------------- |-------------:|--------------:|-------------:|-----------:|-----------:|----------:|-------------:| -| PetStoreYaml | 276.3 μs | 38.27 μs | 2.10 μs | 74.2188 | 11.7188 | - | 305.91 KB | -| PetStoreJson | 112.8 μs | 2.80 μs | 0.15 μs | 41.0156 | 0.4883 | - | 168.05 KB | -| GHESYaml | 608,668.3 μs | 188,763.29 μs | 10,346.75 μs | 44000.0000 | 18000.0000 | 3000.0000 | 250121.85 KB | -| GHESJson | 244,147.6 μs | 361,794.79 μs | 19,831.19 μs | 17000.0000 | 9000.0000 | 2000.0000 | 107293.42 KB | -| GHESNextYaml | 765,440.1 μs | 23,162.26 μs | 1,269.60 μs | 79000.0000 | 20000.0000 | 3000.0000 | 443655.46 KB | -| GHESNextJson | 435,329.2 μs | 241,612.89 μs | 13,243.62 μs | 51000.0000 | 11000.0000 | 2000.0000 | 305423.41 KB | +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | +|------------- |-------------:|--------------:|------------:|-----------:|-----------:|----------:|-------------:| +| PetStoreYaml | 371.5 μs | 35.60 μs | 1.95 μs | 74.2188 | 11.7188 | - | 307.17 KB | +| PetStoreJson | 155.7 μs | 10.23 μs | 0.56 μs | 41.0156 | 6.8359 | - | 169.31 KB | +| GHESYaml | 771,340.7 μs | 72,493.09 μs | 3,973.59 μs | 44000.0000 | 18000.0000 | 3000.0000 | 252535.98 KB | +| GHESJson | 308,100.8 μs | 132,615.87 μs | 7,269.12 μs | 17000.0000 | 9000.0000 | 2000.0000 | 109706.91 KB | +| GHESNextYaml | 999,238.5 μs | 116,421.98 μs | 6,381.48 μs | 80000.0000 | 20000.0000 | 3000.0000 | 446197.67 KB | +| GHESNextJson | 565,582.8 μs | 54,146.09 μs | 2,967.93 μs | 52000.0000 | 14000.0000 | 3000.0000 | 307956.73 KB | diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv index 6ca713e4b..655f0f4b4 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.csv @@ -1,7 +1,7 @@ Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Gen1,Gen2,Allocated -PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,276.3 μs,38.27 μs,2.10 μs,74.2188,11.7188,0.0000,305.91 KB -PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,112.8 μs,2.80 μs,0.15 μs,41.0156,0.4883,0.0000,168.05 KB -GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"608,668.3 μs","188,763.29 μs","10,346.75 μs",44000.0000,18000.0000,3000.0000,250121.85 KB -GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"244,147.6 μs","361,794.79 μs","19,831.19 μs",17000.0000,9000.0000,2000.0000,107293.42 KB -GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"765,440.1 μs","23,162.26 μs","1,269.60 μs",79000.0000,20000.0000,3000.0000,443655.46 KB -GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"435,329.2 μs","241,612.89 μs","13,243.62 μs",51000.0000,11000.0000,2000.0000,305423.41 KB +PetStoreYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,371.5 μs,35.60 μs,1.95 μs,74.2188,11.7188,0.0000,307.17 KB +PetStoreJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,155.7 μs,10.23 μs,0.56 μs,41.0156,6.8359,0.0000,169.31 KB +GHESYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"771,340.7 μs","72,493.09 μs","3,973.59 μs",44000.0000,18000.0000,3000.0000,252535.98 KB +GHESJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"308,100.8 μs","132,615.87 μs","7,269.12 μs",17000.0000,9000.0000,2000.0000,109706.91 KB +GHESNextYaml,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"999,238.5 μs","116,421.98 μs","6,381.48 μs",80000.0000,20000.0000,3000.0000,446197.67 KB +GHESNextJson,ShortRun,False,Default,Default,Default,Default,Default,Default,111111111111,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 8.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"565,582.8 μs","54,146.09 μs","2,967.93 μs",52000.0000,14000.0000,3000.0000,307956.73 KB diff --git a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html index a6a592c7b..b45bbfcc7 100644 --- a/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html +++ b/performance/benchmark/BenchmarkDotNet.Artifacts/results/performance.Descriptions-report.html @@ -2,7 +2,7 @@ -performance.Descriptions-20260526-120411 +performance.Descriptions-20260609-124950