[codex] Harden workflow JSON type resolution (#7499)

* Harden workflow JSON type resolution

* Harden workflow type alias serialization

* Register JSON island type aliases

* Fix workflow JSON aliases for runtime types

* Register CLR workflow type aliases at startup

* Register safe workflow serialization aliases

* Use registered workflow type aliases in serializers

* Restore trusted legacy workflow JSON aliases

* Share runtime workflow type alias registration

* Address workflow JSON review follow-ups

* Address workflow JSON review follow-ups

* Address workflow JSON review edge cases

* Address workflow JSON converter review feedback

* Register workflow JSON types for HTTP and JavaScript failures

* Address secure type serialization review comments

* Address deserialization review feedback

* Address workflow serialization review comments

* Address workflow type review feedback

* Tighten workflow type hardening fixes

* Avoid workflow alias string type resolution

* Address workflow type alias review feedback

* Stabilize publish event payload assertion

* Stabilize bulk dispatch component test

* Make workflow dictionary aliases idempotent

* Declare workflow runtime feature dependency

* Assert trigger payload alias serialization

* Import workflow helper contracts

* Avoid duplicate CLR workflow materialization

* Normalize workflow factory aliases
This commit is contained in:
Sipke Schoorstra 2026-05-22 15:25:49 +02:00 committed by GitHub
parent 2e712d367a
commit cec3281a20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1796 additions and 141 deletions

View file

@ -1,10 +1,12 @@
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Extensions;
using Elsa.Alterations.Core.Models;
using Elsa.Alterations.Core.Stores;
using Elsa.Alterations.Extensions;
using Elsa.Alterations.Services;
using Elsa.Alterations.Workflows;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
@ -58,6 +60,12 @@ public class AlterationsFeature : FeatureBase
/// <inheritdoc />
public override void Apply()
{
Services.Configure<ExpressionOptions>(options =>
{
options.RegisterTypeAlias(typeof(AlterationPlanParams), typeof(AlterationPlanParams).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(AlterationPlanParams), nameof(AlterationPlanParams));
});
Services.AddScoped<IAlterationPlanManager, AlterationPlanManager>();
Services.AddAlterations();
Services.AddAlterationsCore();
@ -73,4 +81,4 @@ public class AlterationsFeature : FeatureBase
Services.AddScoped(AlterationJobStoreFactory);
Services.AddScoped(AlterationJobDispatcherFactory);
}
}
}

View file

@ -1,4 +1,5 @@
using System.Linq.Expressions;
using System.Text.Json.Serialization;
namespace Elsa.Common.Entities;
@ -31,5 +32,12 @@ public class OrderDefinition<T, TProp>
/// <summary>
/// The key selector to use to order the results.
/// </summary>
[JsonIgnore]
public Expression<Func<T, TProp>> KeySelector { get; set; } = null!;
}
/// <summary>
/// A best-effort textual representation of the key selector for cache-key generation.
/// </summary>
[JsonIgnore]
public string? KeySelectorText => KeySelector?.ToString();
}

View file

@ -1,4 +1,5 @@
using Elsa.Caching.Features;
using Elsa.Expressions.Options;
using Elsa.Common.Features;
using Elsa.Expressions.Features;
using Elsa.Extensions;
@ -59,6 +60,7 @@ public class JavaScriptFeature : FeatureBase
public override void Apply()
{
Services.Configure(JintOptions);
Services.Configure<ExpressionOptions>(JavaScriptExceptionTypeAliasRegistrar.Register);
// JavaScript services.
Services
@ -94,4 +96,4 @@ public class JavaScriptFeature : FeatureBase
// UI property handlers.
Services.AddScoped<IPropertyUIHandler, RunJavaScriptOptionsProvider>();
}
}
}

View file

@ -0,0 +1,21 @@
using System.Reflection;
using Acornima;
using Elsa.Expressions.Options;
using Jint;
using Jint.Runtime;
namespace Elsa.Expressions.JavaScript;
internal static class JavaScriptExceptionTypeAliasRegistrar
{
public static void Register(ExpressionOptions options)
{
options.RegisterTypeAlias(typeof(ScriptPreparationException), nameof(ScriptPreparationException));
options.RegisterTypeAlias(typeof(JavaScriptException), nameof(JavaScriptException));
options.RegisterTypeAlias(typeof(SyntaxErrorException), nameof(SyntaxErrorException));
var wrapperExceptionType = typeof(JavaScriptException).GetNestedType("JavaScriptErrorWrapperException", BindingFlags.Public | BindingFlags.NonPublic);
if (wrapperExceptionType != null)
options.RegisterTypeAlias(wrapperExceptionType, "Jint.JavaScriptErrorWrapperException");
}
}

View file

@ -8,6 +8,7 @@ using Elsa.Expressions.JavaScript.Providers;
using Elsa.Expressions.JavaScript.Services;
using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts;
using Elsa.Expressions.JavaScript.TypeDefinitions.Services;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.PackageManifest.Generator.Hints;
using Elsa.Workflows;
@ -104,6 +105,7 @@ public class JavaScriptFeature : IShellFeature
options.DisableWrappers = DisableWrappers;
options.DisableVariableCopying = DisableVariableCopying;
});
services.Configure<ExpressionOptions>(JavaScriptExceptionTypeAliasRegistrar.Register);
// JavaScript services.
services

View file

@ -40,6 +40,7 @@ public class ExpressionOptions
this.AddTypeAlias<JsonElement>();
this.AddTypeAlias<JsonNode>();
this.AddTypeAlias<JsonObject>();
this.AddTypeAlias<JsonArray>();
this.AddTypeAlias<IDictionary<string, string>>("StringDictionary");
this.AddTypeAlias<IDictionary<string, object>>("ObjectDictionary");
this.AddTypeAlias<Dictionary<string, string>>("StringMap");
@ -59,4 +60,4 @@ public class ExpressionOptions
_aliasTypeDictionary[alias] = type;
return this;
}
}
}

View file

@ -1,3 +1,4 @@
using System.Net;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
@ -259,6 +260,9 @@ public class HttpFeature(IModule module) : FeatureBase(module)
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
options.AddTypeAlias<Downloadable>("Downloadable");
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
options.AddTypeAlias<HttpStatusCode>();
options.AddTypeAlias<HttpRequestException>();
options.AddTypeAlias<HttpEndpointBookmarkPayload>();
});
}
}
}

View file

@ -1,3 +1,4 @@
using System.Net;
using CShells.AspNetCore.Features;
using CShells.Features;
using Elsa.Expressions.Options;
@ -230,6 +231,9 @@ public class HttpFeature : IMiddlewareShellFeature
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
options.AddTypeAlias<Downloadable>("Downloadable");
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
options.AddTypeAlias<HttpStatusCode>();
options.AddTypeAlias<HttpRequestException>();
options.AddTypeAlias<HttpEndpointBookmarkPayload>();
});
}
@ -238,4 +242,4 @@ public class HttpFeature : IMiddlewareShellFeature
{
app.UseWorkflows();
}
}
}

View file

@ -1,3 +1,4 @@
using Elsa.Expressions.Options;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Workflows.Management.Entities;
@ -26,6 +27,7 @@ public class EFCoreWorkflowDefinitionPersistenceFeature(IModule module) : Persis
public override void Apply()
{
base.Apply();
Services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(EFCoreWorkflowDefinitionStore.WorkflowDefinitionStateType, "EFCoreWorkflowDefinitionState"));
AddEntityStore<WorkflowDefinition, EFCoreWorkflowDefinitionStore>();
}
}
}

View file

@ -22,6 +22,8 @@ namespace Elsa.Persistence.EFCore.Modules.Management;
public class EFCoreWorkflowDefinitionStore(EntityStore<ManagementElsaDbContext, WorkflowDefinition> store, IPayloadSerializer payloadSerializer, ILogger<EFCoreWorkflowDefinitionStore> logger)
: IWorkflowDefinitionStore
{
internal static Type WorkflowDefinitionStateType => typeof(WorkflowDefinitionState);
/// <inheritdoc />
public async Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
{
@ -267,4 +269,4 @@ public class EFCoreWorkflowDefinitionStore(EntityStore<ManagementElsaDbContext,
public ICollection<string> Outcomes { get; set; } = new List<string>();
public IDictionary<string, object> CustomProperties { get; set; } = new Dictionary<string, object>();
}
}
}

View file

@ -1,5 +1,6 @@
using Elsa.Common.Features;
using Elsa.Common.Multitenancy;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
@ -58,6 +59,16 @@ public class SchedulingFeature : FeatureBase
// Graceful shutdown: register scheduled-trigger ingress for diagnostic visibility (FR-006).
.AddSingleton<Elsa.Workflows.Runtime.IIngressSource, Elsa.Scheduling.IngressSources.ScheduledTriggerIngressSource>();
Services.Configure<ExpressionOptions>(options =>
{
options.AddTypeAlias<CronBookmarkPayload>();
options.AddTypeAlias<CronTriggerPayload>();
options.AddTypeAlias<DelayPayload>();
options.AddTypeAlias<StartAtPayload>();
options.AddTypeAlias<TimerBookmarkPayload>();
options.AddTypeAlias<TimerTriggerPayload>();
});
Module.Configure<WorkflowManagementFeature>(management => management.AddActivitiesFrom<SchedulingFeature>());
}
}
}

View file

@ -2,13 +2,16 @@ using Elsa.Common;
using Elsa.Common.Features;
using Elsa.Common.Serialization;
using Elsa.Expressions.Features;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Workflows.ActivationValidators;
using Elsa.Workflows.Activities.Flowchart.Models;
using Elsa.Workflows.Builders;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.IncidentStrategies;
using Elsa.Workflows.LogPersistence;
using Elsa.Workflows.LogPersistence.Strategies;
@ -21,12 +24,14 @@ using Elsa.Workflows.Serialization.Configurators;
using Elsa.Workflows.Serialization.Helpers;
using Elsa.Workflows.Serialization.Serializers;
using Elsa.Workflows.Services;
using Elsa.Workflows.State;
using Elsa.Workflows.UIHints.CheckList;
using Elsa.Workflows.UIHints.Dictionary;
using Elsa.Workflows.UIHints.Dropdown;
using Elsa.Workflows.UIHints.JsonEditor;
using Elsa.Workflows.UIHints.RadioList;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
namespace Elsa.Workflows.Features;
@ -159,6 +164,36 @@ public class WorkflowsFeature : FeatureBase
private void AddElsaCore(IServiceCollection services)
{
services.Configure<ExpressionOptions>(options =>
{
options.RegisterTypeAlias(typeof(ExceptionState), nameof(ExceptionState));
options.RegisterTypeAlias(typeof(FaultException), nameof(FaultException));
options.RegisterTypeAlias(typeof(VariablesDictionary), nameof(VariablesDictionary));
options.RegisterTypeAlias(typeof(Token), nameof(Token));
options.RegisterTypeAlias(typeof(FlowJoinMode), "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core");
options.RegisterTypeAlias(typeof(FlowJoinMode), typeof(FlowJoinMode).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(FlowJoinMode), nameof(FlowJoinMode));
options.RegisterTypeAlias(typeof(WorkflowStorageDriver), typeof(WorkflowStorageDriver).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver));
options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), nameof(WorkflowInstanceStorageDriver));
options.RegisterTypeAlias(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(MemoryStorageDriver), nameof(MemoryStorageDriver));
options.RegisterTypeAlias(typeof(FaultStrategy), typeof(FaultStrategy).GetSimpleAssemblyQualifiedName());
options.RegisterTypeAlias(typeof(FaultStrategy), nameof(FaultStrategy));
options.RegisterTypeAlias(typeof(Exception), nameof(Exception));
options.RegisterTypeAlias(typeof(ArgumentException), nameof(ArgumentException));
options.RegisterTypeAlias(typeof(ArgumentNullException), nameof(ArgumentNullException));
options.RegisterTypeAlias(typeof(InvalidOperationException), nameof(InvalidOperationException));
options.RegisterTypeAlias(typeof(NullReferenceException), nameof(NullReferenceException));
options.RegisterTypeAlias(typeof(OperationCanceledException), nameof(OperationCanceledException));
options.RegisterTypeAlias(typeof(TaskCanceledException), nameof(TaskCanceledException));
options.RegisterTypeAlias(typeof(TimeoutException), nameof(TimeoutException));
options.RegisterTypeAlias(typeof(NotSupportedException), nameof(NotSupportedException));
options.RegisterTypeAlias(typeof(JObject), nameof(JObject));
options.RegisterTypeAlias(typeof(JArray), nameof(JArray));
});
services
// Core.
@ -259,4 +294,4 @@ public class WorkflowsFeature : FeatureBase
// Logging
.AddLogging();
}
}
}

View file

@ -1,4 +1,5 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Extensions;
@ -8,9 +9,12 @@ namespace Elsa.Workflows.Serialization.Converters;
/// <summary>
/// Serializes an object to JSON, excluding properties marked with <see cref="ExcludeFromHashAttribute"/>.
/// Properties ignored by <see cref="JsonIgnoreAttribute"/> are also excluded according to their configured ignore condition;
/// avoid adding or changing these attributes on bookmark or stimulus payloads whose hashes must remain compatible with existing stored hashes.
/// </summary>
public class ExcludeFromHashConverter : JsonConverter<object>
{
private static readonly ConditionalWeakTable<Type, PropertyMetadata[]> PropertyCache = new();
private JsonSerializerOptions? _options;
/// <inheritdoc />
@ -25,21 +29,73 @@ public class ExcludeFromHashConverter : JsonConverter<object>
writer.WriteStartObject();
var newOptions = GetClonedOptions(options);
foreach (var property in value.GetType().GetProperties())
foreach (var metadata in GetSerializableProperties(value.GetType()))
{
var attribute = property.GetCustomAttribute<ExcludeFromHashAttribute>();
var property = metadata.Property;
var propertyValue = property.GetValue(value);
if (attribute != null)
{
if (ShouldIgnoreProperty(metadata.JsonIgnoreCondition, property.PropertyType, propertyValue))
continue;
}
writer.WritePropertyName(property.Name);
JsonSerializer.Serialize(writer, property.GetValue(value), newOptions);
JsonSerializer.Serialize(writer, propertyValue, newOptions);
}
writer.WriteEndObject();
}
private static PropertyMetadata[] GetSerializableProperties(Type type)
{
return PropertyCache.GetValue(type, static itemType => GetPublicInstanceProperties(itemType)
.Where(property => property.GetIndexParameters().Length == 0)
.Select(property => new
{
Property = property,
ExcludeFromHash = property.GetCustomAttribute<ExcludeFromHashAttribute>(),
JsonIgnore = property.GetCustomAttribute<JsonIgnoreAttribute>()
})
.Where(x => x.ExcludeFromHash == null && !ShouldAlwaysIgnoreProperty(x.JsonIgnore?.Condition))
.Select(x => new PropertyMetadata(x.Property, x.JsonIgnore?.Condition))
.ToArray());
}
private static IEnumerable<PropertyInfo> GetPublicInstanceProperties(Type type)
{
for (var currentType = type; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
{
foreach (var property in currentType
.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public)
.OrderBy(x => x.MetadataToken))
{
yield return property;
}
}
}
private static bool ShouldAlwaysIgnoreProperty(JsonIgnoreCondition? condition)
{
return condition == JsonIgnoreCondition.Always;
}
private static bool ShouldIgnoreProperty(JsonIgnoreCondition? condition, Type declaredType, object? value)
{
return condition switch
{
null => false,
JsonIgnoreCondition.Never => false,
JsonIgnoreCondition.Always => true,
JsonIgnoreCondition.WhenWritingNull => value == null,
JsonIgnoreCondition.WhenWritingDefault => value == null || IsDefaultValue(declaredType, value),
_ => false
};
}
private static bool IsDefaultValue(Type declaredType, object value)
{
return declaredType.IsValueType && value.Equals(Activator.CreateInstance(declaredType));
}
private sealed record PropertyMetadata(PropertyInfo Property, JsonIgnoreCondition? JsonIgnoreCondition);
private JsonSerializerOptions GetClonedOptions(JsonSerializerOptions options)
{
@ -62,4 +118,4 @@ public class ExcludeFromHashConverterFactory : JsonConverterFactory
/// <inheritdoc />
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ExcludeFromHashConverter();
}
}

View file

@ -4,7 +4,10 @@ using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Workflows.Serialization.Helpers;
using Elsa.Workflows.Serialization.ReferenceHandlers;
using Newtonsoft.Json.Linq;
@ -21,6 +24,23 @@ public class PolymorphicObjectConverter : JsonConverter<object>
private const string IdPropertyName = "$id";
private const string RefPropertyName = "$ref";
private const string ValuesPropertyName = "$values";
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;
/// <summary>
/// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
/// </summary>
public PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry)
{
_wellKnownTypeRegistry = wellKnownTypeRegistry;
}
/// <summary>
/// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
/// </summary>
public PolymorphicObjectConverter()
{
_wellKnownTypeRegistry = WellKnownTypeRegistry.CreateDefault();
}
/// <inheritdoc />
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
@ -34,6 +54,8 @@ public class PolymorphicObjectConverter : JsonConverter<object>
if (targetType == null)
return ReadObject(ref reader, newOptions);
targetType = GetInstantiableTargetType(targetType);
// If the target type is not an IEnumerable, or is a dictionary, deserialize the object directly.
var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType);
@ -199,7 +221,7 @@ public class PolymorphicObjectConverter : JsonConverter<object>
{
writer.WriteStartObject();
writer.WriteString(IslandPropertyName, value.ToString());
writer.WriteString(TypePropertyName, type.GetSimpleAssemblyQualifiedName());
WriteTypeMetadata(writer, type);
writer.WriteEndObject();
return;
}
@ -270,17 +292,7 @@ public class PolymorphicObjectConverter : JsonConverter<object>
if (type != typeof(ExpandoObject))
{
if (shouldWriteTypeField)
{
if (newOptions.Converters.OfType<TypeJsonConverter>().FirstOrDefault() is { } typeJsonConverter)
{
writer.WritePropertyName(TypePropertyName);
typeJsonConverter.Write(writer, type, newOptions);
}
else
{
writer.WriteString(TypePropertyName, type.GetSimpleAssemblyQualifiedName());
}
}
WriteTypeMetadata(writer, type);
}
writer.WriteEndObject();
@ -338,8 +350,30 @@ public class PolymorphicObjectConverter : JsonConverter<object>
}
// If we found the _type property, attempt to resolve the type.
var targetType = typeName != null ? Type.GetType(typeName) : default;
return targetType;
return typeName != null ? WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeName) : default;
}
private void WriteTypeMetadata(Utf8JsonWriter writer, Type type)
{
if (!WorkflowJsonTypeResolver.TryGetAlias(_wellKnownTypeRegistry, type, out var typeAlias))
return;
writer.WritePropertyName(TypePropertyName);
writer.WriteStringValue(typeAlias);
}
private static Type GetInstantiableTargetType(Type targetType)
{
if (targetType.ContainsGenericParameters)
throw new JsonException($"Workflow JSON type alias resolved to open generic type '{targetType}'.");
if (!targetType.IsInterface && !targetType.IsAbstract)
return targetType;
if (WorkflowJsonTypeResolver.TryGetInstantiableCollectionType(targetType, out var instantiableCollectionType))
return instantiableCollectionType;
throw new JsonException($"Workflow JSON type alias resolved to non-instantiable type '{targetType}'.");
}
private static object ReadPrimitive(ref Utf8JsonReader reader, JsonSerializerOptions options)
@ -436,4 +470,4 @@ public class PolymorphicObjectConverter : JsonConverter<object>
{
return key.Replace(@"\\$", "$");
}
}
}

View file

@ -51,6 +51,6 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory
if (typeof(IDictionary<string, object>).IsAssignableFrom(typeToConvert))
return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry);
return new PolymorphicObjectConverter();
return new PolymorphicObjectConverter(_wellKnownTypeRegistry);
}
}
}

View file

@ -2,16 +2,22 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Workflows.Serialization.Helpers;
using JetBrains.Annotations;
namespace Elsa.Workflows.Serialization.Converters;
/// <summary>
/// Serializes <see cref="Type"/> objects to a simple alias representing the type.
/// Unregistered types are written as metadata-only aliases and intentionally deserialize to <see cref="Exception"/> instead of loading the original CLR type.
/// </summary>
[UsedImplicitly]
public class TypeJsonConverter : JsonConverter<Type>
{
/// <summary>
/// Prefix for unregistered type metadata that is not used for CLR type loading during deserialization.
/// </summary>
private const string UnregisteredTypeAliasPrefix = "UnregisteredClrType:";
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;
/// <inheritdoc />
@ -29,53 +35,19 @@ public class TypeJsonConverter : JsonConverter<Type>
/// <inheritdoc />
public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var typeAlias = reader.GetString()!;
var typeAlias = reader.GetString();
if (typeAlias?.StartsWith(UnregisteredTypeAliasPrefix, StringComparison.Ordinal) == true)
return typeof(Exception);
// Handle array types.
if (typeAlias.EndsWith("[]"))
{
var elementTypeAlias = typeAlias[..^2];
var elementType = _wellKnownTypeRegistry.TryGetType(elementTypeAlias, out var t) ? t : Type.GetType(elementTypeAlias)!;
return elementType.MakeArrayType();
}
// Handle collection types.
if (typeAlias.StartsWith("List<") && typeAlias.EndsWith(">"))
{
var elementTypeAlias = typeAlias[5..^1];
var elementType = _wellKnownTypeRegistry.TryGetType(elementTypeAlias, out var t) ? t : Type.GetType(elementTypeAlias)!;
return typeof(List<>).MakeGenericType(elementType);
}
return _wellKnownTypeRegistry.TryGetType(typeAlias, out var type) ? type : Type.GetType(typeAlias);
return WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeAlias);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options)
{
// Handle array types.
if (value.IsArray)
{
var elementType = value.GetElementType()!;
var elementTypeAlias = _wellKnownTypeRegistry.TryGetAlias(elementType, out var elementTypeAliasValue) ? elementTypeAliasValue : elementType.GetSimpleAssemblyQualifiedName();
writer.WriteStringValue($"{elementTypeAlias}[]");
return;
}
// Handle collection types.
if (value is { IsGenericType: true, GenericTypeArguments.Length: 1 })
{
var elementType = value.GenericTypeArguments.First();
var typedEnumerable = typeof(IEnumerable<>).MakeGenericType(elementType);
if (!WorkflowJsonTypeResolver.TryGetAlias(_wellKnownTypeRegistry, value, out var typeAlias))
typeAlias = $"{UnregisteredTypeAliasPrefix}{value.GetSimpleAssemblyQualifiedName()}";
if (typedEnumerable.IsAssignableFrom(value) && _wellKnownTypeRegistry.TryGetAlias(elementType, out var elementTypeAlias))
{
writer.WriteStringValue($"List<{elementTypeAlias}>");
return;
}
}
var typeAlias = _wellKnownTypeRegistry.TryGetAlias(value, out var alias) ? alias : value.GetSimpleAssemblyQualifiedName();
writer.WriteStringValue(typeAlias);
}
}
}

View file

@ -0,0 +1,319 @@
using System.Collections;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Text.Json;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
namespace Elsa.Workflows.Serialization.Helpers;
/// <summary>
/// Resolves workflow JSON type aliases without loading arbitrary CLR type names.
/// </summary>
public static class WorkflowJsonTypeResolver
{
private static readonly IDictionary<string, Type> GenericCollectionTypes = new Dictionary<string, Type>(StringComparer.Ordinal)
{
["IEnumerable"] = typeof(IEnumerable<>),
["ICollection"] = typeof(ICollection<>),
["IList"] = typeof(IList<>),
["IReadOnlyCollection"] = typeof(IReadOnlyCollection<>),
["IReadOnlyList"] = typeof(IReadOnlyList<>),
["ISet"] = typeof(ISet<>),
["List"] = typeof(List<>),
["HashSet"] = typeof(HashSet<>),
["Collection"] = typeof(Collection<>)
};
private static readonly IDictionary<Type, string> GenericCollectionAliases = new Dictionary<Type, string>
{
[typeof(List<>)] = "List",
[typeof(HashSet<>)] = "HashSet",
[typeof(Collection<>)] = "Collection"
};
private static readonly IDictionary<Type, Type> GenericCollectionInterfaceMappings = new Dictionary<Type, Type>
{
[typeof(IEnumerable<>)] = typeof(List<>),
[typeof(ICollection<>)] = typeof(List<>),
[typeof(IList<>)] = typeof(List<>),
[typeof(IReadOnlyCollection<>)] = typeof(List<>),
[typeof(IReadOnlyList<>)] = typeof(List<>),
[typeof(ISet<>)] = typeof(HashSet<>),
[typeof(IDictionary<,>)] = typeof(Dictionary<,>),
[typeof(IReadOnlyDictionary<,>)] = typeof(Dictionary<,>)
};
private static readonly IDictionary<Type, Type> CollectionInterfaceMappings = new Dictionary<Type, Type>
{
[typeof(IEnumerable)] = typeof(List<object>),
[typeof(ICollection)] = typeof(List<object>),
[typeof(IList)] = typeof(List<object>),
[typeof(IDictionary)] = typeof(Dictionary<string, object>)
};
/// <summary>
/// Resolves the specified workflow JSON type alias.
/// </summary>
public static Type ResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string? typeAlias)
{
if (string.IsNullOrWhiteSpace(typeAlias))
throw new JsonException("The workflow JSON type alias is missing.");
if (TryResolveType(wellKnownTypeRegistry, typeAlias, out var type))
return type;
throw new JsonException(
$"Unknown workflow JSON type alias '{typeAlias}'. Only registered aliases and supported compound aliases can be deserialized.");
}
/// <summary>
/// Attempts to resolve the specified workflow JSON type alias.
/// </summary>
public static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, out Type type)
{
IReadOnlyList<Type>? registeredTypes = null;
return TryResolveType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type);
}
private static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
{
if (wellKnownTypeRegistry.TryGetType(typeAlias, out var registeredType))
{
type = registeredType;
return true;
}
if (TryResolveArrayType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var arrayType))
{
type = arrayType;
return true;
}
if (TryResolveGenericCollectionType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var genericCollectionType))
{
type = genericCollectionType;
return true;
}
if (TryResolveRegisteredLegacyTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var legacyType))
{
type = legacyType;
return true;
}
type = null!;
return false;
}
/// <summary>
/// Attempts to return a workflow JSON type alias that this resolver can read back.
/// </summary>
public static bool TryGetAlias(IWellKnownTypeRegistry wellKnownTypeRegistry, Type type, out string alias)
{
if (wellKnownTypeRegistry.TryGetAlias(type, out alias!))
return true;
if (type.IsArray)
{
var elementType = type.GetElementType()!;
if (TryGetAlias(wellKnownTypeRegistry, elementType, out var elementTypeAlias))
{
alias = $"{elementTypeAlias}[]";
return true;
}
}
if (type is { IsGenericType: true, GenericTypeArguments.Length: 1 })
{
var genericTypeDefinition = type.GetGenericTypeDefinition();
if (TryGetWritableGenericCollectionAlias(genericTypeDefinition, out var genericTypeAlias) &&
TryGetAlias(wellKnownTypeRegistry, type.GenericTypeArguments[0], out var elementTypeAlias))
{
alias = $"{genericTypeAlias}<{elementTypeAlias}>";
return true;
}
}
alias = null!;
return false;
}
/// <summary>
/// Attempts to map a supported collection interface type to an instantiable concrete type.
/// </summary>
public static bool TryGetInstantiableCollectionType(Type type, out Type instantiableType)
{
if (type.IsGenericType)
{
var genericTypeDefinition = type.GetGenericTypeDefinition();
if (GenericCollectionInterfaceMappings.TryGetValue(genericTypeDefinition, out var instantiableGenericTypeDefinition))
{
instantiableType = instantiableGenericTypeDefinition.MakeGenericType(type.GenericTypeArguments);
return true;
}
}
if (CollectionInterfaceMappings.TryGetValue(type, out instantiableType!))
return true;
instantiableType = null!;
return false;
}
private static bool TryResolveArrayType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
{
type = null!;
if (!typeAlias.EndsWith("[]", StringComparison.Ordinal))
return false;
var elementTypeAlias = typeAlias[..^2];
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
return false;
type = elementType.MakeArrayType();
return true;
}
private static bool TryResolveGenericCollectionType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
{
type = null!;
var genericStart = typeAlias.IndexOf('<', StringComparison.Ordinal);
if (genericStart <= 0 || !typeAlias.EndsWith(">", StringComparison.Ordinal))
return false;
var genericTypeAlias = typeAlias[..genericStart];
if (!GenericCollectionTypes.TryGetValue(genericTypeAlias, out var genericTypeDefinition))
return false;
var elementTypeAlias = typeAlias[(genericStart + 1)..^1];
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
return false;
type = genericTypeDefinition.MakeGenericType(elementType);
return true;
}
private static bool TryGetWritableGenericCollectionAlias(Type genericTypeDefinition, out string alias)
{
if (GenericCollectionAliases.TryGetValue(genericTypeDefinition, out alias!))
return true;
if (GenericCollectionInterfaceMappings.TryGetValue(genericTypeDefinition, out var instantiableGenericTypeDefinition) &&
GenericCollectionAliases.TryGetValue(instantiableGenericTypeDefinition, out alias!))
{
return true;
}
alias = null!;
return false;
}
private static bool TryResolveRegisteredLegacyTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
{
registeredTypes ??= GetRegisteredTypes(wellKnownTypeRegistry);
var registeredTypeSnapshot = registeredTypes;
if (TryResolveRegisteredSimpleAssemblyQualifiedName(registeredTypeSnapshot, typeAlias, out type))
return true;
if (TryResolveLegacyGenericCollectionTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type))
return true;
Type? resolvedType;
try
{
resolvedType = Type.GetType(
typeAlias,
assemblyName => ResolveAssembly(registeredTypeSnapshot, assemblyName),
(assembly, typeName, ignoreCase) => ResolveType(registeredTypeSnapshot, assembly, typeName, ignoreCase),
false);
}
catch (Exception e) when (e is ArgumentException or FileLoadException)
{
resolvedType = null;
}
type = resolvedType!;
return resolvedType != null;
}
private static IReadOnlyList<Type> GetRegisteredTypes(IWellKnownTypeRegistry wellKnownTypeRegistry)
{
return wellKnownTypeRegistry.ListTypes().ToArray();
}
private static bool TryResolveRegisteredSimpleAssemblyQualifiedName(IEnumerable<Type> registeredTypes, string typeAlias, out Type type)
{
type = registeredTypes.FirstOrDefault(x =>
string.Equals(x.GetSimpleAssemblyQualifiedName(), typeAlias, StringComparison.Ordinal) ||
string.Equals(x.AssemblyQualifiedName, typeAlias, StringComparison.Ordinal))!;
return type != null;
}
private static bool TryResolveLegacyGenericCollectionTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
{
type = null!;
foreach (var genericTypeDefinition in GenericCollectionTypes.Values)
{
var prefix = $"{genericTypeDefinition.FullName}[[";
var separatorIndex = typeAlias.LastIndexOf("]], ", StringComparison.Ordinal);
if (!typeAlias.StartsWith(prefix, StringComparison.Ordinal) || separatorIndex <= prefix.Length)
continue;
var assemblyName = typeAlias[(separatorIndex + 4)..].Split(',')[0];
if (!string.Equals(assemblyName, genericTypeDefinition.Assembly.GetName().Name, StringComparison.Ordinal))
continue;
var elementTypeAlias = typeAlias[prefix.Length..separatorIndex];
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
return false;
// The resolver only closes known collection definitions over registered element types.
#pragma warning disable IL2055
type = genericTypeDefinition.MakeGenericType(elementType);
#pragma warning restore IL2055
return true;
}
return false;
}
private static Assembly? ResolveAssembly(IEnumerable<Type> registeredTypes, AssemblyName assemblyName)
{
var coreLibAssembly = typeof(List<>).Assembly;
if (AssemblyName.ReferenceMatchesDefinition(coreLibAssembly.GetName(), assemblyName))
return coreLibAssembly;
return registeredTypes
.Select(x => x.Assembly)
.Distinct()
.FirstOrDefault(x => AssemblyName.ReferenceMatchesDefinition(x.GetName(), assemblyName));
}
private static Type? ResolveType(IEnumerable<Type> registeredTypes, Assembly? assembly, string typeName, bool ignoreCase)
{
if (assembly == typeof(List<>).Assembly)
{
var genericCollectionType = GenericCollectionTypes.Values.FirstOrDefault(x =>
x.FullName != null && string.Equals(x.FullName, typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
if (genericCollectionType != null)
return genericCollectionType;
}
return registeredTypes.FirstOrDefault(x =>
x.Assembly == assembly &&
x.FullName != null &&
(string.Equals(x.FullName, typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
string.Equals(x.FullName.Replace('+', '.'), typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)));
}
}

View file

@ -72,9 +72,10 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer
protected override void AddConverters(JsonSerializerOptions options)
{
var expressionDescriptorRegistry = ServiceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
var wellKnownTypeRegistry = ServiceProvider.GetRequiredService<IWellKnownTypeRegistry>();
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.Converters.Add(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()));
options.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry));
options.Converters.Add(new SafeValueConverterFactory());
options.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry));
options.Converters.Add(new FuncExpressionValueConverter());

View file

@ -5,6 +5,7 @@ using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Serialization.Helpers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@ -40,13 +41,7 @@ public class VariableMapper
/// </summary>
public Variable Map(VariableModel source)
{
var typeName = source.TypeName;
if (string.IsNullOrWhiteSpace(source.TypeName))
typeName = _wellKnownTypeRegistry.GetAliasOrDefault(typeof(object));
if (!_wellKnownTypeRegistry.TryGetTypeOrDefault(typeName, out var type))
type = typeof(object);
var type = ResolveVariableType(source.TypeName);
var variableGenericType = typeof(Variable<>).MakeGenericType(type);
var variable = (Variable)Activator.CreateInstance(variableGenericType)!;
@ -59,7 +54,7 @@ public class VariableMapper
.OnSuccess(value => variable.Value = value)
.OnFailure(e => _logger.LogWarning("Failed to convert {SourceValue} to {TargetType}", source.Value, type.Name));
variable.StorageDriverType = !string.IsNullOrEmpty(source.StorageDriverTypeName) ? Type.GetType(source.StorageDriverTypeName) : null;
variable.StorageDriverType = ResolveStorageDriverType(source.StorageDriverTypeName);
return variable;
}
@ -78,4 +73,33 @@ public class VariableMapper
return new(source.Id, source.Name, valueTypeAlias, serializedValue, storageDriverTypeName);
}
}
private Type ResolveVariableType(string? typeAlias)
{
if (string.IsNullOrWhiteSpace(typeAlias))
return typeof(object);
if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type))
return type;
_logger.LogWarning("Failed to resolve variable type alias {VariableTypeName}", typeAlias);
return typeof(object);
}
private Type? ResolveStorageDriverType(string? typeAlias)
{
if (string.IsNullOrWhiteSpace(typeAlias))
return null;
if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type) && IsStorageDriverType(type))
return type;
_logger.LogWarning("Failed to resolve storage driver type alias {StorageDriverTypeName}", typeAlias);
return null;
}
private static bool IsStorageDriverType(Type type)
{
return typeof(IStorageDriver).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false };
}
}

View file

@ -8,6 +8,7 @@ using Elsa.Caching.Features;
using Elsa.Common.Codecs;
using Elsa.Common.Features;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
@ -312,7 +313,8 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
options.IsReadOnlyMode = IsReadOnlyMode;
});
Services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext)));
Services.Configure<HostMethodActivitiesOptions>(_ => { });
Services.Configure<WorkflowReferenceGraphOptions>(_ => { });
}
}
}

View file

@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Serialization.Converters;
@ -17,7 +18,14 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowTriggerEqualityComparer"/> class.
/// </summary>
public WorkflowTriggerEqualityComparer()
public WorkflowTriggerEqualityComparer() : this(WellKnownTypeRegistry.CreateDefault())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowTriggerEqualityComparer"/> class.
/// </summary>
public WorkflowTriggerEqualityComparer(IWellKnownTypeRegistry wellKnownTypeRegistry)
{
_settings = new()
{
@ -36,8 +44,8 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
// polymorphic object properties serialize identically to their stored representation.
_settings.Converters.Add(new JsonStringEnumConverter());
_settings.Converters.Add(JsonMetadataServices.TimeSpanConverter);
_settings.Converters.Add(new PolymorphicObjectConverterFactory());
_settings.Converters.Add(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()));
_settings.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry));
_settings.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry));
}
/// <inheritdoc />
@ -91,4 +99,3 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
return JsonSerializer.Serialize(payload, payload.GetType(), _settings);
}
}

View file

@ -1,4 +1,5 @@
using Elsa.Workflows;
using Elsa.Workflows.Runtime.Helpers;
using Microsoft.Extensions.DependencyInjection;
// ReSharper disable once CheckNamespace
@ -14,12 +15,7 @@ public static class WorkflowDictionaryExtensions
/// </summary>
public static void Add<TWorkflow>(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary) where TWorkflow : IWorkflow
{
// FullName should never be null here, as we filter out generic types
dictionary.Add(typeof(TWorkflow).FullName!, sp =>
{
var workflow = ActivatorUtilities.GetServiceOrCreateInstance<TWorkflow>(sp);
return new ValueTask<IWorkflow>(workflow);
});
dictionary.Add(typeof(TWorkflow));
}
/// <summary>
@ -27,11 +23,30 @@ public static class WorkflowDictionaryExtensions
/// </summary>
public static void Add(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary, Type workflowType)
{
// FullName should never be null here, as we filter out generic types
dictionary.Add(workflowType.FullName!, sp =>
WorkflowTypeValidator.Validate(workflowType);
var key = workflowType.GetSimpleAssemblyQualifiedName();
var legacyKey = workflowType.FullName;
var hasFactory = dictionary.TryGetValue(key, out var factory);
if (!hasFactory && !string.IsNullOrWhiteSpace(legacyKey))
hasFactory = dictionary.TryGetValue(legacyKey, out factory);
if (!hasFactory)
{
var workflow = (IWorkflow)ActivatorUtilities.GetServiceOrCreateInstance(sp, workflowType);
return new ValueTask<IWorkflow>(workflow);
});
factory = sp =>
{
var workflow = (IWorkflow)ActivatorUtilities.GetServiceOrCreateInstance(sp, workflowType);
return new ValueTask<IWorkflow>(workflow);
};
}
dictionary[key] = factory!;
if (!string.IsNullOrWhiteSpace(legacyKey) && legacyKey != key)
dictionary[legacyKey] = factory!;
if (dictionary is IWorkflowTypeRegistry workflowTypeRegistry)
workflowTypeRegistry.AddWorkflowType(workflowType);
}
}
}

View file

@ -4,6 +4,7 @@ using Elsa.Common;
using Elsa.Common.DistributedHosting;
using Elsa.Common.Features;
using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
@ -14,10 +15,14 @@ using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Contracts;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Runtime.ActivationValidators;
using Elsa.Workflows.Runtime.Bookmarks;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Handlers;
using Elsa.Workflows.Runtime.Helpers;
using Elsa.Workflows.Runtime.Models;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Providers;
using Elsa.Workflows.Runtime.Stimuli;
using Elsa.Workflows.Runtime.Stores;
using Elsa.Workflows.Runtime.Tasks;
using Elsa.Workflows.Runtime.UIHints;
@ -33,6 +38,7 @@ namespace Elsa.Workflows.Runtime.Features;
/// Installs and configures workflow runtime features.
/// </summary>
[DependsOn(typeof(SystemClockFeature))]
[DependsOn(typeof(WorkflowsFeature))]
public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
{
private IDictionary<string, DispatcherChannel> WorkflowDispatcherChannels { get; set; } = new Dictionary<string, DispatcherChannel>();
@ -40,7 +46,8 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
/// <summary>
/// A list of workflow builders configured during application startup.
/// </summary>
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>();
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new WorkflowFactoryDictionary();
private ISet<Type> WorkflowTypes { get; } = new HashSet<Type>();
/// <summary>
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
@ -186,7 +193,18 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
/// </summary>
public WorkflowRuntimeFeature AddWorkflow<T>() where T : IWorkflow
{
Workflows.Add<T>();
AddWorkflow(typeof(T));
return this;
}
/// <summary>
/// Register the specified workflow type.
/// </summary>
public WorkflowRuntimeFeature AddWorkflow(Type workflowType)
{
WorkflowTypeValidator.Validate(workflowType);
Workflows.Add(workflowType);
WorkflowTypes.Add(workflowType);
return this;
}
@ -197,11 +215,11 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly)
{
var workflowTypes = assembly.GetExportedTypes()
.Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, IsGenericType: false })
.Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false })
.ToList();
foreach (var workflowType in workflowTypes)
Workflows.Add(workflowType);
AddWorkflow(workflowType);
return this;
}
@ -257,6 +275,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
Services.Configure(WorkflowInboxCleanupOptions);
Services.Configure(WorkflowDispatcherOptions);
Services.Configure(BookmarkQueuePurgeOptions);
Services.Configure<ExpressionOptions>(RegisterWorkflowTypeAliases);
Services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
Services.Configure<WorkflowDispatcherOptions>(options =>
{
@ -428,4 +447,13 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
Services.TryAddScoped(WorkflowDispatchOutboxStore);
Services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
}
private void RegisterWorkflowTypeAliases(ExpressionOptions options)
{
var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry
? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes)
: WorkflowTypes;
WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes);
}
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Workflows.Runtime.Helpers;
internal interface IWorkflowTypeRegistry
{
IEnumerable<Type> WorkflowTypes { get; }
void AddWorkflowType(Type workflowType);
}

View file

@ -0,0 +1,16 @@
using Elsa.Workflows;
namespace Elsa.Workflows.Runtime.Helpers;
internal sealed class WorkflowFactoryDictionary : Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>, IWorkflowTypeRegistry
{
private readonly ISet<Type> _workflowTypes = new HashSet<Type>();
public IEnumerable<Type> WorkflowTypes => _workflowTypes;
public void AddWorkflowType(Type workflowType)
{
WorkflowTypeValidator.Validate(workflowType);
_workflowTypes.Add(workflowType);
}
}

View file

@ -0,0 +1,17 @@
using Elsa.Workflows;
namespace Elsa.Workflows.Runtime.Helpers;
internal static class WorkflowTypeValidator
{
public static void Validate(Type workflowType)
{
if (!typeof(IWorkflow).IsAssignableFrom(workflowType))
throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must implement {nameof(IWorkflow)}.", nameof(workflowType));
if (workflowType.IsAbstract || workflowType.IsInterface || workflowType.IsGenericTypeDefinition || workflowType.ContainsGenericParameters)
throw new ArgumentException($"Workflow type '{GetDisplayName(workflowType)}' must be a concrete, closed type.", nameof(workflowType));
}
private static string GetDisplayName(Type type) => type.FullName ?? type.Name;
}

View file

@ -22,7 +22,7 @@ public class ClrWorkflowsProvider(
/// <inheritdoc />
public async ValueTask<IEnumerable<MaterializedWorkflow>> GetWorkflowsAsync(CancellationToken cancellationToken = default)
{
var buildWorkflowTasks = options.Value.Workflows.Values.Select(async x => await BuildWorkflowAsync(x, cancellationToken)).ToList();
var buildWorkflowTasks = options.Value.Workflows.Values.Distinct().Select(async x => await BuildWorkflowAsync(x, cancellationToken)).ToList();
var workflowDefinitions = await Task.WhenAll(buildWorkflowTasks);
return workflowDefinitions;
}
@ -48,4 +48,4 @@ public class ClrWorkflowsProvider(
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());
return new(workflow, Name, ClrWorkflowMaterializer.MaterializerName, materializerContext);
}
}
}

View file

@ -31,6 +31,7 @@ public class TriggerIndexer : ITriggerIndexer
private readonly IServiceProvider _serviceProvider;
private readonly IStimulusHasher _hasher;
private readonly IDistributedLockProvider _distributedLockProvider;
private readonly WorkflowTriggerEqualityComparer _triggerEqualityComparer;
private readonly DistributedLockingOptions _lockingOptions;
private readonly ILogger _logger;
@ -48,6 +49,7 @@ public class TriggerIndexer : ITriggerIndexer
IServiceProvider serviceProvider,
IStimulusHasher hasher,
IDistributedLockProvider distributedLockProvider,
IWellKnownTypeRegistry wellKnownTypeRegistry,
IOptions<DistributedLockingOptions> lockingOptions,
ILogger<TriggerIndexer> logger)
{
@ -60,6 +62,7 @@ public class TriggerIndexer : ITriggerIndexer
_serviceProvider = serviceProvider;
_hasher = hasher;
_distributedLockProvider = distributedLockProvider;
_triggerEqualityComparer = new WorkflowTriggerEqualityComparer(wellKnownTypeRegistry);
_lockingOptions = lockingOptions.Value;
_logger = logger;
_workflowDefinitionService = workflowDefinitionService;
@ -118,7 +121,7 @@ public class TriggerIndexer : ITriggerIndexer
: new(0);
// Diff triggers.
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer());
var diff = Diff.For(currentTriggers, newTriggers, _triggerEqualityComparer);
// Replace triggers for the specified workflow.
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
@ -140,7 +143,7 @@ public class TriggerIndexer : ITriggerIndexer
{
var emptyTriggerList = new List<StoredTrigger>(0);
var currentTriggers = await GetCurrentTriggersAsync(workflow.Identity.DefinitionId, cancellationToken).ToList();
var diff = Diff.For(currentTriggers, emptyTriggerList, new WorkflowTriggerEqualityComparer());
var diff = Diff.For(currentTriggers, emptyTriggerList, _triggerEqualityComparer);
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
var indexedWorkflow = new IndexedWorkflowTriggers(workflow, emptyTriggerList, currentTriggers, emptyTriggerList);
await _notificationSender.SendAsync(new WorkflowTriggersIndexed(indexedWorkflow), cancellationToken);
@ -226,4 +229,4 @@ public class TriggerIndexer : ITriggerIndexer
return new(0);
}
}
}

View file

@ -1,6 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using CShells.Features;
using Elsa.Common;
using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.CommitStates;
@ -10,6 +13,7 @@ using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Runtime.ActivationValidators;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Handlers;
using Elsa.Workflows.Runtime.Helpers;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Providers;
using Elsa.Workflows.Runtime.Services;
@ -38,7 +42,8 @@ public class WorkflowRuntimeFeature : IShellFeature
/// <summary>
/// A list of workflow builders configured during application startup.
/// </summary>
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>();
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new WorkflowFactoryDictionary();
private ISet<Type> WorkflowTypes { get; } = new HashSet<Type>();
/// <summary>
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
@ -146,10 +151,45 @@ public class WorkflowRuntimeFeature : IShellFeature
/// </summary>
public GracefulShutdownOptions? GracefulShutdown { get; set; }
/// <summary>
/// Register the specified workflow type.
/// </summary>
public WorkflowRuntimeFeature AddWorkflow<T>() where T : IWorkflow
{
return AddWorkflow(typeof(T));
}
/// <summary>
/// Register the specified workflow type.
/// </summary>
public WorkflowRuntimeFeature AddWorkflow(Type workflowType)
{
WorkflowTypeValidator.Validate(workflowType);
Workflows.Add(workflowType);
WorkflowTypes.Add(workflowType);
return this;
}
/// <summary>
/// Register all workflows in the specified assembly.
/// </summary>
[RequiresUnreferencedCode("The assembly is required to be referenced.")]
public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly)
{
var workflowTypes = assembly.GetExportedTypes()
.Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false })
.ToList();
foreach (var workflowType in workflowTypes)
AddWorkflow(workflowType);
return this;
}
public void ConfigureServices(IServiceCollection services)
{
// Options.
services.Configure<ExpressionOptions>(RegisterWorkflowTypeAliases);
services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
services.Configure<WorkflowDispatcherOptions>(options =>
{
@ -325,4 +365,13 @@ public class WorkflowRuntimeFeature : IShellFeature
services.TryAddScoped(WorkflowDispatchOutboxStore);
services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
}
private void RegisterWorkflowTypeAliases(ExpressionOptions options)
{
var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry
? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes)
: WorkflowTypes;
WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes);
}
}

View file

@ -0,0 +1,35 @@
using Elsa.Expressions.Options;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Runtime.Bookmarks;
using Elsa.Workflows.Runtime.Stimuli;
namespace Elsa.Workflows.Runtime;
internal static class WorkflowRuntimeTypeAliasRegistrar
{
public static void Register(ExpressionOptions options, IEnumerable<Type> workflowTypes)
{
options.RegisterTypeAlias(typeof(EventBookmarkPayload), nameof(EventBookmarkPayload));
options.RegisterTypeAlias(typeof(ExecuteWorkflowPayload), nameof(ExecuteWorkflowPayload));
options.RegisterTypeAlias(typeof(RunTaskBookmarkPayload), nameof(RunTaskBookmarkPayload));
options.RegisterTypeAlias(typeof(BookmarkTokenPayload), nameof(BookmarkTokenPayload));
options.RegisterTypeAlias(typeof(EventTokenPayload), nameof(EventTokenPayload));
options.RegisterTypeAlias(typeof(ExecuteWorkflowResult), nameof(ExecuteWorkflowResult));
options.RegisterTypeAlias(typeof(WorkflowInterruptedPayload), nameof(WorkflowInterruptedPayload));
options.RegisterTypeAlias(typeof(BackgroundActivityStimulus), nameof(BackgroundActivityStimulus));
options.RegisterTypeAlias(typeof(BulkDispatchWorkflowsStimulus), nameof(BulkDispatchWorkflowsStimulus));
options.RegisterTypeAlias(typeof(DispatchWorkflowStimulus), nameof(DispatchWorkflowStimulus));
options.RegisterTypeAlias(typeof(EventStimulus), nameof(EventStimulus));
options.RegisterTypeAlias(typeof(ExecuteWorkflowStimulus), nameof(ExecuteWorkflowStimulus));
options.RegisterTypeAlias(typeof(RunTaskStimulus), nameof(RunTaskStimulus));
foreach (var workflowType in workflowTypes.Where(IsConcreteWorkflowType).Distinct())
options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName());
}
private static bool IsConcreteWorkflowType(Type type)
{
return typeof(IWorkflow).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false };
}
}

View file

@ -142,25 +142,24 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
return (T?)variables.FirstOrDefault(v => v.Variable.Name == variableName)?.Value;
}
private async Task<(TestWorkflowExecutionResult Result, List<WorkflowState> CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync(
private async Task<(TestWorkflowExecutionResult Result, List<WorkflowInstance> CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync(
string parentWorkflowDefinitionId,
string childWorkflowDefinitionId,
int expectedChildCount)
{
var result = await RunWorkflowAsync(parentWorkflowDefinitionId);
var completedChildWorkflows = await WaitForCompletedChildWorkflowsAsync(result.WorkflowExecutionContext.Id, childWorkflowDefinitionId, expectedChildCount);
var completedChildWorkflows = await WaitForCompletedChildWorkflowInstancesAsync(result.WorkflowExecutionContext.Id, childWorkflowDefinitionId, expectedChildCount);
return (result, completedChildWorkflows);
}
private async Task<List<WorkflowState>> WaitForCompletedChildWorkflowsAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount)
private async Task<List<WorkflowInstance>> WaitForCompletedChildWorkflowInstancesAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount)
{
var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService<IWorkflowInstanceStore>();
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(ChildWorkflowTimeoutSeconds);
while (DateTimeOffset.UtcNow < timeoutAt)
{
var completedChildWorkflows = await FindChildWorkflowStatesAsync(workflowInstanceStore, parentWorkflowInstanceId, childWorkflowDefinitionId, WorkflowStatus.Finished);
var completedChildWorkflows = await FindChildWorkflowInstancesAsync(parentWorkflowInstanceId, childWorkflowDefinitionId, WorkflowStatus.Finished);
if (completedChildWorkflows.Count >= expectedChildCount)
return completedChildWorkflows;
@ -168,13 +167,12 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
var childWorkflows = await FindChildWorkflowStatesAsync(workflowInstanceStore, parentWorkflowInstanceId, childWorkflowDefinitionId);
var childWorkflows = await FindChildWorkflowInstancesAsync(parentWorkflowInstanceId, childWorkflowDefinitionId);
var observedStates = string.Join(", ", childWorkflows.Select(x => $"{x.Id}:{x.Status}:{x.SubStatus}"));
throw new TimeoutException($"Expected {expectedChildCount} completed child workflows with definition ID {childWorkflowDefinitionId}, but observed {childWorkflows.Count(x => x.Status == WorkflowStatus.Finished)}. Observed child workflow states: {observedStates}");
}
private static async Task<List<WorkflowState>> FindChildWorkflowStatesAsync(
IWorkflowInstanceStore workflowInstanceStore,
private async Task<List<WorkflowInstance>> FindChildWorkflowInstancesAsync(
string parentWorkflowInstanceId,
string childWorkflowDefinitionId,
WorkflowStatus? workflowStatus = null)
@ -185,8 +183,8 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
ParentWorkflowInstanceIds = new[] { parentWorkflowInstanceId },
WorkflowStatus = workflowStatus
};
var instances = await workflowInstanceStore.FindManyAsync(filter);
return instances.Select(x => x.WorkflowState).ToList();
var instances = await _workflowInstanceStore.FindManyAsync(filter);
return instances.OrderBy(x => x.CreatedAt).ToList();
}
private async Task<IReadOnlyCollection<WorkflowInstance>> WaitForChildWorkflowInstancesAsync(

View file

@ -74,8 +74,9 @@ public class PublishEventTests : AppComponentTest
// Verify the payload structure and content
var payloadJson = JsonSerializer.Serialize(receivedPayload);
Assert.Contains("\"Status\"", payloadJson);
Assert.Contains("\"Shipped\"", payloadJson);
using var payloadDocument = JsonDocument.Parse(payloadJson);
var statusProperty = Assert.Single(payloadDocument.RootElement.EnumerateObject(), x => string.Equals(x.Name, "Status", StringComparison.OrdinalIgnoreCase));
Assert.Equal("Shipped", statusProperty.Value.GetString());
}
private async Task<WorkflowInstance> GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000)

View file

@ -0,0 +1,31 @@
using System.Reflection;
using Elsa.Expressions.JavaScript.ShellFeatures;
using Elsa.Expressions.Options;
using Elsa.Expressions.Services;
using Jint.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Elsa.JavaScript.IntegrationTests;
public class JavaScriptFeatureTests
{
[Fact]
public void ConfigureServices_RegistersUniqueWrapperExceptionAlias()
{
var services = new ServiceCollection();
var feature = new JavaScriptFeature();
var wrapperExceptionType = typeof(JavaScriptException).GetNestedType("JavaScriptErrorWrapperException", BindingFlags.Public | BindingFlags.NonPublic);
feature.ConfigureServices(services);
using var serviceProvider = services.BuildServiceProvider();
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
var registry = new WellKnownTypeRegistry(expressionOptions);
Assert.NotNull(wrapperExceptionType);
Assert.True(registry.TryGetType("Jint.JavaScriptErrorWrapperException", out var type));
Assert.Equal(wrapperExceptionType, type);
Assert.False(registry.TryGetType(wrapperExceptionType.Name, out _));
}
}

View file

@ -2,7 +2,7 @@
"StatusCode": "Created",
"Content": {
"_island": "[\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n]",
"_type": "Newtonsoft.Json.Linq.JArray, Newtonsoft.Json"
"_type": "JArray"
},
"_type": "ObjectMap"
}
}

View file

@ -2,7 +2,7 @@
"StatusCode": "Created",
"Content": {
"_island": "{\r\n \u0022file1\u0022: {\r\n \u0022script\u0022: [\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n ]\r\n }\r\n}",
"_type": "Newtonsoft.Json.Linq.JObject, Newtonsoft.Json"
"_type": "JObject"
},
"_type": "ObjectMap"
}
}

View file

@ -2,7 +2,7 @@
"StatusCode": "Created",
"Content": {
"_island": "[\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n]",
"_type": "System.Text.Json.Nodes.JsonArray, System.Text.Json"
"_type": "JsonArray"
},
"_type": "ObjectMap"
}
}

View file

@ -2,7 +2,7 @@
"StatusCode": "Created",
"Content": {
"_island": "{\r\n \u0022file1\u0022: {\r\n \u0022script\u0022: [\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n ]\r\n }\r\n}",
"_type": "System.Text.Json.Nodes.JsonObject, System.Text.Json"
"_type": "JsonObject"
},
"_type": "ObjectMap"
}
}

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Elsa.Expressions.Options;
using Elsa.Testing.Shared;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
@ -9,7 +10,9 @@ namespace Elsa.Workflows.IntegrationTests.Serialization.JsonSerialization;
public class SerializationTests(ITestOutputHelper testOutputHelper)
{
private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper).Build();
private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper)
.ConfigureServices(services => services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(typeof(TestObject), nameof(TestObject))))
.Build();
[Theory(DisplayName = "write")]
[InlineData(typeof(JsonObject), "JsonObjectIsland")]
@ -182,4 +185,4 @@ public class SerializationTests(ITestOutputHelper testOutputHelper)
public class TestObject
{
public string? Data { get; set; }
}
}

View file

@ -14,7 +14,7 @@ public class Tests
public void Test1()
{
var model = CreateModel();
var expectedJson = File.ReadAllText("Serialization/Polymorphism/data.json");
var expectedJson = File.ReadAllText("Serialization/Polymorphism/data.json").TrimEnd();
var actualJson = JsonSerializer.Serialize(model, GetSerializerOptions());
Assert.Equal(expectedJson, actualJson);
}
@ -74,6 +74,10 @@ public class Tests
private JsonSerializerOptions GetSerializerOptions()
{
var referenceHandler = new CrossScopedReferenceHandler();
var wellKnownTypeRegistry = new WellKnownTypeRegistry();
wellKnownTypeRegistry.RegisterType(typeof(Model), nameof(Model));
wellKnownTypeRegistry.RegisterType(typeof(CustomDictionary), nameof(CustomDictionary));
var options = new JsonSerializerOptions
{
ReferenceHandler = referenceHandler,
@ -84,7 +88,7 @@ public class Tests
options.Converters.Add(new JsonStringEnumConverter());
options.Converters.Add(JsonMetadataServices.TimeSpanConverter);
options.Converters.Add(new PolymorphicObjectConverterFactory(new WellKnownTypeRegistry()));
options.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry));
return options;
}
@ -113,4 +117,4 @@ public class Tests
public override void Write(Utf8JsonWriter writer, MyNumber value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value.Number);
}
}
}

View file

@ -1 +1 @@
{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.CustomDictionary, Elsa.Workflows.IntegrationTests"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"System.Collections.Generic.List\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"System.Collections.Generic.HashSet\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}}
{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"CustomDictionary"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"List\u003CModel\u003E"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Model"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"HashSet\u003CModel\u003E"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}}

View file

@ -0,0 +1,22 @@
using System.Text.Json;
using Elsa.Common.Entities;
namespace Elsa.Common.UnitTests.Entities;
public class OrderDefinitionTests
{
[Fact]
public void Serialize_DoesNotIncludeKeySelectorText()
{
var order = new OrderDefinition<TestEntity, string>(x => x.Name, OrderDirection.Ascending);
var json = JsonSerializer.Serialize(order);
Assert.DoesNotContain(nameof(OrderDefinition<TestEntity, string>.KeySelectorText), json);
}
private sealed class TestEntity
{
public string Name { get; set; } = null!;
}
}

View file

@ -0,0 +1,29 @@
using Elsa.Expressions.Options;
using Elsa.Expressions.Services;
using Elsa.Http.Bookmarks;
using Elsa.Http.ShellFeatures;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Elsa.Http.UnitTests.ShellFeatures;
public class HttpFeatureTests
{
[Fact]
public void ConfigureServices_RegistersHttpTypeAliases()
{
var services = new ServiceCollection();
var feature = new HttpFeature();
feature.ConfigureServices(services);
using var serviceProvider = services.BuildServiceProvider();
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
var registry = new WellKnownTypeRegistry(expressionOptions);
Assert.True(registry.TryGetType(nameof(HttpEndpointBookmarkPayload), out var type));
Assert.Equal(typeof(HttpEndpointBookmarkPayload), type);
Assert.True(registry.TryGetType(nameof(HttpRequestException), out var exceptionType));
Assert.Equal(typeof(HttpRequestException), exceptionType);
}
}

View file

@ -0,0 +1,180 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Serialization.Converters;
namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters;
public class ExcludeFromHashConverterTests
{
private readonly JsonSerializerOptions _options = new()
{
Converters = { new ExcludeFromHashConverterFactory() }
};
[Fact]
public void Write_IncludesConditionallyIgnoredProperty_WhenValueIsNotNull()
{
var json = JsonSerializer.Serialize<object>(new ConditionalIgnoreModel { Name = "Alice" }, _options);
Assert.Contains("\"Name\":\"Alice\"", json);
}
[Fact]
public void Write_ExcludesConditionallyIgnoredProperty_WhenValueIsNull()
{
var json = JsonSerializer.Serialize<object>(new ConditionalIgnoreModel(), _options);
Assert.DoesNotContain("\"Name\"", json);
}
[Fact]
public void Write_ExcludesAlwaysIgnoredProperty()
{
var json = JsonSerializer.Serialize<object>(new AlwaysIgnoreModel { Secret = "hidden" }, _options);
Assert.DoesNotContain("\"Secret\"", json);
}
[Fact]
public void Write_SkipsIndexerProperties()
{
var json = JsonSerializer.Serialize<object>(new IndexerModel(), _options);
Assert.DoesNotContain("\"Item\"", json);
}
[Fact]
public void Write_DoesNotEvaluateExcludedProperty()
{
var json = JsonSerializer.Serialize<object>(new ExcludedThrowingModel(), _options);
Assert.DoesNotContain("\"Secret\"", json);
}
[Fact]
public void Write_DoesNotEvaluateAlwaysIgnoredProperty()
{
var json = JsonSerializer.Serialize<object>(new AlwaysIgnoredThrowingModel(), _options);
Assert.DoesNotContain("\"Secret\"", json);
}
[Fact]
public void Write_IncludesNullableValueTypeWithDefaultUnderlyingValue_WhenIgnoringDefaults()
{
var json = JsonSerializer.Serialize<object>(new DefaultIgnoreNullableModel { Count = 0 }, _options);
Assert.Contains("\"Count\":0", json);
}
[Fact]
public void Write_IncludesProperty_WhenJsonIgnoreConditionIsUnknown()
{
var json = JsonSerializer.Serialize<object>(new UnknownIgnoreConditionModel { Name = "Alice" }, _options);
Assert.Contains("\"Name\":\"Alice\"", json);
}
[Fact]
public void Write_ExcludesStaticProperties()
{
var json = JsonSerializer.Serialize<object>(new StaticPropertyModel { Name = "Alice" }, _options);
Assert.Contains("\"Name\":\"Alice\"", json);
Assert.DoesNotContain("\"Secret\"", json);
}
[Fact]
public void Write_PreservesDeclarationOrder()
{
var json = JsonSerializer.Serialize<object>(new OrderedModel { B = "second", A = "first" }, _options);
Assert.True(json.IndexOf("\"B\"", StringComparison.Ordinal) < json.IndexOf("\"A\"", StringComparison.Ordinal));
}
[Fact]
public void Write_OrdersInheritedProperties_ByDeclaringTypeThenDeclarationOrder()
{
var json = JsonSerializer.Serialize<object>(new DerivedOrderedModel
{
BaseB = "base second",
BaseA = "base first",
DerivedB = "derived second",
DerivedA = "derived first"
}, _options);
Assert.True(json.IndexOf("\"DerivedB\"", StringComparison.Ordinal) < json.IndexOf("\"DerivedA\"", StringComparison.Ordinal));
Assert.True(json.IndexOf("\"DerivedA\"", StringComparison.Ordinal) < json.IndexOf("\"BaseB\"", StringComparison.Ordinal));
Assert.True(json.IndexOf("\"BaseB\"", StringComparison.Ordinal) < json.IndexOf("\"BaseA\"", StringComparison.Ordinal));
}
private sealed class ConditionalIgnoreModel
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
}
private sealed class AlwaysIgnoreModel
{
[JsonIgnore]
public string? Secret { get; set; }
}
private sealed class IndexerModel
{
public string this[int index] => index.ToString();
}
private sealed class ExcludedThrowingModel
{
[ExcludeFromHash]
public string Secret => throw new InvalidOperationException();
}
private sealed class AlwaysIgnoredThrowingModel
{
[JsonIgnore]
public string Secret => throw new InvalidOperationException();
}
private sealed class DefaultIgnoreNullableModel
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? Count { get; set; }
}
private sealed class UnknownIgnoreConditionModel
{
[JsonIgnore(Condition = (JsonIgnoreCondition)999)]
public string? Name { get; set; }
}
private sealed class StaticPropertyModel
{
public static string Secret => throw new InvalidOperationException();
public string? Name { get; set; }
}
private sealed class OrderedModel
{
public string? B { get; set; }
public string? A { get; set; }
}
private class BaseOrderedModel
{
public string? BaseB { get; set; }
public string? BaseA { get; set; }
}
private sealed class DerivedOrderedModel : BaseOrderedModel
{
public string? DerivedB { get; set; }
public string? DerivedA { get; set; }
}
}

View file

@ -0,0 +1,368 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Elsa.Expressions.Options;
using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Serialization.Converters;
using Elsa.Workflows.Serialization.Helpers;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters;
public sealed class WorkflowJsonTypeResolverTests
{
private static readonly string UnsafeAssemblyQualifiedTypeAlias = typeof(System.Text.StringBuilder).AssemblyQualifiedName!;
private readonly WellKnownTypeRegistry _wellKnownTypeRegistry = new(Microsoft.Extensions.Options.Options.Create(new ExpressionOptions()));
private readonly JsonSerializerOptions _options;
public WorkflowJsonTypeResolverTests()
{
_wellKnownTypeRegistry.RegisterType(typeof(ExceptionState), nameof(ExceptionState));
_wellKnownTypeRegistry.RegisterType(typeof(FaultException), nameof(FaultException));
_wellKnownTypeRegistry.RegisterType(typeof(RegisteredPayload), "RegisteredPayload");
_options = CreateOptions(_wellKnownTypeRegistry);
}
[Theory]
[InlineData("String", typeof(string))]
[InlineData("String[]", typeof(string[]))]
[InlineData("String[][]", typeof(string[][]))]
[InlineData("List<String>", typeof(List<string>))]
[InlineData("List<String[]>", typeof(List<string[]>))]
[InlineData("List<List<String>>", typeof(List<List<string>>))]
[InlineData("ExceptionState", typeof(ExceptionState))]
[InlineData("FaultException", typeof(FaultException))]
[InlineData("ObjectDictionary", typeof(IDictionary<string, object>))]
public void When_DeserializeRegisteredTypeAlias_Then_ReturnsExpectedType(string typeAlias, Type expectedType)
{
var result = JsonSerializer.Deserialize<Type>(JsonSerializer.Serialize(typeAlias), _options);
Assert.Equal(expectedType, result);
}
[Fact]
public void When_DeserializeRegisteredLegacyAssemblyQualifiedTypeAlias_Then_ReturnsExpectedType()
{
var typeAlias = typeof(RegisteredPayload).GetSimpleAssemblyQualifiedName();
var result = JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options);
Assert.Equal(typeof(RegisteredPayload), result);
}
[Fact]
public void When_DeserializeRegisteredLegacyGenericCollectionTypeAlias_Then_ReturnsExpectedType()
{
var typeAlias = typeof(List<RegisteredPayload>).GetSimpleAssemblyQualifiedName();
var result = JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options);
Assert.Equal(typeof(List<RegisteredPayload>), result);
}
[Theory]
[InlineData(typeof(string), "String")]
[InlineData(typeof(string[]), "String[]")]
[InlineData(typeof(string[][]), "String[][]")]
[InlineData(typeof(List<string>), "List<String>")]
[InlineData(typeof(List<string[]>), "List<String[]>")]
[InlineData(typeof(List<List<string>>), "List<List<String>>")]
[InlineData(typeof(ExceptionState), "ExceptionState")]
[InlineData(typeof(FaultException), "FaultException")]
public void When_SerializeSupportedType_Then_EmitsAliasThatCanBeDeserialized(Type type, string expectedAlias)
{
var json = JsonSerializer.Serialize(type, _options);
var alias = JsonSerializer.Deserialize<string>(json);
var result = JsonSerializer.Deserialize<Type>(json, _options);
Assert.Equal(expectedAlias, alias);
Assert.Equal(type, result);
}
[Theory]
[InlineData(typeof(IEnumerable<string>), "List<String>", typeof(List<string>))]
[InlineData(typeof(ICollection<string>), "List<String>", typeof(List<string>))]
[InlineData(typeof(IList<string>), "List<String>", typeof(List<string>))]
[InlineData(typeof(IReadOnlyCollection<string>), "List<String>", typeof(List<string>))]
[InlineData(typeof(IReadOnlyList<string>), "List<String>", typeof(List<string>))]
[InlineData(typeof(ISet<string>), "HashSet<String>", typeof(HashSet<string>))]
public void When_SerializeInterfaceCollectionType_Then_EmitsInstantiableAlias(Type type, string expectedAlias, Type expectedRoundTripType)
{
var json = JsonSerializer.Serialize(type, _options);
var alias = JsonSerializer.Deserialize<string>(json);
var result = JsonSerializer.Deserialize<Type>(json, _options);
Assert.Equal(expectedAlias, alias);
Assert.Equal(expectedRoundTripType, result);
}
[Theory]
[MemberData(nameof(JsonIslandValues))]
public void When_SerializeSpecialJsonIslandType_Then_CanBeDeserialized(object value, Type expectedType)
{
_wellKnownTypeRegistry.RegisterType(typeof(JObject), nameof(JObject));
_wellKnownTypeRegistry.RegisterType(typeof(JArray), nameof(JArray));
var json = JsonSerializer.Serialize(value, _options);
var result = JsonSerializer.Deserialize<object>(json, _options);
Assert.IsType(expectedType, result);
}
[Theory]
[InlineData(typeof(System.Text.StringBuilder))]
[InlineData(typeof(System.Text.StringBuilder[]))]
[InlineData(typeof(List<System.Text.StringBuilder>))]
public void When_SerializeUnsupportedType_Then_EmitsSafeUnregisteredTypeAlias(Type type)
{
var json = JsonSerializer.Serialize(type, _options);
var alias = JsonSerializer.Deserialize<string>(json);
var result = JsonSerializer.Deserialize<Type>(json, _options);
Assert.StartsWith("UnregisteredClrType:", alias);
Assert.Equal(typeof(Exception), result);
}
[Fact]
public void When_SerializeExceptionStateWithUnregisteredExceptionType_Then_DoesNotThrow()
{
var exceptionState = ExceptionState.FromException(new NullReferenceException("Test"));
var json = JsonSerializer.Serialize(exceptionState, _options);
var result = JsonSerializer.Deserialize<ExceptionState>(json, _options)!;
Assert.Contains("UnregisteredClrType:", json);
Assert.Equal(typeof(Exception), result.Type);
Assert.Equal("Test", result.Message);
}
[Fact]
public void When_ConfigureWorkflowsFeature_Then_RegistersCoreAliases()
{
var services = new ServiceCollection();
var module = services.CreateModule();
module.UseWorkflows();
module.Apply();
using var serviceProvider = services.BuildServiceProvider();
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
var registry = new WellKnownTypeRegistry(expressionOptions);
var aliasRegistered = registry.TryGetAlias(typeof(NullReferenceException), out var alias);
var typeRegistered = registry.TryGetType(nameof(NullReferenceException), out var type);
Assert.True(aliasRegistered);
Assert.Equal(nameof(NullReferenceException), alias);
Assert.True(typeRegistered);
Assert.Equal(typeof(NullReferenceException), type);
Assert.True(registry.TryGetAlias(typeof(MemoryStorageDriver), out var memoryStorageDriverAlias));
Assert.Equal(nameof(MemoryStorageDriver), memoryStorageDriverAlias);
Assert.True(registry.TryGetType(nameof(MemoryStorageDriver), out var memoryStorageDriverType));
Assert.Equal(typeof(MemoryStorageDriver), memoryStorageDriverType);
Assert.True(registry.TryGetType(typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName(), out var legacyMemoryStorageDriverType));
Assert.Equal(typeof(MemoryStorageDriver), legacyMemoryStorageDriverType);
}
[Fact]
public void When_SerializePolymorphicObjectWithUnregisteredType_Then_OmitsTypeMetadata()
{
var json = JsonSerializer.Serialize<object>(new UnregisteredPayload { Name = "Alice" }, _options);
var result = JsonSerializer.Deserialize<object>(json, _options);
Assert.DoesNotContain("\"_type\"", json);
var payload = Assert.IsAssignableFrom<IDictionary<string, object>>(result);
Assert.Equal("Alice", payload["name"]);
}
[Fact]
public void When_DeserializeUnknownAssemblyQualifiedTypeAlias_Then_ThrowsJsonException()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<Type>(JsonString(UnsafeAssemblyQualifiedTypeAlias), _options));
}
[Fact]
public void When_DeserializeUnknownGenericElementTypeAlias_Then_ThrowsJsonException()
{
var typeAlias = $"List<{UnsafeAssemblyQualifiedTypeAlias}>";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options));
}
[Fact]
public void When_DeserializePolymorphicObjectWithRegisteredTypeAlias_Then_ReturnsTypedObject()
{
var json = """
{
"name": "Alice",
"_type": "RegisteredPayload"
}
""";
var result = JsonSerializer.Deserialize<object>(json, _options);
var payload = Assert.IsType<RegisteredPayload>(result);
Assert.Equal("Alice", payload.Name);
}
[Theory]
[InlineData("IEnumerable<String>", typeof(List<string>))]
[InlineData("ICollection<String>", typeof(List<string>))]
[InlineData("IList<String>", typeof(List<string>))]
[InlineData("IReadOnlyCollection<String>", typeof(List<string>))]
[InlineData("IReadOnlyList<String>", typeof(List<string>))]
[InlineData("ISet<String>", typeof(HashSet<string>))]
public void When_DeserializePolymorphicCollectionInterface_Then_ReturnsConcreteCollection(string typeAlias, Type expectedType)
{
var json = $$"""
{
"_items": ["Alice"],
"_type": "{{typeAlias}}"
}
""";
var result = JsonSerializer.Deserialize<object>(json, _options);
Assert.IsType(expectedType, result);
}
[Fact]
public void When_DeserializePolymorphicObjectWithNonInstantiableType_Then_ThrowsJsonException()
{
_wellKnownTypeRegistry.RegisterType(typeof(AbstractPayload), "AbstractPayload");
var json = """
{
"_type": "AbstractPayload"
}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, _options));
}
[Fact]
public void When_DeserializePolymorphicObjectWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
{
var json = $$"""
{
"capacity": 16,
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, _options));
}
[Fact]
public void When_DeserializePolymorphicObjectWithoutTypeJsonConverterAndUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
{
var options = CreatePolymorphicOnlyOptions(_wellKnownTypeRegistry);
var json = $$"""
{
"capacity": 16,
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, options));
}
[Fact]
public void When_DeserializeDictionaryObjectPayloadWithRegisteredTypeAlias_Then_ReturnsTypedObjectValue()
{
var json = """
{
"payload": {
"name": "Alice",
"_type": "RegisteredPayload"
}
}
""";
var result = JsonSerializer.Deserialize<IDictionary<string, object>>(json, _options)!;
var payload = Assert.IsType<RegisteredPayload>(result["payload"]);
Assert.Equal("Alice", payload.Name);
}
[Fact]
public void When_DeserializeDictionaryObjectPayloadWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
{
var json = $$"""
{
"payload": {
"capacity": 16,
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
}
}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<IDictionary<string, object>>(json, _options));
}
[Fact]
public void When_RegistryChangesAfterLegacyResolutionAttempt_Then_LegacyResolutionUsesCurrentRegistry()
{
var typeAlias = typeof(LateRegisteredPayload).GetSimpleAssemblyQualifiedName();
Assert.False(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out _));
_wellKnownTypeRegistry.RegisterType(typeof(LateRegisteredPayload), "LateRegisteredPayload");
Assert.True(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var result));
Assert.Equal(typeof(LateRegisteredPayload), result);
}
private static JsonSerializerOptions CreateOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters =
{
new PolymorphicObjectConverterFactory(wellKnownTypeRegistry),
new TypeJsonConverter(wellKnownTypeRegistry)
}
};
private static JsonSerializerOptions CreatePolymorphicOnlyOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters =
{
new PolymorphicObjectConverterFactory(wellKnownTypeRegistry)
}
};
private static string JsonString(string value) => JsonSerializer.Serialize(value);
public static TheoryData<object, Type> JsonIslandValues() => new()
{
{ new JObject { ["name"] = "Alice" }, typeof(JObject) },
{ new JArray("Alice", "Bob"), typeof(JArray) },
{ new JsonObject { ["name"] = "Alice" }, typeof(JsonObject) },
{ new JsonArray("Alice", "Bob"), typeof(JsonArray) }
};
public sealed class RegisteredPayload
{
public string? Name { get; set; }
}
public sealed class UnregisteredPayload
{
public string? Name { get; set; }
}
public abstract class AbstractPayload
{
public string? Name { get; set; }
}
public sealed class LateRegisteredPayload
{
public string? Name { get; set; }
}
}

View file

@ -0,0 +1,64 @@
using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace Elsa.Workflows.Core.UnitTests.Services;
public class VariableMapperTests
{
private readonly WellKnownTypeRegistry _registry = new();
private readonly VariableMapper _mapper;
public VariableMapperTests()
{
_registry.RegisterType(typeof(string), "String");
_registry.RegisterType(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver));
_registry.RegisterType(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName());
_mapper = new(_registry, NullLogger<VariableMapper>.Instance);
}
[Fact]
public void Map_ResolvesRegisteredVariableTypeAlias()
{
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", null));
Assert.IsType<Variable<string>>(variable);
}
[Fact]
public void Map_ResolvesRegisteredStorageDriverAlias()
{
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", nameof(WorkflowStorageDriver)));
Assert.Equal(typeof(WorkflowStorageDriver), variable.StorageDriverType);
}
[Fact]
public void Map_ResolvesRegisteredMemoryStorageDriverAssemblyQualifiedName()
{
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()));
Assert.Equal(typeof(MemoryStorageDriver), variable.StorageDriverType);
}
[Fact]
public void Map_DoesNotLoadUnregisteredStorageDriverAssemblyQualifiedName()
{
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", typeof(VariableMapperTests).AssemblyQualifiedName));
Assert.Null(variable.StorageDriverType);
}
[Fact]
public void Map_DoesNotUseRegisteredNonStorageDriverAliasAsStorageDriver()
{
_registry.RegisterType(typeof(string), "NotAStorageDriver");
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", "NotAStorageDriver"));
Assert.Null(variable.StorageDriverType);
}
}

View file

@ -1,5 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Services;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Runtime.Comparers;
using Elsa.Workflows.Runtime.Entities;
@ -25,6 +27,8 @@ public class WorkflowTriggerEqualityComparerTests
/// A simple payload class that mimics real trigger payloads like HttpEndpointBookmarkPayload.
/// </summary>
private record TestPayload(string Path, string Method);
private record TypedPayload(Type PayloadType, object Value);
private record NestedPayload(string Name);
[Fact(DisplayName = "Fresh and round-tripped triggers with identical logical content should be considered equal")]
public void FreshAndRoundTrippedTriggers_ShouldBeEqual()
@ -37,7 +41,7 @@ public class WorkflowTriggerEqualityComparerTests
var roundTrippedPayload = SimulatePayloadRoundTrip(freshPayload);
var loadedTrigger = CreateTrigger("trigger-1", roundTrippedPayload);
var comparer = new WorkflowTriggerEqualityComparer();
var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry());
// Act
var areEqual = comparer.Equals(freshTrigger, loadedTrigger);
@ -68,7 +72,7 @@ public class WorkflowTriggerEqualityComparerTests
var newTriggers = new List<StoredTrigger> { freshTrigger };
// Act: this is exactly what TriggerIndexer.IndexTriggersInternalAsync does
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer());
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer(CreateTypeRegistry()));
// Assert: the diff should find no changes.
// Before the fix, it reported Removed=[existingTrigger] and Added=[freshTrigger]
@ -106,6 +110,50 @@ public class WorkflowTriggerEqualityComparerTests
Assert.Equal("{\"Path\":\"/api/test\",\"Method\":\"GET\"}", freshJson);
Assert.Equal("{\"path\":\"/api/test\",\"method\":\"GET\"}", roundTrippedJson);
}
[Fact(DisplayName = "Comparer serializes typed payload properties using the registered type aliases")]
public void TypedPayloadProperties_ShouldSerializeWithRegisteredAliases()
{
var payload = new TypedPayload(typeof(NestedPayload), new NestedPayload("orders"));
var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry());
var trigger = CreateTrigger("trigger-1", payload);
var expectedAliasPayload = JsonSerializer.Deserialize<object>(
"""
{
"payloadType": "NestedPayload",
"value": {
"name": "orders",
"_type": "NestedPayload"
}
}
""",
PayloadSerializerOptions);
var assemblyQualifiedPayload = JsonSerializer.Deserialize<object>(
$$"""
{
"payloadType": "{{typeof(NestedPayload).AssemblyQualifiedName}}",
"value": {
"name": "orders",
"_type": "{{typeof(NestedPayload).AssemblyQualifiedName}}"
}
}
""",
PayloadSerializerOptions);
var missingTypePayload = JsonSerializer.Deserialize<object>(
"""
{
"payloadType": "NestedPayload",
"value": {
"name": "orders"
}
}
""",
PayloadSerializerOptions);
Assert.True(comparer.Equals(trigger, CreateTrigger("trigger-2", expectedAliasPayload!)));
Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-3", assemblyQualifiedPayload!)));
Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-4", missingTypePayload!)));
}
/// <summary>
/// IPayloadSerializer options: camelCase with case-insensitive deserialization.
@ -142,6 +190,15 @@ public class WorkflowTriggerEqualityComparerTests
return deserialized!;
}
private static IWellKnownTypeRegistry CreateTypeRegistry()
{
var registry = WellKnownTypeRegistry.CreateDefault();
registry.RegisterType(typeof(TestPayload), nameof(TestPayload));
registry.RegisterType(typeof(TypedPayload), nameof(TypedPayload));
registry.RegisterType(typeof(NestedPayload), nameof(NestedPayload));
return registry;
}
/// <summary>
/// Creates a StoredTrigger with default values that can be overridden.
/// </summary>
@ -163,4 +220,3 @@ public class WorkflowTriggerEqualityComparerTests
Payload = payload
};
}

View file

@ -0,0 +1,213 @@
using System.Reflection;
using Elsa.Expressions.Options;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Features;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Providers;
using NSubstitute;
using RuntimeFeature = Elsa.Workflows.Runtime.Features.WorkflowRuntimeFeature;
using ShellRuntimeFeature = Elsa.Workflows.Runtime.ShellFeatures.WorkflowRuntimeFeature;
namespace Elsa.Workflows.Runtime.UnitTests.Features;
public class WorkflowRuntimeFeatureTests
{
private readonly RuntimeFeature _feature = new(Substitute.For<IModule>());
private readonly ShellRuntimeFeature _shellFeature = new();
[Fact]
public void AddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow()
{
Assert.Throws<ArgumentException>(() => _feature.AddWorkflow(typeof(NotAWorkflow)));
}
[Theory]
[MemberData(nameof(NonInstantiableWorkflowTypes))]
public void AddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType)
{
Assert.Throws<ArgumentException>(() => _feature.AddWorkflow(workflowType));
}
[Fact]
public void ShellAddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow()
{
Assert.Throws<ArgumentException>(() => _shellFeature.AddWorkflow(typeof(NotAWorkflow)));
}
[Theory]
[MemberData(nameof(NonInstantiableWorkflowTypes))]
public void ShellAddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType)
{
Assert.Throws<ArgumentException>(() => _shellFeature.AddWorkflow(workflowType));
}
[Fact]
public void AddWorkflow_AllowsClosedGenericWorkflowType()
{
var workflowType = typeof(GenericWorkflow<int>);
_feature.AddWorkflow(workflowType);
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys);
Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys);
}
[Fact]
public void ShellAddWorkflow_AllowsClosedGenericWorkflowType()
{
var workflowType = typeof(GenericWorkflow<int>);
_shellFeature.AddWorkflow(workflowType);
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _shellFeature.Workflows.Keys);
Assert.Contains(workflowType.FullName!, _shellFeature.Workflows.Keys);
}
[Fact]
public void WorkflowsAdd_RegistersWorkflowTypeAlias()
{
var workflowType = typeof(GenericWorkflow<int>);
var options = new ExpressionOptions();
_feature.Workflows.Add(workflowType);
RegisterWorkflowTypeAliases(_feature, options);
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
}
[Fact]
public void WorkflowsAdd_DoesNotThrow_WhenLegacyKeyAlreadyExists()
{
var workflowType = typeof(GenericWorkflow<int>);
_feature.Workflows.Add(workflowType.FullName!, _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>()));
_feature.Workflows.Add(workflowType);
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys);
Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys);
Assert.Same(_feature.Workflows[workflowType.FullName!], _feature.Workflows[workflowType.GetSimpleAssemblyQualifiedName()]);
}
[Fact]
public async Task ClrWorkflowsProvider_MaterializesWorkflowOnce_WhenCanonicalAndLegacyKeysExist()
{
CountingWorkflow.CreatedCount = 0;
var builder = Substitute.For<IWorkflowBuilder>();
var builderFactory = Substitute.For<IWorkflowBuilderFactory>();
var provider = new ClrWorkflowsProvider(
Microsoft.Extensions.Options.Options.Create(new RuntimeOptions { Workflows = _feature.Workflows }),
builderFactory,
Substitute.For<IServiceProvider>());
builderFactory.CreateBuilder().Returns(builder);
builder.BuildWorkflowAsync(Arg.Any<CancellationToken>()).Returns(new Workflow());
_feature.Workflows.Add(typeof(CountingWorkflow));
var workflows = await provider.GetWorkflowsAsync();
Assert.Single(workflows);
Assert.Equal(1, CountingWorkflow.CreatedCount);
}
[Fact]
public void ShellWorkflowsAdd_RegistersWorkflowTypeAlias()
{
var workflowType = typeof(GenericWorkflow<int>);
var options = new ExpressionOptions();
_shellFeature.Workflows.Add(workflowType);
RegisterWorkflowTypeAliases(_shellFeature, options);
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
}
[Fact]
public void WorkflowsAdd_Throws_WhenTypeDoesNotImplementWorkflow()
{
Assert.Throws<ArgumentException>(() => _feature.Workflows.Add(typeof(NotAWorkflow)));
}
[Fact]
public void RuntimeFeature_DependsOnWorkflowsFeature()
{
var dependencyTypes = typeof(RuntimeFeature)
.GetCustomAttributes<DependsOnAttribute>()
.Select(x => x.Type);
Assert.Contains(typeof(WorkflowsFeature), dependencyTypes);
}
[Fact]
public void RegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes()
{
var workflowType = typeof(GenericWorkflow<int>);
var options = new ExpressionOptions();
_feature.AddWorkflow(workflowType);
_feature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>());
RegisterWorkflowTypeAliases(_feature, options);
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys);
}
[Fact]
public void ShellRegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes()
{
var workflowType = typeof(GenericWorkflow<int>);
var options = new ExpressionOptions();
_shellFeature.AddWorkflow(workflowType);
_shellFeature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>());
RegisterWorkflowTypeAliases(_shellFeature, options);
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys);
}
public static TheoryData<Type> NonInstantiableWorkflowTypes() => new()
{
typeof(IWorkflow),
typeof(WorkflowBase),
typeof(GenericWorkflow<>)
};
private sealed class NotAWorkflow
{
}
private sealed class GenericWorkflow<T> : IWorkflow
{
public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default)
{
return ValueTask.CompletedTask;
}
}
public sealed class CountingWorkflow : IWorkflow
{
public static int CreatedCount { get; set; }
public CountingWorkflow()
{
CreatedCount++;
}
public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default)
{
return ValueTask.CompletedTask;
}
}
private static void RegisterWorkflowTypeAliases(object feature, ExpressionOptions options)
{
feature.GetType()
.GetMethod("RegisterWorkflowTypeAliases", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(feature, new object[] { options });
}
}