Skip to content

Commit 2f8b3d2

Browse files
Mahdiglnbaywet
andauthored
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 <vibiret@microsoft.com> * chore: restores system import Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: do not serialize BOM Signed-off-by: Vincent Biret <vibiret@microsoft.com> --------- Signed-off-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com>
1 parent 3764142 commit 2f8b3d2

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
using System;
5+
using System.IO;
6+
using System.Text;
7+
using System.Text.Json;
8+
using System.Text.Json.Nodes;
9+
using System.Text.Json.Serialization;
10+
using Microsoft.OpenApi.Reader;
11+
12+
namespace Microsoft.OpenApi
13+
{
14+
/// <summary>
15+
/// Enables System.Text.Json serialization and deserialization of <see cref="OpenApiSchema"/>
16+
/// using the OpenAPI wire format rather than the default reflection-based output.
17+
/// </summary>
18+
/// <remarks>
19+
/// <para>Register this converter via <see cref="JsonSerializerOptions.Converters"/>:</para>
20+
/// <code>
21+
/// var options = new JsonSerializerOptions();
22+
/// options.Converters.Add(new OpenApiSchemaJsonConverter());
23+
/// var json = JsonSerializer.Serialize(schema, options);
24+
/// </code>
25+
/// </remarks>
26+
public sealed class OpenApiSchemaJsonConverter : JsonConverter<OpenApiSchema>
27+
{
28+
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
29+
private readonly OpenApiSpecVersion _version;
30+
31+
/// <summary>
32+
/// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting OpenAPI 3.2.
33+
/// </summary>
34+
public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { }
35+
36+
/// <summary>
37+
/// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting the specified OpenAPI version.
38+
/// </summary>
39+
/// <param name="version">The OpenAPI specification version to use when serializing the schema.</param>
40+
public OpenApiSchemaJsonConverter(OpenApiSpecVersion version)
41+
{
42+
_version = version;
43+
}
44+
45+
/// <inheritdoc/>
46+
/// <remarks>
47+
/// Deserializes a bare JSON Schema object into an <see cref="OpenApiSchema"/> using
48+
/// <see cref="OpenApiJsonReader"/> to parse it as a schema fragment.
49+
/// Only OpenAPI 3.x versions support JSON Schema; deserializing with <see cref="OpenApiSpecVersion.OpenApi2_0"/>
50+
/// is not supported and will throw <see cref="NotSupportedException"/>.
51+
/// </remarks>
52+
public override OpenApiSchema? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
53+
{
54+
if (_version == OpenApiSpecVersion.OpenApi2_0)
55+
throw new NotSupportedException("Deserializing OpenApiSchema is not supported for OpenAPI 2.0.");
56+
57+
var jsonNode = JsonNode.Parse(ref reader)
58+
?? throw new JsonException("Failed to parse the JSON input into a valid JsonNode.");
59+
var jsonReader = new OpenApiJsonReader();
60+
return jsonReader.ReadFragment<OpenApiSchema>(jsonNode, _version, new OpenApiDocument(), out _);
61+
}
62+
63+
/// <inheritdoc/>
64+
public override void Write(Utf8JsonWriter writer, OpenApiSchema value, JsonSerializerOptions options)
65+
{
66+
Utils.CheckArgumentNull(writer);
67+
Utils.CheckArgumentNull(value);
68+
69+
using var stream = new MemoryStream();
70+
using (var textWriter = new StreamWriter(stream, Utf8NoBom, bufferSize: 1024, leaveOpen: true))
71+
{
72+
var openApiWriter = new OpenApiJsonWriter(textWriter);
73+
SerializeSchema(value, openApiWriter);
74+
textWriter.Flush();
75+
}
76+
77+
writer.WriteRawValue(stream.ToArray(), skipInputValidation: true);
78+
}
79+
80+
private void SerializeSchema(OpenApiSchema schema, OpenApiJsonWriter writer)
81+
{
82+
switch (_version)
83+
{
84+
case OpenApiSpecVersion.OpenApi3_2:
85+
schema.SerializeAsV32(writer);
86+
break;
87+
case OpenApiSpecVersion.OpenApi3_1:
88+
schema.SerializeAsV31(writer);
89+
break;
90+
case OpenApiSpecVersion.OpenApi3_0:
91+
schema.SerializeAsV3(writer);
92+
break;
93+
case OpenApiSpecVersion.OpenApi2_0:
94+
schema.SerializeAsV2(writer);
95+
break;
96+
default:
97+
throw new ArgumentOutOfRangeException(nameof(_version), _version,
98+
string.Format(SRResource.OpenApiSpecVersionNotSupported, _version));
99+
}
100+
}
101+
}
102+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
#nullable enable
2+
Microsoft.OpenApi.OpenApiSchemaJsonConverter
3+
Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter() -> void
4+
Microsoft.OpenApi.OpenApiSchemaJsonConverter.OpenApiSchemaJsonConverter(Microsoft.OpenApi.OpenApiSpecVersion version) -> void
5+
override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type! typeToConvert, System.Text.Json.JsonSerializerOptions! options) -> Microsoft.OpenApi.OpenApiSchema?
6+
override Microsoft.OpenApi.OpenApiSchemaJsonConverter.Write(System.Text.Json.Utf8JsonWriter! writer, Microsoft.OpenApi.OpenApiSchema! value, System.Text.Json.JsonSerializerOptions! options) -> void
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Text.Json;
7+
using Xunit;
8+
9+
namespace Microsoft.OpenApi.Tests.Converters
10+
{
11+
[Collection("DefaultSettings")]
12+
public class OpenApiSchemaJsonConverterTests
13+
{
14+
private static readonly JsonSerializerOptions _optionsV31 = new()
15+
{
16+
Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi3_1) }
17+
};
18+
19+
private static readonly JsonSerializerOptions _optionsV32 = new()
20+
{
21+
Converters = { new OpenApiSchemaJsonConverter() }
22+
};
23+
24+
[Fact]
25+
public void Serialize_SimpleStringSchema_ProducesOpenApiWireFormat()
26+
{
27+
var schema = new OpenApiSchema
28+
{
29+
Type = JsonSchemaType.String,
30+
Description = "A simple string"
31+
};
32+
33+
var json = JsonSerializer.Serialize(schema, _optionsV31);
34+
35+
using var doc = JsonDocument.Parse(json);
36+
Assert.Equal("string", doc.RootElement.GetProperty("type").GetString());
37+
Assert.Equal("A simple string", doc.RootElement.GetProperty("description").GetString());
38+
}
39+
40+
[Fact]
41+
public void Serialize_SchemaWithProperties_ProducesCorrectJson()
42+
{
43+
var schema = new OpenApiSchema
44+
{
45+
Type = JsonSchemaType.Object,
46+
Properties = new Dictionary<string, IOpenApiSchema>
47+
{
48+
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
49+
["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer }
50+
}
51+
};
52+
53+
var json = JsonSerializer.Serialize(schema, _optionsV31);
54+
55+
using var doc = JsonDocument.Parse(json);
56+
Assert.Equal("object", doc.RootElement.GetProperty("type").GetString());
57+
var props = doc.RootElement.GetProperty("properties");
58+
Assert.True(props.TryGetProperty("name", out _));
59+
Assert.True(props.TryGetProperty("age", out _));
60+
}
61+
62+
[Fact]
63+
public void Serialize_DefaultConstructor_TargetsV32()
64+
{
65+
var schema = new OpenApiSchema { Type = JsonSchemaType.Boolean };
66+
67+
var json = JsonSerializer.Serialize(schema, _optionsV32);
68+
69+
using var doc = JsonDocument.Parse(json);
70+
Assert.True(doc.RootElement.TryGetProperty("type", out _));
71+
}
72+
73+
[Fact]
74+
public void Deserialize_SimpleStringSchema_ReturnsCorrectSchema()
75+
{
76+
const string json = """{"type":"string","description":"A simple string"}""";
77+
78+
var schema = JsonSerializer.Deserialize<OpenApiSchema>(json, _optionsV31);
79+
80+
Assert.NotNull(schema);
81+
Assert.Equal(JsonSchemaType.String, schema.Type);
82+
Assert.Equal("A simple string", schema.Description);
83+
}
84+
85+
[Fact]
86+
public void Deserialize_SchemaWithEnum_ReturnsCorrectSchema()
87+
{
88+
const string json = """{"type":"string","enum":["active","inactive"]}""";
89+
90+
var schema = JsonSerializer.Deserialize<OpenApiSchema>(json, _optionsV31);
91+
92+
Assert.NotNull(schema);
93+
Assert.Equal(2, schema.Enum?.Count);
94+
}
95+
96+
[Fact]
97+
public void RoundTrip_ComplexSchema_PreservesData()
98+
{
99+
var original = new OpenApiSchema
100+
{
101+
Type = JsonSchemaType.Object,
102+
Title = "User",
103+
Description = "A user object",
104+
Required = new System.Collections.Generic.HashSet<string> { "name" },
105+
Properties = new Dictionary<string, IOpenApiSchema>
106+
{
107+
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
108+
["age"] = new OpenApiSchema { Type = JsonSchemaType.Integer | JsonSchemaType.Null }
109+
}
110+
};
111+
112+
var json = JsonSerializer.Serialize(original, _optionsV31);
113+
var deserialized = JsonSerializer.Deserialize<OpenApiSchema>(json, _optionsV31);
114+
115+
Assert.NotNull(deserialized);
116+
Assert.Equal("User", deserialized.Title);
117+
Assert.Equal("A user object", deserialized.Description);
118+
Assert.True(deserialized.Properties?.ContainsKey("name"));
119+
Assert.True(deserialized.Properties?.ContainsKey("age"));
120+
}
121+
122+
[Fact]
123+
public void Serialize_NullSchema_WritesNullLiteral()
124+
{
125+
// System.Text.Json handles null at the serializer level before invoking the converter.
126+
var json = JsonSerializer.Serialize<OpenApiSchema>(null!, _optionsV31);
127+
128+
Assert.Equal("null", json);
129+
}
130+
131+
[Fact]
132+
public void Serialize_V31Schema_IncludesJsonSchemaKeywords()
133+
{
134+
var schema = new OpenApiSchema
135+
{
136+
Type = JsonSchemaType.String,
137+
Id = "https://example.com/schema"
138+
};
139+
140+
var json = JsonSerializer.Serialize(schema, _optionsV31);
141+
142+
using var doc = JsonDocument.Parse(json);
143+
Assert.True(doc.RootElement.TryGetProperty("$id", out _), "$id is a v3.1 JSON Schema keyword");
144+
}
145+
146+
[Fact]
147+
public void Deserialize_WithV2Version_ThrowsNotSupportedException()
148+
{
149+
const string json = """{"type":"string"}""";
150+
var optionsV2 = new JsonSerializerOptions
151+
{
152+
Converters = { new OpenApiSchemaJsonConverter(OpenApiSpecVersion.OpenApi2_0) }
153+
};
154+
155+
Assert.Throws<NotSupportedException>(() =>
156+
JsonSerializer.Deserialize<OpenApiSchema>(json, optionsV2));
157+
}
158+
159+
[Fact]
160+
public void Serialize_SchemaWithAllOf_ProducesCorrectJson()
161+
{
162+
var schema = new OpenApiSchema
163+
{
164+
AllOf =
165+
[
166+
new OpenApiSchema { Type = JsonSchemaType.String }
167+
]
168+
};
169+
170+
var json = JsonSerializer.Serialize(schema, _optionsV31);
171+
172+
using var doc = JsonDocument.Parse(json);
173+
Assert.True(doc.RootElement.TryGetProperty("allOf", out _));
174+
}
175+
176+
[Fact]
177+
public void Serialize_SchemaWithInlineReference_ProducesRefInAllOf()
178+
{
179+
// A schema that uses an OpenApiSchemaReference inside allOf produces a $ref in the output.
180+
var document = new OpenApiDocument();
181+
document.Components = new OpenApiComponents
182+
{
183+
Schemas = new Dictionary<string, IOpenApiSchema>
184+
{
185+
["MySchema"] = new OpenApiSchema { Type = JsonSchemaType.String }
186+
}
187+
};
188+
document.RegisterComponents();
189+
190+
var schema = new OpenApiSchema
191+
{
192+
AllOf = [new OpenApiSchemaReference("MySchema", document)]
193+
};
194+
195+
var json = JsonSerializer.Serialize(schema, _optionsV31);
196+
197+
using var doc = JsonDocument.Parse(json);
198+
Assert.True(doc.RootElement.TryGetProperty("allOf", out var allOf));
199+
var firstItem = allOf.EnumerateArray().GetEnumerator();
200+
Assert.True(firstItem.MoveNext());
201+
Assert.True(firstItem.Current.TryGetProperty("$ref", out _), "allOf item should contain a $ref");
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)