< Summary

Information
Class: Elsa.Workflows.Serialization.Converters.PolymorphicObjectConverter
Assembly: Elsa.Workflows.Core
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs
Line coverage
87%
Covered lines: 198
Uncovered lines: 28
Coverable lines: 226
Total lines: 503
Line coverage: 87.6%
Branch coverage
84%
Covered branches: 172
Total branches: 203
Branch coverage: 84.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
.ctor()100%210%
Read(...)65.15%786686.11%
Write(...)82.35%423481.25%
IsPrimitive()100%2424100%
ReadType(...)88.88%292785%
WriteTypeMetadata(...)100%22100%
WarnAboutUnaliasedType(...)100%66100%
GetInstantiableTargetType(...)87.5%8885.71%
ReadPrimitive(...)87.5%8890%
ReadObject(...)60.71%732861.29%
EscapeKey(...)100%11100%
UnescapeKey(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs

#LineLine coverage
 1using System.Collections;
 2using System.Collections.Concurrent;
 3using System.Dynamic;
 4using System.Reflection;
 5using System.Text.Json;
 6using System.Text.Json.Nodes;
 7using System.Text.Json.Serialization;
 8using Elsa.Extensions;
 9using Elsa.Workflows.Serialization.ReferenceHandlers;
 10using Newtonsoft.Json.Linq;
 11using Elsa.Common.Serialization;
 12using Microsoft.Extensions.Logging;
 13
 14namespace Elsa.Workflows.Serialization.Converters;
 15
 16/// <summary>
 17/// Reads objects as primitive types rather than <see cref="JsonElement"/> values while also maintaining the .NET type n
 18/// </summary>
 19public class PolymorphicObjectConverter : JsonConverter<object>
 20{
 21    private const string TypePropertyName = "_type";
 22    private const string ItemsPropertyName = "_items";
 23    private const string IslandPropertyName = "_island";
 24    private const string IdPropertyName = "$id";
 25    private const string RefPropertyName = "$ref";
 26    private const string ValuesPropertyName = "$values";
 27    private readonly ISerializationTypeRegistry _workflowJsonTypeRegistry;
 28    private readonly ILogger? _logger;
 29
 30    // Types already reported as having no alias. Append-only and bounded by the number of distinct payload types
 31    // the process ever serializes, so that the warning below is emitted once per type rather than once per write.
 132    private static readonly ConcurrentDictionary<Type, byte> ReportedUnaliasedTypes = new();
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
 36    /// </summary>
 5381537    public PolymorphicObjectConverter(ISerializationTypeRegistry workflowJsonTypeRegistry, ILogger? logger = null)
 38    {
 5381539        _workflowJsonTypeRegistry = workflowJsonTypeRegistry;
 5381540        _logger = logger;
 5381541    }
 42
 43    /// <summary>
 44    /// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
 45    /// </summary>
 046    public PolymorphicObjectConverter()
 47    {
 048        _workflowJsonTypeRegistry = SerializationTypeRegistry.CreateDefault();
 049    }
 50
 51    /// <inheritdoc />
 52    public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
 53    {
 3888054        var newOptions = options.Clone();
 55
 3888056        if (reader.TokenType != JsonTokenType.StartObject && reader.TokenType != JsonTokenType.StartArray)
 2462457            return ReadPrimitive(ref reader, newOptions);
 58
 1425659        var targetType = ReadType(reader, options);
 1425360        if (targetType == null)
 1282161            return ReadObject(ref reader, newOptions);
 62
 143263        targetType = GetInstantiableTargetType(targetType);
 64
 65        // If the target type is not an IEnumerable, or is a dictionary, deserialize the object directly.
 143166        var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType);
 67
 143168        if (!isEnumerable)
 69        {
 70            try
 71            {
 66472                return JsonSerializer.Deserialize(ref reader, targetType, newOptions)!;
 73            }
 074            catch (Exception e) when (e is NotSupportedException or TargetException)
 75            {
 076                return default!;
 77            }
 78        }
 79
 80        // If the target type is a Newtonsoft.JObject, parse the JSON island.
 76781        var isNewtonsoftObject = targetType == typeof(JObject);
 82
 76783        if (isNewtonsoftObject)
 84        {
 185            var parsedModel = JsonElement.ParseValue(ref reader);
 186            var newtonsoftJson = parsedModel.GetProperty(IslandPropertyName).GetString();
 187            return !string.IsNullOrWhiteSpace(newtonsoftJson) ? JObject.Parse(newtonsoftJson) : new JObject();
 88        }
 89
 90        // If the target type is a Newtonsoft.JArray, parse the JSON island.
 76691        var isNewtonsoftArray = targetType == typeof(JArray);
 92
 76693        if (isNewtonsoftArray)
 94        {
 195            var parsedModel = JsonElement.ParseValue(ref reader);
 196            var newtonsoftJson = parsedModel.GetProperty(IslandPropertyName).GetString();
 197            return !string.IsNullOrWhiteSpace(newtonsoftJson) ? JArray.Parse(newtonsoftJson) : new JArray();
 98        }
 99
 100        // If the target type is a System.Text.JsonObject, parse the JSON island.
 765101        var isJsonObject = targetType == typeof(JsonObject);
 102
 765103        if (isJsonObject)
 104        {
 1105            var parsedModel = JsonElement.ParseValue(ref reader);
 1106            var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString();
 1107            return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonObject();
 108        }
 109
 764110        var isJsonArray = targetType == typeof(JsonArray);
 111
 764112        if (isJsonArray)
 113        {
 1114            var parsedModel = JsonElement.ParseValue(ref reader);
 1115            var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString();
 1116            return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonArray();
 117        }
 118
 763119        var isDictionary = typeof(IDictionary).IsAssignableFrom(targetType);
 763120        if (isDictionary)
 121        {
 122            // Remove the _type property name from the JSON, if any.
 730123            var parsedNode = JsonNode.Parse(ref reader)!;
 1460124            if (parsedNode is JsonObject parsedModel) parsedModel.Remove(TypePropertyName);
 730125            return parsedNode.Deserialize(targetType, newOptions)!;
 126        }
 127
 33128        var isCollection = typeof(ICollection).IsAssignableFrom(targetType);
 129
 130        // Otherwise, deserialize the object as an array.
 33131        var elementType = targetType.IsArray
 33132            ? targetType.GetElementType()
 33133            : targetType.GenericTypeArguments.FirstOrDefault() ??
 33134              (isCollection // Could be a class derived from Collection<T> or List<T>.
 33135                  ? targetType.BaseType?.GenericTypeArguments[0]
 33136                  : targetType.GenericTypeArguments.FirstOrDefault()
 33137                    ?? typeof(object));
 33138        if (elementType == null)
 0139            throw new InvalidOperationException($"Cannot determine the element type of array '{targetType}'.");
 140
 33141        var model = JsonElement.ParseValue(ref reader);
 33142        var referenceResolver = (newOptions.ReferenceHandler as CrossScopedReferenceHandler)?.GetResolver();
 143
 33144        if (model.TryGetProperty(RefPropertyName, out var refProperty))
 145        {
 0146            var refId = refProperty.GetString()!;
 0147            return referenceResolver?.ResolveReference(refId)!;
 148        }
 149
 33150        var values = model.TryGetProperty(ItemsPropertyName, out var itemsProp) ? itemsProp.EnumerateArray().ToList() : 
 33151        var id = model.TryGetProperty(IdPropertyName, out var idProp) ? idProp.GetString() : default;
 33152        var collection = targetType.IsArray ? Array.CreateInstance(elementType, values.Count) : Activator.CreateInstance
 33153        var index = 0;
 154
 33155        if (id != null)
 5156            referenceResolver?.AddReference(id, collection);
 157
 33158        var isHashSet = targetType.GenericTypeArguments.Length == 1 && typeof(ISet<>).MakeGenericType(targetType.Generic
 33159        var addSetMethod = targetType.GetMethod("Add", [elementType])!;
 160
 172161        foreach (var element in values)
 162        {
 53163            var deserializedElement = JsonSerializer.Deserialize(JsonSerializer.Serialize(element), elementType, newOpti
 53164            if (collection is Array array)
 165            {
 36166                array.SetValue(deserializedElement, index++);
 167            }
 17168            else if (isHashSet)
 169            {
 1170                addSetMethod.Invoke(collection, [
 1171                    deserializedElement
 1172                ]);
 173            }
 16174            else if (collection is IList list)
 175            {
 16176                list.Add(deserializedElement);
 177            }
 178        }
 179
 33180        return collection;
 664181    }
 182
 183    /// <inheritdoc />
 184    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
 185    {
 16701186        if (value == null!)
 187        {
 104188            writer.WriteNullValue();
 104189            return;
 190        }
 191
 16597192        var newOptions = options.Clone();
 16597193        var type = value.GetType();
 194
 195        // If the type is a primitive type or an enumerable of a primitive type, serialize the value directly.
 196        bool IsPrimitive(Type valueType)
 197        {
 16597198            return type.IsPrimitive
 16597199                   || valueType == typeof(string)
 16597200                   || valueType == typeof(decimal)
 16597201                   || valueType == typeof(DateTimeOffset)
 16597202                   || valueType == typeof(DateTime)
 16597203                   || valueType == typeof(DateOnly)
 16597204                   || valueType == typeof(TimeOnly)
 16597205                   || valueType == typeof(JsonElement)
 16597206                   || valueType == typeof(Guid)
 16597207                   || valueType == typeof(TimeSpan)
 16597208                   || valueType == typeof(Uri)
 16597209                   || valueType == typeof(Version)
 16597210                   || valueType.IsEnum;
 211        }
 212
 16597213        if (IsPrimitive(type))
 214        {
 215            // Remove the converter so that we don't end up in an infinite loop.
 42912216            newOptions.Converters.RemoveWhere(x => x is PolymorphicObjectConverterFactory);
 217
 218            // Serialize the value directly.
 3304219            JsonSerializer.Serialize(writer, value, newOptions);
 3304220            return;
 221        }
 222
 223        // Special case for Newtonsoft.Json and System.Text.Json types.
 224        // Newtonsoft.Json types are not supported by the System.Text.Json serializer and should be written as a string 
 225        // We include metadata about the type so that we can deserialize it later.
 13293226        if (type == typeof(JObject) || type == typeof(JArray) || type == typeof(JsonObject) || type == typeof(JsonArray)
 227        {
 25228            writer.WriteStartObject();
 25229            writer.WriteString(IslandPropertyName, value.ToString());
 25230            WriteTypeMetadata(writer, type);
 25231            writer.WriteEndObject();
 25232            return;
 233        }
 234
 235        // Determine if the value is going to be serialized for the first time.
 236        // Later on, we need to know this information to determine if we need to write the type name or not, so that we 
 13268237        var shouldWriteTypeField = true;
 13268238        var referenceResolver = (CustomPreserveReferenceResolver?)(newOptions.ReferenceHandler as CrossScopedReferenceHa
 239
 13268240        if (referenceResolver != null)
 241        {
 132242            var exists = referenceResolver.HasReference(value);
 132243            shouldWriteTypeField = !exists;
 244        }
 245
 246        // Before we serialize the value, check to see if it's an ExpandoObject.
 247        // If it is, we need to sanitize its property names, because they can contain invalid characters.
 13268248        if (value is ExpandoObject)
 249        {
 1250            var sanitized = new ExpandoObject();
 1251            var dictionary = (IDictionary<string, object?>)sanitized;
 1252            var expando = (IDictionary<string, object?>)value;
 253
 8254            foreach (var kvp in expando)
 255            {
 3256                var key = EscapeKey(kvp.Key);
 3257                dictionary[key] = kvp.Value;
 258            }
 259
 1260            value = sanitized;
 261        }
 262
 13268263        var jsonElement = JsonDocument.Parse(JsonSerializer.Serialize(value, type, newOptions)).RootElement;
 264
 265        // If the value is a string, serialize it directly.
 13268266        if (jsonElement.ValueKind == JsonValueKind.String)
 267        {
 268            // Serialize the value directly.
 0269            JsonSerializer.Serialize(writer, jsonElement, newOptions);
 0270            return;
 271        }
 272
 273        // If the value was serialized as a primitive by another converter,
 274        // write it directly instead of assuming an object structure.
 13268275        if (jsonElement.ValueKind != JsonValueKind.Object &&
 13268276            jsonElement.ValueKind != JsonValueKind.Array)
 277        {
 0278            jsonElement.WriteTo(writer);
 0279            return;
 280        }
 281
 13268282        writer.WriteStartObject();
 283
 13268284        if (jsonElement.ValueKind == JsonValueKind.Array)
 285        {
 745286            writer.WritePropertyName(ItemsPropertyName);
 745287            jsonElement.WriteTo(writer);
 288        }
 289        else
 290        {
 88151291            foreach (var property in jsonElement.EnumerateObject().Where(property => !property.NameEquals(TypePropertyNa
 292            {
 21035293                writer.WritePropertyName(property.Name);
 21035294                property.Value.WriteTo(writer);
 295            }
 296        }
 297
 13268298        if (type != typeof(ExpandoObject))
 299        {
 13267300            if (shouldWriteTypeField)
 13267301                WriteTypeMetadata(writer, type);
 302        }
 303
 13268304        writer.WriteEndObject();
 13268305    }
 306
 307    private Type? ReadType(Utf8JsonReader reader, JsonSerializerOptions options)
 308    {
 14256309        if (reader.TokenType != JsonTokenType.StartObject)
 2603310            return null;
 311
 11653312        reader.Read(); // Move to the first token inside the object.
 11653313        string? typeName = null;
 314
 315        // Read while we haven't reached the end of the object.
 53653316        while (reader.TokenType != JsonTokenType.EndObject)
 317        {
 318            // If we find the _type property, read its value and break out of the loop.
 43435319            if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals(TypePropertyName))
 320            {
 1435321                reader.Read(); // Move to the value of the _type property
 1435322                if (options.Converters.OfType<TypeJsonConverter>().FirstOrDefault() is { } typeJsonConverter)
 323                {
 1433324                    return typeJsonConverter.Read(ref reader, typeof(Type), options);
 325                }
 326                else
 327                {
 2328                    typeName = reader.GetString();
 329                }
 2330                break;
 331            }
 332
 333            // Skip through nested objects and arrays.
 42000334            if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray)
 335            {
 6489336                var depth = 1;
 337
 36437338                while (depth > 0 && reader.Read())
 339                {
 29948340                    switch (reader.TokenType)
 341                    {
 342                        case JsonTokenType.StartObject:
 343                        case JsonTokenType.StartArray:
 0344                            depth++;
 0345                            break;
 346
 347                        case JsonTokenType.EndObject:
 348                        case JsonTokenType.EndArray:
 6489349                            depth--;
 350                            break;
 351                    }
 352                }
 353            }
 354
 42000355            reader.Read(); // Move to the next token
 356        }
 357
 358        // If we found the _type property, attempt to resolve the type.
 10220359        return typeName != null ? SerializationTypeResolver.ResolveType(_workflowJsonTypeRegistry, typeName) : default;
 360    }
 361
 362    private void WriteTypeMetadata(Utf8JsonWriter writer, Type type)
 363    {
 13292364        if (!SerializationTypeResolver.TryGetAlias(_workflowJsonTypeRegistry, type, out var typeAlias))
 365        {
 2848366            WarnAboutUnaliasedType(type);
 2848367            return;
 368        }
 369
 10444370        writer.WritePropertyName(TypePropertyName);
 10444371        writer.WriteStringValue(typeAlias);
 10444372    }
 373
 374    /// <summary>
 375    /// Warns that <paramref name="type"/> has no registered alias, which is not an error but is lossy: without an
 376    /// alias no <c>_type</c> discriminator is written, so on read there is no target type to deserialize into and
 377    /// the value comes back as an <see cref="ExpandoObject"/> whose keys carry the serializer's camel-case naming
 378    /// policy rather than the property names as they were authored.
 379    /// </summary>
 380    private void WarnAboutUnaliasedType(Type type)
 381    {
 382        // Check the level before claiming the once-per-type slot: claiming it first would spend the type's single
 383        // report on a call that logs nothing, and the type would then stay silent if the level is raised later.
 2848384        if (_logger == null || !_logger.IsEnabled(LogLevel.Warning) || !ReportedUnaliasedTypes.TryAdd(type, 0))
 2847385            return;
 386
 1387        _logger.LogWarning(
 1388            "Value of type {PayloadType} has no registered serialization alias, so it is stored without a type discrimin
 1389            "Register it during startup with AddTypeAlias<{PayloadTypeName}>(), or use a Dictionary<string, object> if a
 1390            type,
 1391            type,
 1392            type.Name);
 1393    }
 394
 395    private static Type GetInstantiableTargetType(Type targetType)
 396    {
 1432397        if (targetType.ContainsGenericParameters)
 0398            throw new JsonException($"Workflow JSON type alias resolved to open generic type '{targetType}'.");
 399
 1432400        if (!targetType.IsInterface && !targetType.IsAbstract)
 1425401            return targetType;
 402
 7403        if (SerializationTypeResolver.TryGetInstantiableCollectionType(targetType, out var instantiableCollectionType))
 6404            return instantiableCollectionType;
 405
 1406        throw new JsonException($"Workflow JSON type alias resolved to non-instantiable type '{targetType}'.");
 407    }
 408
 409    private static object ReadPrimitive(ref Utf8JsonReader reader, JsonSerializerOptions options)
 410    {
 24624411        return (reader.TokenType switch
 24624412        {
 364413            JsonTokenType.True => true,
 8271414            JsonTokenType.False => false,
 14982415            JsonTokenType.Number when reader.TryGetInt64(out var l) => l,
 4200416            JsonTokenType.Number => reader.GetDouble(),
 5806417            JsonTokenType.String => reader.GetString(),
 592418            JsonTokenType.Null => null,
 0419            _ => throw new JsonException("Not a primitive type.")
 24624420        })!;
 421    }
 422
 423    private object ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options)
 424    {
 12821425        switch (reader.TokenType)
 426        {
 427            case JsonTokenType.StartArray:
 428                {
 2603429                    var list = new List<object>();
 2603430                    while (reader.Read())
 431                    {
 2603432                        switch (reader.TokenType)
 433                        {
 434                            default:
 0435                                list.Add(Read(ref reader, typeof(object), options));
 0436                                break;
 437
 438                            case JsonTokenType.EndArray:
 2603439                                return list;
 440                        }
 441                    }
 442
 0443                    throw new JsonException();
 444                }
 445            case JsonTokenType.StartObject:
 10218446                var dict = new ExpandoObject() as IDictionary<string, object>;
 10218447                var referenceResolver = (CustomPreserveReferenceResolver)(options.ReferenceHandler as CrossScopedReferen
 29279448                while (reader.Read())
 449                {
 29279450                    switch (reader.TokenType)
 451                    {
 452                        case JsonTokenType.EndObject:
 453                            // If the object contains a single entry with a key of $ref, return the referenced object.
 10218454                            if (dict.Count == 1 && dict.TryGetValue(RefPropertyName, out var referencedObject))
 0455                                return referencedObject;
 10218456                            return dict;
 457
 458                        case JsonTokenType.PropertyName:
 19061459                            var key = reader.GetString()!;
 19061460                            reader.Read();
 19061461                            if (referenceResolver != null && key == RefPropertyName)
 462                            {
 0463                                var referenceId = reader.GetString();
 0464                                var reference = referenceResolver.ResolveReference(referenceId!);
 0465                                dict.Add(key, reference);
 466                            }
 19061467                            else if (referenceResolver != null && key == IdPropertyName)
 468                            {
 0469                                var referenceId = reader.GetString()!;
 470
 471                                // Attempt to add the reference; if not found, we can ignore it and assume that the user
 0472                                referenceResolver.TryAddReference(referenceId, dict);
 473                            }
 474                            else
 475                            {
 19061476                                var value = Read(ref reader, typeof(object), options);
 19061477                                var unescapedKey = UnescapeKey(key);
 19061478                                dict.Add(unescapedKey, value);
 479                            }
 480
 19061481                            break;
 482
 483                        default:
 0484                            throw new JsonException();
 485                    }
 486                }
 487
 0488                throw new JsonException();
 489            default:
 0490                throw new JsonException($"Unknown token {reader.TokenType}");
 491        }
 492    }
 493
 494    private string EscapeKey(string key)
 495    {
 3496        return key.Replace("$", @"\\$");
 497    }
 498
 499    private string UnescapeKey(string key)
 500    {
 19061501        return key.Replace(@"\\$", "$");
 502    }
 503}