diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Nodes/JsonObject.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Nodes/JsonObject.cs index 9cfe0b8cb8fc86..d83d6594e9da75 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Nodes/JsonObject.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Nodes/JsonObject.cs @@ -172,7 +172,30 @@ public override void WriteTo(Utf8JsonWriter writer, JsonSerializerOptions? optio else { writer.WriteStartObject(); + WriteContentsTo(writer, options); + writer.WriteEndObject(); + } + } + /// + /// Writes the properties of this JsonObject to the writer without the surrounding braces. + /// This is used for extension data serialization where the properties should be flattened + /// into the parent object. + /// + internal void WriteContentsTo(Utf8JsonWriter writer, JsonSerializerOptions? options) + { + GetUnderlyingRepresentation(out OrderedDictionary? dictionary, out JsonElement? jsonElement); + + if (dictionary is null && jsonElement.HasValue) + { + // Write properties from the underlying JsonElement without converting to nodes. + foreach (JsonProperty property in jsonElement.Value.EnumerateObject()) + { + property.WriteTo(writer); + } + } + else + { foreach (KeyValuePair entry in Dictionary) { writer.WritePropertyName(entry.Key); @@ -186,8 +209,6 @@ public override void WriteTo(Utf8JsonWriter writer, JsonSerializerOptions? optio entry.Value.WriteTo(writer, options); } } - - writer.WriteEndObject(); } } diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Node/JsonObjectConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Node/JsonObjectConverter.cs index fb97edcf75f54d..7de235cef7aafe 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Node/JsonObjectConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Node/JsonObjectConverter.cs @@ -41,6 +41,15 @@ internal override void ReadElementAndSetProperty( } } + internal override void WriteExtensionDataValue( + Utf8JsonWriter writer, + object value, + JsonSerializerOptions options) + { + Debug.Assert(value is JsonObject); + ((JsonObject)value).WriteContentsTo(writer, options); + } + public override void Write(Utf8JsonWriter writer, JsonObject? value, JsonSerializerOptions options) { if (value is null) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverter.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverter.cs index 1a0e007695dcfb..20b271d53e4b40 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverter.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverter.cs @@ -110,6 +110,20 @@ internal virtual void ReadElementAndSetProperty( throw new InvalidOperationException(); } + /// + /// Used to support JsonObject as an extension property in a loosely-typed, trimmable manner. + /// Writes the extension data contents without wrapping object braces. + /// + internal virtual void WriteExtensionDataValue( + Utf8JsonWriter writer, + object value, + JsonSerializerOptions options) + { + Debug.Fail("Should not be reachable."); + + throw new InvalidOperationException(); + } + internal virtual JsonTypeInfo CreateJsonTypeInfo(JsonSerializerOptions options) { Debug.Fail("Should not be reachable."); diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs index 3a9684f3fd86e9..bf2d5897ac9e5e 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Converters; using System.Text.Json.Serialization.Metadata; @@ -458,8 +459,11 @@ internal bool TryWriteDataExtensionProperty(Utf8JsonWriter writer, T value, Json { // If not JsonDictionaryConverter then we are JsonObject. // Avoid a type reference to JsonObject and its converter to support trimming. - Debug.Assert(Type == typeof(Nodes.JsonObject)); - return TryWrite(writer, value, options, ref state); + // The WriteExtensionDataValue virtual method is overridden by the JsonObject converter. + Debug.Assert(Type == typeof(JsonObject)); + WriteExtensionDataValue(writer, value!, options); + + return true; } if (writer.CurrentDepth >= options.EffectiveMaxDepth) diff --git a/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs b/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs index 570624a2a229df..ed8297a2decab0 100644 --- a/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs @@ -815,6 +815,100 @@ public async Task DeserializeIntoJsonObjectProperty() Assert.Equal(1, obj.MyOverflow["MyDict"]["Property1"].GetValue()); } + [Fact] + public async Task SerializeJsonObjectExtensionData_ProducesValidJson() + { + // JsonSerializer.Serialize was producing invalid JSON for [JsonExtensionData] property of type JsonObject + // Output was: {"Id":1,{"nested":true}} instead of {"Id":1,"nested":true} + + var obj = new ClassWithJsonObjectExtensionDataAndProperty + { + Id = 1, + Extra = new JsonObject { ["nested"] = true } + }; + + string json = await Serializer.SerializeWrapper(obj); + + // Verify the JSON is valid by parsing it + using JsonDocument doc = JsonDocument.Parse(json); + + // Verify the structure is correct: extension data should be flattened, not nested + Assert.True(doc.RootElement.TryGetProperty("Id", out JsonElement idElement)); + Assert.Equal(1, idElement.GetInt32()); + + Assert.True(doc.RootElement.TryGetProperty("nested", out JsonElement nestedElement)); + Assert.True(nestedElement.GetBoolean()); + + // Extension data property name should NOT appear in the output + Assert.False(doc.RootElement.TryGetProperty("Extra", out _)); + + // Verify expected JSON format + Assert.Equal(@"{""Id"":1,""nested"":true}", json); + } + + [Fact] + public async Task SerializeJsonObjectExtensionData_RoundTrip() + { + var original = new ClassWithJsonObjectExtensionDataAndProperty + { + Id = 42, + Extra = new JsonObject + { + ["string"] = "value", + ["number"] = 123, + ["boolean"] = false, + ["nested"] = new JsonObject { ["inner"] = "data" } + } + }; + + string json = await Serializer.SerializeWrapper(original); + + // Verify round-trip + var deserialized = await Serializer.DeserializeWrapper(json); + + Assert.Equal(original.Id, deserialized.Id); + Assert.NotNull(deserialized.Extra); + Assert.Equal(4, deserialized.Extra.Count); + Assert.Equal("value", deserialized.Extra["string"]!.GetValue()); + Assert.Equal(123, deserialized.Extra["number"]!.GetValue()); + Assert.False(deserialized.Extra["boolean"]!.GetValue()); + Assert.Equal("data", deserialized.Extra["nested"]!["inner"]!.GetValue()); + } + + [Fact] + public async Task SerializeJsonObjectExtensionData_EmptyJsonObject() + { + var obj = new ClassWithJsonObjectExtensionDataAndProperty + { + Id = 1, + Extra = new JsonObject() + }; + + string json = await Serializer.SerializeWrapper(obj); + Assert.Equal(@"{""Id"":1}", json); + } + + [Fact] + public async Task SerializeJsonObjectExtensionData_NullJsonObject() + { + var obj = new ClassWithJsonObjectExtensionDataAndProperty + { + Id = 1, + Extra = null + }; + + string json = await Serializer.SerializeWrapper(obj); + Assert.Equal(@"{""Id"":1}", json); + } + + public class ClassWithJsonObjectExtensionDataAndProperty + { + public int Id { get; set; } + + [JsonExtensionData] + public JsonObject? Extra { get; set; } + } + [Fact] #if BUILDING_SOURCE_GENERATOR_TESTS [ActiveIssue("https://github.com/dotnet/runtime/issues/58945")] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs index 89829b304bf4b2..7b9d4f8ab2fd7c 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/ExtensionDataTests.cs @@ -74,6 +74,7 @@ public ExtensionDataTests_Metadata() [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty))] [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryAlreadyInstantiated))] [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated))] + [JsonSerializable(typeof(ClassWithJsonObjectExtensionDataAndProperty))] internal sealed partial class ExtensionDataTestsContext_Metadata : JsonSerializerContext { } @@ -144,6 +145,7 @@ public ExtensionDataTests_Default() [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryExtensionPropertyAsJsonElementWithProperty))] [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryAlreadyInstantiated))] [JsonSerializable(typeof(ClassWithIReadOnlyDictionaryJsonElementAlreadyInstantiated))] + [JsonSerializable(typeof(ClassWithJsonObjectExtensionDataAndProperty))] internal sealed partial class ExtensionDataTestsContext_Default : JsonSerializerContext { }