From a0bd76f510a934f3374354db681b05a9e62f5587 Mon Sep 17 00:00:00 2001 From: Mahdi Golestan Date: Mon, 29 Jun 2026 13:35:45 +0330 Subject: [PATCH] feat: add JsonConverter for OpenApiSchema System.Text.Json serialization --- .../Converters/OpenApiSchemaJsonConverter.cs | 103 ++++++++++++ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 5 + .../OpenApiSchemaJsonConverterTests.cs | 147 ++++++++++++++++++ 3 files changed, 255 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..37e2d8b55 --- /dev/null +++ b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs @@ -0,0 +1,103 @@ +// 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.Serialization; + +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 readonly OpenApiSpecVersion _version; + + /// + /// Initializes a new instance of targeting OpenAPI 3.1. + /// + public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_1) { } + + /// + /// 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 by temporarily + /// embedding it in a minimal OpenAPI 3.1 document for parsing. + /// + public override OpenApiSchema? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var schemaJson = document.RootElement.GetRawText(); + + var wrapper = string.Concat( + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"temp\",\"version\":\"0.0.0\"},", + "\"components\":{\"schemas\":{\"schema\":", schemaJson, "}}}"); + + var result = OpenApiDocument.Parse(wrapper); + IOpenApiSchema? schema = null; + result.Document?.Components?.Schemas?.TryGetValue("schema", out schema); + return schema as OpenApiSchema; + } + + /// + 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, Encoding.UTF8, bufferSize: 1024, leaveOpen: true)) + { + var openApiWriter = new OpenApiJsonWriter(textWriter); + SerializeSchema(value, openApiWriter); + textWriter.Flush(); + } + + stream.Position = 0; + using var document = JsonDocument.Parse(stream); + document.RootElement.WriteTo(writer); + } + + 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..18454f54c --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using FluentAssertions; +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 _optionsV3 = new() + { + Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi3_0) } + }; + + [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); + doc.RootElement.GetProperty("type").GetString().Should().Be("string"); + doc.RootElement.GetProperty("description").GetString().Should().Be("A simple string"); + } + + [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); + doc.RootElement.GetProperty("type").GetString().Should().Be("object"); + doc.RootElement.GetProperty("properties").EnumerateObject().Should().HaveCount(2); + } + + [Fact] + public void Serialize_DefaultConstructor_TargetsV31() + { + var converter = new OpenApiSchemaJsonConverter(); + var options = new JsonSerializerOptions { Converters = { converter } }; + + var schema = new OpenApiSchema { Type = JsonSchemaType.Boolean }; + var json = JsonSerializer.Serialize(schema, options); + + json.Should().Contain("\"type\""); + } + + [Fact] + public void Deserialize_SimpleStringSchema_ReturnsCorrectSchema() + { + const string json = """{"type":"string","description":"A simple string"}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + schema.Should().NotBeNull(); + schema!.Type.Should().Be(JsonSchemaType.String); + schema.Description.Should().Be("A simple string"); + } + + [Fact] + public void Deserialize_SchemaWithEnum_ReturnsCorrectSchema() + { + const string json = """{"type":"string","enum":["active","inactive"]}"""; + + var schema = JsonSerializer.Deserialize(json, _optionsV31); + + schema.Should().NotBeNull(); + schema!.Enum.Should().HaveCount(2); + } + + [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); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be("User"); + deserialized.Description.Should().Be("A user object"); + deserialized.Properties.Should().ContainKey("name"); + deserialized.Properties.Should().ContainKey("age"); + } + + [Fact] + public void Serialize_NullSchema_WritesNullLiteral() + { + // System.Text.Json handles null at the serializer level before invoking the converter, + // producing a JSON null literal rather than throwing. + var json = JsonSerializer.Serialize(null!, _optionsV31); + + json.Should().Be("null"); + } + + [Fact] + public void Serialize_V31Schema_IncludesJsonSchemaKeywords() + { + var schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Id = "https://example.com/schema" + }; + + var jsonV31 = JsonSerializer.Serialize(schema, _optionsV31); + + using var doc = JsonDocument.Parse(jsonV31); + // $id is a JSON Schema 2020-12 keyword only written in v3.1+ + doc.RootElement.TryGetProperty("$id", out _).Should().BeTrue("$id is a v3.1 JSON Schema keyword"); + } + } +}