diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net8.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net8.0.cs index bf925ab040f7..c153389d749c 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net8.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net8.0.cs @@ -22,8 +22,13 @@ 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; } + [System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation.")] + public static System.ClientModel.BinaryContent CreateJson(T jsonSerializable, System.Text.Json.JsonSerializerOptions? options = null) { throw null; } + public static System.ClientModel.BinaryContent CreateJson(T jsonSerializable, System.Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo) { throw null; } public static System.ClientModel.BinaryContent Create(T model, System.ClientModel.Primitives.ModelReaderWriterOptions? options = null) where T : System.ClientModel.Primitives.IPersistableModel { throw null; } public abstract void Dispose(); public abstract bool TryComputeLength(out long length); diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 5d5574de8d9f..9261ec03741f 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -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 jsonSerializable, System.Text.Json.JsonSerializerOptions? options = null) { throw null; } + public static System.ClientModel.BinaryContent CreateJson(T jsonSerializable, System.Text.Json.Serialization.Metadata.JsonTypeInfo jsonTypeInfo) { throw null; } public static System.ClientModel.BinaryContent Create(T model, System.ClientModel.Primitives.ModelReaderWriterOptions? options = null) where T : System.ClientModel.Primitives.IPersistableModel { throw null; } public abstract void Dispose(); public abstract bool TryComputeLength(out long length); diff --git a/sdk/core/System.ClientModel/src/Message/BinaryContent.cs b/sdk/core/System.ClientModel/src/Message/BinaryContent.cs index c67f6fd4fccd..f69594e6bfb0 100644 --- a/sdk/core/System.ClientModel/src/Message/BinaryContent.cs +++ b/sdk/core/System.ClientModel/src/Message/BinaryContent.cs @@ -4,7 +4,10 @@ using System.Buffers; using System.ClientModel.Internal; using System.ClientModel.Primitives; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; @@ -17,6 +20,13 @@ namespace System.ClientModel; public abstract class BinaryContent : IDisposable { private static readonly ModelReaderWriterOptions ModelWriteWireOptions = new ModelReaderWriterOptions("W"); + private const string JsonSerializerRequiresDynamicCode = "JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation."; + private const string JsonSerializerRequiresUnreferencedCode = "JSON serialization and deserialization might require types that cannot be statically analyzed."; + + /// + /// Gets the media type of the content. + /// + public string? MediaType { get; protected set; } /// /// Creates an instance of that contains the @@ -65,6 +75,91 @@ public static BinaryContent Create(Stream stream) return new StreamBinaryContent(stream); } + /// + /// Creates an instance of that contains the + /// JSON representation of the provided object. + /// + /// The type of the object to serialize. + /// The object to serialize to JSON. + /// The to use for serialization. + /// If not provided, the default options will be used. + /// An instance of that contains the + /// JSON representation of the provided object. +#pragma warning disable AZC0014 // Avoid using banned types in public API + [RequiresDynamicCode(JsonSerializerRequiresDynamicCode)] + [RequiresUnreferencedCode(JsonSerializerRequiresUnreferencedCode)] + public static BinaryContent CreateJson(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"); + } + + /// + /// Creates an instance of that contains the + /// JSON representation of the provided object using the specified JSON type information. + /// + /// The type of the object to serialize. + /// The object to serialize to JSON. + /// The to use for serialization. + /// An instance of that contains the + /// JSON representation of the provided object. +#pragma warning disable AZC0014 // Avoid using banned types in public API + public static BinaryContent CreateJson(T jsonSerializable, JsonTypeInfo 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"); + } + + /// + /// Creates an instance of that contains the + /// provided JSON string. + /// + /// The JSON string to be used as the content. + /// 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. + /// An instance of that contains the + /// provided JSON string. + /// Thrown when is true and + /// is not valid JSON. + 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); + } + } + /// /// Attempts to compute the length of the underlying body content, if available. /// @@ -96,9 +191,10 @@ private sealed class BinaryDataBinaryContent : BinaryContent { private readonly ReadOnlyMemory _bytes; - public BinaryDataBinaryContent(ReadOnlyMemory bytes) + public BinaryDataBinaryContent(ReadOnlyMemory bytes, string? mediaType = null) { _bytes = bytes; + MediaType = mediaType; } public override bool TryComputeLength(out long length) @@ -140,6 +236,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 diff --git a/sdk/core/System.ClientModel/tests/Message/BinaryContentTests.cs b/sdk/core/System.ClientModel/tests/Message/BinaryContentTests.cs index 0bfaf4cc020e..22b83bcdeb28 100644 --- a/sdk/core/System.ClientModel/tests/Message/BinaryContentTests.cs +++ b/sdk/core/System.ClientModel/tests/Message/BinaryContentTests.cs @@ -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; @@ -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); } @@ -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); } @@ -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); @@ -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); } @@ -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); @@ -156,4 +164,182 @@ public void StreamBinaryContentMustBeSeekable() Assert.Throws(() => { 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(() => 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(() => BinaryContent.CreateJson(malformedJson, validate: true), + $"Expected validation to fail for: {malformedJson}"); + } + } }