Skip to content

Add CreateJson overloads to BinaryContent for simplified JSON serialization #50921

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
merged 11 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ protected AuthenticationTokenProvider() { }
public abstract partial class BinaryContent : System.IDisposable
{
protected BinaryContent() { }
public string? MediaType { get { throw null; } protected set { } }
public static System.ClientModel.BinaryContent Create(System.BinaryData value) { throw null; }
public static System.ClientModel.BinaryContent Create(System.IO.Stream stream) { throw null; }
public static System.ClientModel.BinaryContent CreateJson(string jsonString, bool validate = false) { throw null; }
public static System.ClientModel.BinaryContent CreateJson<T>(T jsonSerializable, System.Text.Json.JsonSerializerOptions? options = null) { throw null; }
public static System.ClientModel.BinaryContent CreateJson<T>(T jsonSerializable, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> jsonTypeInfo) { throw null; }
public static System.ClientModel.BinaryContent Create<T>(T model, System.ClientModel.Primitives.ModelReaderWriterOptions? options = null) where T : System.ClientModel.Primitives.IPersistableModel<T> { throw null; }
public abstract void Dispose();
public abstract bool TryComputeLength(out long length);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ protected AuthenticationTokenProvider() { }
public abstract partial class BinaryContent : System.IDisposable
{
protected BinaryContent() { }
public string? MediaType { get { throw null; } protected set { } }
public static System.ClientModel.BinaryContent Create(System.BinaryData value) { throw null; }
public static System.ClientModel.BinaryContent Create(System.IO.Stream stream) { throw null; }
public static System.ClientModel.BinaryContent CreateJson(string jsonString, bool validate = false) { throw null; }
public static System.ClientModel.BinaryContent CreateJson<T>(T jsonSerializable, System.Text.Json.JsonSerializerOptions? options = null) { throw null; }
public static System.ClientModel.BinaryContent CreateJson<T>(T jsonSerializable, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> jsonTypeInfo) { throw null; }
public static System.ClientModel.BinaryContent Create<T>(T model, System.ClientModel.Primitives.ModelReaderWriterOptions? options = null) where T : System.ClientModel.Primitives.IPersistableModel<T> { throw null; }
public abstract void Dispose();
public abstract bool TryComputeLength(out long length);
Expand Down
101 changes: 100 additions & 1 deletion sdk/core/System.ClientModel/src/Message/BinaryContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.ClientModel.Internal;
using System.ClientModel.Primitives;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -18,6 +20,11 @@ public abstract class BinaryContent : IDisposable
{
private static readonly ModelReaderWriterOptions ModelWriteWireOptions = new ModelReaderWriterOptions("W");

/// <summary>
/// Gets the media type of the content.
/// </summary>
public string? MediaType { get; protected set; }

/// <summary>
/// Creates an instance of <see cref="BinaryContent"/> that contains the
/// bytes held in the provided <see cref="BinaryData"/> instance.
Expand Down Expand Up @@ -65,6 +72,89 @@ public static BinaryContent Create(Stream stream)
return new StreamBinaryContent(stream);
}

/// <summary>
/// Creates an instance of <see cref="BinaryContent"/> that contains the
/// JSON representation of the provided object.
/// </summary>
/// <typeparam name="T">The type of the object to serialize.</typeparam>
/// <param name="jsonSerializable">The object to serialize to JSON.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> to use for serialization.
/// If not provided, the default options will be used.</param>
/// <returns>An instance of <see cref="BinaryContent"/> that contains the
/// JSON representation of the provided object.</returns>
#pragma warning disable AZC0014 // Avoid using banned types in public API
public static BinaryContent CreateJson<T>(T jsonSerializable, JsonSerializerOptions? options = default)
#pragma warning restore AZC0014 // Avoid using banned types in public API
{
Argument.AssertNotNull(jsonSerializable, nameof(jsonSerializable));

BinaryData data = BinaryData.FromObjectAsJson(jsonSerializable, options);
return new BinaryDataBinaryContent(data.ToMemory(), "application/json");
}

/// <summary>
/// Creates an instance of <see cref="BinaryContent"/> that contains the
/// JSON representation of the provided object using the specified JSON type information.
/// </summary>
/// <typeparam name="T">The type of the object to serialize.</typeparam>
/// <param name="jsonSerializable">The object to serialize to JSON.</param>
/// <param name="jsonTypeInfo">The <see cref="JsonTypeInfo{T}"/> to use for serialization.</param>
/// <returns>An instance of <see cref="BinaryContent"/> that contains the
/// JSON representation of the provided object.</returns>
#pragma warning disable AZC0014 // Avoid using banned types in public API
public static BinaryContent CreateJson<T>(T jsonSerializable, JsonTypeInfo<T> jsonTypeInfo)
#pragma warning restore AZC0014 // Avoid using banned types in public API
{
Argument.AssertNotNull(jsonSerializable, nameof(jsonSerializable));
Argument.AssertNotNull(jsonTypeInfo, nameof(jsonTypeInfo));

BinaryData data = BinaryData.FromObjectAsJson(jsonSerializable, jsonTypeInfo);
return new BinaryDataBinaryContent(data.ToMemory(), "application/json");
}

/// <summary>
/// Creates an instance of <see cref="BinaryContent"/> that contains the
/// provided JSON string.
/// </summary>
/// <param name="jsonString">The JSON string to be used as the content.</param>
/// <param name="validate">Whether to validate that the string contains valid JSON.
/// If true, the method will validate the JSON format and throw an exception if invalid.
/// Defaults to false for backwards compatibility.</param>
/// <returns>An instance of <see cref="BinaryContent"/> that contains the
/// provided JSON string.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="validate"/> is true and
/// <paramref name="jsonString"/> is not valid JSON.</exception>
public static BinaryContent CreateJson(string jsonString, bool validate = false)
{
Argument.AssertNotNull(jsonString, nameof(jsonString));

if (validate)
{
ValidateJsonString(jsonString);
}

BinaryData data = BinaryData.FromString(jsonString);
return new BinaryDataBinaryContent(data.ToMemory(), "application/json");
}

private static void ValidateJsonString(string jsonString)
{
try
{
var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(jsonString));

// Skip through the entire JSON document to validate it's well-formed
while (reader.Read())
{
// The Read() method will throw JsonException if the JSON is malformed
}
}
catch (JsonException ex)
{
throw new ArgumentException($"The provided string is not valid JSON: {ex.Message}", nameof(jsonString), ex);
}
}

/// <summary>
/// Attempts to compute the length of the underlying body content, if available.
/// </summary>
Expand Down Expand Up @@ -96,9 +186,10 @@ private sealed class BinaryDataBinaryContent : BinaryContent
{
private readonly ReadOnlyMemory<byte> _bytes;

public BinaryDataBinaryContent(ReadOnlyMemory<byte> bytes)
public BinaryDataBinaryContent(ReadOnlyMemory<byte> bytes, string? mediaType = null)
{
_bytes = bytes;
MediaType = mediaType;
}

public override bool TryComputeLength(out long length)
Expand Down Expand Up @@ -140,6 +231,14 @@ public ModelBinaryContent(T model, ModelReaderWriterOptions options)
{
_model = model;
_options = options;

// Set MediaType to JSON if the model will be serialized as JSON
// This checks if JSON format is requested, either explicitly ("J") or
// via wire format ("W") where the model returns "J" as its preferred format
if (options.Format == "J" || (options.Format == "W" && model.GetFormatFromOptions(options) == "J"))
{
MediaType = "application/json";
}
}

private UnsafeBufferSequence.Reader SequenceReader
Expand Down
186 changes: 186 additions & 0 deletions sdk/core/System.ClientModel/tests/Message/BinaryContentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.ClientModel.Primitives;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core.TestFramework;
Expand All @@ -26,6 +27,7 @@ public void CanGetLengthFromBinaryDataBinaryContent()
BinaryData data = BinaryData.FromString(value);
using BinaryContent content = BinaryContent.Create(data);

Assert.IsNull(content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(value.Length, length);
}
Expand All @@ -50,6 +52,7 @@ public void CanGetLengthFromModelBinaryContent()
MockPersistableModel model = new MockPersistableModel(404, "abcde");
using BinaryContent content = BinaryContent.Create(model);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(model.SerializedValue.Length, length);
}
Expand All @@ -60,6 +63,8 @@ public async Task CanWriteToStreamFromModelBinaryContent()
MockPersistableModel model = new MockPersistableModel(404, "abcde");
using BinaryContent content = BinaryContent.Create(model);

Assert.AreEqual("application/json", content.MediaType);

MemoryStream stream = new MemoryStream();
await content.WriteToSyncOrAsync(stream, CancellationToken.None, IsAsync);

Expand All @@ -75,6 +80,7 @@ public void CanGetLengthFromJsonModelBinaryContent()
MockJsonModel model = new MockJsonModel(404, "abcde");
using BinaryContent content = BinaryContent.Create(model, ModelReaderWriterOptions.Json);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(model.Utf8BytesValue.Length, length);
}
Expand All @@ -85,6 +91,8 @@ public async Task CanWriteToStreamFromJsonModelBinaryContent()
MockJsonModel model = new MockJsonModel(404, "abcde");
using BinaryContent content = BinaryContent.Create(model, ModelReaderWriterOptions.Json);

Assert.AreEqual("application/json", content.MediaType);

MemoryStream contentStream = new MemoryStream();
await content.WriteToSyncOrAsync(contentStream, CancellationToken.None, IsAsync);

Expand Down Expand Up @@ -156,4 +164,182 @@ public void StreamBinaryContentMustBeSeekable()

Assert.Throws<ArgumentException>(() => { BinaryContent.Create(stream); });
}

[Test]
public async Task CanCreateAndWriteJsonBinaryContentFromObject()
{
var testObject = new { Name = "test", Value = 42 };
using BinaryContent content = BinaryContent.CreateJson(testObject);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.Greater(length, 0);

MemoryStream stream = new MemoryStream();
await content.WriteToSyncOrAsync(stream, CancellationToken.None, IsAsync);

string json = System.Text.Encoding.UTF8.GetString(stream.ToArray());
Assert.IsTrue(json.Contains("test"));
Assert.IsTrue(json.Contains("42"));
}

[Test]
public async Task CanCreateAndWriteJsonBinaryContentWithOptions()
{
var testObject = new { Name = "TEST", Value = 42 };
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

using BinaryContent content = BinaryContent.CreateJson(testObject, options);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.Greater(length, 0);

MemoryStream stream = new MemoryStream();
await content.WriteToSyncOrAsync(stream, CancellationToken.None, IsAsync);

string json = System.Text.Encoding.UTF8.GetString(stream.ToArray());
// With camelCase naming policy, "Name" should become "name"
Assert.IsTrue(json.Contains("name"));
Assert.IsTrue(json.Contains("value"));
}

[Test]
public void JsonBinaryContentMatchesBinaryDataFromObjectAsJson()
{
var testObject = new { Name = "test", Value = 42 };

// Create using the new CreateJson method
using BinaryContent content = BinaryContent.CreateJson(testObject);

// Create using the existing pattern
BinaryData binaryData = BinaryData.FromObjectAsJson(testObject);
using BinaryContent expectedContent = BinaryContent.Create(binaryData);

// They should have the same length
Assert.IsTrue(content.TryComputeLength(out long contentLength));
Assert.IsTrue(expectedContent.TryComputeLength(out long expectedLength));
Assert.AreEqual(expectedLength, contentLength);
}

[Test]
public async Task CanCreateAndWriteJsonBinaryContentFromString()
{
string jsonString = """{"name":"test","value":42}""";
using BinaryContent content = BinaryContent.CreateJson(jsonString);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(jsonString.Length, length);

MemoryStream stream = new MemoryStream();
await content.WriteToSyncOrAsync(stream, CancellationToken.None, IsAsync);

string result = System.Text.Encoding.UTF8.GetString(stream.ToArray());
Assert.AreEqual(jsonString, result);
}

[Test]
public void JsonStringBinaryContentMatchesBinaryDataFromString()
{
string jsonString = """{"name":"test","value":42}""";

// Create using the new CreateJson string method
using BinaryContent content = BinaryContent.CreateJson(jsonString);

// Create using the existing pattern
BinaryData binaryData = BinaryData.FromString(jsonString);
using BinaryContent expectedContent = BinaryContent.Create(binaryData);

// They should have the same length
Assert.IsTrue(content.TryComputeLength(out long contentLength));
Assert.IsTrue(expectedContent.TryComputeLength(out long expectedLength));
Assert.AreEqual(expectedLength, contentLength);

// Content should have JSON media type, while regular Create should not
Assert.AreEqual("application/json", content.MediaType);
Assert.IsNull(expectedContent.MediaType);
}

[Test]
public void CreateJsonWithValidationSucceedsForValidJson()
{
string validJson = """{"name":"test","value":42,"nested":{"array":[1,2,3]}}""";
using BinaryContent content = BinaryContent.CreateJson(validJson, validate: true);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(validJson.Length, length);
}

[Test]
public void CreateJsonWithValidationThrowsForInvalidJson()
{
string invalidJson = """{"name":"test","value":42"""; // Missing closing brace

var ex = Assert.Throws<ArgumentException>(() => BinaryContent.CreateJson(invalidJson, validate: true));
#pragma warning disable CS8602 // Dereference of a possibly null reference.
Assert.AreEqual("jsonString", ex.ParamName);
#pragma warning restore CS8602 // Dereference of a possibly null reference.
Assert.IsTrue(ex.Message.Contains("not valid JSON"));
}

[Test]
public void CreateJsonWithoutValidationSucceedsForInvalidJson()
{
string invalidJson = """{"name":"test","value":42"""; // Missing closing brace

// Should not throw when validation is disabled (default behavior)
using BinaryContent content = BinaryContent.CreateJson(invalidJson, validate: false);
Assert.AreEqual("application/json", content.MediaType);

// Should also not throw when validation parameter is omitted
using BinaryContent content2 = BinaryContent.CreateJson(invalidJson);
Assert.AreEqual("application/json", content2.MediaType);
}

[Test]
public void CreateJsonWithValidationHandlesEmptyObject()
{
string emptyObject = "{}";
using BinaryContent content = BinaryContent.CreateJson(emptyObject, validate: true);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(2, length);
}

[Test]
public void CreateJsonWithValidationHandlesEmptyArray()
{
string emptyArray = "[]";
using BinaryContent content = BinaryContent.CreateJson(emptyArray, validate: true);

Assert.AreEqual("application/json", content.MediaType);
Assert.IsTrue(content.TryComputeLength(out long length));
Assert.AreEqual(2, length);
}

[Test]
public void CreateJsonWithValidationThrowsForMalformedJson()
{
string[] malformedJsonStrings = new[]
{
"{",
"}",
"[",
"]",
"{\"key\":}",
"{\"key\"",
"\"unterminated string",
"{\"key\": value}", // unquoted value
"{key: \"value\"}", // unquoted key
};

foreach (string malformedJson in malformedJsonStrings)
{
Assert.Throws<ArgumentException>(() => BinaryContent.CreateJson(malformedJson, validate: true),
$"Expected validation to fail for: {malformedJson}");
}
}
}
Loading