Skip to content

Conversation

@YunchuWang
Copy link
Member

No description provided.

Copilot AI review requested due to automatic review settings December 11, 2025 17:43
Comment on lines +32 to +39
foreach (JsonConverter converter in options.Converters)
{
if (converter is ObjectWithTypeMetadataJsonConverterFactory)
{
hasConverter = true;
break;
}
}
Comment on lines +135 to +161
if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
{
// Check if wrapped type is a generic collection that implements IEnumerable<T>
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);
}
}
}
}
}
Comment on lines +165 to +204
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
}
}
}
Comment on lines +199 to +202
catch
{
// If deserialization fails, fall through to normal deserialization
}
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces complex type deserialization support for the standalone Durable Task SDK by implementing a type metadata preservation mechanism. The changes fix issue #430 where Dictionary<string, object> values and object-typed properties were being deserialized as JsonElement instead of their original types.

Key Changes:

  • Added ObjectWithTypeMetadataJsonConverter that wraps complex types with $type and $value metadata during serialization
  • Modified JsonDataConverter to automatically include this converter in default options and handle unwrapping during deserialization
  • Added comprehensive test coverage for various serialization scenarios including nested objects, arrays, and primitives

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 14 comments.

File Description
src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs New converter factory that preserves type information by wrapping complex objects with $type and $value metadata during serialization
src/Abstractions/Converters/JsonDataConverter.cs Modified to include ObjectWithTypeMetadataJsonConverter in default options and added unwrapping logic in Deserialize method
test/Abstractions.Tests/Converters/JsonDataConverterTests.cs New test file with comprehensive coverage for the type preservation feature including dictionaries, arrays, nested objects, and edge cases

Comment on lines +88 to +101
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();
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JsonDocument is being parsed twice in the Deserialize method - once here for JsonElement unwrapping (lines 88-101) and again immediately after (lines 105-206) for general unwrapping logic. This creates unnecessary overhead as JsonDocument.Parse is called twice on the same data. Consider consolidating these checks into a single parse operation to improve performance.

Copilot uses AI. Check for mistakes.
innerRoot.TryGetProperty("$value", out JsonElement innerValueElement))
{
string innerTypeName = innerTypeElement.GetString()!;
Type? innerWrappedType = Type.GetType(innerTypeName, throwOnError: false);
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Type.GetType with type names from JSON input can pose security risks. An attacker could provide a malicious type name that loads unexpected assemblies or triggers unintended code execution during type loading. Consider implementing a type allowlist or validating that loaded types are from trusted assemblies only.

Copilot uses AI. Check for mistakes.
Comment on lines +199 to +202
catch
{
// If deserialization fails, fall through to normal deserialization
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty catch block silently swallows all exceptions without logging or handling them. This makes debugging difficult and can hide important errors. At minimum, the exception should be logged, or the catch should be more specific about which exceptions are expected (e.g., JsonException for malformed JSON).

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +204
// 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
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for checking if wrapped type is an array and target is NOT an array (lines 163-204) is complex and appears to be attempting to handle a very specific edge case. The comment mentions "if the array contains a single wrapped element" but the code continues even when the array has multiple elements (only checking the first). This logic seems overly complex for the value it provides. Consider simplifying this or documenting why this specific case needs to be handled.

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +120
}

Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation is inconsistent here - this line should be indented to the same level as the if statement above (line 105). The closing brace on line 119 is properly indented, but the if statement starting on line 112 has extra indentation.

Suggested change
}

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +272
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.DurableTask.Converters;

namespace Microsoft.DurableTask.Tests.Converters;

public class JsonDataConverterTests
{
// Test types matching ConsoleAppMinimal sample
public record ComponentContext(string Name, string Type, List<string> Dependencies);
public record PlanResult(bool Success, int Status, string Reason);
public record TestActivityInput(Dictionary<string, object> Properties);

[Fact]
public void SerializeDeserialize_DictionaryWithComplexTypes_PreservesTypes()
{
// Arrange
var converter = JsonDataConverter.Default;
var componentContext = new ComponentContext(
Name: "loganalytics",
Type: "terraform",
Dependencies: ["resourcegroup"]);

var planResult = new PlanResult(
Success: true,
Status: 2,
Reason: "replace_because_tainted");

var input = new TestActivityInput(new Dictionary<string, object>
{
{ "ComponentContext", componentContext },
{ "PlanResult", planResult }
});

// Act
string serialized = converter.Serialize(input);
TestActivityInput? deserialized = converter.Deserialize<TestActivityInput>(serialized);

// Assert
deserialized.Should().NotBeNull();
deserialized!.Properties.Should().NotBeNull().And.NotBeEmpty();
deserialized.Properties.Should().HaveCount(2);

// Verify ComponentContext is preserved (not JsonElement)
deserialized.Properties["ComponentContext"].Should().BeOfType<ComponentContext>();
var deserializedComponent = (ComponentContext)deserialized.Properties["ComponentContext"];
deserializedComponent.Name.Should().Be("loganalytics");
deserializedComponent.Type.Should().Be("terraform");
deserializedComponent.Dependencies.Should().Equal(["resourcegroup"]);

// Verify PlanResult is preserved (not JsonElement)
deserialized.Properties["PlanResult"].Should().BeOfType<PlanResult>();
var deserializedPlan = (PlanResult)deserialized.Properties["PlanResult"];
deserializedPlan.Success.Should().BeTrue();
deserializedPlan.Status.Should().Be(2);
deserializedPlan.Reason.Should().Be("replace_because_tainted");
}

[Fact]
public void SerializeDeserialize_DirectComplexType_PreservesType()
{
// Arrange
var converter = JsonDataConverter.Default;
var componentContext = new ComponentContext(
Name: "test",
Type: "type",
Dependencies: ["dep1", "dep2"]);

// Act
string serialized = converter.Serialize(componentContext);
ComponentContext? deserialized = converter.Deserialize<ComponentContext>(serialized);

// Assert
deserialized.Should().NotBeNull();
ComponentContext result = deserialized!;
result.Name.Should().Be("test");
result.Type.Should().Be("type");
result.Dependencies.Should().Equal(["dep1", "dep2"]);
}

[Fact]
public void SerializeDeserialize_DictionaryWithPrimitives_PreservesTypes()
{
// Arrange
var converter = JsonDataConverter.Default;
var input = new Dictionary<string, object>
{
{ "StringValue", "test" },
{ "IntValue", 42 },
{ "BoolValue", true },
{ "DoubleValue", 3.14 },
{ "NullValue", null! }
};

// Act
string serialized = converter.Serialize(input);
Dictionary<string, object>? deserialized = converter.Deserialize<Dictionary<string, object>>(serialized);

// Assert
deserialized.Should().NotBeNull();
Dictionary<string, object> result = deserialized!;
result.Should().HaveCount(5);
// Note: Primitives in Dictionary<string, object> are deserialized as JsonElement
// because they don't have type metadata (primitives are not wrapped).
// This is expected behavior - the converter only wraps complex types.
result["StringValue"].Should().BeOfType<JsonElement>().Subject.GetString().Should().Be("test");
result["IntValue"].Should().BeOfType<JsonElement>().Subject.GetInt32().Should().Be(42);
result["BoolValue"].Should().BeOfType<JsonElement>().Subject.GetBoolean().Should().Be(true);
result["DoubleValue"].Should().BeOfType<JsonElement>().Subject.GetDouble().Should().BeApproximately(3.14, 0.01);
result["NullValue"].Should().BeNull();
}

[Fact]
public void SerializeDeserialize_DictionaryWithNestedObjects_PreservesTypes()
{
// Arrange
var converter = JsonDataConverter.Default;
var inner = new ComponentContext("inner", "type", ["dep"]);
var input = new Dictionary<string, object>
{
{ "Outer", new Dictionary<string, object> { { "Inner", inner } } }
};

// Act
string serialized = converter.Serialize(input);
Dictionary<string, object>? deserialized = converter.Deserialize<Dictionary<string, object>>(serialized);

// Assert
deserialized.Should().NotBeNull();
Dictionary<string, object> result = deserialized!;
var outer = result["Outer"].Should().BeOfType<Dictionary<string, object>>().Subject;
var innerDeserialized = outer["Inner"].Should().BeOfType<ComponentContext>().Subject;
innerDeserialized.Name.Should().Be("inner");
}

[Fact]
public void SerializeDeserialize_ArrayOfComplexTypes_PreservesTypes()
{
// Arrange
var converter = JsonDataConverter.Default;
var items = new ComponentContext[]
{
new("item1", "type1", ["dep1"]),
new("item2", "type2", ["dep2"])
};

// Act
string serialized = converter.Serialize(items);
ComponentContext[]? deserialized = converter.Deserialize<ComponentContext[]>(serialized);

// Assert
deserialized.Should().NotBeNull();
ComponentContext[] result = deserialized!;
result.Should().HaveCount(2);
result[0].Name.Should().Be("item1");
result[1].Name.Should().Be("item2");
}

[Fact]
public void SerializeDeserialize_DictionaryWithArray_PreservesTypes()
{
// Arrange
var converter = JsonDataConverter.Default;
var input = new Dictionary<string, object>
{
{ "Items", new ComponentContext[]
{
new("item1", "type1", ["dep1"]),
new("item2", "type2", ["dep2"])
} }
};

// Act
string serialized = converter.Serialize(input);
Dictionary<string, object>? deserialized = converter.Deserialize<Dictionary<string, object>>(serialized);

// Assert
deserialized.Should().NotBeNull();
Dictionary<string, object> result = deserialized!;
var items = result["Items"].Should().BeOfType<ComponentContext[]>().Subject;
items.Should().HaveCount(2);
items[0].Name.Should().Be("item1");
items[1].Name.Should().Be("item2");
}

[Fact]
public void SerializeDeserialize_NullValue_HandlesCorrectly()
{
// Arrange
var converter = JsonDataConverter.Default;

// Act
string? serialized = converter.Serialize(null);
object? deserialized = converter.Deserialize<object>(serialized);

// Assert
serialized.Should().BeNull();
deserialized.Should().BeNull();
}

[Fact]
public void SerializeDeserialize_JsonElement_UnwrapsTypeMetadata()
{
// Arrange
var converter = JsonDataConverter.Default;
var input = new Dictionary<string, object>
{
{ "Key", "Value" }
};

// Act
string serialized = converter.Serialize(input);
JsonElement deserialized = converter.Deserialize<JsonElement>(serialized);

// Assert
deserialized.ValueKind.Should().Be(JsonValueKind.Object);
deserialized.TryGetProperty("Key", out JsonElement keyElement).Should().BeTrue();
keyElement.GetString().Should().Be("Value");
}

[Fact]
public void SerializeDeserialize_ObjectArray_DoesNotWrap()
{
// Arrange
var converter = JsonDataConverter.Default;
object[] input = ["item1", "item2", "item3"];

// Act
string serialized = converter.Serialize(input);
object[]? deserialized = converter.Deserialize<object[]>(serialized);

// Assert
deserialized.Should().NotBeNull();
object[] result = deserialized!;
result.Should().HaveCount(3);
// Note: Elements in object[] may be deserialized as JsonElement if they don't have type metadata
// This is expected behavior - object[] arrays are not wrapped to maintain raw JSON format
result[0].Should().BeOfType<JsonElement>().Subject.GetString().Should().Be("item1");
result[1].Should().BeOfType<JsonElement>().Subject.GetString().Should().Be("item2");
result[2].Should().BeOfType<JsonElement>().Subject.GetString().Should().Be("item3");

// Verify it's not wrapped with type metadata (should be raw JSON array)
serialized.Should().StartWith("[");
serialized.Should().EndWith("]");
}

[Fact]
public void SerializeDeserialize_JsonNode_DoesNotWrap()
{
// Arrange
var converter = JsonDataConverter.Default;
JsonNode input = JsonNode.Parse("""{"key": "value"}""")!;

// Act
string serialized = converter.Serialize(input);
JsonNode? deserialized = converter.Deserialize<JsonNode>(serialized);

// Assert
deserialized.Should().NotBeNull();
deserialized!["key"]!.GetValue<string>().Should().Be("value");

// Verify it's not wrapped with type metadata
serialized.Should().Contain("\"key\"");
serialized.Should().Contain("\"value\"");
serialized.Should().NotContain("$type");
}
}

Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test coverage for the security-sensitive Type.GetType paths that deserialize untrusted type metadata. Consider adding tests that verify behavior when provided with malformed or malicious type names (e.g., non-existent types, types from unexpected assemblies, or types that should not be allowed). This would help ensure the security of the deserialization process.

Copilot uses AI. Check for mistakes.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections;
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The using directive for System.Collections appears to be unused. The code only uses ICollection, IEnumerable, IList, List, Dictionary<TKey, TValue>, and IDictionary<TKey, TValue>, all of which are in System.Collections.Generic (already imported on line 5). Consider removing this unused using directive.

Suggested change
using System.Collections;

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +39
foreach (JsonConverter converter in options.Converters)
{
if (converter is ObjectWithTypeMetadataJsonConverterFactory)
{
hasConverter = true;
break;
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +160
if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
{
// Check if wrapped type is a generic collection that implements IEnumerable<T>
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);
}
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Suggested change
if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array)
{
// Check if wrapped type is a generic collection that implements IEnumerable<T>
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);
}
}
}
}
if (
wrappedType != null &&
targetType.IsArray &&
valueElement.ValueKind == JsonValueKind.Array &&
wrappedType.IsGenericType &&
(
wrappedType.GetGenericTypeDefinition() == typeof(List<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(IList<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(ICollection<>) ||
wrappedType.GetGenericTypeDefinition() == typeof(IEnumerable<>)
) &&
wrappedType.GetGenericArguments().Length == 1 &&
wrappedType.GetGenericArguments()[0] == targetType.GetElementType()
)
{
string unwrappedJson = valueElement.GetRawText();
return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options);

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +204
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
}
}
}
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Copilot uses AI. Check for mistakes.
@YunchuWang YunchuWang marked this pull request as draft December 11, 2025 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants