Skip to content

Commit b7fc1ab

Browse files
CopilotYunchuWang
andcommitted
Implement fix for JsonElement deserialization issue with Dictionary<string, object>
Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com>
1 parent e9aaa7e commit b7fc1ab

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

src/Abstractions/Converters/JsonDataConverter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class JsonDataConverter : DataConverter
1414
static readonly JsonSerializerOptions DefaultOptions = new()
1515
{
1616
IncludeFields = true,
17+
Converters = { new ObjectToInferredTypesConverter() },
1718
};
1819

1920
readonly JsonSerializerOptions? options;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
7+
namespace Microsoft.DurableTask.Converters;
8+
9+
/// <summary>
10+
/// A custom JSON converter that deserializes JSON tokens into inferred .NET types instead of JsonElement.
11+
/// </summary>
12+
/// <remarks>
13+
/// When deserializing to <c>object</c> type, System.Text.Json defaults to returning JsonElement instances.
14+
/// This converter infers the appropriate .NET type based on the JSON token type:
15+
/// - JSON strings become <see cref="string"/>
16+
/// - JSON numbers become <see cref="int"/>, <see cref="long"/>, or <see cref="double"/>
17+
/// - JSON booleans become <see cref="bool"/>
18+
/// - JSON objects become <see cref="Dictionary{TKey, TValue}"/> where TValue is <see cref="object"/>
19+
/// - JSON arrays become <see cref="object"/>[]
20+
/// - JSON null becomes null
21+
/// This is particularly useful when working with <see cref="Dictionary{TKey, TValue}"/> where TValue is
22+
/// <see cref="object"/>, ensuring that complex types are preserved as dictionaries rather than JsonElement.
23+
/// </remarks>
24+
internal sealed class ObjectToInferredTypesConverter : JsonConverter<object>
25+
{
26+
/// <inheritdoc/>
27+
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
28+
{
29+
switch (reader.TokenType)
30+
{
31+
case JsonTokenType.True:
32+
case JsonTokenType.False:
33+
return reader.GetBoolean();
34+
case JsonTokenType.Number:
35+
if (reader.TryGetInt32(out int intValue))
36+
{
37+
return intValue;
38+
}
39+
40+
if (reader.TryGetInt64(out long longValue))
41+
{
42+
return longValue;
43+
}
44+
45+
return reader.GetDouble();
46+
case JsonTokenType.String:
47+
return reader.GetString();
48+
case JsonTokenType.StartObject:
49+
Dictionary<string, object?> dictionary = new();
50+
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
51+
{
52+
if (reader.TokenType != JsonTokenType.PropertyName)
53+
{
54+
throw new JsonException("Expected property name.");
55+
}
56+
57+
string propertyName = reader.GetString() ?? throw new JsonException("Property name cannot be null.");
58+
reader.Read();
59+
dictionary[propertyName] = this.Read(ref reader, typeof(object), options);
60+
}
61+
62+
return dictionary;
63+
case JsonTokenType.StartArray:
64+
List<object?> list = new();
65+
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
66+
{
67+
list.Add(this.Read(ref reader, typeof(object), options));
68+
}
69+
70+
return list.ToArray();
71+
case JsonTokenType.Null:
72+
return null;
73+
default:
74+
throw new JsonException($"Unsupported JSON token type: {reader.TokenType}");
75+
}
76+
}
77+
78+
/// <inheritdoc/>
79+
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
80+
{
81+
JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(object), options);
82+
}
83+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using Microsoft.DurableTask.Converters;
6+
7+
namespace Microsoft.DurableTask.Tests;
8+
9+
public class JsonDataConverterTests
10+
{
11+
readonly JsonDataConverter converter = JsonDataConverter.Default;
12+
13+
[Fact]
14+
public void Serialize_Null_ReturnsNull()
15+
{
16+
// Act
17+
string? result = this.converter.Serialize(null);
18+
19+
// Assert
20+
result.Should().BeNull();
21+
}
22+
23+
[Fact]
24+
public void Deserialize_Null_ReturnsNull()
25+
{
26+
// Act
27+
object? result = this.converter.Deserialize(null, typeof(object));
28+
29+
// Assert
30+
result.Should().BeNull();
31+
}
32+
33+
[Fact]
34+
public void RoundTrip_SimpleTypes_PreservesTypes()
35+
{
36+
// Arrange
37+
Dictionary<string, object> input = new()
38+
{
39+
{ "string", "value" },
40+
{ "int", 42 },
41+
{ "long", 9223372036854775807L },
42+
{ "double", 3.14 },
43+
{ "bool", true },
44+
{ "null", null! },
45+
};
46+
47+
// Act
48+
string serialized = this.converter.Serialize(input)!;
49+
Dictionary<string, object>? result = this.converter.Deserialize<Dictionary<string, object>>(serialized);
50+
51+
// Assert
52+
result.Should().NotBeNull();
53+
result!["string"].Should().BeOfType<string>().And.Be("value");
54+
result["int"].Should().BeOfType<int>().And.Be(42);
55+
56+
// Note: Large integers that don't fit in int32 will be deserialized as long
57+
result["long"].Should().Match<object>(o => o is long || o is double);
58+
59+
result["double"].Should().BeOfType<double>().And.Be(3.14);
60+
result["bool"].Should().BeOfType<bool>().And.Be(true);
61+
result["null"].Should().BeNull();
62+
}
63+
64+
[Fact]
65+
public void RoundTrip_NestedDictionary_PreservesStructure()
66+
{
67+
// Arrange
68+
Dictionary<string, object> input = new()
69+
{
70+
{
71+
"nested", new Dictionary<string, object>
72+
{
73+
{ "key1", "value1" },
74+
{ "key2", 123 },
75+
}
76+
},
77+
};
78+
79+
// Act
80+
string serialized = this.converter.Serialize(input)!;
81+
Dictionary<string, object>? result = this.converter.Deserialize<Dictionary<string, object>>(serialized);
82+
83+
// Assert
84+
result.Should().NotBeNull();
85+
result!["nested"].Should().BeOfType<Dictionary<string, object>>();
86+
Dictionary<string, object> nested = (Dictionary<string, object>)result["nested"];
87+
nested["key1"].Should().BeOfType<string>().And.Be("value1");
88+
nested["key2"].Should().BeOfType<int>().And.Be(123);
89+
}
90+
91+
[Fact]
92+
public void RoundTrip_Array_PreservesElements()
93+
{
94+
// Arrange
95+
Dictionary<string, object> input = new()
96+
{
97+
{ "array", new object[] { "string", 42, true } },
98+
};
99+
100+
// Act
101+
string serialized = this.converter.Serialize(input)!;
102+
Dictionary<string, object>? result = this.converter.Deserialize<Dictionary<string, object>>(serialized);
103+
104+
// Assert
105+
result.Should().NotBeNull();
106+
result!["array"].Should().BeOfType<object[]>();
107+
object[] array = (object[])result["array"];
108+
array.Should().HaveCount(3);
109+
array[0].Should().BeOfType<string>().And.Be("string");
110+
array[1].Should().BeOfType<int>().And.Be(42);
111+
array[2].Should().BeOfType<bool>().And.Be(true);
112+
}
113+
114+
[Fact]
115+
public void RoundTrip_ComplexObject_PreservesStructure()
116+
{
117+
// Arrange - simulate the issue described in the GitHub issue
118+
Dictionary<string, object> input = new()
119+
{
120+
{ "ComponentContext", new { Name = "TestComponent", Id = 123 } },
121+
{ "PlanResult", new { Status = "Success", Count = 5 } },
122+
};
123+
124+
// Act
125+
string serialized = this.converter.Serialize(input)!;
126+
Dictionary<string, object>? result = this.converter.Deserialize<Dictionary<string, object>>(serialized);
127+
128+
// Assert
129+
result.Should().NotBeNull();
130+
131+
// The anonymous objects should be deserialized as dictionaries, not JsonElements
132+
result!["ComponentContext"].Should().BeOfType<Dictionary<string, object>>();
133+
Dictionary<string, object> componentContext = (Dictionary<string, object>)result["ComponentContext"];
134+
componentContext["Name"].Should().BeOfType<string>().And.Be("TestComponent");
135+
componentContext["Id"].Should().BeOfType<int>().And.Be(123);
136+
137+
result["PlanResult"].Should().BeOfType<Dictionary<string, object>>();
138+
Dictionary<string, object> planResult = (Dictionary<string, object>)result["PlanResult"];
139+
planResult["Status"].Should().BeOfType<string>().And.Be("Success");
140+
planResult["Count"].Should().BeOfType<int>().And.Be(5);
141+
}
142+
143+
[Fact]
144+
public void Deserialize_JsonWithoutConverter_ProducesJsonElements()
145+
{
146+
// Arrange - use standard System.Text.Json without our custom converter
147+
JsonSerializerOptions standardOptions = new() { IncludeFields = true };
148+
Dictionary<string, object> input = new()
149+
{
150+
{ "key", "value" },
151+
};
152+
string json = JsonSerializer.Serialize(input, standardOptions);
153+
154+
// Act
155+
Dictionary<string, object>? result = JsonSerializer.Deserialize<Dictionary<string, object>>(json, standardOptions);
156+
157+
// Assert - without our converter, values are JsonElements
158+
result.Should().NotBeNull();
159+
result!["key"].Should().BeOfType<JsonElement>();
160+
}
161+
162+
[Fact]
163+
public void Deserialize_JsonWithConverter_ProducesConcreteTypes()
164+
{
165+
// Arrange
166+
Dictionary<string, object> input = new()
167+
{
168+
{ "key", "value" },
169+
};
170+
string json = this.converter.Serialize(input)!;
171+
172+
// Act
173+
Dictionary<string, object>? result = this.converter.Deserialize<Dictionary<string, object>>(json);
174+
175+
// Assert - with our converter, values are concrete types
176+
result.Should().NotBeNull();
177+
result!["key"].Should().BeOfType<string>().And.Be("value");
178+
}
179+
180+
[Fact]
181+
public void RoundTrip_RecordType_PreservesProperties()
182+
{
183+
// Arrange
184+
TestRecord input = new("TestValue", 42, new Dictionary<string, object>
185+
{
186+
{ "nested", "data" },
187+
});
188+
189+
// Act
190+
string serialized = this.converter.Serialize(input)!;
191+
TestRecord? result = this.converter.Deserialize<TestRecord>(serialized);
192+
193+
// Assert
194+
result.Should().NotBeNull();
195+
result!.Name.Should().Be("TestValue");
196+
result.Value.Should().Be(42);
197+
result.Properties.Should().ContainKey("nested");
198+
result.Properties["nested"].Should().BeOfType<string>().And.Be("data");
199+
}
200+
201+
record TestRecord(string Name, int Value, Dictionary<string, object> Properties);
202+
}

test/Grpc.IntegrationTests/OrchestrationPatterns.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,4 +1197,81 @@ async Task<int> OrchestratorFunc(TaskOrchestrationContext ctx, int counter)
11971197

11981198
// TODO: Test for multiple external events with the same name
11991199
// TODO: Test for catching activity exceptions of specific types
1200+
1201+
[Fact]
1202+
public async Task ActivityInput_ComplexTypesInDictionary_PreservesTypes()
1203+
{
1204+
// This test verifies the fix for: https://github.com/microsoft/durabletask-dotnet/issues/XXX
1205+
// Previously, when passing Dictionary<string, object> to activities, the object values
1206+
// would be deserialized as JsonElement instead of their original types.
1207+
TaskName orchestratorName = "ComplexInputOrchestrator";
1208+
TaskName activityName = "ComplexInputActivity";
1209+
1210+
Dictionary<string, object> capturedInput = null!;
1211+
1212+
await using HostTestLifetime server = await this.StartWorkerAsync(b =>
1213+
{
1214+
b.AddTasks(tasks => tasks
1215+
.AddOrchestratorFunc<object?, string>(orchestratorName, async (ctx, _) =>
1216+
{
1217+
Dictionary<string, object> complexInput = new()
1218+
{
1219+
{ "StringValue", "test" },
1220+
{ "IntValue", 42 },
1221+
{ "BoolValue", true },
1222+
{ "NestedObject", new { Name = "TestObject", Id = 123 } },
1223+
{ "ArrayValue", new object[] { "item1", 2, false } },
1224+
};
1225+
1226+
return await ctx.CallActivityAsync<string>(activityName, complexInput);
1227+
})
1228+
.AddActivityFunc<Dictionary<string, object>, string>(activityName, (ctx, input) =>
1229+
{
1230+
capturedInput = input;
1231+
return Task.FromResult("success");
1232+
}));
1233+
});
1234+
1235+
string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
1236+
OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync(
1237+
instanceId, getInputsAndOutputs: true, this.TimeoutToken);
1238+
1239+
Assert.NotNull(metadata);
1240+
Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus);
1241+
Assert.Equal("success", metadata.ReadOutputAs<string>());
1242+
1243+
// Verify that the input was correctly deserialized with concrete types, not JsonElement
1244+
Assert.NotNull(capturedInput);
1245+
1246+
// String should remain a string
1247+
Assert.IsType<string>(capturedInput["StringValue"]);
1248+
Assert.Equal("test", capturedInput["StringValue"]);
1249+
1250+
// Int should remain an int
1251+
Assert.IsType<int>(capturedInput["IntValue"]);
1252+
Assert.Equal(42, capturedInput["IntValue"]);
1253+
1254+
// Bool should remain a bool
1255+
Assert.IsType<bool>(capturedInput["BoolValue"]);
1256+
Assert.Equal(true, capturedInput["BoolValue"]);
1257+
1258+
// Nested object should be a Dictionary<string, object>, not JsonElement
1259+
Assert.IsType<Dictionary<string, object>>(capturedInput["NestedObject"]);
1260+
Dictionary<string, object> nestedObject = (Dictionary<string, object>)capturedInput["NestedObject"];
1261+
Assert.IsType<string>(nestedObject["Name"]);
1262+
Assert.Equal("TestObject", nestedObject["Name"]);
1263+
Assert.IsType<int>(nestedObject["Id"]);
1264+
Assert.Equal(123, nestedObject["Id"]);
1265+
1266+
// Array should be object[], not JsonElement
1267+
Assert.IsType<object[]>(capturedInput["ArrayValue"]);
1268+
object[] arrayValue = (object[])capturedInput["ArrayValue"];
1269+
Assert.Equal(3, arrayValue.Length);
1270+
Assert.IsType<string>(arrayValue[0]);
1271+
Assert.Equal("item1", arrayValue[0]);
1272+
Assert.IsType<int>(arrayValue[1]);
1273+
Assert.Equal(2, arrayValue[1]);
1274+
Assert.IsType<bool>(arrayValue[2]);
1275+
Assert.Equal(false, arrayValue[2]);
1276+
}
12001277
}

0 commit comments

Comments
 (0)