-
Notifications
You must be signed in to change notification settings - Fork 280
feat: add JsonConverter for OpenApiSchema System.Text.Json serialization #2915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
baywet
merged 8 commits into
microsoft:main
from
Mahdigln:feature/openapi-schema-json-converter
Jul 2, 2026
+311
−0
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a0bd76f
feat: add JsonConverter for OpenApiSchema System.Text.Json serialization
Mahdigln 5fe96db
fix: address review comments on OpenApiSchemaJsonConverter
Mahdigln 375d44f
refactor: pass JsonNode directly to reader and improve reference tests
Mahdigln 8228557
fix: throw explicit JsonException on null node and add reference regr…
Mahdigln 87c5877
chore: reverts noisy global.json change
baywet 422275b
chore: reduce allocations during serialization
baywet 85be2db
chore: restores system import
baywet 1132144
fix: do not serialize BOM
baywet File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <summary> | ||
| /// Enables System.Text.Json serialization and deserialization of <see cref="OpenApiSchema"/> | ||
| /// using the OpenAPI wire format rather than the default reflection-based output. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para>Register this converter via <see cref="JsonSerializerOptions.Converters"/>:</para> | ||
| /// <code> | ||
| /// var options = new JsonSerializerOptions(); | ||
| /// options.Converters.Add(new OpenApiSchemaJsonConverter()); | ||
| /// var json = JsonSerializer.Serialize(schema, options); | ||
| /// </code> | ||
| /// </remarks> | ||
| public sealed class OpenApiSchemaJsonConverter : JsonConverter<OpenApiSchema> | ||
| { | ||
| private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); | ||
| private readonly OpenApiSpecVersion _version; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting OpenAPI 3.2. | ||
| /// </summary> | ||
| public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { } | ||
|
baywet marked this conversation as resolved.
|
||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting the specified OpenAPI version. | ||
| /// </summary> | ||
| /// <param name="version">The OpenAPI specification version to use when serializing the schema.</param> | ||
| public OpenApiSchemaJsonConverter(OpenApiSpecVersion version) | ||
| { | ||
| _version = version; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| /// <remarks> | ||
| /// Deserializes a bare JSON Schema object into an <see cref="OpenApiSchema"/> using | ||
| /// <see cref="OpenApiJsonReader"/> to parse it as a schema fragment. | ||
| /// Only OpenAPI 3.x versions support JSON Schema; deserializing with <see cref="OpenApiSpecVersion.OpenApi2_0"/> | ||
| /// is not supported and will throw <see cref="NotSupportedException"/>. | ||
| /// </remarks> | ||
| 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<OpenApiSchema>(jsonNode, _version, new OpenApiDocument(), out _); | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| 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)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
204 changes: 204 additions & 0 deletions
204
test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, IOpenApiSchema> | ||
| { | ||
| ["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<OpenApiSchema>(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<OpenApiSchema>(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<string> { "name" }, | ||
| Properties = new Dictionary<string, IOpenApiSchema> | ||
| { | ||
| ["name"] = new OpenApiSchema { Type = JsonSchemaType.String }, | ||
| ["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer | JsonSchemaType.Null } | ||
| } | ||
| }; | ||
|
|
||
| var json = JsonSerializer.Serialize(original, _optionsV31); | ||
| var deserialized = JsonSerializer.Deserialize<OpenApiSchema>(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<OpenApiSchema>(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<NotSupportedException>(() => | ||
| JsonSerializer.Deserialize<OpenApiSchema>(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<string, IOpenApiSchema> | ||
| { | ||
| ["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"); | ||
| } | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.