-
Notifications
You must be signed in to change notification settings - Fork 279
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| { | ||
| /// <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 readonly OpenApiSpecVersion _version; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting OpenAPI 3.1. | ||
| /// </summary> | ||
| public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_1) { } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default should be 3.2 like it is for serialization helpers. (maybe all those should rely on a common constant?) |
||
|
|
||
| /// <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"/> by temporarily | ||
| /// embedding it in a minimal OpenAPI 3.1 document for parsing. | ||
| /// </remarks> | ||
| public override OpenApiSchema? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||
| { | ||
| using var document = JsonDocument.ParseValue(ref reader); | ||
| var schemaJson = document.RootElement.GetRawText(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this ends up in a hot path, it'll lead to a lot of string allocations |
||
|
|
||
| var wrapper = string.Concat( | ||
| "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"temp\",\"version\":\"0.0.0\"},", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is going to break down based on the version that's passed in the constructor. |
||
| "\"components\":{\"schemas\":{\"schema\":", schemaJson, "}}}"); | ||
|
|
||
| var result = OpenApiDocument.Parse(wrapper); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not use the model factory instead of creating an empty document? |
||
| IOpenApiSchema? schema = null; | ||
| result.Document?.Components?.Schemas?.TryGetValue("schema", out schema); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This might break if any reference is used, the unit tests should at least account for this. |
||
| return schema as OpenApiSchema; | ||
| } | ||
|
|
||
| /// <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, 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)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
|
Comment on lines
+36
to
+37
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's use the assert API. I thought we had gotten rid of FluentAssertions? |
||
| } | ||
|
|
||
| [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); | ||
| 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<OpenApiSchema>(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<OpenApiSchema>(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<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); | ||
|
|
||
| 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<OpenApiSchema>(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"); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in theory this could be expanded to any model from this library (operations, path items, etc)