diff --git a/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs b/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs index 426a34109..947221322 100644 --- a/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs +++ b/src/modules/Elsa.Alterations/Features/AlterationsFeature.cs @@ -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 /// public override void Apply() { + Services.Configure(options => + { + options.RegisterTypeAlias(typeof(AlterationPlanParams), typeof(AlterationPlanParams).GetSimpleAssemblyQualifiedName()); + options.RegisterTypeAlias(typeof(AlterationPlanParams), nameof(AlterationPlanParams)); + }); + Services.AddScoped(); Services.AddAlterations(); Services.AddAlterationsCore(); @@ -73,4 +81,4 @@ public class AlterationsFeature : FeatureBase Services.AddScoped(AlterationJobStoreFactory); Services.AddScoped(AlterationJobDispatcherFactory); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Common/Entities/OrderDefinition.cs b/src/modules/Elsa.Common/Entities/OrderDefinition.cs index 99108ec84..8a50560f5 100644 --- a/src/modules/Elsa.Common/Entities/OrderDefinition.cs +++ b/src/modules/Elsa.Common/Entities/OrderDefinition.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Text.Json.Serialization; namespace Elsa.Common.Entities; @@ -31,5 +32,12 @@ public class OrderDefinition /// /// The key selector to use to order the results. /// + [JsonIgnore] public Expression> KeySelector { get; set; } = null!; -} \ No newline at end of file + + /// + /// A best-effort textual representation of the key selector for cache-key generation. + /// + [JsonIgnore] + public string? KeySelectorText => KeySelector?.ToString(); +} diff --git a/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs b/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs index 043008a60..b41b8bec2 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Features/JavaScriptFeature.cs @@ -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(JavaScriptExceptionTypeAliasRegistrar.Register); // JavaScript services. Services @@ -94,4 +96,4 @@ public class JavaScriptFeature : FeatureBase // UI property handlers. Services.AddScoped(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs b/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs new file mode 100644 index 000000000..dea8c76a7 --- /dev/null +++ b/src/modules/Elsa.Expressions.JavaScript/JavaScriptExceptionTypeAliasRegistrar.cs @@ -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"); + } +} diff --git a/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs b/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs index 8111b335b..1b5f26760 100644 --- a/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs +++ b/src/modules/Elsa.Expressions.JavaScript/ShellFeatures/JavaScriptFeature.cs @@ -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(JavaScriptExceptionTypeAliasRegistrar.Register); // JavaScript services. services diff --git a/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs b/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs index e9ba8b338..5e448a8a2 100644 --- a/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs +++ b/src/modules/Elsa.Expressions/Options/ExpressionOptions.cs @@ -40,6 +40,7 @@ public class ExpressionOptions this.AddTypeAlias(); this.AddTypeAlias(); this.AddTypeAlias(); + this.AddTypeAlias(); this.AddTypeAlias>("StringDictionary"); this.AddTypeAlias>("ObjectDictionary"); this.AddTypeAlias>("StringMap"); @@ -59,4 +60,4 @@ public class ExpressionOptions _aliasTypeDictionary[alias] = type; return this; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index f147cdb63..441789ee4 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -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[]"); options.AddTypeAlias("Downloadable"); options.AddTypeAlias("Downloadable[]"); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); }); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs index f87dc3daa..956460357 100644 --- a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs +++ b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs @@ -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[]"); options.AddTypeAlias("Downloadable"); options.AddTypeAlias("Downloadable[]"); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); }); } @@ -238,4 +242,4 @@ public class HttpFeature : IMiddlewareShellFeature { app.UseWorkflows(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs index 0c8e27392..104a90694 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionPersistenceFeature.cs @@ -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(options => options.RegisterTypeAlias(EFCoreWorkflowDefinitionStore.WorkflowDefinitionStateType, "EFCoreWorkflowDefinitionState")); AddEntityStore(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs index e805caeaa..451792359 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs @@ -22,6 +22,8 @@ namespace Elsa.Persistence.EFCore.Modules.Management; public class EFCoreWorkflowDefinitionStore(EntityStore store, IPayloadSerializer payloadSerializer, ILogger logger) : IWorkflowDefinitionStore { + internal static Type WorkflowDefinitionStateType => typeof(WorkflowDefinitionState); + /// public async Task FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) { @@ -267,4 +269,4 @@ public class EFCoreWorkflowDefinitionStore(EntityStore Outcomes { get; set; } = new List(); public IDictionary CustomProperties { get; set; } = new Dictionary(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs index 6b9db34c3..e29405991 100644 --- a/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs +++ b/src/modules/Elsa.Scheduling/Features/SchedulingFeature.cs @@ -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(); + Services.Configure(options => + { + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + options.AddTypeAlias(); + }); + Module.Configure(management => management.AddActivitiesFrom()); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 6fde09cbf..05c84b7d2 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -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(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(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs index 4e3ee4925..b425eb865 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExcludeFromHashConverter.cs @@ -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; /// /// Serializes an object to JSON, excluding properties marked with . +/// Properties ignored by 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. /// public class ExcludeFromHashConverter : JsonConverter { + private static readonly ConditionalWeakTable PropertyCache = new(); private JsonSerializerOptions? _options; /// @@ -25,21 +29,73 @@ public class ExcludeFromHashConverter : JsonConverter writer.WriteStartObject(); var newOptions = GetClonedOptions(options); - foreach (var property in value.GetType().GetProperties()) + foreach (var metadata in GetSerializableProperties(value.GetType())) { - var attribute = property.GetCustomAttribute(); + 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(), + JsonIgnore = property.GetCustomAttribute() + }) + .Where(x => x.ExcludeFromHash == null && !ShouldAlwaysIgnoreProperty(x.JsonIgnore?.Condition)) + .Select(x => new PropertyMetadata(x.Property, x.JsonIgnore?.Condition)) + .ToArray()); + } + + private static IEnumerable 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 /// public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ExcludeFromHashConverter(); -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs index f4c280f97..b25ae5751 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs @@ -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 private const string IdPropertyName = "$id"; private const string RefPropertyName = "$ref"; private const string ValuesPropertyName = "$values"; + private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; + + /// + /// Initializes a new instance of the class. + /// + public PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) + { + _wellKnownTypeRegistry = wellKnownTypeRegistry; + } + + /// + /// Initializes a new instance of the class. + /// + public PolymorphicObjectConverter() + { + _wellKnownTypeRegistry = WellKnownTypeRegistry.CreateDefault(); + } /// public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -34,6 +54,8 @@ public class PolymorphicObjectConverter : JsonConverter 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 { 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 if (type != typeof(ExpandoObject)) { if (shouldWriteTypeField) - { - if (newOptions.Converters.OfType().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 } // 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 { return key.Replace(@"\\$", "$"); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs index 2317a6372..9bf659435 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs @@ -51,6 +51,6 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory if (typeof(IDictionary).IsAssignableFrom(typeToConvert)) return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry); - return new PolymorphicObjectConverter(); + return new PolymorphicObjectConverter(_wellKnownTypeRegistry); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs index 5fe74064e..c74191e41 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs @@ -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; /// /// Serializes objects to a simple alias representing the type. +/// Unregistered types are written as metadata-only aliases and intentionally deserialize to instead of loading the original CLR type. /// [UsedImplicitly] public class TypeJsonConverter : JsonConverter { + /// + /// Prefix for unregistered type metadata that is not used for CLR type loading during deserialization. + /// + private const string UnregisteredTypeAliasPrefix = "UnregisteredClrType:"; private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; /// @@ -29,53 +35,19 @@ public class TypeJsonConverter : JsonConverter /// 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); } /// 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); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs b/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs new file mode 100644 index 000000000..86771b0e8 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Helpers/WorkflowJsonTypeResolver.cs @@ -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; + +/// +/// Resolves workflow JSON type aliases without loading arbitrary CLR type names. +/// +public static class WorkflowJsonTypeResolver +{ + private static readonly IDictionary GenericCollectionTypes = new Dictionary(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 GenericCollectionAliases = new Dictionary + { + [typeof(List<>)] = "List", + [typeof(HashSet<>)] = "HashSet", + [typeof(Collection<>)] = "Collection" + }; + + private static readonly IDictionary GenericCollectionInterfaceMappings = new Dictionary + { + [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 CollectionInterfaceMappings = new Dictionary + { + [typeof(IEnumerable)] = typeof(List), + [typeof(ICollection)] = typeof(List), + [typeof(IList)] = typeof(List), + [typeof(IDictionary)] = typeof(Dictionary) + }; + + /// + /// Resolves the specified workflow JSON type alias. + /// + 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."); + } + + /// + /// Attempts to resolve the specified workflow JSON type alias. + /// + public static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, out Type type) + { + IReadOnlyList? registeredTypes = null; + return TryResolveType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type); + } + + private static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList? 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; + } + + /// + /// Attempts to return a workflow JSON type alias that this resolver can read back. + /// + 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; + } + + /// + /// Attempts to map a supported collection interface type to an instantiable concrete type. + /// + 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? 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? 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? 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 GetRegisteredTypes(IWellKnownTypeRegistry wellKnownTypeRegistry) + { + return wellKnownTypeRegistry.ListTypes().ToArray(); + } + + private static bool TryResolveRegisteredSimpleAssemblyQualifiedName(IEnumerable 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? 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 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 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))); + } +} diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs index 46b7b5400..281e24b13 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs @@ -72,9 +72,10 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer protected override void AddConverters(JsonSerializerOptions options) { var expressionDescriptorRegistry = ServiceProvider.GetRequiredService(); + var wellKnownTypeRegistry = ServiceProvider.GetRequiredService(); 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()); diff --git a/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs b/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs index 37899fc1b..91c19fd03 100644 --- a/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs +++ b/src/modules/Elsa.Workflows.Core/Services/VariableMapper.cs @@ -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 /// 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); } -} \ No newline at end of file + + 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 }; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 21ae2edfd..46608c0c7 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -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(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext))); Services.Configure(_ => { }); Services.Configure(_ => { }); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs b/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs index 8f1bb4d98..45498dd5e 100644 --- a/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Comparers/WorkflowTriggerEqualityComparer.cs @@ -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 /// /// Initializes a new instance of the class. /// - public WorkflowTriggerEqualityComparer() + public WorkflowTriggerEqualityComparer() : this(WellKnownTypeRegistry.CreateDefault()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public WorkflowTriggerEqualityComparer(IWellKnownTypeRegistry wellKnownTypeRegistry) { _settings = new() { @@ -36,8 +44,8 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer // 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)); } /// @@ -91,4 +99,3 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer return JsonSerializer.Serialize(payload, payload.GetType(), _settings); } } - diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs index dfb6ed889..952c28da7 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowDictionaryExtensions.cs @@ -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 /// public static void Add(this IDictionary>> 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(sp); - return new ValueTask(workflow); - }); + dictionary.Add(typeof(TWorkflow)); } /// @@ -27,11 +23,30 @@ public static class WorkflowDictionaryExtensions /// public static void Add(this IDictionary>> 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(workflow); - }); + factory = sp => + { + var workflow = (IWorkflow)ActivatorUtilities.GetServiceOrCreateInstance(sp, workflowType); + return new ValueTask(workflow); + }; + } + + dictionary[key] = factory!; + + if (!string.IsNullOrWhiteSpace(legacyKey) && legacyKey != key) + dictionary[legacyKey] = factory!; + + if (dictionary is IWorkflowTypeRegistry workflowTypeRegistry) + workflowTypeRegistry.AddWorkflowType(workflowType); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index d33303bbb..870c4148d 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -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. /// [DependsOn(typeof(SystemClockFeature))] +[DependsOn(typeof(WorkflowsFeature))] public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) { private IDictionary WorkflowDispatcherChannels { get; set; } = new Dictionary(); @@ -40,7 +46,8 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// /// A list of workflow builders configured during application startup. /// - public IDictionary>> Workflows { get; set; } = new Dictionary>>(); + public IDictionary>> Workflows { get; set; } = new WorkflowFactoryDictionary(); + private ISet WorkflowTypes { get; } = new HashSet(); /// /// A factory that instantiates a concrete . @@ -186,7 +193,18 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// public WorkflowRuntimeFeature AddWorkflow() where T : IWorkflow { - Workflows.Add(); + AddWorkflow(typeof(T)); + return this; + } + + /// + /// Register the specified workflow type. + /// + 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(RegisterWorkflowTypeAliases); Services.Configure(options => { options.Workflows = Workflows; }); Services.Configure(options => { @@ -428,4 +447,13 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) Services.TryAddScoped(WorkflowDispatchOutboxStore); Services.TryAddScoped(); } + + private void RegisterWorkflowTypeAliases(ExpressionOptions options) + { + var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry + ? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes) + : WorkflowTypes; + + WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes); + } } diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs new file mode 100644 index 000000000..4b7b977f2 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Helpers/IWorkflowTypeRegistry.cs @@ -0,0 +1,8 @@ +namespace Elsa.Workflows.Runtime.Helpers; + +internal interface IWorkflowTypeRegistry +{ + IEnumerable WorkflowTypes { get; } + + void AddWorkflowType(Type workflowType); +} diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs new file mode 100644 index 000000000..872f80423 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowFactoryDictionary.cs @@ -0,0 +1,16 @@ +using Elsa.Workflows; + +namespace Elsa.Workflows.Runtime.Helpers; + +internal sealed class WorkflowFactoryDictionary : Dictionary>>, IWorkflowTypeRegistry +{ + private readonly ISet _workflowTypes = new HashSet(); + + public IEnumerable WorkflowTypes => _workflowTypes; + + public void AddWorkflowType(Type workflowType) + { + WorkflowTypeValidator.Validate(workflowType); + _workflowTypes.Add(workflowType); + } +} diff --git a/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs new file mode 100644 index 000000000..069aed8ab --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Helpers/WorkflowTypeValidator.cs @@ -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; +} diff --git a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs index 38517c2bb..d31f72e83 100644 --- a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs +++ b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowsProvider.cs @@ -22,7 +22,7 @@ public class ClrWorkflowsProvider( /// public async ValueTask> 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); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs index b77f9ab13..c69353ac9 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs @@ -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 lockingOptions, ILogger 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(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); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs index e83c1666c..c66a829f5 100644 --- a/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/ShellFeatures/WorkflowRuntimeFeature.cs @@ -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 /// /// A list of workflow builders configured during application startup. /// - public IDictionary>> Workflows { get; set; } = new Dictionary>>(); + public IDictionary>> Workflows { get; set; } = new WorkflowFactoryDictionary(); + private ISet WorkflowTypes { get; } = new HashSet(); /// /// A factory that instantiates a concrete . @@ -146,10 +151,45 @@ public class WorkflowRuntimeFeature : IShellFeature /// public GracefulShutdownOptions? GracefulShutdown { get; set; } + /// + /// Register the specified workflow type. + /// + public WorkflowRuntimeFeature AddWorkflow() where T : IWorkflow + { + return AddWorkflow(typeof(T)); + } + + /// + /// Register the specified workflow type. + /// + public WorkflowRuntimeFeature AddWorkflow(Type workflowType) + { + WorkflowTypeValidator.Validate(workflowType); + Workflows.Add(workflowType); + WorkflowTypes.Add(workflowType); + return this; + } + + /// + /// Register all workflows in the specified assembly. + /// + [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(RegisterWorkflowTypeAliases); services.Configure(options => { options.Workflows = Workflows; }); services.Configure(options => { @@ -325,4 +365,13 @@ public class WorkflowRuntimeFeature : IShellFeature services.TryAddScoped(WorkflowDispatchOutboxStore); services.TryAddScoped(); } + + private void RegisterWorkflowTypeAliases(ExpressionOptions options) + { + var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry + ? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes) + : WorkflowTypes; + + WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes); + } } diff --git a/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs b/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs new file mode 100644 index 000000000..b3811a01e --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/WorkflowRuntimeTypeAliasRegistrar.cs @@ -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 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 }; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs index 847bdebee..4ad856b7e 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs @@ -142,25 +142,24 @@ public class BulkDispatchWorkflowsTests : AppComponentTest return (T?)variables.FirstOrDefault(v => v.Variable.Name == variableName)?.Value; } - private async Task<(TestWorkflowExecutionResult Result, List CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync( + private async Task<(TestWorkflowExecutionResult Result, List 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> WaitForCompletedChildWorkflowsAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount) + private async Task> WaitForCompletedChildWorkflowInstancesAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount) { - var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService(); 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> FindChildWorkflowStatesAsync( - IWorkflowInstanceStore workflowInstanceStore, + private async Task> 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> WaitForChildWorkflowInstancesAsync( diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs index fd7306a23..8b6b696c5 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Primitives/Event/PublishEventTests.cs @@ -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 GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000) diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs new file mode 100644 index 000000000..a6fcd4dfb --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/JavaScriptFeatureTests.cs @@ -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>(); + 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 _)); + } +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JArrayIsland.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JArrayIsland.json index b5b0378dc..628395419 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JArrayIsland.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JArrayIsland.json @@ -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" -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JObjectIsland.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JObjectIsland.json index 088f33e15..1776704d3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JObjectIsland.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JObjectIsland.json @@ -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" -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonArrayIsland.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonArrayIsland.json index a73017d36..6771e3ec7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonArrayIsland.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonArrayIsland.json @@ -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" -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonObjectIsland.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonObjectIsland.json index 661b6327e..50a7cdeaf 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonObjectIsland.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/JsonObjectIsland.json @@ -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" -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs index e8e6343a9..7684f1da3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs @@ -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(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; } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs index 6278555f8..29155c1b7 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/Tests.cs @@ -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); } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json index 623a5cacc..35109c75a 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json +++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/Polymorphism/data.json @@ -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}}} \ No newline at end of file +{"$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}}} diff --git a/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs b/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs new file mode 100644 index 000000000..dbecd8972 --- /dev/null +++ b/test/unit/Elsa.Common.UnitTests/Entities/OrderDefinitionTests.cs @@ -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(x => x.Name, OrderDirection.Ascending); + + var json = JsonSerializer.Serialize(order); + + Assert.DoesNotContain(nameof(OrderDefinition.KeySelectorText), json); + } + + private sealed class TestEntity + { + public string Name { get; set; } = null!; + } +} diff --git a/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs b/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs new file mode 100644 index 000000000..2774848a7 --- /dev/null +++ b/test/unit/Elsa.Http.UnitTests/ShellFeatures/HttpFeatureTests.cs @@ -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>(); + 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); + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs new file mode 100644 index 000000000..493432cd4 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ExcludeFromHashConverterTests.cs @@ -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(new ConditionalIgnoreModel { Name = "Alice" }, _options); + + Assert.Contains("\"Name\":\"Alice\"", json); + } + + [Fact] + public void Write_ExcludesConditionallyIgnoredProperty_WhenValueIsNull() + { + var json = JsonSerializer.Serialize(new ConditionalIgnoreModel(), _options); + + Assert.DoesNotContain("\"Name\"", json); + } + + [Fact] + public void Write_ExcludesAlwaysIgnoredProperty() + { + var json = JsonSerializer.Serialize(new AlwaysIgnoreModel { Secret = "hidden" }, _options); + + Assert.DoesNotContain("\"Secret\"", json); + } + + [Fact] + public void Write_SkipsIndexerProperties() + { + var json = JsonSerializer.Serialize(new IndexerModel(), _options); + + Assert.DoesNotContain("\"Item\"", json); + } + + [Fact] + public void Write_DoesNotEvaluateExcludedProperty() + { + var json = JsonSerializer.Serialize(new ExcludedThrowingModel(), _options); + + Assert.DoesNotContain("\"Secret\"", json); + } + + [Fact] + public void Write_DoesNotEvaluateAlwaysIgnoredProperty() + { + var json = JsonSerializer.Serialize(new AlwaysIgnoredThrowingModel(), _options); + + Assert.DoesNotContain("\"Secret\"", json); + } + + [Fact] + public void Write_IncludesNullableValueTypeWithDefaultUnderlyingValue_WhenIgnoringDefaults() + { + var json = JsonSerializer.Serialize(new DefaultIgnoreNullableModel { Count = 0 }, _options); + + Assert.Contains("\"Count\":0", json); + } + + [Fact] + public void Write_IncludesProperty_WhenJsonIgnoreConditionIsUnknown() + { + var json = JsonSerializer.Serialize(new UnknownIgnoreConditionModel { Name = "Alice" }, _options); + + Assert.Contains("\"Name\":\"Alice\"", json); + } + + [Fact] + public void Write_ExcludesStaticProperties() + { + var json = JsonSerializer.Serialize(new StaticPropertyModel { Name = "Alice" }, _options); + + Assert.Contains("\"Name\":\"Alice\"", json); + Assert.DoesNotContain("\"Secret\"", json); + } + + [Fact] + public void Write_PreservesDeclarationOrder() + { + var json = JsonSerializer.Serialize(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(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; } + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs new file mode 100644 index 000000000..fd1a5d414 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/WorkflowJsonTypeResolverTests.cs @@ -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", typeof(List))] + [InlineData("List", typeof(List))] + [InlineData("List>", typeof(List>))] + [InlineData("ExceptionState", typeof(ExceptionState))] + [InlineData("FaultException", typeof(FaultException))] + [InlineData("ObjectDictionary", typeof(IDictionary))] + public void When_DeserializeRegisteredTypeAlias_Then_ReturnsExpectedType(string typeAlias, Type expectedType) + { + var result = JsonSerializer.Deserialize(JsonSerializer.Serialize(typeAlias), _options); + + Assert.Equal(expectedType, result); + } + + [Fact] + public void When_DeserializeRegisteredLegacyAssemblyQualifiedTypeAlias_Then_ReturnsExpectedType() + { + var typeAlias = typeof(RegisteredPayload).GetSimpleAssemblyQualifiedName(); + + var result = JsonSerializer.Deserialize(JsonString(typeAlias), _options); + + Assert.Equal(typeof(RegisteredPayload), result); + } + + [Fact] + public void When_DeserializeRegisteredLegacyGenericCollectionTypeAlias_Then_ReturnsExpectedType() + { + var typeAlias = typeof(List).GetSimpleAssemblyQualifiedName(); + + var result = JsonSerializer.Deserialize(JsonString(typeAlias), _options); + + Assert.Equal(typeof(List), result); + } + + [Theory] + [InlineData(typeof(string), "String")] + [InlineData(typeof(string[]), "String[]")] + [InlineData(typeof(string[][]), "String[][]")] + [InlineData(typeof(List), "List")] + [InlineData(typeof(List), "List")] + [InlineData(typeof(List>), "List>")] + [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(json); + var result = JsonSerializer.Deserialize(json, _options); + + Assert.Equal(expectedAlias, alias); + Assert.Equal(type, result); + } + + [Theory] + [InlineData(typeof(IEnumerable), "List", typeof(List))] + [InlineData(typeof(ICollection), "List", typeof(List))] + [InlineData(typeof(IList), "List", typeof(List))] + [InlineData(typeof(IReadOnlyCollection), "List", typeof(List))] + [InlineData(typeof(IReadOnlyList), "List", typeof(List))] + [InlineData(typeof(ISet), "HashSet", typeof(HashSet))] + public void When_SerializeInterfaceCollectionType_Then_EmitsInstantiableAlias(Type type, string expectedAlias, Type expectedRoundTripType) + { + var json = JsonSerializer.Serialize(type, _options); + var alias = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(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(json, _options); + + Assert.IsType(expectedType, result); + } + + [Theory] + [InlineData(typeof(System.Text.StringBuilder))] + [InlineData(typeof(System.Text.StringBuilder[]))] + [InlineData(typeof(List))] + public void When_SerializeUnsupportedType_Then_EmitsSafeUnregisteredTypeAlias(Type type) + { + var json = JsonSerializer.Serialize(type, _options); + var alias = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(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(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>(); + 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(new UnregisteredPayload { Name = "Alice" }, _options); + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.DoesNotContain("\"_type\"", json); + var payload = Assert.IsAssignableFrom>(result); + Assert.Equal("Alice", payload["name"]); + } + + [Fact] + public void When_DeserializeUnknownAssemblyQualifiedTypeAlias_Then_ThrowsJsonException() + { + Assert.Throws(() => JsonSerializer.Deserialize(JsonString(UnsafeAssemblyQualifiedTypeAlias), _options)); + } + + [Fact] + public void When_DeserializeUnknownGenericElementTypeAlias_Then_ThrowsJsonException() + { + var typeAlias = $"List<{UnsafeAssemblyQualifiedTypeAlias}>"; + + Assert.Throws(() => JsonSerializer.Deserialize(JsonString(typeAlias), _options)); + } + + [Fact] + public void When_DeserializePolymorphicObjectWithRegisteredTypeAlias_Then_ReturnsTypedObject() + { + var json = """ + { + "name": "Alice", + "_type": "RegisteredPayload" + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + var payload = Assert.IsType(result); + Assert.Equal("Alice", payload.Name); + } + + [Theory] + [InlineData("IEnumerable", typeof(List))] + [InlineData("ICollection", typeof(List))] + [InlineData("IList", typeof(List))] + [InlineData("IReadOnlyCollection", typeof(List))] + [InlineData("IReadOnlyList", typeof(List))] + [InlineData("ISet", typeof(HashSet))] + public void When_DeserializePolymorphicCollectionInterface_Then_ReturnsConcreteCollection(string typeAlias, Type expectedType) + { + var json = $$""" + { + "_items": ["Alice"], + "_type": "{{typeAlias}}" + } + """; + + var result = JsonSerializer.Deserialize(json, _options); + + Assert.IsType(expectedType, result); + } + + [Fact] + public void When_DeserializePolymorphicObjectWithNonInstantiableType_Then_ThrowsJsonException() + { + _wellKnownTypeRegistry.RegisterType(typeof(AbstractPayload), "AbstractPayload"); + var json = """ + { + "_type": "AbstractPayload" + } + """; + + Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); + } + + [Fact] + public void When_DeserializePolymorphicObjectWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException() + { + var json = $$""" + { + "capacity": 16, + "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} + } + """; + + Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); + } + + [Fact] + public void When_DeserializePolymorphicObjectWithoutTypeJsonConverterAndUnknownAssemblyQualifiedType_Then_ThrowsJsonException() + { + var options = CreatePolymorphicOnlyOptions(_wellKnownTypeRegistry); + var json = $$""" + { + "capacity": 16, + "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} + } + """; + + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + [Fact] + public void When_DeserializeDictionaryObjectPayloadWithRegisteredTypeAlias_Then_ReturnsTypedObjectValue() + { + var json = """ + { + "payload": { + "name": "Alice", + "_type": "RegisteredPayload" + } + } + """; + + var result = JsonSerializer.Deserialize>(json, _options)!; + + var payload = Assert.IsType(result["payload"]); + Assert.Equal("Alice", payload.Name); + } + + [Fact] + public void When_DeserializeDictionaryObjectPayloadWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException() + { + var json = $$""" + { + "payload": { + "capacity": 16, + "_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}} + } + } + """; + + Assert.Throws(() => JsonSerializer.Deserialize>(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 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; } + } +} diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs new file mode 100644 index 000000000..4cc8b8eb1 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Services/VariableMapperTests.cs @@ -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.Instance); + } + + [Fact] + public void Map_ResolvesRegisteredVariableTypeAlias() + { + var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", null)); + + Assert.IsType>(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); + } +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs index 2a2d95f86..6299d3a86 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Comparers/WorkflowTriggerEqualityComparerTests.cs @@ -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. /// 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 { 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( + """ + { + "payloadType": "NestedPayload", + "value": { + "name": "orders", + "_type": "NestedPayload" + } + } + """, + PayloadSerializerOptions); + var assemblyQualifiedPayload = JsonSerializer.Deserialize( + $$""" + { + "payloadType": "{{typeof(NestedPayload).AssemblyQualifiedName}}", + "value": { + "name": "orders", + "_type": "{{typeof(NestedPayload).AssemblyQualifiedName}}" + } + } + """, + PayloadSerializerOptions); + var missingTypePayload = JsonSerializer.Deserialize( + """ + { + "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!))); + } /// /// 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; + } + /// /// Creates a StoredTrigger with default values that can be overridden. /// @@ -163,4 +220,3 @@ public class WorkflowTriggerEqualityComparerTests Payload = payload }; } - diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs new file mode 100644 index 000000000..d68ca615b --- /dev/null +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Features/WorkflowRuntimeFeatureTests.cs @@ -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()); + private readonly ShellRuntimeFeature _shellFeature = new(); + + [Fact] + public void AddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow() + { + Assert.Throws(() => _feature.AddWorkflow(typeof(NotAWorkflow))); + } + + [Theory] + [MemberData(nameof(NonInstantiableWorkflowTypes))] + public void AddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType) + { + Assert.Throws(() => _feature.AddWorkflow(workflowType)); + } + + [Fact] + public void ShellAddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow() + { + Assert.Throws(() => _shellFeature.AddWorkflow(typeof(NotAWorkflow))); + } + + [Theory] + [MemberData(nameof(NonInstantiableWorkflowTypes))] + public void ShellAddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType) + { + Assert.Throws(() => _shellFeature.AddWorkflow(workflowType)); + } + + [Fact] + public void AddWorkflow_AllowsClosedGenericWorkflowType() + { + var workflowType = typeof(GenericWorkflow); + + _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); + + _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); + 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); + _feature.Workflows.Add(workflowType.FullName!, _ => new ValueTask(new GenericWorkflow())); + + _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(); + var builderFactory = Substitute.For(); + var provider = new ClrWorkflowsProvider( + Microsoft.Extensions.Options.Options.Create(new RuntimeOptions { Workflows = _feature.Workflows }), + builderFactory, + Substitute.For()); + builderFactory.CreateBuilder().Returns(builder); + builder.BuildWorkflowAsync(Arg.Any()).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); + 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(() => _feature.Workflows.Add(typeof(NotAWorkflow))); + } + + [Fact] + public void RuntimeFeature_DependsOnWorkflowsFeature() + { + var dependencyTypes = typeof(RuntimeFeature) + .GetCustomAttributes() + .Select(x => x.Type); + + Assert.Contains(typeof(WorkflowsFeature), dependencyTypes); + } + + [Fact] + public void RegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes() + { + var workflowType = typeof(GenericWorkflow); + var options = new ExpressionOptions(); + _feature.AddWorkflow(workflowType); + _feature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask(new GenericWorkflow()); + + 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); + var options = new ExpressionOptions(); + _shellFeature.AddWorkflow(workflowType); + _shellFeature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask(new GenericWorkflow()); + + RegisterWorkflowTypeAliases(_shellFeature, options); + + Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]); + Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys); + } + + public static TheoryData NonInstantiableWorkflowTypes() => new() + { + typeof(IWorkflow), + typeof(WorkflowBase), + typeof(GenericWorkflow<>) + }; + + private sealed class NotAWorkflow + { + } + + private sealed class GenericWorkflow : 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 }); + } +}