diff --git a/global.json b/global.json index 12f5b9de6..a66c46e40 100644 --- a/global.json +++ b/global.json @@ -1,4 +1,4 @@ -{ +{ "sdk": { "version": "10.0.301" } diff --git a/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs b/src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs new file mode 100644 index 000000000..74f869da4 --- /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.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 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, 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..980f949be --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Converters/OpenApiSchemaJsonConverterTests.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +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"); + } + } +}