< 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
84%
Covered lines: 192
Uncovered lines: 34
Coverable lines: 226
Total lines: 503
Line coverage: 84.9%
Branch coverage
85%
Covered branches: 173
Total branches: 203
Branch coverage: 85.2%
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(...)76.47%693468.75%
IsPrimitive()100%2424100%
ReadType(...)88.88%292785%
WriteTypeMetadata(...)100%22100%
WarnAboutUnaliasedType(...)100%66100%
GetInstantiableTargetType(...)87.5%8885.71%
ReadPrimitive(...)87.5%8890%
ReadObject(...)75%542867.74%
EscapeKey(...)100%210%
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>
 5209137    public PolymorphicObjectConverter(ISerializationTypeRegistry workflowJsonTypeRegistry, ILogger? logger = null)
 38    {
 5209139        _workflowJsonTypeRegistry = workflowJsonTypeRegistry;
 5209140        _logger = logger;
 5209141    }
 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    {
 3659954        var newOptions = options.Clone();
 55
 3659956        if (reader.TokenType != JsonTokenType.StartObject && reader.TokenType != JsonTokenType.StartArray)
 2360857            return ReadPrimitive(ref reader, newOptions);
 58
 1299159        var targetType = ReadType(reader, options);
 1298860        if (targetType == null)
 1151061            return ReadObject(ref reader, newOptions);
 62
 147863        targetType = GetInstantiableTargetType(targetType);
 64
 65        // If the target type is not an IEnumerable, or is a dictionary, deserialize the object directly.
 147766        var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType);
 67
 147768        if (!isEnumerable)
 69        {
 70            try
 71            {
 72072                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.
 75781        var isNewtonsoftObject = targetType == typeof(JObject);
 82
 75783        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.
 75691        var isNewtonsoftArray = targetType == typeof(JArray);
 92
 75693        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.
 755101        var isJsonObject = targetType == typeof(JsonObject);
 102
 755103        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
 754110        var isJsonArray = targetType == typeof(JsonArray);
 111
 754112        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
 753119        var isDictionary = typeof(IDictionary).IsAssignableFrom(targetType);
 753120        if (isDictionary)
 121        {
 122            // Remove the _type property name from the JSON, if any.
 722123            var parsedNode = JsonNode.Parse(ref reader)!;
 1444124            if (parsedNode is JsonObject parsedModel) parsedModel.Remove(TypePropertyName);
 722125            return parsedNode.Deserialize(targetType, newOptions)!;
 126        }
 127
 31128        var isCollection = typeof(ICollection).IsAssignableFrom(targetType);
 129
 130        // Otherwise, deserialize the object as an array.
 31131        var elementType = targetType.IsArray
 31132            ? targetType.GetElementType()
 31133            : targetType.GenericTypeArguments.FirstOrDefault() ??
 31134              (isCollection // Could be a class derived from Collection<T> or List<T>.
 31135                  ? targetType.BaseType?.GenericTypeArguments[0]
 31136                  : targetType.GenericTypeArguments.FirstOrDefault()
 31137                    ?? typeof(object));
 31138        if (elementType == null)
 0139            throw new InvalidOperationException($"Cannot determine the element type of array '{targetType}'.");
 140
 31141        var model = JsonElement.ParseValue(ref reader);
 31142        var referenceResolver = (newOptions.ReferenceHandler as CrossScopedReferenceHandler)?.GetResolver();
 143
 31144        if (model.TryGetProperty(RefPropertyName, out var refProperty))
 145        {
 0146            var refId = refProperty.GetString()!;
 0147            return referenceResolver?.ResolveReference(refId)!;
 148        }
 149
 31150        var values = model.TryGetProperty(ItemsPropertyName, out var itemsProp) ? itemsProp.EnumerateArray().ToList() : 
 31151        var id = model.TryGetProperty(IdPropertyName, out var idProp) ? idProp.GetString() : default;
 31152        var collection = targetType.IsArray ? Array.CreateInstance(elementType, values.Count) : Activator.CreateInstance
 31153        var index = 0;
 154
 31155        if (id != null)
 5156            referenceResolver?.AddReference(id, collection);
 157
 31158        var isHashSet = targetType.GenericTypeArguments.Length == 1 && typeof(ISet<>).MakeGenericType(targetType.Generic
 31159        var addSetMethod = targetType.GetMethod("Add", [elementType])!;
 160
 168161        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
 31180        return collection;
 720181    }
 182
 183    /// <inheritdoc />
 184    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
 185    {
 16317186        if (value == null!)
 187        {
 104188            writer.WriteNullValue();
 104189            return;
 190        }
 191
 16213192        var newOptions = options.Clone();
 16213193        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        {
 16213198            return type.IsPrimitive
 16213199                   || valueType == typeof(string)
 16213200                   || valueType == typeof(decimal)
 16213201                   || valueType == typeof(DateTimeOffset)
 16213202                   || valueType == typeof(DateTime)
 16213203                   || valueType == typeof(DateOnly)
 16213204                   || valueType == typeof(TimeOnly)
 16213205                   || valueType == typeof(JsonElement)
 16213206                   || valueType == typeof(Guid)
 16213207                   || valueType == typeof(TimeSpan)
 16213208                   || valueType == typeof(Uri)
 16213209                   || valueType == typeof(Version)
 16213210                   || valueType.IsEnum;
 211        }
 212
 16213213        if (IsPrimitive(type))
 214        {
 215            // Remove the converter so that we don't end up in an infinite loop.
 41282216            newOptions.Converters.RemoveWhere(x => x is PolymorphicObjectConverterFactory);
 217
 218            // Serialize the value directly.
 3090219            JsonSerializer.Serialize(writer, value, newOptions);
 3090220            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.
 13123226        if (type == typeof(JObject) || type == typeof(JArray) || type == typeof(JsonObject) || type == typeof(JsonArray)
 227        {
 21228            writer.WriteStartObject();
 21229            writer.WriteString(IslandPropertyName, value.ToString());
 21230            WriteTypeMetadata(writer, type);
 21231            writer.WriteEndObject();
 21232            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 
 13102237        var shouldWriteTypeField = true;
 13102238        var referenceResolver = (CustomPreserveReferenceResolver?)(newOptions.ReferenceHandler as CrossScopedReferenceHa
 239
 13102240        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.
 13102248        if (value is ExpandoObject)
 249        {
 0250            var sanitized = new ExpandoObject();
 0251            var dictionary = (IDictionary<string, object?>)sanitized;
 0252            var expando = (IDictionary<string, object?>)value;
 253
 0254            foreach (var kvp in expando)
 255            {
 0256                var key = EscapeKey(kvp.Key);
 0257                dictionary[key] = kvp.Value;
 258            }
 259
 0260            value = sanitized;
 261        }
 262
 13102263        var jsonElement = JsonDocument.Parse(JsonSerializer.Serialize(value, type, newOptions)).RootElement;
 264
 265        // If the value is a string, serialize it directly.
 13102266        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.
 13102275        if (jsonElement.ValueKind != JsonValueKind.Object &&
 13102276            jsonElement.ValueKind != JsonValueKind.Array)
 277        {
 0278            jsonElement.WriteTo(writer);
 0279            return;
 280        }
 281
 13102282        writer.WriteStartObject();
 283
 13102284        if (jsonElement.ValueKind == JsonValueKind.Array)
 285        {
 741286            writer.WritePropertyName(ItemsPropertyName);
 741287            jsonElement.WriteTo(writer);
 288        }
 289        else
 290        {
 86882291            foreach (var property in jsonElement.EnumerateObject().Where(property => !property.NameEquals(TypePropertyNa
 292            {
 20720293                writer.WritePropertyName(property.Name);
 20720294                property.Value.WriteTo(writer);
 295            }
 296        }
 297
 13102298        if (type != typeof(ExpandoObject))
 299        {
 13102300            if (shouldWriteTypeField)
 13102301                WriteTypeMetadata(writer, type);
 302        }
 303
 13102304        writer.WriteEndObject();
 13102305    }
 306
 307    private Type? ReadType(Utf8JsonReader reader, JsonSerializerOptions options)
 308    {
 12991309        if (reader.TokenType != JsonTokenType.StartObject)
 2137310            return null;
 311
 10854312        reader.Read(); // Move to the first token inside the object.
 10854313        string? typeName = null;
 314
 315        // Read while we haven't reached the end of the object.
 50282316        while (reader.TokenType != JsonTokenType.EndObject)
 317        {
 318            // If we find the _type property, read its value and break out of the loop.
 40909319            if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals(TypePropertyName))
 320            {
 1481321                reader.Read(); // Move to the value of the _type property
 1481322                if (options.Converters.OfType<TypeJsonConverter>().FirstOrDefault() is { } typeJsonConverter)
 323                {
 1480324                    return typeJsonConverter.Read(ref reader, typeof(Type), options);
 325                }
 326                else
 327                {
 1328                    typeName = reader.GetString();
 329                }
 1330                break;
 331            }
 332
 333            // Skip through nested objects and arrays.
 39428334            if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray)
 335            {
 5968336                var depth = 1;
 337
 33641338                while (depth > 0 && reader.Read())
 339                {
 27673340                    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:
 5968349                            depth--;
 350                            break;
 351                    }
 352                }
 353            }
 354
 39428355            reader.Read(); // Move to the next token
 356        }
 357
 358        // If we found the _type property, attempt to resolve the type.
 9374359        return typeName != null ? SerializationTypeResolver.ResolveType(_workflowJsonTypeRegistry, typeName) : default;
 360    }
 361
 362    private void WriteTypeMetadata(Utf8JsonWriter writer, Type type)
 363    {
 13123364        if (!SerializationTypeResolver.TryGetAlias(_workflowJsonTypeRegistry, type, out var typeAlias))
 365        {
 2804366            WarnAboutUnaliasedType(type);
 2804367            return;
 368        }
 369
 10319370        writer.WritePropertyName(TypePropertyName);
 10319371        writer.WriteStringValue(typeAlias);
 10319372    }
 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.
 2804384        if (_logger == null || !_logger.IsEnabled(LogLevel.Warning) || !ReportedUnaliasedTypes.TryAdd(type, 0))
 2803385            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    {
 1478397        if (targetType.ContainsGenericParameters)
 0398            throw new JsonException($"Workflow JSON type alias resolved to open generic type '{targetType}'.");
 399
 1478400        if (!targetType.IsInterface && !targetType.IsAbstract)
 1471401            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    {
 23608411        return (reader.TokenType switch
 23608412        {
 411413            JsonTokenType.True => true,
 8162414            JsonTokenType.False => false,
 15383415            JsonTokenType.Number when reader.TryGetInt64(out var l) => l,
 4529416            JsonTokenType.Number => reader.GetDouble(),
 4902417            JsonTokenType.String => reader.GetString(),
 177418            JsonTokenType.Null => null,
 0419            _ => throw new JsonException("Not a primitive type.")
 23608420        })!;
 421    }
 422
 423    private object ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options)
 424    {
 11510425        switch (reader.TokenType)
 426        {
 427            case JsonTokenType.StartArray:
 428                {
 2137429                    var list = new List<object>();
 2137430                    while (reader.Read())
 431                    {
 2137432                        switch (reader.TokenType)
 433                        {
 434                            default:
 0435                                list.Add(Read(ref reader, typeof(object), options));
 0436                                break;
 437
 438                            case JsonTokenType.EndArray:
 2137439                                return list;
 440                        }
 441                    }
 442
 0443                    throw new JsonException();
 444                }
 445            case JsonTokenType.StartObject:
 9373446                var dict = new ExpandoObject() as IDictionary<string, object>;
 9373447                var referenceResolver = (CustomPreserveReferenceResolver)(options.ReferenceHandler as CrossScopedReferen
 26980448                while (reader.Read())
 449                {
 26980450                    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.
 9373454                            if (dict.Count == 1 && dict.TryGetValue(RefPropertyName, out var referencedObject))
 0455                                return referencedObject;
 9373456                            return dict;
 457
 458                        case JsonTokenType.PropertyName:
 17607459                            var key = reader.GetString()!;
 17607460                            reader.Read();
 17607461                            if (referenceResolver != null && key == RefPropertyName)
 462                            {
 0463                                var referenceId = reader.GetString();
 0464                                var reference = referenceResolver.ResolveReference(referenceId!);
 0465                                dict.Add(key, reference);
 466                            }
 17607467                            else if (referenceResolver != null && key == IdPropertyName)
 468                            {
 1469                                var referenceId = reader.GetString()!;
 470
 471                                // Attempt to add the reference; if not found, we can ignore it and assume that the user
 1472                                referenceResolver.TryAddReference(referenceId, dict);
 473                            }
 474                            else
 475                            {
 17606476                                var value = Read(ref reader, typeof(object), options);
 17606477                                var unescapedKey = UnescapeKey(key);
 17606478                                dict.Add(unescapedKey, value);
 479                            }
 480
 17606481                            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    {
 0496        return key.Replace("$", @"\\$");
 497    }
 498
 499    private string UnescapeKey(string key)
 500    {
 17606501        return key.Replace(@"\\$", "$");
 502    }
 503}