Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,30 @@ public override void WriteTo(Utf8JsonWriter writer, JsonSerializerOptions? optio
else
{
writer.WriteStartObject();
WriteContentsTo(writer, options);
writer.WriteEndObject();
}
}

/// <summary>
/// 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.
/// </summary>
internal void WriteContentsTo(Utf8JsonWriter writer, JsonSerializerOptions? options)
{
GetUnderlyingRepresentation(out OrderedDictionary<string, JsonNode?>? 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<string, JsonNode?> entry in Dictionary)
{
writer.WritePropertyName(entry.Key);
Expand All @@ -186,8 +209,6 @@ public override void WriteTo(Utf8JsonWriter writer, JsonSerializerOptions? optio
entry.Value.WriteTo(writer, options);
}
}

writer.WriteEndObject();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ internal override void ReadElementAndSetProperty(
}
}

internal override void WriteExtensionDataValue(Utf8JsonWriter writer, JsonObject? value, JsonSerializerOptions options)
{
Debug.Assert(value is not null);
value.WriteContentsTo(writer, options);
}

public override void Write(Utf8JsonWriter writer, JsonObject? value, JsonSerializerOptions options)
{
if (value is null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,11 @@ internal bool TryWriteDataExtensionProperty(Utf8JsonWriter writer, T value, Json
{
// If not JsonDictionaryConverter<T> then we are JsonObject.
// Avoid a type reference to JsonObject and its converter to support trimming.
// The WriteExtensionDataValue virtual method is overridden by the JsonObject converter.
Debug.Assert(Type == typeof(Nodes.JsonObject));
return TryWrite(writer, value, options, ref state);
WriteExtensionDataValue(writer, value!, options);

return true;
}

if (writer.CurrentDepth >= options.EffectiveMaxDepth)
Expand Down Expand Up @@ -493,6 +496,19 @@ internal bool TryWriteDataExtensionProperty(Utf8JsonWriter writer, T value, Json
return success;
}

/// <summary>
/// Used to support JsonObject as an extension property in a loosely-typed, trimmable manner.
/// </summary>
/// <remarks>
/// Writes the extension data contents without wrapping object braces.
/// </remarks>
internal virtual void WriteExtensionDataValue(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
Debug.Fail("Should not be reachable.");

throw new InvalidOperationException();
}

/// <inheritdoc/>
public sealed override Type Type { get; } = typeof(T);

Expand Down
94 changes: 94 additions & 0 deletions src/libraries/System.Text.Json/tests/Common/ExtensionDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,100 @@ public async Task DeserializeIntoJsonObjectProperty()
Assert.Equal(1, obj.MyOverflow["MyDict"]["Property1"].GetValue<int>());
}

[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<ClassWithJsonObjectExtensionDataAndProperty>(json);

Assert.Equal(original.Id, deserialized.Id);
Assert.NotNull(deserialized.Extra);
Assert.Equal(4, deserialized.Extra.Count);
Assert.Equal("value", deserialized.Extra["string"]!.GetValue<string>());
Assert.Equal(123, deserialized.Extra["number"]!.GetValue<int>());
Assert.False(deserialized.Extra["boolean"]!.GetValue<bool>());
Assert.Equal("data", deserialized.Extra["nested"]!["inner"]!.GetValue<string>());
}

[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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
Expand Down Expand Up @@ -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
{
}
Expand Down