Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"sdk": {
"version": "10.0.301"
}
Expand Down
110 changes: 110 additions & 0 deletions src/Microsoft.OpenApi/Converters/OpenApiSchemaJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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;
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>

Copy link
Copy Markdown
Member

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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Generalizing to other models like operations and path items would be a great follow-up PR. Keeping this focused on OpenApiSchema for now since that's the use case from the issue

{
private readonly OpenApiSpecVersion _version;

/// <summary>
/// Initializes a new instance of <see cref="OpenApiSchemaJsonConverter"/> targeting OpenAPI 3.2.
/// </summary>
public OpenApiSchemaJsonConverter() : this(OpenApiSpecVersion.OpenApi3_2) { }

/// <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="OpenApiModelFactory"/> 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.");

using var document = JsonDocument.ParseValue(ref reader);
var schemaBytes = Encoding.UTF8.GetBytes(document.RootElement.GetRawText());

using var stream = new MemoryStream(schemaBytes);
var schema = OpenApiModelFactory.Load<OpenApiSchema>(
stream,
_version,
OpenApiConstants.Json,
new OpenApiDocument(),
out _);

return schema;
}

/// <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));
}
}
}
}
5 changes: 5 additions & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
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,178 @@
// 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<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_SchemaWithRef_ProducesInlinedSchema()
{
// OpenApiSchemaJsonConverter targets OpenApiSchema directly.
// When a schema contains a $ref via allOf, the referenced schema is inlined
// during serialization using the existing OpenAPI writer behavior.
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 _), "allOf should be present");
}
}
}