From 465521ba723ea9377d6838395d58e7212d64ece7 Mon Sep 17 00:00:00 2001 From: Mahdigln <139457032+Mahdigln@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:13:40 +0330 Subject: [PATCH 1/2] feat: add JsonConverter for OpenApiSchema System.Text.Json serialization (#2915) * feat: add JsonConverter for OpenApiSchema System.Text.Json serialization * fix: address review comments on OpenApiSchemaJsonConverter * refactor: pass JsonNode directly to reader and improve reference tests * fix: throw explicit JsonException on null node and add reference regression test * chore: reverts noisy global.json change * chore: reduce allocations during serialization Signed-off-by: Vincent Biret * chore: restores system import Signed-off-by: Vincent Biret * fix: do not serialize BOM Signed-off-by: Vincent Biret --------- Signed-off-by: Vincent Biret Co-authored-by: Vincent Biret --- .../Converters/OpenApiSchemaJsonConverter.cs | 102 +++++++++ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 5 + .../OpenApiSchemaJsonConverterTests.cs | 204 ++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs create mode 100644 test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs diff --git a/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs new file mode 100644 index 000000000..2523fdd6b --- /dev/null +++ b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.OpenApi.Reader; + +namespace Microsoft.OpenApi +{ + /// + /// Enables System.Text.Json serialization and deserialization of + /// using the OpenAPI wire format rather than the default reflection-based output. + /// + /// + /// Register this converter via : + /// + /// var options = new JsonSerializerOptions(); + /// options.Converters.Add(new OpenApiSchemaJsonConverter()); + /// var json = JsonSerializer.Serialize(schema, options); + /// + /// + public sealed class OpenApiSchemaJsonConverter : JsonConverter + { + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private readonly OpenApiSpecVersion _version; + + /// + /// Initializes a new instance of targeting OpenAPI 3.2. + /// + public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { } + + /// + /// Initializes a new instance of targeting the specified OpenAPI version. + /// + /// The OpenAPI specification version to use when serializing the schema. + public OpenApiSchemaJsonConverter(OpenApiSpecVersion version) + { + _version = version; + } + + /// + /// + /// Deserializes a bare JSON Schema object into an using + /// to parse it as a schema fragment. + /// Only OpenAPI 3.x versions support JSON Schema; deserializing with + /// is not supported and will throw . + /// + public override OpenApiSchema? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (_version == OpenApiSpecVersion.OpenApi2_0) + throw new NotSupportedException("Deserializing OpenApiSchema is not supported for OpenAPI 2.0."); + + var jsonNode = JsonNode.Parse(ref reader) + ?? throw new JsonException("Failed to parse the JSON input into a valid JsonNode."); + var jsonReader = new OpenApiJsonReader(); + return jsonReader.ReadFragment(jsonNode, _version, new OpenApiDocument(), out _); + } + + /// + public override void Write(Utf8JsonWriter writer, OpenApiSchema value, JsonSerializerOptions options) + { + Utils.CheckArgumentNull(writer); + Utils.CheckArgumentNull(value); + + using var stream = new MemoryStream(); + using (var textWriter = new StreamWriter(stream, Utf8NoBom, bufferSize: 1024, leaveOpen: true)) + { + var openApiWriter = new OpenApiJsonWriter(textWriter); + SerializeSchema(value, openApiWriter); + textWriter.Flush(); + } + + writer.WriteRawValue(stream.ToArray(), skipInputValidation: true); + } + + private void SerializeSchema(OpenApiSchema schema, OpenApiJsonWriter writer) + { + switch (_version) + { + case OpenApiSpecVersion.OpenApi3_2: + schema.SerializeAsV32(writer); + break; + case OpenApiSpecVersion.OpenApi3_1: + schema.SerializeAsV31(writer); + break; + case OpenApiSpecVersion.OpenApi3_0: + schema.SerializeAsV3(writer); + break; + case OpenApiSpecVersion.OpenApi2_0: + schema.SerializeAsV2(writer); + break; + default: + throw new ArgumentOutOfRangeException(nameof(_version), _version, + string.Format(SRResource.OpenApiSpecVersionNotSupported, _version)); + } + } + } +} diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..9dee238ef 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,6 @@ #nullable enable +Microsoft.OpenApi.OpenApiSchemaJsonConverter +Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter() -> void +Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter(Microsoft.OpenApi.OpenApiSpecVersion version) -> void +override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type! typeToConvert, System.Text.Json.JsonSerializerOptions! options) -> Microsoft.OpenApi.OpenApiSchema? +override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Write(System.Text.Json.Utf8JsonWriter! writer, Microsoft.OpenApi.OpenApiSchema! value, System.Text.Json.JsonSerializerOptions! options) -> void diff --git a/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs new file mode 100644 index 000000000..255a1c1ed --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Converters +{ + [Collection("DefaultSettings")] + public class OpenApiSchemaJsonConverterTests + { + private static readonly JsonSerializerOptions _optionsV31 = new() + { + Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi3_1) } + }; + + private static readonly JsonSerializerOptions _optionsV32 = new() + { + Converters = { new OpenApiSchemaJsonConverter() } + }; + + [Fact] + public void Serialize_SimpleStringSchema_ProducesOpenApiWireFormat() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "A simple string" + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.Equal("string", doc.RootElement.GetProperty("type").GetString()); + Assert.Equal("A simple string", doc.RootElement.GetProperty("description").GetString()); + } + + [Fact] + public void Serialize_SchemaWithProperties_ProducesCorrectJson() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.Equal("object", doc.RootElement.GetProperty("type").GetString()); + var props = doc.RootElement.GetProperty("properties"); + Assert.True(props.TryGetProperty("name", out _)); + Assert.True(props.TryGetProperty("age", out _)); + } + + [Fact] + public void Serialize_DefaultConstructor_TargetsV32() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Boolean }; + + var json = JsonSerializer.Serialize(schema, _optionsV32); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("type", out _)); + } + + [Fact] + public void Deserialize_SimpleStringSchema_ReturnsCorrectSchema() + { + const string json = """{"type":"string","description":"A simple string"}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(schema); + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("A simple string", schema.Description); + } + + [Fact] + public void Deserialize_SchemaWithEnum_ReturnsCorrectSchema() + { + const string json = """{"type":"string","enum":["active","inactive"]}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(schema); + Assert.Equal(2, schema.Enum?.Count); + } + + [Fact] + public void RoundTrip_ComplexSchema_PreservesData() + { + var original = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Title = "User", + Description = "A user object", + Required = new System.Collections.Generic.HashSet { "name" }, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer | JsonSchemaType.Null } + } + }; + + var json = JsonSerializer.Serialize(original, _optionsV31); + var deserialized = JsonSerializer.Deserialize(json, _optionsV31); + + Assert.NotNull(deserialized); + Assert.Equal("User", deserialized.Title); + Assert.Equal("A user object", deserialized.Description); + Assert.True(deserialized.Properties?.ContainsKey("name")); + Assert.True(deserialized.Properties?.ContainsKey("age")); + } + + [Fact] + public void Serialize_NullSchema_WritesNullLiteral() + { + // System.Text.Json handles null at the serializer level before invoking the converter. + var json = JsonSerializer.Serialize(null!, _optionsV31); + + Assert.Equal("null", json); + } + + [Fact] + public void Serialize_V31Schema_IncludesJsonSchemaKeywords() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Id = "https://example.com/schema" + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("$id", out _), "$id is a v3.1 JSON Schema keyword"); + } + + [Fact] + public void Deserialize_WithV2Version_ThrowsNotSupportedException() + { + const string json = """{"type":"string"}"""; + var optionsV2 = new JsonSerializerOptions + { + Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi2_0) } + }; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, optionsV2)); + } + + [Fact] + public void Serialize_SchemaWithAllOf_ProducesCorrectJson() + { + var schema = new OpenApiSchema + { + AllOf = + [ + new OpenApiSchema { Type = JsonSchemaType.String } + ] + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("allOf", out _)); + } + + [Fact] + public void Serialize_SchemaWithInlineReference_ProducesRefInAllOf() + { + // A schema that uses an OpenApiSchemaReference inside allOf produces a $ref in the output. + var document = new OpenApiDocument(); + document.Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["MySchema"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }; + document.RegisterComponents(); + + var schema = new OpenApiSchema + { + AllOf = [new OpenApiSchemaReference("MySchema", document)] + }; + + var json = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("allOf", out var allOf)); + var firstItem = allOf.EnumerateArray().GetEnumerator(); + Assert.True(firstItem.MoveNext()); + Assert.True(firstItem.Current.TryGetProperty("$ref", out _), "allOf item should contain a $ref"); + } + } +} From ebe93ff44ef374a4d7867e98d973cdcaeac6a41f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 2 Jul 2026 12:45:59 -0400 Subject: [PATCH 2/2] chore; removes 3.2 references after cherry-pick Signed-off-by: Vincent Biret --- .../Converters/OpenApiSchemaJsonConverter.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs index 2523fdd6b..c9c867d4d 100644 --- a/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs +++ b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs @@ -29,9 +29,9 @@ public sealed class OpenApiSchemaJsonConverter : JsonConverter private readonly OpenApiSpecVersion _version; /// - /// Initializes a new instance of targeting OpenAPI 3.2. + /// Initializes a new instance of targeting OpenAPI 3.1. /// - public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { } + public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_1) { } /// /// Initializes a new instance of targeting the specified OpenAPI version. @@ -81,9 +81,6 @@ private void SerializeSchema(OpenApiSchema schema, OpenApiJsonWriter writer) { switch (_version) { - case OpenApiSpecVersion.OpenApi3_2: - schema.SerializeAsV32(writer); - break; case OpenApiSpecVersion.OpenApi3_1: schema.SerializeAsV31(writer); break;