From b0edc97914f00c1ae7e70e24816efba548b6eb3a Mon Sep 17 00:00:00 2001
From: peterstone2017 <12449837+YunchuWang@users.noreply.github.com>
Date: Thu, 11 Dec 2025 09:42:59 -0800
Subject: [PATCH] Support complex type deserialization (standalone)
---
.../Converters/JsonDataConverter.cs | 177 ++++++++-
.../ObjectWithTypeMetadataJsonConverter.cs | 376 ++++++++++++++++++
.../Converters/JsonDataConverterTests.cs | 272 +++++++++++++
3 files changed, 819 insertions(+), 6 deletions(-)
create mode 100644 src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs
create mode 100644 test/Abstractions.Tests/Converters/JsonDataConverterTests.cs
diff --git a/src/Abstractions/Converters/JsonDataConverter.cs b/src/Abstractions/Converters/JsonDataConverter.cs
index eeca67a35..dc734bb66 100644
--- a/src/Abstractions/Converters/JsonDataConverter.cs
+++ b/src/Abstractions/Converters/JsonDataConverter.cs
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Collections;
+using System.Collections.Generic;
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace Microsoft.DurableTask.Converters;
@@ -11,19 +14,37 @@ namespace Microsoft.DurableTask.Converters;
public class JsonDataConverter : DataConverter
{
// WARNING: Changing default serialization options could potentially be breaking for in-flight orchestrations.
- static readonly JsonSerializerOptions DefaultOptions = new()
- {
- IncludeFields = true,
- };
+ static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions();
readonly JsonSerializerOptions? options;
///
/// Initializes a new instance of the class.
///
- /// The serializer options.
+ /// The serializer options. If null, default options with type metadata preservation are used.
public JsonDataConverter(JsonSerializerOptions? options = null)
{
+ if (options != null)
+ {
+ // Ensure the ObjectWithTypeMetadataJsonConverterFactory is present in custom options
+ // Check if it's already there to avoid duplicates
+ bool hasConverter = false;
+ foreach (JsonConverter converter in options.Converters)
+ {
+ if (converter is ObjectWithTypeMetadataJsonConverterFactory)
+ {
+ hasConverter = true;
+ break;
+ }
+ }
+
+ if (!hasConverter)
+ {
+ // Add at the beginning to ensure it handles object types first
+ options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory());
+ }
+ }
+
this.options = options ?? DefaultOptions;
}
@@ -32,6 +53,22 @@ public JsonDataConverter(JsonSerializerOptions? options = null)
///
public static JsonDataConverter Default { get; } = new JsonDataConverter();
+ static JsonSerializerOptions CreateDefaultOptions()
+ {
+ JsonSerializerOptions options = new()
+ {
+ IncludeFields = true,
+ };
+
+ // Add converter factory for preserving type information when deserializing to object type
+ // and Dictionary values. This must be added early to handle object types
+ // before default JsonElement conversion.
+ // See issue #430: https://github.com/microsoft/durabletask-dotnet/issues/430
+ options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory());
+
+ return options;
+ }
+
///
public override string? Serialize(object? value)
{
@@ -41,6 +78,134 @@ public JsonDataConverter(JsonSerializerOptions? options = null)
///
public override object? Deserialize(string? data, Type targetType)
{
- return data != null ? JsonSerializer.Deserialize(data, targetType, this.options) : null;
+ if (data == null)
+ {
+ return null;
+ }
+
+ // Special case: If target type is JsonElement, we should unwrap any type metadata
+ // and return the actual JSON content, not the wrapped structure
+ if (targetType == typeof(JsonElement))
+ {
+ using (JsonDocument doc = JsonDocument.Parse(data))
+ {
+ JsonElement root = doc.RootElement;
+ if (root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty("$type", out JsonElement typeElement) &&
+ root.TryGetProperty("$value", out JsonElement valueElement))
+ {
+ // Unwrap and return the actual value as JsonElement
+ return valueElement.Clone();
+ }
+ }
+ }
+
+ // Check if the JSON is a wrapped object (has $type and $value)
+ // This handles both array wrappers and object wrappers
+ using (JsonDocument doc = JsonDocument.Parse(data))
+ {
+ JsonElement root = doc.RootElement;
+ if (root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty("$type", out JsonElement typeElement) &&
+ root.TryGetProperty("$value", out JsonElement valueElement))
+ {
+ // This is a wrapped object - check what type it is
+ string typeName = typeElement.GetString()!;
+ Type? wrappedType = Type.GetType(typeName, throwOnError: false);
+
+ // If the wrapped type matches the target type exactly, unwrap and deserialize
+ if (wrappedType != null && wrappedType == targetType)
+ {
+ // The wrapped type matches the target type - unwrap and deserialize
+ string unwrappedJson = valueElement.GetRawText();
+ return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
+ }
+
+ // Special case: If wrapped type is an array and target type is also an array,
+ // try to deserialize the entire array (for cases like int[] -> int[])
+ if (wrappedType != null && wrappedType.IsArray && targetType.IsArray)
+ {
+ string unwrappedJson = valueElement.GetRawText();
+ return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
+ }
+
+ // Special case: If wrapped type is a collection (like List) and target type is an array (like T[]),
+ // deserialize the $value JSON directly as the target array type
+ // This works because List and T[] have the same JSON representation: [item1, item2, ...]
+ if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
+ {
+ // Check if wrapped type is a generic collection that implements IEnumerable
+ if (wrappedType.IsGenericType)
+ {
+ Type genericTypeDef = wrappedType.GetGenericTypeDefinition();
+ if (genericTypeDef == typeof(List<>) ||
+ genericTypeDef == typeof(IList<>) ||
+ genericTypeDef == typeof(ICollection<>) ||
+ genericTypeDef == typeof(IEnumerable<>))
+ {
+ Type[] genericArgs = wrappedType.GetGenericArguments();
+ if (genericArgs.Length == 1)
+ {
+ Type elementType = genericArgs[0];
+ Type targetElementType = targetType.GetElementType()!;
+
+ // If the element types match, we can deserialize directly
+ if (elementType == targetElementType)
+ {
+ string unwrappedJson = valueElement.GetRawText();
+ return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
+ }
+ }
+ }
+ }
+ }
+
+ // Special case: If wrapped type is an array and target type is NOT an array,
+ // check if the array contains a single wrapped element that matches the target type
+ if (wrappedType != null && wrappedType.IsArray && !targetType.IsArray)
+ {
+ if (valueElement.ValueKind == JsonValueKind.Array && valueElement.GetArrayLength() > 0)
+ {
+ // Get the first element of the array
+ JsonElement firstElement = valueElement[0];
+ string firstElementJson = firstElement.GetRawText();
+
+ // Check if the first element is also wrapped
+ using (JsonDocument innerDoc = JsonDocument.Parse(firstElementJson))
+ {
+ JsonElement innerRoot = innerDoc.RootElement;
+ if (innerRoot.ValueKind == JsonValueKind.Object &&
+ innerRoot.TryGetProperty("$type", out JsonElement innerTypeElement) &&
+ innerRoot.TryGetProperty("$value", out JsonElement innerValueElement))
+ {
+ string innerTypeName = innerTypeElement.GetString()!;
+ Type? innerWrappedType = Type.GetType(innerTypeName, throwOnError: false);
+
+ if (innerWrappedType != null && innerWrappedType == targetType)
+ {
+ // Unwrap the inner object
+ string unwrappedJson = innerValueElement.GetRawText();
+ return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);
+ }
+ }
+ }
+
+ // First element is not wrapped, try to deserialize it directly
+ // This handles cases where the array contains a single unwrapped element
+ try
+ {
+ return JsonSerializer.Deserialize(firstElementJson, targetType, this.options);
+ }
+ catch
+ {
+ // If deserialization fails, fall through to normal deserialization
+ }
+ }
+ }
+ }
+ }
+
+ // Not wrapped or type doesn't match - deserialize normally
+ return JsonSerializer.Deserialize(data, targetType, this.options);
}
}
diff --git a/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs b/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs
new file mode 100644
index 000000000..6d695c1a1
--- /dev/null
+++ b/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs
@@ -0,0 +1,376 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DurableTask.Converters;
+
+///
+/// A JSON converter factory that preserves type information for complex objects when deserializing to type
+/// and Dictionary<string, object> values. This fixes issue #430 where Dictionary<string, object> values and other
+/// object-typed properties were being deserialized as JsonElement instead of their original types.
+///
+sealed class ObjectWithTypeMetadataJsonConverterFactory : JsonConverterFactory
+{
+ ///
+ public override bool CanConvert(Type typeToConvert)
+ {
+ // Handle object type
+ if (typeToConvert == typeof(object))
+ {
+ return true;
+ }
+
+ // Handle Dictionary and IDictionary
+ if (typeToConvert.IsGenericType)
+ {
+ Type genericType = typeToConvert.GetGenericTypeDefinition();
+ if (genericType == typeof(Dictionary<,>) || genericType == typeof(IDictionary<,>))
+ {
+ Type[] args = typeToConvert.GetGenericArguments();
+ if (args.Length == 2 && args[0] == typeof(string) && args[1] == typeof(object))
+ {
+ return true;
+ }
+ }
+ }
+
+ // Also handle if the type implements IDictionary
+ if (typeof(IDictionary).IsAssignableFrom(typeToConvert) && typeToConvert != typeof(object))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (typeToConvert == typeof(object))
+ {
+ return new ObjectJsonConverter();
+ }
+
+ if (typeToConvert.IsGenericType)
+ {
+ Type genericType = typeToConvert.GetGenericTypeDefinition();
+ if (genericType == typeof(Dictionary<,>) || genericType == typeof(IDictionary<,>))
+ {
+ Type[] args = typeToConvert.GetGenericArguments();
+ if (args.Length == 2 && args[0] == typeof(string) && args[1] == typeof(object))
+ {
+ return new DictionaryStringObjectJsonConverter();
+ }
+ }
+ }
+
+ // Handle types that implement IDictionary
+ if (typeof(IDictionary).IsAssignableFrom(typeToConvert) && typeToConvert != typeof(object))
+ {
+ return new DictionaryStringObjectJsonConverter();
+ }
+
+ throw new NotSupportedException($"Type {typeToConvert} is not supported by this converter factory.");
+ }
+
+ ///
+ /// Converter for object type that preserves type information.
+ ///
+ sealed class ObjectJsonConverter : JsonConverter