// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Aspire.TypeSystem;
namespace Aspire.Hosting.RemoteHost.Ats;
/// <summary>
/// Shared marshalling logic for converting between .NET objects and JSON in the ATS type system.
/// </summary>
internal sealed class AtsMarshaller
{
private readonly HandleRegistry _handles;
private readonly AtsContext _context;
private readonly CancellationTokenRegistry _cancellationTokenRegistry;
private readonly Lazy<AtsCallbackProxyFactory> _callbackProxyFactory;
private readonly ConditionalWeakTable<UnmarshalContext, JsonSerializerOptions> _dtoJsonOptionsByContext = new();
/// <summary>
/// Creates a new marshaller instance.
/// </summary>
/// <param name="handles">The handle registry for marshalling handles.</param>
/// <param name="context">The ATS context for type classification.</param>
/// <param name="cancellationTokenRegistry">The cancellation token registry.</param>
/// <param name="callbackProxyFactory">Lazy callback proxy factory to break circular dependency.</param>
public AtsMarshaller(
HandleRegistry handles,
AtsContext context,
CancellationTokenRegistry cancellationTokenRegistry,
Lazy<AtsCallbackProxyFactory> callbackProxyFactory)
{
_handles = handles;
_context = context;
_cancellationTokenRegistry = cancellationTokenRegistry;
_callbackProxyFactory = callbackProxyFactory;
}
/// <summary>
/// Context for unmarshalling operations, providing error context info.
/// </summary>
internal sealed class UnmarshalContext
{
public string? CapabilityId { get; init; }
public string? ParameterName { get; init; }
}
// NOTE: ReferenceHandler.IgnoreCycles prevents JsonException when serializing DTOs
// with circular references (e.g. parent/child back-references). Cycles are broken by
// writing null for back-edges. This means the TypeScript side will receive null for
// cycle-broken properties. During DTO writeback, those null values will overwrite the
// original non-null references on the C# side — this is intentional, since TypeScript
// cannot represent object cycles and the writeback reflects what TypeScript observed.
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new JsonStringEnumConverter(),
new TimeSpanMillisecondsJsonConverter()
},
ReferenceHandler = ReferenceHandler.IgnoreCycles,
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};
/// <summary>
/// Simple types that can be serialized directly as JSON primitives.
/// </summary>
private static readonly HashSet<Type> s_simpleTypes =
[
typeof(string),
typeof(bool),
typeof(int),
typeof(long),
typeof(double),
typeof(float),
typeof(decimal),
typeof(byte),
typeof(short),
typeof(uint),
typeof(ulong),
typeof(ushort),
typeof(sbyte),
typeof(char),
typeof(DateTime),
typeof(DateTimeOffset),
typeof(DateOnly),
typeof(TimeOnly),
typeof(TimeSpan),
typeof(Guid),
typeof(Uri)
];
/// <summary>
/// Gets the shared JSON serializer options.
/// </summary>
public static JsonSerializerOptions JsonOptions => s_jsonOptions;
/// <summary>
/// Checks if a type is a simple/primitive type that can be serialized directly.
/// </summary>
public static bool IsSimpleType(Type type)
{
if (s_simpleTypes.Contains(type))
{
return true;
}
// Enums are serialized as strings
if (type.IsEnum)
{
return true;
}
// Nullable<T> where T is simple
if (Nullable.GetUnderlyingType(type) is { } underlying)
{
return IsSimpleType(underlying);
}
return false;
}
/// <summary>
/// Marshals a .NET object to JSON for sending to the guest using type metadata.
/// Uses the scanner's type classification instead of runtime type inspection.
/// </summary>
/// <param name="value">The value to marshal.</param>
/// <param name="typeRef">The type metadata from the scanner.</param>
/// <returns>The JSON representation, or null if the value is null.</returns>
public JsonNode? MarshalToJson(object? value, AtsTypeRef typeRef)
{
if (value == null)
{
return null;
}
if (typeRef.TypeId == AtsConstants.CancellationToken && value is CancellationToken cancellationToken)
{
return SerializeCancellationToken(cancellationToken);
}
// Handle 'any' type - fall back to runtime type inspection
if (typeRef.TypeId == TypeSystem.AtsConstants.Any)
{
return MarshalToJson(value);
}
return typeRef.Category switch
{
AtsTypeCategory.Handle => _handles.Marshal(value, typeRef.TypeId),
AtsTypeCategory.Primitive => SerializePrimitive(value),
AtsTypeCategory.Enum => JsonValue.Create(value.ToString()),
AtsTypeCategory.Dto => SerializeDto(value),
AtsTypeCategory.Array => SerializeArray(value, typeRef.ElementType),
AtsTypeCategory.List => _handles.Marshal(value, typeRef.TypeId),
AtsTypeCategory.Dict => _handles.Marshal(value, typeRef.TypeId),
_ => throw new InvalidOperationException($"Unknown type category: {typeRef.Category}")
};
}
/// <summary>
/// Marshals a .NET object to JSON for sending to the guest using a declared CLR type.
/// </summary>
/// <param name="value">The value to marshal.</param>
/// <param name="declaredType">The declared type that should be exposed to the guest.</param>
/// <returns>The JSON representation, or null if the value is null.</returns>
public JsonNode? MarshalToJson(object? value, Type declaredType)
{
if (value == null)
{
return null;
}
if (declaredType == typeof(object))
{
return MarshalToJson(value);
}
if (declaredType == typeof(CancellationToken))
{
return SerializeCancellationToken((CancellationToken)value);
}
var typeId = AtsTypeMapping.DeriveTypeId(declaredType);
var category = _context.GetCategory(declaredType);
return category switch
{
AtsTypeCategory.Primitive => SerializePrimitive(value),
AtsTypeCategory.Enum => JsonValue.Create(value.ToString()),
AtsTypeCategory.Dto => SerializeDto(value),
AtsTypeCategory.Array => SerializeArray(value, CreateElementTypeRef(declaredType)),
AtsTypeCategory.List => _handles.Marshal(value, typeId),
AtsTypeCategory.Dict => _handles.Marshal(value, typeId),
AtsTypeCategory.Handle => _handles.Marshal(value, typeId),
_ => _handles.Marshal(value, typeId)
};
}
private static JsonNode? SerializePrimitive(object value)
{
var type = value.GetType();
// TimeSpan is serialized as total milliseconds for easy JS interop
if (type == typeof(TimeSpan))
{
return JsonValue.Create(((TimeSpan)value).TotalMilliseconds);
}
// DateOnly is serialized as ISO date string (yyyy-MM-dd)
if (type == typeof(DateOnly))
{
return JsonValue.Create(((DateOnly)value).ToString("O", System.Globalization.CultureInfo.InvariantCulture));
}
// TimeOnly is serialized as ISO time string (HH:mm:ss.fffffff)
if (type == typeof(TimeOnly))
{
return JsonValue.Create(((TimeOnly)value).ToString("O", System.Globalization.CultureInfo.InvariantCulture));
}
return JsonValue.Create(value);
}
private static JsonNode? SerializeDto(object value)
{
var json = JsonSerializer.Serialize(value, s_jsonOptions);
return JsonNode.Parse(json);
}
private sealed class TimeSpanMillisecondsJsonConverter : JsonConverter<TimeSpan>
{
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
{
var milliseconds = reader.GetDouble();
if (!double.IsFinite(milliseconds))
{
throw new JsonException("TimeSpan milliseconds must be a finite number.");
}
try
{
return TimeSpan.FromMilliseconds(milliseconds);
}
catch (OverflowException ex)
{
throw new JsonException("TimeSpan milliseconds are outside the supported range.", ex);
}
}
if (reader.TokenType == JsonTokenType.String &&
TimeSpan.TryParse(reader.GetString(), System.Globalization.CultureInfo.InvariantCulture, out var value))
{
return value;
}
throw new JsonException("TimeSpan values must be milliseconds or a standard TimeSpan string.");
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.TotalMilliseconds);
}
}
private object? DeserializeDto(JsonObject jsonObj, Type targetType, UnmarshalContext context)
{
try
{
return jsonObj.Deserialize(targetType, GetDtoJsonOptions(context));
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
throw CapabilityException.InvalidArgument(
context.CapabilityId ?? "unknown",
context.ParameterName ?? "unknown",
$"Failed to deserialize DTO '{targetType.Name}': {ex.Message}");
}
}
private JsonSerializerOptions GetDtoJsonOptions(UnmarshalContext context)
{
return _dtoJsonOptionsByContext.GetValue(context, CreateDtoJsonOptions);
}
private JsonSerializerOptions CreateDtoJsonOptions(UnmarshalContext context)
{
var options = new JsonSerializerOptions(s_jsonOptions);
options.PreferredObjectCreationHandling = JsonObjectCreationHandling.Replace;
options.Converters.Add(new CallbackJsonConverterFactory(this, context));
options.Converters.Add(new ReferenceExpressionJsonConverterFactory(this, context));
return options;
}
private sealed class CallbackJsonConverterFactory(AtsMarshaller marshaller, UnmarshalContext context) : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return typeof(Delegate).IsAssignableFrom(typeToConvert);
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return (JsonConverter)Activator.CreateInstance(typeof(CallbackJsonConverter<>).MakeGenericType(typeToConvert), marshaller, context)!;
}
}
private sealed class CallbackJsonConverter<TDelegate>(AtsMarshaller marshaller, UnmarshalContext context) : JsonConverter<TDelegate>
where TDelegate : Delegate
{
public override TDelegate? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType != JsonTokenType.String)
{
throw CapabilityException.InvalidArgument(
context.CapabilityId ?? "unknown",
context.ParameterName ?? "unknown",
"Callback parameter must be a string callback ID");
}
var callbackId = reader.GetString();
var proxy = marshaller.CreateCallbackProxy(callbackId, typeToConvert, context);
return (TDelegate?)proxy;
}
public override void Write(Utf8JsonWriter writer, TDelegate value, JsonSerializerOptions options)
{
throw new NotSupportedException();
}
}
private object? CreateCallbackProxy(string? callbackId, Type callbackType, UnmarshalContext context)
{
if (string.IsNullOrEmpty(callbackId))
{
throw CapabilityException.InvalidArgument(
context.CapabilityId ?? "unknown",
context.ParameterName ?? "unknown",
"Callback parameter must be a string callback ID");
}
try
{
var proxy = _callbackProxyFactory.Value.CreateProxy(callbackId, callbackType);
return proxy ?? throw CapabilityException.InvalidArgument(
context.CapabilityId ?? "unknown",
context.ParameterName ?? "unknown",
$"Failed to create callback proxy for type '{callbackType.Name}'");
}
catch (Exception ex) when (ex is not CapabilityException)
{
throw CapabilityException.InvalidArgument(
context.CapabilityId ?? "unknown",
context.ParameterName ?? "unknown",
$"Callback proxy factory not available: {ex.Message}");
}
}
private sealed class ReferenceExpressionJsonConverterFactory(AtsMarshaller marshaller, UnmarshalContext context) : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.FullName == "Aspire.Hosting.ApplicationModel.ReferenceExpression";
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return (JsonConverter)Activator.CreateInstance(typeof(ReferenceExpressionJsonConverter<>).MakeGenericType(typeToConvert), marshaller, context)!;
}
}
private sealed class ReferenceExpressionJsonConverter<TReferenceExpression>(AtsMarshaller marshaller, UnmarshalContext context) : JsonConverter<TReferenceExpression>
{
public override TReferenceExpression? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var node = JsonNode.Parse(ref reader);
return (TReferenceExpression?)marshaller.UnmarshalFromJson(node, typeToConvert, context);
}
public override void Write(Utf8JsonWriter writer, TReferenceExpression value, JsonSerializerOptions options)
{
throw new NotSupportedException();
}
}
private JsonNode? SerializeArray(object value, AtsTypeRef? elementType)
{
var jsonArray = new JsonArray();
foreach (var item in (IEnumerable)value)
{
if (elementType != null)
{
jsonArray.Add(MarshalToJson(item, elementType));
}
else
{
jsonArray.Add(MarshalToJson(item));
}
}
return jsonArray;
}
private AtsTypeRef? CreateElementTypeRef(Type declaredType)
{
if (declaredType.IsArray)
{
return CreateTypeRef(declaredType.GetElementType()!);
}
if (declaredType.IsGenericType)
{
var genericTypeDefinition = declaredType.GetGenericTypeDefinition();
if (genericTypeDefinition == typeof(IReadOnlyList<>)
|| genericTypeDefinition == typeof(IReadOnlyCollection<>)
|| genericTypeDefinition == typeof(IEnumerable<>))
{
return CreateTypeRef(declaredType.GetGenericArguments()[0]);
}
}
return null;
}
private AtsTypeRef CreateTypeRef(Type type)
{
return new AtsTypeRef
{
TypeId = AtsTypeMapping.DeriveTypeId(type),
ClrType = type,
Category = _context.GetCategory(type)
};
}
/// <summary>
/// Marshals a .NET object to JSON for sending to the guest.
/// Uses runtime type inspection based on scanned AtsContext.
/// </summary>
/// <param name="value">The value to marshal.</param>
/// <returns>The JSON representation, or null if the value is null.</returns>
public JsonNode? MarshalToJson(object? value)
{
if (value == null)
{
return null;
}
var type = value.GetType();
if (type == typeof(CancellationToken))
{
return SerializeCancellationToken((CancellationToken)value);
}
var category = _context.GetCategory(type);
return category switch
{
AtsTypeCategory.Primitive => SerializePrimitive(value),
AtsTypeCategory.Enum => JsonValue.Create(value.ToString()),
AtsTypeCategory.Dto => SerializeDto(value),
AtsTypeCategory.Array => SerializeArrayRuntime(value),
AtsTypeCategory.List => MarshalListHandle(value, type),
AtsTypeCategory.Dict => MarshalDictHandle(value, type),
AtsTypeCategory.Handle => _handles.Marshal(value, AtsTypeMapping.DeriveTypeId(type)),
_ => _handles.Marshal(value, AtsTypeMapping.DeriveTypeId(type))
};
}
private JsonNode? SerializeArrayRuntime(object value)
{
var jsonArray = new JsonArray();
foreach (var item in (IEnumerable)value)
{
jsonArray.Add(MarshalToJson(item));
}
return jsonArray;
}
private JsonNode? SerializeCancellationToken(CancellationToken cancellationToken)
{
if (cancellationToken == CancellationToken.None)
{
return null;
}
var (tokenId, _) = _cancellationTokenRegistry.CreateLinked(cancellationToken);
return JsonValue.Create(tokenId);
}
private JsonNode? MarshalListHandle(object value, Type type)
{
if (type.IsGenericType)
{
var genericArgs = type.GetGenericArguments();
if (genericArgs.Length == 1)
{
var typeId = $"Aspire.Hosting/List<{genericArgs[0].Name}>";
return _handles.Marshal(value, typeId);
}
}
// Fallback for non-generic lists
return _handles.Marshal(value, AtsTypeMapping.DeriveTypeId(type));
}
private JsonNode? MarshalDictHandle(object value, Type type)
{
if (type.IsGenericType)
{
var genericArgs = type.GetGenericArguments();
if (genericArgs.Length == 2)
{
var typeId = $"Aspire.Hosting/Dict<{genericArgs[0].Name},{genericArgs[1].Name}>";
return _handles.Marshal(value, typeId);
}
}
// Fallback for non-generic dicts
return _handles.Marshal(value, AtsTypeMapping.DeriveTypeId(type));
}
/// <summary>
/// Unmarshals a JSON node to a .NET object of the specified type.
/// Handles: primitives, arrays, lists, dictionaries, [AspireDto] types, and delegate callbacks.
/// </summary>
/// <param name="node">The JSON node to unmarshal.</param>
/// <param name="targetType">The target .NET type.</param>
/// <param name="context">The unmarshalling context with registries and error info.</param>
/// <returns>The unmarshalled .NET object.</returns>
public object? UnmarshalFromJson(JsonNode? node, Type targetType, UnmarshalContext context)
{
if (node == null)
{
if (targetType == typeof(CancellationToken))
{
return CancellationToken.None;
}
return null;
}
var capabilityId = context.CapabilityId ?? "unknown";
var paramName = context.ParameterName ?? "unknown";
// Check for handle reference
var handleRef = HandleRef.FromJsonNode(node);
if (handleRef != null)
{
if (!_handles.TryGet(handleRef.HandleId, out var handleObj, out _))
{
throw CapabilityException.HandleNotFound(handleRef.HandleId, capabilityId);
}
return handleObj;
}
// Check for reference expression (value or conditional)
// Value format: { "$expr": { "format": "...", "valueProviders": [...] } }
// Conditional format: { "$expr": { "condition": <handle>, "whenTrue": <$expr>, "whenFalse": <$expr> } }
var exprRef = ReferenceExpressionRef.FromJsonNode(node);
if (exprRef != null)
{
return exprRef.ToReferenceExpression(_handles, capabilityId, paramName);
}
if (targetType == typeof(object))
{
return node is JsonValue value ? ConvertPrimitive(value, targetType) : node;
}
// Handle callbacks - any delegate type is treated as a callback
if (typeof(Delegate).IsAssignableFrom(targetType))
{
// Callback ID is passed as a string
if (node is JsonValue callbackValue && callbackValue.TryGetValue<string>(out var callbackId))
{
return CreateCallbackProxy(callbackId, targetType, context);
}
else
{
throw CapabilityException.InvalidArgument(
capabilityId, paramName,
"Callback parameter must be a string callback ID");
}
}
// Handle CancellationToken - token ID is passed as a string, or null/missing for CancellationToken.None
if (targetType == typeof(CancellationToken))
{
// Token ID as string - get or create a token for this ID
if (node is JsonValue tokenValue && tokenValue.TryGetValue<string>(out var tokenId) && !string.IsNullOrEmpty(tokenId))
{
// Get or create a CancellationToken for this guest-provided ID
// The guest can later cancel this token by calling cancelToken RPC
return _cancellationTokenRegistry.GetOrCreate(tokenId);
}
// null, empty, or not a string means no cancellation
return CancellationToken.None;
}
try
{
// Handle primitives
if (node is JsonValue value)
{
return ConvertPrimitive(value, targetType);
}
// Handle JsonArray -> Array or List<T>
if (node is JsonArray array)
{
// T[] arrays
if (targetType.IsArray)
{
var elementType = targetType.GetElementType()!;
var converted = Array.CreateInstance(elementType, array.Count);
for (int i = 0; i < array.Count; i++)
{
var elementContext = new UnmarshalContext
{
CapabilityId = context.CapabilityId,
ParameterName = $"{paramName}[{i}]"
};
converted.SetValue(UnmarshalFromJson(array[i], elementType, elementContext), i);
}
return converted;
}
// List<T> or IList<T>
if (targetType.IsGenericType)
{
var genericDef = targetType.GetGenericTypeDefinition();
if (genericDef == typeof(List<>) || genericDef == typeof(IList<>) || genericDef == typeof(IEnumerable<>) || genericDef == typeof(ICollection<>) || genericDef == typeof(IReadOnlyList<>) || genericDef == typeof(IReadOnlyCollection<>))
{
var elementType = targetType.GetGenericArguments()[0];
var listType = typeof(List<>).MakeGenericType(elementType);
var list = (IList)Activator.CreateInstance(listType)!;
for (int i = 0; i < array.Count; i++)
{
var elementContext = new UnmarshalContext
{
CapabilityId = context.CapabilityId,
ParameterName = $"{paramName}[{i}]"
};
list.Add(UnmarshalFromJson(array[i], elementType, elementContext));
}
return list;
}
}
}
// Handle JsonObject -> Dictionary<string, T> or DTO
if (node is JsonObject jsonObj)
{
// Dictionary<string, T> or IDictionary<string, T>
if (targetType.IsGenericType)
{
var genericDef = targetType.GetGenericTypeDefinition();
if (genericDef == typeof(Dictionary<,>) || genericDef == typeof(IDictionary<,>) || genericDef == typeof(IReadOnlyDictionary<,>))
{
var genericArgs = targetType.GetGenericArguments();
if (genericArgs[0] == typeof(string))
{
var valueType = genericArgs[1];
var dictType = typeof(Dictionary<,>).MakeGenericType(typeof(string), valueType);
var dict = (IDictionary)Activator.CreateInstance(dictType)!;
foreach (var prop in jsonObj)
{
var valueContext = new UnmarshalContext
{
CapabilityId = context.CapabilityId,
ParameterName = $"{paramName}[{prop.Key}]"
};
dict[prop.Key] = UnmarshalFromJson(prop.Value, valueType, valueContext);
}
return dict;
}
}
}
// DTOs - must have [AspireDto] attribute
if (!AttributeDataReader.HasAspireDtoData(targetType))
{
throw CapabilityException.InvalidArgument(
capabilityId, paramName,
$"Parameter type '{targetType.Name}' must have [AspireDto] attribute to be deserialized from JSON");
}
return DeserializeDto(jsonObj, targetType, context);
}
return null;
}
catch (CapabilityException)
{
throw;
}
catch (Exception ex) when (IsTypeMismatchException(ex))
{
throw CapabilityException.TypeMismatch(
capabilityId, paramName, DescribeType(targetType), DescribeJsonNode(node));
}
catch (JsonException ex)
{
throw CapabilityException.InvalidArgument(
capabilityId, paramName, $"Failed to deserialize '{paramName}' as {DescribeType(targetType)}: {ex.Message}");
}
}
/// <summary>
/// Checks if an exception indicates a type mismatch.
/// </summary>
private static bool IsTypeMismatchException(Exception ex)
{
// Check for common type mismatch patterns in exception messages
var message = ex.Message;
return message.Contains("cannot be converted") ||
message.Contains("could not be converted") ||
message.Contains("is not assignable") ||
message.Contains("type mismatch", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Describes a JSON node's type for error reporting.
/// </summary>
private static string DescribeJsonNode(JsonNode? node)
{
return node switch
{
null => "null",
JsonValue v when v.TryGetValue<string>(out _) => "string",
JsonValue v when v.TryGetValue<bool>(out _) => "bool",
JsonValue v when v.TryGetValue<long>(out _) => "number",
JsonValue v when v.TryGetValue<double>(out _) => "number",
JsonValue => "value",
JsonArray => "array",
JsonObject obj when obj.ContainsKey("$handle") => "handle",
JsonObject => "object",
_ => node.GetType().Name
};
}
/// <summary>
/// Describes a .NET type in a human-readable way for error messages.
/// Formats Nullable<T> as the underlying type.
/// </summary>
private static string DescribeType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
{
return underlying.ToString();
}
return type.ToString();
}
// IEEE 754 doubles can exactly represent integers only through 2^53 - 1.
private const double MaxSafeIntegerDouble = 9_007_199_254_740_991d;
private static decimal ReadDecimalValue(JsonValue value, Type targetType)
{
if (value.TryGetValue<int>(out var intValue))
{
return intValue;
}
// Prefer decimal here because this helper is only used for decimal targets.
if (value.TryGetValue<decimal>(out var decimalValue))
{
return decimalValue;
}
if (value.TryGetValue<long>(out var longValue))
{
return longValue;
}
if (value.TryGetValue<ulong>(out var ulongValue))
{
return ulongValue;
}
if (value.TryGetValue<double>(out var doubleValue) && double.IsFinite(doubleValue))
{
try
{
return (decimal)doubleValue;
}
catch (OverflowException)
{
throw new InvalidCastException($"Value cannot be converted to {targetType.Name}.");
}
}
throw new InvalidCastException($"Value cannot be converted to {targetType.Name}.");
}
private static decimal ReadIntegralNumericValue(JsonValue value, Type targetType, decimal minValue, decimal maxValue)
{
if (value.TryGetValue<int>(out var intValue))
{
return ValidateIntegralNumericValue(intValue, targetType, minValue, maxValue);
}
if (value.TryGetValue<long>(out var longValue))
{
return ValidateIntegralNumericValue(longValue, targetType, minValue, maxValue);
}
if (value.TryGetValue<ulong>(out var ulongValue))
{
return ValidateIntegralNumericValue(ulongValue, targetType, minValue, maxValue);
}
if (value.TryGetValue<decimal>(out var decimalValue))
{
return ValidateIntegralNumericValue(decimalValue, targetType, minValue, maxValue);
}
if (value.TryGetValue<double>(out var doubleValue) && double.IsFinite(doubleValue))
{
if (Math.Abs(doubleValue) > MaxSafeIntegerDouble)
{
throw new InvalidCastException($"Value cannot be converted to {targetType.Name}.");
}
return ValidateIntegralNumericValue((decimal)doubleValue, targetType, minValue, maxValue);
}
throw new InvalidCastException($"Value cannot be converted to {targetType.Name}.");
}
private static decimal ValidateIntegralNumericValue(decimal numericValue, Type targetType, decimal minValue, decimal maxValue)
{
if (decimal.Truncate(numericValue) != numericValue || numericValue < minValue || numericValue > maxValue)
{
throw new InvalidCastException($"Value cannot be converted to {targetType.Name}.");
}
return numericValue;
}
/// <summary>
/// Converts a JSON primitive to the target .NET type.
/// </summary>
public static object? ConvertPrimitive(JsonValue value, Type targetType)
{
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
// When target type is object (union parameter), infer from JSON value
if (underlyingType == typeof(object))
{
if (value.TryGetValue<string>(out var s))
{
return s;
}
if (value.TryGetValue<bool>(out var b))
{
return b;
}
if (value.TryGetValue<long>(out var l))
{
return l;
}
if (value.TryGetValue<double>(out var d))
{
return d;
}
return value.ToJsonString();
}
// Handle enums - they come as string names
if (underlyingType.IsEnum)
{
if (value.TryGetValue<string>(out var enumName))
{
return Enum.Parse(underlyingType, enumName, ignoreCase: true);
}
// Also support numeric enum values
if (value.TryGetValue<int>(out var enumValue))
{
return Enum.ToObject(underlyingType, enumValue);
}
}
if (underlyingType == typeof(string))
{
return value.GetValue<string>();
}
if (underlyingType == typeof(bool))
{
return value.GetValue<bool>();
}
if (underlyingType == typeof(int))
{
return decimal.ToInt32(ReadIntegralNumericValue(value, underlyingType, int.MinValue, int.MaxValue));
}
if (underlyingType == typeof(long))
{
return decimal.ToInt64(ReadIntegralNumericValue(value, underlyingType, long.MinValue, long.MaxValue));
}
if (underlyingType == typeof(byte))
{
return decimal.ToByte(ReadIntegralNumericValue(value, underlyingType, byte.MinValue, byte.MaxValue));
}
if (underlyingType == typeof(short))
{
return decimal.ToInt16(ReadIntegralNumericValue(value, underlyingType, short.MinValue, short.MaxValue));
}
if (underlyingType == typeof(uint))
{
return decimal.ToUInt32(ReadIntegralNumericValue(value, underlyingType, uint.MinValue, uint.MaxValue));
}
if (underlyingType == typeof(ulong))
{
return decimal.ToUInt64(ReadIntegralNumericValue(value, underlyingType, ulong.MinValue, ulong.MaxValue));
}
if (underlyingType == typeof(ushort))
{
return decimal.ToUInt16(ReadIntegralNumericValue(value, underlyingType, ushort.MinValue, ushort.MaxValue));
}
if (underlyingType == typeof(sbyte))
{
return decimal.ToSByte(ReadIntegralNumericValue(value, underlyingType, sbyte.MinValue, sbyte.MaxValue));
}
if (underlyingType == typeof(double))
{
return value.GetValue<double>();
}
if (underlyingType == typeof(float))
{
return value.TryGetValue<float>(out var floatValue) ? floatValue : (float)value.GetValue<double>();
}
if (underlyingType == typeof(decimal))
{
return ReadDecimalValue(value, underlyingType);
}
// TimeSpan: accept milliseconds (number) or parseable string
if (underlyingType == typeof(TimeSpan))
{
if (value.TryGetValue<double>(out var ms))
{
return TimeSpan.FromMilliseconds(ms);
}
if (value.TryGetValue<string>(out var str))
{
// Support standard TimeSpan format (e.g., "01:30:00") or ISO 8601 duration
return TimeSpan.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
}
}
// DateOnly: accept ISO date string (yyyy-MM-dd)
if (underlyingType == typeof(DateOnly))
{
if (value.TryGetValue<string>(out var str))
{
return DateOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
}
}
// TimeOnly: accept ISO time string (HH:mm:ss.fffffff)
if (underlyingType == typeof(TimeOnly))
{
if (value.TryGetValue<string>(out var str))
{
return TimeOnly.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
}
}
return JsonSerializer.Deserialize(value.ToJsonString(), targetType, s_jsonOptions);
}
/// <summary>
/// Checks if a type is a DTO type (marked with [AspireDto] and registered in the context).
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns><c>true</c> if the type is a DTO type; otherwise, <c>false</c>.</returns>
public bool IsDtoType(Type type)
{
return _context.GetCategory(type) == AtsTypeCategory.Dto;
}
/// <summary>
/// Applies property values from a JSON object to an existing DTO instance.
/// Used for applying mutations made in callbacks back to the original objects.
/// Only writable properties and public fields whose types can be deserialized
/// from the JSON are updated. Properties with complex types that lack a
/// parameterless constructor (e.g. <c>EndpointReference</c>) are silently skipped.
/// </summary>
/// <param name="source">The JSON object containing the updated values.</param>
/// <param name="target">The existing DTO instance to update.</param>
/// <param name="targetType">The CLR type of the target object.</param>
/// <remarks>
/// <para>
/// Nested DTO properties are deserialized as new instances, not patched in-place.
/// External references to the original nested object will not see the mutations.
/// </para>
/// <para>
/// When <c>ReferenceHandler.IgnoreCycles</c> is active, cycle-broken properties
/// arrive as <c>null</c> and will overwrite the original non-null reference.
/// </para>
/// </remarks>
// Instance method for consistency with IsDtoType and future extensibility.
#pragma warning disable CA1822 // Member does not access instance data
public void ApplyDtoProperties(JsonObject source, object target, Type targetType)
#pragma warning restore CA1822
{
// Use JsonTypeInfo metadata instead of raw reflection so that
// [JsonPropertyName], [JsonIgnore], the naming policy, and field
// inclusion configuration are all respected automatically.
var typeInfo = s_jsonOptions.GetTypeInfo(targetType);
foreach (var prop in typeInfo.Properties)
{
if (prop.Set is null)
{
continue;
}
if (!source.TryGetPropertyValue(prop.Name, out var jsonValue))
{
continue;
}
try
{
prop.Set(target, jsonValue.Deserialize(prop.PropertyType, s_jsonOptions));
}
catch (NotSupportedException)
{
// Silently skip properties whose types can't be deserialized by
// System.Text.Json (e.g. EndpointReference with no parameterless
// constructor). This is expected for DTO types that carry complex
// navigation properties alongside simple writable data.
}
catch (JsonException)
{
// Silently skip properties where the JSON value is incompatible
// with the CLR property type (e.g. string sent for an int field).
// This is a best-effort writeback — partial updates are acceptable.
}
}
}
}