[codex] Harden workflow JSON type resolution (#7499)
* Harden workflow JSON type resolution * Harden workflow type alias serialization * Register JSON island type aliases * Fix workflow JSON aliases for runtime types * Register CLR workflow type aliases at startup * Register safe workflow serialization aliases * Use registered workflow type aliases in serializers * Restore trusted legacy workflow JSON aliases * Share runtime workflow type alias registration * Address workflow JSON review follow-ups * Address workflow JSON review follow-ups * Address workflow JSON review edge cases * Address workflow JSON converter review feedback * Register workflow JSON types for HTTP and JavaScript failures * Address secure type serialization review comments * Address deserialization review feedback * Address workflow serialization review comments * Address workflow type review feedback * Tighten workflow type hardening fixes * Avoid workflow alias string type resolution * Address workflow type alias review feedback * Stabilize publish event payload assertion * Stabilize bulk dispatch component test * Make workflow dictionary aliases idempotent * Declare workflow runtime feature dependency * Assert trigger payload alias serialization * Import workflow helper contracts * Avoid duplicate CLR workflow materialization * Normalize workflow factory aliases
This commit is contained in:
parent
2e712d367a
commit
cec3281a20
|
|
@ -1,10 +1,12 @@
|
||||||
using Elsa.Alterations.Core.Contracts;
|
using Elsa.Alterations.Core.Contracts;
|
||||||
using Elsa.Alterations.Core.Entities;
|
using Elsa.Alterations.Core.Entities;
|
||||||
using Elsa.Alterations.Core.Extensions;
|
using Elsa.Alterations.Core.Extensions;
|
||||||
|
using Elsa.Alterations.Core.Models;
|
||||||
using Elsa.Alterations.Core.Stores;
|
using Elsa.Alterations.Core.Stores;
|
||||||
using Elsa.Alterations.Extensions;
|
using Elsa.Alterations.Extensions;
|
||||||
using Elsa.Alterations.Services;
|
using Elsa.Alterations.Services;
|
||||||
using Elsa.Alterations.Workflows;
|
using Elsa.Alterations.Workflows;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
using Elsa.Features.Services;
|
using Elsa.Features.Services;
|
||||||
|
|
@ -58,6 +60,12 @@ public class AlterationsFeature : FeatureBase
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Apply()
|
public override void Apply()
|
||||||
{
|
{
|
||||||
|
Services.Configure<ExpressionOptions>(options =>
|
||||||
|
{
|
||||||
|
options.RegisterTypeAlias(typeof(AlterationPlanParams), typeof(AlterationPlanParams).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(AlterationPlanParams), nameof(AlterationPlanParams));
|
||||||
|
});
|
||||||
|
|
||||||
Services.AddScoped<IAlterationPlanManager, AlterationPlanManager>();
|
Services.AddScoped<IAlterationPlanManager, AlterationPlanManager>();
|
||||||
Services.AddAlterations();
|
Services.AddAlterations();
|
||||||
Services.AddAlterationsCore();
|
Services.AddAlterationsCore();
|
||||||
|
|
@ -73,4 +81,4 @@ public class AlterationsFeature : FeatureBase
|
||||||
Services.AddScoped(AlterationJobStoreFactory);
|
Services.AddScoped(AlterationJobStoreFactory);
|
||||||
Services.AddScoped(AlterationJobDispatcherFactory);
|
Services.AddScoped(AlterationJobDispatcherFactory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Elsa.Common.Entities;
|
namespace Elsa.Common.Entities;
|
||||||
|
|
||||||
|
|
@ -31,5 +32,12 @@ public class OrderDefinition<T, TProp>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The key selector to use to order the results.
|
/// The key selector to use to order the results.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
public Expression<Func<T, TProp>> KeySelector { get; set; } = null!;
|
public Expression<Func<T, TProp>> KeySelector { get; set; } = null!;
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// A best-effort textual representation of the key selector for cache-key generation.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? KeySelectorText => KeySelector?.ToString();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Elsa.Caching.Features;
|
using Elsa.Caching.Features;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Common.Features;
|
using Elsa.Common.Features;
|
||||||
using Elsa.Expressions.Features;
|
using Elsa.Expressions.Features;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
|
|
@ -59,6 +60,7 @@ public class JavaScriptFeature : FeatureBase
|
||||||
public override void Apply()
|
public override void Apply()
|
||||||
{
|
{
|
||||||
Services.Configure(JintOptions);
|
Services.Configure(JintOptions);
|
||||||
|
Services.Configure<ExpressionOptions>(JavaScriptExceptionTypeAliasRegistrar.Register);
|
||||||
|
|
||||||
// JavaScript services.
|
// JavaScript services.
|
||||||
Services
|
Services
|
||||||
|
|
@ -94,4 +96,4 @@ public class JavaScriptFeature : FeatureBase
|
||||||
// UI property handlers.
|
// UI property handlers.
|
||||||
Services.AddScoped<IPropertyUIHandler, RunJavaScriptOptionsProvider>();
|
Services.AddScoped<IPropertyUIHandler, RunJavaScriptOptionsProvider>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ using Elsa.Expressions.JavaScript.Providers;
|
||||||
using Elsa.Expressions.JavaScript.Services;
|
using Elsa.Expressions.JavaScript.Services;
|
||||||
using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts;
|
using Elsa.Expressions.JavaScript.TypeDefinitions.Contracts;
|
||||||
using Elsa.Expressions.JavaScript.TypeDefinitions.Services;
|
using Elsa.Expressions.JavaScript.TypeDefinitions.Services;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.PackageManifest.Generator.Hints;
|
using Elsa.PackageManifest.Generator.Hints;
|
||||||
using Elsa.Workflows;
|
using Elsa.Workflows;
|
||||||
|
|
@ -104,6 +105,7 @@ public class JavaScriptFeature : IShellFeature
|
||||||
options.DisableWrappers = DisableWrappers;
|
options.DisableWrappers = DisableWrappers;
|
||||||
options.DisableVariableCopying = DisableVariableCopying;
|
options.DisableVariableCopying = DisableVariableCopying;
|
||||||
});
|
});
|
||||||
|
services.Configure<ExpressionOptions>(JavaScriptExceptionTypeAliasRegistrar.Register);
|
||||||
|
|
||||||
// JavaScript services.
|
// JavaScript services.
|
||||||
services
|
services
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ public class ExpressionOptions
|
||||||
this.AddTypeAlias<JsonElement>();
|
this.AddTypeAlias<JsonElement>();
|
||||||
this.AddTypeAlias<JsonNode>();
|
this.AddTypeAlias<JsonNode>();
|
||||||
this.AddTypeAlias<JsonObject>();
|
this.AddTypeAlias<JsonObject>();
|
||||||
|
this.AddTypeAlias<JsonArray>();
|
||||||
this.AddTypeAlias<IDictionary<string, string>>("StringDictionary");
|
this.AddTypeAlias<IDictionary<string, string>>("StringDictionary");
|
||||||
this.AddTypeAlias<IDictionary<string, object>>("ObjectDictionary");
|
this.AddTypeAlias<IDictionary<string, object>>("ObjectDictionary");
|
||||||
this.AddTypeAlias<Dictionary<string, string>>("StringMap");
|
this.AddTypeAlias<Dictionary<string, string>>("StringMap");
|
||||||
|
|
@ -59,4 +60,4 @@ public class ExpressionOptions
|
||||||
_aliasTypeDictionary[alias] = type;
|
_aliasTypeDictionary[alias] = type;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Net;
|
||||||
using Elsa.Expressions.Options;
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
|
|
@ -259,6 +260,9 @@ public class HttpFeature(IModule module) : FeatureBase(module)
|
||||||
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
|
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
|
||||||
options.AddTypeAlias<Downloadable>("Downloadable");
|
options.AddTypeAlias<Downloadable>("Downloadable");
|
||||||
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
|
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
|
||||||
|
options.AddTypeAlias<HttpStatusCode>();
|
||||||
|
options.AddTypeAlias<HttpRequestException>();
|
||||||
|
options.AddTypeAlias<HttpEndpointBookmarkPayload>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Net;
|
||||||
using CShells.AspNetCore.Features;
|
using CShells.AspNetCore.Features;
|
||||||
using CShells.Features;
|
using CShells.Features;
|
||||||
using Elsa.Expressions.Options;
|
using Elsa.Expressions.Options;
|
||||||
|
|
@ -230,6 +231,9 @@ public class HttpFeature : IMiddlewareShellFeature
|
||||||
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
|
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
|
||||||
options.AddTypeAlias<Downloadable>("Downloadable");
|
options.AddTypeAlias<Downloadable>("Downloadable");
|
||||||
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
|
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
|
||||||
|
options.AddTypeAlias<HttpStatusCode>();
|
||||||
|
options.AddTypeAlias<HttpRequestException>();
|
||||||
|
options.AddTypeAlias<HttpEndpointBookmarkPayload>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,4 +242,4 @@ public class HttpFeature : IMiddlewareShellFeature
|
||||||
{
|
{
|
||||||
app.UseWorkflows();
|
app.UseWorkflows();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Features.Attributes;
|
using Elsa.Features.Attributes;
|
||||||
using Elsa.Features.Services;
|
using Elsa.Features.Services;
|
||||||
using Elsa.Workflows.Management.Entities;
|
using Elsa.Workflows.Management.Entities;
|
||||||
|
|
@ -26,6 +27,7 @@ public class EFCoreWorkflowDefinitionPersistenceFeature(IModule module) : Persis
|
||||||
public override void Apply()
|
public override void Apply()
|
||||||
{
|
{
|
||||||
base.Apply();
|
base.Apply();
|
||||||
|
Services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(EFCoreWorkflowDefinitionStore.WorkflowDefinitionStateType, "EFCoreWorkflowDefinitionState"));
|
||||||
AddEntityStore<WorkflowDefinition, EFCoreWorkflowDefinitionStore>();
|
AddEntityStore<WorkflowDefinition, EFCoreWorkflowDefinitionStore>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ namespace Elsa.Persistence.EFCore.Modules.Management;
|
||||||
public class EFCoreWorkflowDefinitionStore(EntityStore<ManagementElsaDbContext, WorkflowDefinition> store, IPayloadSerializer payloadSerializer, ILogger<EFCoreWorkflowDefinitionStore> logger)
|
public class EFCoreWorkflowDefinitionStore(EntityStore<ManagementElsaDbContext, WorkflowDefinition> store, IPayloadSerializer payloadSerializer, ILogger<EFCoreWorkflowDefinitionStore> logger)
|
||||||
: IWorkflowDefinitionStore
|
: IWorkflowDefinitionStore
|
||||||
{
|
{
|
||||||
|
internal static Type WorkflowDefinitionStateType => typeof(WorkflowDefinitionState);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
|
public async Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|
@ -267,4 +269,4 @@ public class EFCoreWorkflowDefinitionStore(EntityStore<ManagementElsaDbContext,
|
||||||
public ICollection<string> Outcomes { get; set; } = new List<string>();
|
public ICollection<string> Outcomes { get; set; } = new List<string>();
|
||||||
public IDictionary<string, object> CustomProperties { get; set; } = new Dictionary<string, object>();
|
public IDictionary<string, object> CustomProperties { get; set; } = new Dictionary<string, object>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Elsa.Common.Features;
|
using Elsa.Common.Features;
|
||||||
using Elsa.Common.Multitenancy;
|
using Elsa.Common.Multitenancy;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
using Elsa.Features.Attributes;
|
using Elsa.Features.Attributes;
|
||||||
|
|
@ -58,6 +59,16 @@ public class SchedulingFeature : FeatureBase
|
||||||
// Graceful shutdown: register scheduled-trigger ingress for diagnostic visibility (FR-006).
|
// Graceful shutdown: register scheduled-trigger ingress for diagnostic visibility (FR-006).
|
||||||
.AddSingleton<Elsa.Workflows.Runtime.IIngressSource, Elsa.Scheduling.IngressSources.ScheduledTriggerIngressSource>();
|
.AddSingleton<Elsa.Workflows.Runtime.IIngressSource, Elsa.Scheduling.IngressSources.ScheduledTriggerIngressSource>();
|
||||||
|
|
||||||
|
Services.Configure<ExpressionOptions>(options =>
|
||||||
|
{
|
||||||
|
options.AddTypeAlias<CronBookmarkPayload>();
|
||||||
|
options.AddTypeAlias<CronTriggerPayload>();
|
||||||
|
options.AddTypeAlias<DelayPayload>();
|
||||||
|
options.AddTypeAlias<StartAtPayload>();
|
||||||
|
options.AddTypeAlias<TimerBookmarkPayload>();
|
||||||
|
options.AddTypeAlias<TimerTriggerPayload>();
|
||||||
|
});
|
||||||
|
|
||||||
Module.Configure<WorkflowManagementFeature>(management => management.AddActivitiesFrom<SchedulingFeature>());
|
Module.Configure<WorkflowManagementFeature>(management => management.AddActivitiesFrom<SchedulingFeature>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ using Elsa.Common;
|
||||||
using Elsa.Common.Features;
|
using Elsa.Common.Features;
|
||||||
using Elsa.Common.Serialization;
|
using Elsa.Common.Serialization;
|
||||||
using Elsa.Expressions.Features;
|
using Elsa.Expressions.Features;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
using Elsa.Features.Attributes;
|
using Elsa.Features.Attributes;
|
||||||
using Elsa.Features.Services;
|
using Elsa.Features.Services;
|
||||||
using Elsa.Workflows.ActivationValidators;
|
using Elsa.Workflows.ActivationValidators;
|
||||||
|
using Elsa.Workflows.Activities.Flowchart.Models;
|
||||||
using Elsa.Workflows.Builders;
|
using Elsa.Workflows.Builders;
|
||||||
using Elsa.Workflows.CommitStates;
|
using Elsa.Workflows.CommitStates;
|
||||||
|
using Elsa.Workflows.Exceptions;
|
||||||
using Elsa.Workflows.IncidentStrategies;
|
using Elsa.Workflows.IncidentStrategies;
|
||||||
using Elsa.Workflows.LogPersistence;
|
using Elsa.Workflows.LogPersistence;
|
||||||
using Elsa.Workflows.LogPersistence.Strategies;
|
using Elsa.Workflows.LogPersistence.Strategies;
|
||||||
|
|
@ -21,12 +24,14 @@ using Elsa.Workflows.Serialization.Configurators;
|
||||||
using Elsa.Workflows.Serialization.Helpers;
|
using Elsa.Workflows.Serialization.Helpers;
|
||||||
using Elsa.Workflows.Serialization.Serializers;
|
using Elsa.Workflows.Serialization.Serializers;
|
||||||
using Elsa.Workflows.Services;
|
using Elsa.Workflows.Services;
|
||||||
|
using Elsa.Workflows.State;
|
||||||
using Elsa.Workflows.UIHints.CheckList;
|
using Elsa.Workflows.UIHints.CheckList;
|
||||||
using Elsa.Workflows.UIHints.Dictionary;
|
using Elsa.Workflows.UIHints.Dictionary;
|
||||||
using Elsa.Workflows.UIHints.Dropdown;
|
using Elsa.Workflows.UIHints.Dropdown;
|
||||||
using Elsa.Workflows.UIHints.JsonEditor;
|
using Elsa.Workflows.UIHints.JsonEditor;
|
||||||
using Elsa.Workflows.UIHints.RadioList;
|
using Elsa.Workflows.UIHints.RadioList;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace Elsa.Workflows.Features;
|
namespace Elsa.Workflows.Features;
|
||||||
|
|
||||||
|
|
@ -159,6 +164,36 @@ public class WorkflowsFeature : FeatureBase
|
||||||
|
|
||||||
private void AddElsaCore(IServiceCollection services)
|
private void AddElsaCore(IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.Configure<ExpressionOptions>(options =>
|
||||||
|
{
|
||||||
|
options.RegisterTypeAlias(typeof(ExceptionState), nameof(ExceptionState));
|
||||||
|
options.RegisterTypeAlias(typeof(FaultException), nameof(FaultException));
|
||||||
|
options.RegisterTypeAlias(typeof(VariablesDictionary), nameof(VariablesDictionary));
|
||||||
|
options.RegisterTypeAlias(typeof(Token), nameof(Token));
|
||||||
|
options.RegisterTypeAlias(typeof(FlowJoinMode), "Elsa.Workflows.Core.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core");
|
||||||
|
options.RegisterTypeAlias(typeof(FlowJoinMode), typeof(FlowJoinMode).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(FlowJoinMode), nameof(FlowJoinMode));
|
||||||
|
options.RegisterTypeAlias(typeof(WorkflowStorageDriver), typeof(WorkflowStorageDriver).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver));
|
||||||
|
options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), typeof(WorkflowInstanceStorageDriver).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(WorkflowInstanceStorageDriver), nameof(WorkflowInstanceStorageDriver));
|
||||||
|
options.RegisterTypeAlias(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(MemoryStorageDriver), nameof(MemoryStorageDriver));
|
||||||
|
options.RegisterTypeAlias(typeof(FaultStrategy), typeof(FaultStrategy).GetSimpleAssemblyQualifiedName());
|
||||||
|
options.RegisterTypeAlias(typeof(FaultStrategy), nameof(FaultStrategy));
|
||||||
|
options.RegisterTypeAlias(typeof(Exception), nameof(Exception));
|
||||||
|
options.RegisterTypeAlias(typeof(ArgumentException), nameof(ArgumentException));
|
||||||
|
options.RegisterTypeAlias(typeof(ArgumentNullException), nameof(ArgumentNullException));
|
||||||
|
options.RegisterTypeAlias(typeof(InvalidOperationException), nameof(InvalidOperationException));
|
||||||
|
options.RegisterTypeAlias(typeof(NullReferenceException), nameof(NullReferenceException));
|
||||||
|
options.RegisterTypeAlias(typeof(OperationCanceledException), nameof(OperationCanceledException));
|
||||||
|
options.RegisterTypeAlias(typeof(TaskCanceledException), nameof(TaskCanceledException));
|
||||||
|
options.RegisterTypeAlias(typeof(TimeoutException), nameof(TimeoutException));
|
||||||
|
options.RegisterTypeAlias(typeof(NotSupportedException), nameof(NotSupportedException));
|
||||||
|
options.RegisterTypeAlias(typeof(JObject), nameof(JObject));
|
||||||
|
options.RegisterTypeAlias(typeof(JArray), nameof(JArray));
|
||||||
|
});
|
||||||
|
|
||||||
services
|
services
|
||||||
|
|
||||||
// Core.
|
// Core.
|
||||||
|
|
@ -259,4 +294,4 @@ public class WorkflowsFeature : FeatureBase
|
||||||
// Logging
|
// Logging
|
||||||
.AddLogging();
|
.AddLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
|
|
@ -8,9 +9,12 @@ namespace Elsa.Workflows.Serialization.Converters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serializes an object to JSON, excluding properties marked with <see cref="ExcludeFromHashAttribute"/>.
|
/// Serializes an object to JSON, excluding properties marked with <see cref="ExcludeFromHashAttribute"/>.
|
||||||
|
/// Properties ignored by <see cref="JsonIgnoreAttribute"/> are also excluded according to their configured ignore condition;
|
||||||
|
/// avoid adding or changing these attributes on bookmark or stimulus payloads whose hashes must remain compatible with existing stored hashes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExcludeFromHashConverter : JsonConverter<object>
|
public class ExcludeFromHashConverter : JsonConverter<object>
|
||||||
{
|
{
|
||||||
|
private static readonly ConditionalWeakTable<Type, PropertyMetadata[]> PropertyCache = new();
|
||||||
private JsonSerializerOptions? _options;
|
private JsonSerializerOptions? _options;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -25,21 +29,73 @@ public class ExcludeFromHashConverter : JsonConverter<object>
|
||||||
writer.WriteStartObject();
|
writer.WriteStartObject();
|
||||||
var newOptions = GetClonedOptions(options);
|
var newOptions = GetClonedOptions(options);
|
||||||
|
|
||||||
foreach (var property in value.GetType().GetProperties())
|
foreach (var metadata in GetSerializableProperties(value.GetType()))
|
||||||
{
|
{
|
||||||
var attribute = property.GetCustomAttribute<ExcludeFromHashAttribute>();
|
var property = metadata.Property;
|
||||||
|
var propertyValue = property.GetValue(value);
|
||||||
|
|
||||||
if (attribute != null)
|
if (ShouldIgnoreProperty(metadata.JsonIgnoreCondition, property.PropertyType, propertyValue))
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
writer.WritePropertyName(property.Name);
|
writer.WritePropertyName(property.Name);
|
||||||
JsonSerializer.Serialize(writer, property.GetValue(value), newOptions);
|
JsonSerializer.Serialize(writer, propertyValue, newOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.WriteEndObject();
|
writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PropertyMetadata[] GetSerializableProperties(Type type)
|
||||||
|
{
|
||||||
|
return PropertyCache.GetValue(type, static itemType => GetPublicInstanceProperties(itemType)
|
||||||
|
.Where(property => property.GetIndexParameters().Length == 0)
|
||||||
|
.Select(property => new
|
||||||
|
{
|
||||||
|
Property = property,
|
||||||
|
ExcludeFromHash = property.GetCustomAttribute<ExcludeFromHashAttribute>(),
|
||||||
|
JsonIgnore = property.GetCustomAttribute<JsonIgnoreAttribute>()
|
||||||
|
})
|
||||||
|
.Where(x => x.ExcludeFromHash == null && !ShouldAlwaysIgnoreProperty(x.JsonIgnore?.Condition))
|
||||||
|
.Select(x => new PropertyMetadata(x.Property, x.JsonIgnore?.Condition))
|
||||||
|
.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<PropertyInfo> GetPublicInstanceProperties(Type type)
|
||||||
|
{
|
||||||
|
for (var currentType = type; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
|
||||||
|
{
|
||||||
|
foreach (var property in currentType
|
||||||
|
.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public)
|
||||||
|
.OrderBy(x => x.MetadataToken))
|
||||||
|
{
|
||||||
|
yield return property;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldAlwaysIgnoreProperty(JsonIgnoreCondition? condition)
|
||||||
|
{
|
||||||
|
return condition == JsonIgnoreCondition.Always;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldIgnoreProperty(JsonIgnoreCondition? condition, Type declaredType, object? value)
|
||||||
|
{
|
||||||
|
return condition switch
|
||||||
|
{
|
||||||
|
null => false,
|
||||||
|
JsonIgnoreCondition.Never => false,
|
||||||
|
JsonIgnoreCondition.Always => true,
|
||||||
|
JsonIgnoreCondition.WhenWritingNull => value == null,
|
||||||
|
JsonIgnoreCondition.WhenWritingDefault => value == null || IsDefaultValue(declaredType, value),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDefaultValue(Type declaredType, object value)
|
||||||
|
{
|
||||||
|
return declaredType.IsValueType && value.Equals(Activator.CreateInstance(declaredType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PropertyMetadata(PropertyInfo Property, JsonIgnoreCondition? JsonIgnoreCondition);
|
||||||
|
|
||||||
private JsonSerializerOptions GetClonedOptions(JsonSerializerOptions options)
|
private JsonSerializerOptions GetClonedOptions(JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
|
|
@ -62,4 +118,4 @@ public class ExcludeFromHashConverterFactory : JsonConverterFactory
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ExcludeFromHashConverter();
|
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new ExcludeFromHashConverter();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Elsa.Expressions.Contracts;
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows.Serialization.Helpers;
|
||||||
using Elsa.Workflows.Serialization.ReferenceHandlers;
|
using Elsa.Workflows.Serialization.ReferenceHandlers;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
|
@ -21,6 +24,23 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
private const string IdPropertyName = "$id";
|
private const string IdPropertyName = "$id";
|
||||||
private const string RefPropertyName = "$ref";
|
private const string RefPropertyName = "$ref";
|
||||||
private const string ValuesPropertyName = "$values";
|
private const string ValuesPropertyName = "$values";
|
||||||
|
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry)
|
||||||
|
{
|
||||||
|
_wellKnownTypeRegistry = wellKnownTypeRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PolymorphicObjectConverter"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PolymorphicObjectConverter()
|
||||||
|
{
|
||||||
|
_wellKnownTypeRegistry = WellKnownTypeRegistry.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
|
@ -34,6 +54,8 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
if (targetType == null)
|
if (targetType == null)
|
||||||
return ReadObject(ref reader, newOptions);
|
return ReadObject(ref reader, newOptions);
|
||||||
|
|
||||||
|
targetType = GetInstantiableTargetType(targetType);
|
||||||
|
|
||||||
// If the target type is not an IEnumerable, or is a dictionary, deserialize the object directly.
|
// If the target type is not an IEnumerable, or is a dictionary, deserialize the object directly.
|
||||||
var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType);
|
var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType);
|
||||||
|
|
||||||
|
|
@ -199,7 +221,7 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
{
|
{
|
||||||
writer.WriteStartObject();
|
writer.WriteStartObject();
|
||||||
writer.WriteString(IslandPropertyName, value.ToString());
|
writer.WriteString(IslandPropertyName, value.ToString());
|
||||||
writer.WriteString(TypePropertyName, type.GetSimpleAssemblyQualifiedName());
|
WriteTypeMetadata(writer, type);
|
||||||
writer.WriteEndObject();
|
writer.WriteEndObject();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -270,17 +292,7 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
if (type != typeof(ExpandoObject))
|
if (type != typeof(ExpandoObject))
|
||||||
{
|
{
|
||||||
if (shouldWriteTypeField)
|
if (shouldWriteTypeField)
|
||||||
{
|
WriteTypeMetadata(writer, type);
|
||||||
if (newOptions.Converters.OfType<TypeJsonConverter>().FirstOrDefault() is { } typeJsonConverter)
|
|
||||||
{
|
|
||||||
writer.WritePropertyName(TypePropertyName);
|
|
||||||
typeJsonConverter.Write(writer, type, newOptions);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
writer.WriteString(TypePropertyName, type.GetSimpleAssemblyQualifiedName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.WriteEndObject();
|
writer.WriteEndObject();
|
||||||
|
|
@ -338,8 +350,30 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found the _type property, attempt to resolve the type.
|
// If we found the _type property, attempt to resolve the type.
|
||||||
var targetType = typeName != null ? Type.GetType(typeName) : default;
|
return typeName != null ? WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeName) : default;
|
||||||
return targetType;
|
}
|
||||||
|
|
||||||
|
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)
|
private static object ReadPrimitive(ref Utf8JsonReader reader, JsonSerializerOptions options)
|
||||||
|
|
@ -436,4 +470,4 @@ public class PolymorphicObjectConverter : JsonConverter<object>
|
||||||
{
|
{
|
||||||
return key.Replace(@"\\$", "$");
|
return key.Replace(@"\\$", "$");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,6 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory
|
||||||
if (typeof(IDictionary<string, object>).IsAssignableFrom(typeToConvert))
|
if (typeof(IDictionary<string, object>).IsAssignableFrom(typeToConvert))
|
||||||
return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry);
|
return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry);
|
||||||
|
|
||||||
return new PolymorphicObjectConverter();
|
return new PolymorphicObjectConverter(_wellKnownTypeRegistry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,22 @@ using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Elsa.Expressions.Contracts;
|
using Elsa.Expressions.Contracts;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows.Serialization.Helpers;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace Elsa.Workflows.Serialization.Converters;
|
namespace Elsa.Workflows.Serialization.Converters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serializes <see cref="Type"/> objects to a simple alias representing the type.
|
/// Serializes <see cref="Type"/> objects to a simple alias representing the type.
|
||||||
|
/// Unregistered types are written as metadata-only aliases and intentionally deserialize to <see cref="Exception"/> instead of loading the original CLR type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[UsedImplicitly]
|
[UsedImplicitly]
|
||||||
public class TypeJsonConverter : JsonConverter<Type>
|
public class TypeJsonConverter : JsonConverter<Type>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prefix for unregistered type metadata that is not used for CLR type loading during deserialization.
|
||||||
|
/// </summary>
|
||||||
|
private const string UnregisteredTypeAliasPrefix = "UnregisteredClrType:";
|
||||||
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;
|
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -29,53 +35,19 @@ public class TypeJsonConverter : JsonConverter<Type>
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
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.
|
return WorkflowJsonTypeResolver.ResolveType(_wellKnownTypeRegistry, typeAlias);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options)
|
public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
// Handle array types.
|
if (!WorkflowJsonTypeResolver.TryGetAlias(_wellKnownTypeRegistry, value, out var typeAlias))
|
||||||
if (value.IsArray)
|
typeAlias = $"{UnregisteredTypeAliasPrefix}{value.GetSimpleAssemblyQualifiedName()}";
|
||||||
{
|
|
||||||
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 (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);
|
writer.WriteStringValue(typeAlias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Elsa.Expressions.Contracts;
|
||||||
|
using Elsa.Extensions;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Serialization.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves workflow JSON type aliases without loading arbitrary CLR type names.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkflowJsonTypeResolver
|
||||||
|
{
|
||||||
|
private static readonly IDictionary<string, Type> GenericCollectionTypes = new Dictionary<string, Type>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["IEnumerable"] = typeof(IEnumerable<>),
|
||||||
|
["ICollection"] = typeof(ICollection<>),
|
||||||
|
["IList"] = typeof(IList<>),
|
||||||
|
["IReadOnlyCollection"] = typeof(IReadOnlyCollection<>),
|
||||||
|
["IReadOnlyList"] = typeof(IReadOnlyList<>),
|
||||||
|
["ISet"] = typeof(ISet<>),
|
||||||
|
["List"] = typeof(List<>),
|
||||||
|
["HashSet"] = typeof(HashSet<>),
|
||||||
|
["Collection"] = typeof(Collection<>)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IDictionary<Type, string> GenericCollectionAliases = new Dictionary<Type, string>
|
||||||
|
{
|
||||||
|
[typeof(List<>)] = "List",
|
||||||
|
[typeof(HashSet<>)] = "HashSet",
|
||||||
|
[typeof(Collection<>)] = "Collection"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IDictionary<Type, Type> GenericCollectionInterfaceMappings = new Dictionary<Type, Type>
|
||||||
|
{
|
||||||
|
[typeof(IEnumerable<>)] = typeof(List<>),
|
||||||
|
[typeof(ICollection<>)] = typeof(List<>),
|
||||||
|
[typeof(IList<>)] = typeof(List<>),
|
||||||
|
[typeof(IReadOnlyCollection<>)] = typeof(List<>),
|
||||||
|
[typeof(IReadOnlyList<>)] = typeof(List<>),
|
||||||
|
[typeof(ISet<>)] = typeof(HashSet<>),
|
||||||
|
[typeof(IDictionary<,>)] = typeof(Dictionary<,>),
|
||||||
|
[typeof(IReadOnlyDictionary<,>)] = typeof(Dictionary<,>)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IDictionary<Type, Type> CollectionInterfaceMappings = new Dictionary<Type, Type>
|
||||||
|
{
|
||||||
|
[typeof(IEnumerable)] = typeof(List<object>),
|
||||||
|
[typeof(ICollection)] = typeof(List<object>),
|
||||||
|
[typeof(IList)] = typeof(List<object>),
|
||||||
|
[typeof(IDictionary)] = typeof(Dictionary<string, object>)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the specified workflow JSON type alias.
|
||||||
|
/// </summary>
|
||||||
|
public static Type ResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string? typeAlias)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(typeAlias))
|
||||||
|
throw new JsonException("The workflow JSON type alias is missing.");
|
||||||
|
|
||||||
|
if (TryResolveType(wellKnownTypeRegistry, typeAlias, out var type))
|
||||||
|
return type;
|
||||||
|
|
||||||
|
throw new JsonException(
|
||||||
|
$"Unknown workflow JSON type alias '{typeAlias}'. Only registered aliases and supported compound aliases can be deserialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to resolve the specified workflow JSON type alias.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, out Type type)
|
||||||
|
{
|
||||||
|
IReadOnlyList<Type>? registeredTypes = null;
|
||||||
|
return TryResolveType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
|
||||||
|
{
|
||||||
|
if (wellKnownTypeRegistry.TryGetType(typeAlias, out var registeredType))
|
||||||
|
{
|
||||||
|
type = registeredType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryResolveArrayType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var arrayType))
|
||||||
|
{
|
||||||
|
type = arrayType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryResolveGenericCollectionType(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var genericCollectionType))
|
||||||
|
{
|
||||||
|
type = genericCollectionType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryResolveRegisteredLegacyTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out var legacyType))
|
||||||
|
{
|
||||||
|
type = legacyType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to return a workflow JSON type alias that this resolver can read back.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetAlias(IWellKnownTypeRegistry wellKnownTypeRegistry, Type type, out string alias)
|
||||||
|
{
|
||||||
|
if (wellKnownTypeRegistry.TryGetAlias(type, out alias!))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (type.IsArray)
|
||||||
|
{
|
||||||
|
var elementType = type.GetElementType()!;
|
||||||
|
|
||||||
|
if (TryGetAlias(wellKnownTypeRegistry, elementType, out var elementTypeAlias))
|
||||||
|
{
|
||||||
|
alias = $"{elementTypeAlias}[]";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type is { IsGenericType: true, GenericTypeArguments.Length: 1 })
|
||||||
|
{
|
||||||
|
var genericTypeDefinition = type.GetGenericTypeDefinition();
|
||||||
|
|
||||||
|
if (TryGetWritableGenericCollectionAlias(genericTypeDefinition, out var genericTypeAlias) &&
|
||||||
|
TryGetAlias(wellKnownTypeRegistry, type.GenericTypeArguments[0], out var elementTypeAlias))
|
||||||
|
{
|
||||||
|
alias = $"{genericTypeAlias}<{elementTypeAlias}>";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to map a supported collection interface type to an instantiable concrete type.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetInstantiableCollectionType(Type type, out Type instantiableType)
|
||||||
|
{
|
||||||
|
if (type.IsGenericType)
|
||||||
|
{
|
||||||
|
var genericTypeDefinition = type.GetGenericTypeDefinition();
|
||||||
|
if (GenericCollectionInterfaceMappings.TryGetValue(genericTypeDefinition, out var instantiableGenericTypeDefinition))
|
||||||
|
{
|
||||||
|
instantiableType = instantiableGenericTypeDefinition.MakeGenericType(type.GenericTypeArguments);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CollectionInterfaceMappings.TryGetValue(type, out instantiableType!))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
instantiableType = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveArrayType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
|
||||||
|
{
|
||||||
|
type = null!;
|
||||||
|
|
||||||
|
if (!typeAlias.EndsWith("[]", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var elementTypeAlias = typeAlias[..^2];
|
||||||
|
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
type = elementType.MakeArrayType();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveGenericCollectionType(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
|
||||||
|
{
|
||||||
|
type = null!;
|
||||||
|
var genericStart = typeAlias.IndexOf('<', StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (genericStart <= 0 || !typeAlias.EndsWith(">", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var genericTypeAlias = typeAlias[..genericStart];
|
||||||
|
if (!GenericCollectionTypes.TryGetValue(genericTypeAlias, out var genericTypeDefinition))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var elementTypeAlias = typeAlias[(genericStart + 1)..^1];
|
||||||
|
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
type = genericTypeDefinition.MakeGenericType(elementType);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetWritableGenericCollectionAlias(Type genericTypeDefinition, out string alias)
|
||||||
|
{
|
||||||
|
if (GenericCollectionAliases.TryGetValue(genericTypeDefinition, out alias!))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (GenericCollectionInterfaceMappings.TryGetValue(genericTypeDefinition, out var instantiableGenericTypeDefinition) &&
|
||||||
|
GenericCollectionAliases.TryGetValue(instantiableGenericTypeDefinition, out alias!))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
alias = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveRegisteredLegacyTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
|
||||||
|
{
|
||||||
|
registeredTypes ??= GetRegisteredTypes(wellKnownTypeRegistry);
|
||||||
|
var registeredTypeSnapshot = registeredTypes;
|
||||||
|
|
||||||
|
if (TryResolveRegisteredSimpleAssemblyQualifiedName(registeredTypeSnapshot, typeAlias, out type))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (TryResolveLegacyGenericCollectionTypeName(wellKnownTypeRegistry, typeAlias, ref registeredTypes, out type))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
Type? resolvedType;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resolvedType = Type.GetType(
|
||||||
|
typeAlias,
|
||||||
|
assemblyName => ResolveAssembly(registeredTypeSnapshot, assemblyName),
|
||||||
|
(assembly, typeName, ignoreCase) => ResolveType(registeredTypeSnapshot, assembly, typeName, ignoreCase),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is ArgumentException or FileLoadException)
|
||||||
|
{
|
||||||
|
resolvedType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type = resolvedType!;
|
||||||
|
return resolvedType != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Type> GetRegisteredTypes(IWellKnownTypeRegistry wellKnownTypeRegistry)
|
||||||
|
{
|
||||||
|
return wellKnownTypeRegistry.ListTypes().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveRegisteredSimpleAssemblyQualifiedName(IEnumerable<Type> registeredTypes, string typeAlias, out Type type)
|
||||||
|
{
|
||||||
|
type = registeredTypes.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.GetSimpleAssemblyQualifiedName(), typeAlias, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(x.AssemblyQualifiedName, typeAlias, StringComparison.Ordinal))!;
|
||||||
|
|
||||||
|
return type != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveLegacyGenericCollectionTypeName(IWellKnownTypeRegistry wellKnownTypeRegistry, string typeAlias, ref IReadOnlyList<Type>? registeredTypes, out Type type)
|
||||||
|
{
|
||||||
|
type = null!;
|
||||||
|
|
||||||
|
foreach (var genericTypeDefinition in GenericCollectionTypes.Values)
|
||||||
|
{
|
||||||
|
var prefix = $"{genericTypeDefinition.FullName}[[";
|
||||||
|
var separatorIndex = typeAlias.LastIndexOf("]], ", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (!typeAlias.StartsWith(prefix, StringComparison.Ordinal) || separatorIndex <= prefix.Length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var assemblyName = typeAlias[(separatorIndex + 4)..].Split(',')[0];
|
||||||
|
if (!string.Equals(assemblyName, genericTypeDefinition.Assembly.GetName().Name, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var elementTypeAlias = typeAlias[prefix.Length..separatorIndex];
|
||||||
|
if (!TryResolveType(wellKnownTypeRegistry, elementTypeAlias, ref registeredTypes, out var elementType))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// The resolver only closes known collection definitions over registered element types.
|
||||||
|
#pragma warning disable IL2055
|
||||||
|
type = genericTypeDefinition.MakeGenericType(elementType);
|
||||||
|
#pragma warning restore IL2055
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly? ResolveAssembly(IEnumerable<Type> registeredTypes, AssemblyName assemblyName)
|
||||||
|
{
|
||||||
|
var coreLibAssembly = typeof(List<>).Assembly;
|
||||||
|
if (AssemblyName.ReferenceMatchesDefinition(coreLibAssembly.GetName(), assemblyName))
|
||||||
|
return coreLibAssembly;
|
||||||
|
|
||||||
|
return registeredTypes
|
||||||
|
.Select(x => x.Assembly)
|
||||||
|
.Distinct()
|
||||||
|
.FirstOrDefault(x => AssemblyName.ReferenceMatchesDefinition(x.GetName(), assemblyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type? ResolveType(IEnumerable<Type> registeredTypes, Assembly? assembly, string typeName, bool ignoreCase)
|
||||||
|
{
|
||||||
|
if (assembly == typeof(List<>).Assembly)
|
||||||
|
{
|
||||||
|
var genericCollectionType = GenericCollectionTypes.Values.FirstOrDefault(x =>
|
||||||
|
x.FullName != null && string.Equals(x.FullName, typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (genericCollectionType != null)
|
||||||
|
return genericCollectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registeredTypes.FirstOrDefault(x =>
|
||||||
|
x.Assembly == assembly &&
|
||||||
|
x.FullName != null &&
|
||||||
|
(string.Equals(x.FullName, typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
|
||||||
|
string.Equals(x.FullName.Replace('+', '.'), typeName, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,9 +72,10 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer
|
||||||
protected override void AddConverters(JsonSerializerOptions options)
|
protected override void AddConverters(JsonSerializerOptions options)
|
||||||
{
|
{
|
||||||
var expressionDescriptorRegistry = ServiceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
|
var expressionDescriptorRegistry = ServiceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
|
||||||
|
var wellKnownTypeRegistry = ServiceProvider.GetRequiredService<IWellKnownTypeRegistry>();
|
||||||
|
|
||||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
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 SafeValueConverterFactory());
|
||||||
options.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry));
|
options.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry));
|
||||||
options.Converters.Add(new FuncExpressionValueConverter());
|
options.Converters.Add(new FuncExpressionValueConverter());
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Elsa.Expressions.Services;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Workflows.Memory;
|
using Elsa.Workflows.Memory;
|
||||||
using Elsa.Workflows.Models;
|
using Elsa.Workflows.Models;
|
||||||
|
using Elsa.Workflows.Serialization.Helpers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
|
@ -40,13 +41,7 @@ public class VariableMapper
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Variable Map(VariableModel source)
|
public Variable Map(VariableModel source)
|
||||||
{
|
{
|
||||||
var typeName = source.TypeName;
|
var type = ResolveVariableType(source.TypeName);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(source.TypeName))
|
|
||||||
typeName = _wellKnownTypeRegistry.GetAliasOrDefault(typeof(object));
|
|
||||||
|
|
||||||
if (!_wellKnownTypeRegistry.TryGetTypeOrDefault(typeName, out var type))
|
|
||||||
type = typeof(object);
|
|
||||||
|
|
||||||
var variableGenericType = typeof(Variable<>).MakeGenericType(type);
|
var variableGenericType = typeof(Variable<>).MakeGenericType(type);
|
||||||
var variable = (Variable)Activator.CreateInstance(variableGenericType)!;
|
var variable = (Variable)Activator.CreateInstance(variableGenericType)!;
|
||||||
|
|
@ -59,7 +54,7 @@ public class VariableMapper
|
||||||
.OnSuccess(value => variable.Value = value)
|
.OnSuccess(value => variable.Value = value)
|
||||||
.OnFailure(e => _logger.LogWarning("Failed to convert {SourceValue} to {TargetType}", source.Value, type.Name));
|
.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;
|
return variable;
|
||||||
}
|
}
|
||||||
|
|
@ -78,4 +73,33 @@ public class VariableMapper
|
||||||
|
|
||||||
return new(source.Id, source.Name, valueTypeAlias, serializedValue, storageDriverTypeName);
|
return new(source.Id, source.Name, valueTypeAlias, serializedValue, storageDriverTypeName);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private Type ResolveVariableType(string? typeAlias)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(typeAlias))
|
||||||
|
return typeof(object);
|
||||||
|
|
||||||
|
if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type))
|
||||||
|
return type;
|
||||||
|
|
||||||
|
_logger.LogWarning("Failed to resolve variable type alias {VariableTypeName}", typeAlias);
|
||||||
|
return typeof(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Type? ResolveStorageDriverType(string? typeAlias)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(typeAlias))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var type) && IsStorageDriverType(type))
|
||||||
|
return type;
|
||||||
|
|
||||||
|
_logger.LogWarning("Failed to resolve storage driver type alias {StorageDriverTypeName}", typeAlias);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStorageDriverType(Type type)
|
||||||
|
{
|
||||||
|
return typeof(IStorageDriver).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using Elsa.Caching.Features;
|
||||||
using Elsa.Common.Codecs;
|
using Elsa.Common.Codecs;
|
||||||
using Elsa.Common.Features;
|
using Elsa.Common.Features;
|
||||||
using Elsa.Expressions.Contracts;
|
using Elsa.Expressions.Contracts;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
using Elsa.Features.Attributes;
|
using Elsa.Features.Attributes;
|
||||||
|
|
@ -312,7 +313,8 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
|
||||||
options.IsReadOnlyMode = IsReadOnlyMode;
|
options.IsReadOnlyMode = IsReadOnlyMode;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext)));
|
||||||
Services.Configure<HostMethodActivitiesOptions>(_ => { });
|
Services.Configure<HostMethodActivitiesOptions>(_ => { });
|
||||||
Services.Configure<WorkflowReferenceGraphOptions>(_ => { });
|
Services.Configure<WorkflowReferenceGraphOptions>(_ => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Text.Json.Serialization.Metadata;
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
using Elsa.Expressions.Contracts;
|
||||||
using Elsa.Expressions.Services;
|
using Elsa.Expressions.Services;
|
||||||
using Elsa.Workflows.Runtime.Entities;
|
using Elsa.Workflows.Runtime.Entities;
|
||||||
using Elsa.Workflows.Serialization.Converters;
|
using Elsa.Workflows.Serialization.Converters;
|
||||||
|
|
@ -17,7 +18,14 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="WorkflowTriggerEqualityComparer"/> class.
|
/// Initializes a new instance of the <see cref="WorkflowTriggerEqualityComparer"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WorkflowTriggerEqualityComparer()
|
public WorkflowTriggerEqualityComparer() : this(WellKnownTypeRegistry.CreateDefault())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="WorkflowTriggerEqualityComparer"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public WorkflowTriggerEqualityComparer(IWellKnownTypeRegistry wellKnownTypeRegistry)
|
||||||
{
|
{
|
||||||
_settings = new()
|
_settings = new()
|
||||||
{
|
{
|
||||||
|
|
@ -36,8 +44,8 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
|
||||||
// polymorphic object properties serialize identically to their stored representation.
|
// polymorphic object properties serialize identically to their stored representation.
|
||||||
_settings.Converters.Add(new JsonStringEnumConverter());
|
_settings.Converters.Add(new JsonStringEnumConverter());
|
||||||
_settings.Converters.Add(JsonMetadataServices.TimeSpanConverter);
|
_settings.Converters.Add(JsonMetadataServices.TimeSpanConverter);
|
||||||
_settings.Converters.Add(new PolymorphicObjectConverterFactory());
|
_settings.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry));
|
||||||
_settings.Converters.Add(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()));
|
_settings.Converters.Add(new TypeJsonConverter(wellKnownTypeRegistry));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -91,4 +99,3 @@ public class WorkflowTriggerEqualityComparer : IEqualityComparer<StoredTrigger>
|
||||||
return JsonSerializer.Serialize(payload, payload.GetType(), _settings);
|
return JsonSerializer.Serialize(payload, payload.GetType(), _settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Elsa.Workflows;
|
using Elsa.Workflows;
|
||||||
|
using Elsa.Workflows.Runtime.Helpers;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
// ReSharper disable once CheckNamespace
|
// ReSharper disable once CheckNamespace
|
||||||
|
|
@ -14,12 +15,7 @@ public static class WorkflowDictionaryExtensions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Add<TWorkflow>(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary) where TWorkflow : IWorkflow
|
public static void Add<TWorkflow>(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary) where TWorkflow : IWorkflow
|
||||||
{
|
{
|
||||||
// FullName should never be null here, as we filter out generic types
|
dictionary.Add(typeof(TWorkflow));
|
||||||
dictionary.Add(typeof(TWorkflow).FullName!, sp =>
|
|
||||||
{
|
|
||||||
var workflow = ActivatorUtilities.GetServiceOrCreateInstance<TWorkflow>(sp);
|
|
||||||
return new ValueTask<IWorkflow>(workflow);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -27,11 +23,30 @@ public static class WorkflowDictionaryExtensions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Add(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary, Type workflowType)
|
public static void Add(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> dictionary, Type workflowType)
|
||||||
{
|
{
|
||||||
// FullName should never be null here, as we filter out generic types
|
WorkflowTypeValidator.Validate(workflowType);
|
||||||
dictionary.Add(workflowType.FullName!, sp =>
|
|
||||||
|
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);
|
factory = sp =>
|
||||||
return new ValueTask<IWorkflow>(workflow);
|
{
|
||||||
});
|
var workflow = (IWorkflow)ActivatorUtilities.GetServiceOrCreateInstance(sp, workflowType);
|
||||||
|
return new ValueTask<IWorkflow>(workflow);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionary[key] = factory!;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(legacyKey) && legacyKey != key)
|
||||||
|
dictionary[legacyKey] = factory!;
|
||||||
|
|
||||||
|
if (dictionary is IWorkflowTypeRegistry workflowTypeRegistry)
|
||||||
|
workflowTypeRegistry.AddWorkflowType(workflowType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Elsa.Common;
|
||||||
using Elsa.Common.DistributedHosting;
|
using Elsa.Common.DistributedHosting;
|
||||||
using Elsa.Common.Features;
|
using Elsa.Common.Features;
|
||||||
using Elsa.Common.RecurringTasks;
|
using Elsa.Common.RecurringTasks;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Abstractions;
|
using Elsa.Features.Abstractions;
|
||||||
using Elsa.Features.Attributes;
|
using Elsa.Features.Attributes;
|
||||||
|
|
@ -14,10 +15,14 @@ using Elsa.Workflows.Management;
|
||||||
using Elsa.Workflows.Management.Contracts;
|
using Elsa.Workflows.Management.Contracts;
|
||||||
using Elsa.Workflows.Management.Services;
|
using Elsa.Workflows.Management.Services;
|
||||||
using Elsa.Workflows.Runtime.ActivationValidators;
|
using Elsa.Workflows.Runtime.ActivationValidators;
|
||||||
|
using Elsa.Workflows.Runtime.Bookmarks;
|
||||||
using Elsa.Workflows.Runtime.Entities;
|
using Elsa.Workflows.Runtime.Entities;
|
||||||
using Elsa.Workflows.Runtime.Handlers;
|
using Elsa.Workflows.Runtime.Handlers;
|
||||||
|
using Elsa.Workflows.Runtime.Helpers;
|
||||||
|
using Elsa.Workflows.Runtime.Models;
|
||||||
using Elsa.Workflows.Runtime.Options;
|
using Elsa.Workflows.Runtime.Options;
|
||||||
using Elsa.Workflows.Runtime.Providers;
|
using Elsa.Workflows.Runtime.Providers;
|
||||||
|
using Elsa.Workflows.Runtime.Stimuli;
|
||||||
using Elsa.Workflows.Runtime.Stores;
|
using Elsa.Workflows.Runtime.Stores;
|
||||||
using Elsa.Workflows.Runtime.Tasks;
|
using Elsa.Workflows.Runtime.Tasks;
|
||||||
using Elsa.Workflows.Runtime.UIHints;
|
using Elsa.Workflows.Runtime.UIHints;
|
||||||
|
|
@ -33,6 +38,7 @@ namespace Elsa.Workflows.Runtime.Features;
|
||||||
/// Installs and configures workflow runtime features.
|
/// Installs and configures workflow runtime features.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[DependsOn(typeof(SystemClockFeature))]
|
[DependsOn(typeof(SystemClockFeature))]
|
||||||
|
[DependsOn(typeof(WorkflowsFeature))]
|
||||||
public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
{
|
{
|
||||||
private IDictionary<string, DispatcherChannel> WorkflowDispatcherChannels { get; set; } = new Dictionary<string, DispatcherChannel>();
|
private IDictionary<string, DispatcherChannel> WorkflowDispatcherChannels { get; set; } = new Dictionary<string, DispatcherChannel>();
|
||||||
|
|
@ -40,7 +46,8 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of workflow builders configured during application startup.
|
/// A list of workflow builders configured during application startup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>();
|
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new WorkflowFactoryDictionary();
|
||||||
|
private ISet<Type> WorkflowTypes { get; } = new HashSet<Type>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
|
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
|
||||||
|
|
@ -186,7 +193,18 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WorkflowRuntimeFeature AddWorkflow<T>() where T : IWorkflow
|
public WorkflowRuntimeFeature AddWorkflow<T>() where T : IWorkflow
|
||||||
{
|
{
|
||||||
Workflows.Add<T>();
|
AddWorkflow(typeof(T));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the specified workflow type.
|
||||||
|
/// </summary>
|
||||||
|
public WorkflowRuntimeFeature AddWorkflow(Type workflowType)
|
||||||
|
{
|
||||||
|
WorkflowTypeValidator.Validate(workflowType);
|
||||||
|
Workflows.Add(workflowType);
|
||||||
|
WorkflowTypes.Add(workflowType);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,11 +215,11 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly)
|
public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly)
|
||||||
{
|
{
|
||||||
var workflowTypes = assembly.GetExportedTypes()
|
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();
|
.ToList();
|
||||||
|
|
||||||
foreach (var workflowType in workflowTypes)
|
foreach (var workflowType in workflowTypes)
|
||||||
Workflows.Add(workflowType);
|
AddWorkflow(workflowType);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +275,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
Services.Configure(WorkflowInboxCleanupOptions);
|
Services.Configure(WorkflowInboxCleanupOptions);
|
||||||
Services.Configure(WorkflowDispatcherOptions);
|
Services.Configure(WorkflowDispatcherOptions);
|
||||||
Services.Configure(BookmarkQueuePurgeOptions);
|
Services.Configure(BookmarkQueuePurgeOptions);
|
||||||
|
Services.Configure<ExpressionOptions>(RegisterWorkflowTypeAliases);
|
||||||
Services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
|
Services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
|
||||||
Services.Configure<WorkflowDispatcherOptions>(options =>
|
Services.Configure<WorkflowDispatcherOptions>(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -428,4 +447,13 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
|
||||||
Services.TryAddScoped(WorkflowDispatchOutboxStore);
|
Services.TryAddScoped(WorkflowDispatchOutboxStore);
|
||||||
Services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
|
Services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RegisterWorkflowTypeAliases(ExpressionOptions options)
|
||||||
|
{
|
||||||
|
var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry
|
||||||
|
? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes)
|
||||||
|
: WorkflowTypes;
|
||||||
|
|
||||||
|
WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Elsa.Workflows.Runtime.Helpers;
|
||||||
|
|
||||||
|
internal interface IWorkflowTypeRegistry
|
||||||
|
{
|
||||||
|
IEnumerable<Type> WorkflowTypes { get; }
|
||||||
|
|
||||||
|
void AddWorkflowType(Type workflowType);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Elsa.Workflows;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Runtime.Helpers;
|
||||||
|
|
||||||
|
internal sealed class WorkflowFactoryDictionary : Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>, IWorkflowTypeRegistry
|
||||||
|
{
|
||||||
|
private readonly ISet<Type> _workflowTypes = new HashSet<Type>();
|
||||||
|
|
||||||
|
public IEnumerable<Type> WorkflowTypes => _workflowTypes;
|
||||||
|
|
||||||
|
public void AddWorkflowType(Type workflowType)
|
||||||
|
{
|
||||||
|
WorkflowTypeValidator.Validate(workflowType);
|
||||||
|
_workflowTypes.Add(workflowType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ public class ClrWorkflowsProvider(
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask<IEnumerable<MaterializedWorkflow>> GetWorkflowsAsync(CancellationToken cancellationToken = default)
|
public async ValueTask<IEnumerable<MaterializedWorkflow>> GetWorkflowsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var buildWorkflowTasks = options.Value.Workflows.Values.Select(async x => await BuildWorkflowAsync(x, cancellationToken)).ToList();
|
var buildWorkflowTasks = options.Value.Workflows.Values.Distinct().Select(async x => await BuildWorkflowAsync(x, cancellationToken)).ToList();
|
||||||
var workflowDefinitions = await Task.WhenAll(buildWorkflowTasks);
|
var workflowDefinitions = await Task.WhenAll(buildWorkflowTasks);
|
||||||
return workflowDefinitions;
|
return workflowDefinitions;
|
||||||
}
|
}
|
||||||
|
|
@ -48,4 +48,4 @@ public class ClrWorkflowsProvider(
|
||||||
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());
|
var materializerContext = new ClrWorkflowMaterializerContext(workflowBuilder.GetType());
|
||||||
return new(workflow, Name, ClrWorkflowMaterializer.MaterializerName, materializerContext);
|
return new(workflow, Name, ClrWorkflowMaterializer.MaterializerName, materializerContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IStimulusHasher _hasher;
|
private readonly IStimulusHasher _hasher;
|
||||||
private readonly IDistributedLockProvider _distributedLockProvider;
|
private readonly IDistributedLockProvider _distributedLockProvider;
|
||||||
|
private readonly WorkflowTriggerEqualityComparer _triggerEqualityComparer;
|
||||||
private readonly DistributedLockingOptions _lockingOptions;
|
private readonly DistributedLockingOptions _lockingOptions;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IStimulusHasher hasher,
|
IStimulusHasher hasher,
|
||||||
IDistributedLockProvider distributedLockProvider,
|
IDistributedLockProvider distributedLockProvider,
|
||||||
|
IWellKnownTypeRegistry wellKnownTypeRegistry,
|
||||||
IOptions<DistributedLockingOptions> lockingOptions,
|
IOptions<DistributedLockingOptions> lockingOptions,
|
||||||
ILogger<TriggerIndexer> logger)
|
ILogger<TriggerIndexer> logger)
|
||||||
{
|
{
|
||||||
|
|
@ -60,6 +62,7 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
_distributedLockProvider = distributedLockProvider;
|
_distributedLockProvider = distributedLockProvider;
|
||||||
|
_triggerEqualityComparer = new WorkflowTriggerEqualityComparer(wellKnownTypeRegistry);
|
||||||
_lockingOptions = lockingOptions.Value;
|
_lockingOptions = lockingOptions.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_workflowDefinitionService = workflowDefinitionService;
|
_workflowDefinitionService = workflowDefinitionService;
|
||||||
|
|
@ -118,7 +121,7 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
: new(0);
|
: new(0);
|
||||||
|
|
||||||
// Diff triggers.
|
// Diff triggers.
|
||||||
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer());
|
var diff = Diff.For(currentTriggers, newTriggers, _triggerEqualityComparer);
|
||||||
|
|
||||||
// Replace triggers for the specified workflow.
|
// Replace triggers for the specified workflow.
|
||||||
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
|
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
|
||||||
|
|
@ -140,7 +143,7 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
{
|
{
|
||||||
var emptyTriggerList = new List<StoredTrigger>(0);
|
var emptyTriggerList = new List<StoredTrigger>(0);
|
||||||
var currentTriggers = await GetCurrentTriggersAsync(workflow.Identity.DefinitionId, cancellationToken).ToList();
|
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);
|
await _triggerStore.ReplaceAsync(diff.Removed, diff.Added, cancellationToken);
|
||||||
var indexedWorkflow = new IndexedWorkflowTriggers(workflow, emptyTriggerList, currentTriggers, emptyTriggerList);
|
var indexedWorkflow = new IndexedWorkflowTriggers(workflow, emptyTriggerList, currentTriggers, emptyTriggerList);
|
||||||
await _notificationSender.SendAsync(new WorkflowTriggersIndexed(indexedWorkflow), cancellationToken);
|
await _notificationSender.SendAsync(new WorkflowTriggersIndexed(indexedWorkflow), cancellationToken);
|
||||||
|
|
@ -226,4 +229,4 @@ public class TriggerIndexer : ITriggerIndexer
|
||||||
|
|
||||||
return new(0);
|
return new(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Reflection;
|
||||||
using CShells.Features;
|
using CShells.Features;
|
||||||
using Elsa.Common;
|
using Elsa.Common;
|
||||||
using Elsa.Common.RecurringTasks;
|
using Elsa.Common.RecurringTasks;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Mediator.Contracts;
|
using Elsa.Mediator.Contracts;
|
||||||
using Elsa.Workflows.CommitStates;
|
using Elsa.Workflows.CommitStates;
|
||||||
|
|
@ -10,6 +13,7 @@ using Elsa.Workflows.Management.Services;
|
||||||
using Elsa.Workflows.Runtime.ActivationValidators;
|
using Elsa.Workflows.Runtime.ActivationValidators;
|
||||||
using Elsa.Workflows.Runtime.Entities;
|
using Elsa.Workflows.Runtime.Entities;
|
||||||
using Elsa.Workflows.Runtime.Handlers;
|
using Elsa.Workflows.Runtime.Handlers;
|
||||||
|
using Elsa.Workflows.Runtime.Helpers;
|
||||||
using Elsa.Workflows.Runtime.Options;
|
using Elsa.Workflows.Runtime.Options;
|
||||||
using Elsa.Workflows.Runtime.Providers;
|
using Elsa.Workflows.Runtime.Providers;
|
||||||
using Elsa.Workflows.Runtime.Services;
|
using Elsa.Workflows.Runtime.Services;
|
||||||
|
|
@ -38,7 +42,8 @@ public class WorkflowRuntimeFeature : IShellFeature
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of workflow builders configured during application startup.
|
/// A list of workflow builders configured during application startup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new Dictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>>();
|
public IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> Workflows { get; set; } = new WorkflowFactoryDictionary();
|
||||||
|
private ISet<Type> WorkflowTypes { get; } = new HashSet<Type>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
|
/// A factory that instantiates a concrete <see cref="IWorkflowRuntime"/>.
|
||||||
|
|
@ -146,10 +151,45 @@ public class WorkflowRuntimeFeature : IShellFeature
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GracefulShutdownOptions? GracefulShutdown { get; set; }
|
public GracefulShutdownOptions? GracefulShutdown { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the specified workflow type.
|
||||||
|
/// </summary>
|
||||||
|
public WorkflowRuntimeFeature AddWorkflow<T>() where T : IWorkflow
|
||||||
|
{
|
||||||
|
return AddWorkflow(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the specified workflow type.
|
||||||
|
/// </summary>
|
||||||
|
public WorkflowRuntimeFeature AddWorkflow(Type workflowType)
|
||||||
|
{
|
||||||
|
WorkflowTypeValidator.Validate(workflowType);
|
||||||
|
Workflows.Add(workflowType);
|
||||||
|
WorkflowTypes.Add(workflowType);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register all workflows in the specified assembly.
|
||||||
|
/// </summary>
|
||||||
|
[RequiresUnreferencedCode("The assembly is required to be referenced.")]
|
||||||
|
public WorkflowRuntimeFeature AddWorkflowsFrom(Assembly assembly)
|
||||||
|
{
|
||||||
|
var workflowTypes = assembly.GetExportedTypes()
|
||||||
|
.Where(x => typeof(IWorkflow).IsAssignableFrom(x) && x is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var workflowType in workflowTypes)
|
||||||
|
AddWorkflow(workflowType);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public void ConfigureServices(IServiceCollection services)
|
public void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
// Options.
|
// Options.
|
||||||
|
services.Configure<ExpressionOptions>(RegisterWorkflowTypeAliases);
|
||||||
services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
|
services.Configure<RuntimeOptions>(options => { options.Workflows = Workflows; });
|
||||||
services.Configure<WorkflowDispatcherOptions>(options =>
|
services.Configure<WorkflowDispatcherOptions>(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -325,4 +365,13 @@ public class WorkflowRuntimeFeature : IShellFeature
|
||||||
services.TryAddScoped(WorkflowDispatchOutboxStore);
|
services.TryAddScoped(WorkflowDispatchOutboxStore);
|
||||||
services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
|
services.TryAddScoped<IWorkflowDispatchOutboxProcessor, WorkflowDispatchOutboxProcessor>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RegisterWorkflowTypeAliases(ExpressionOptions options)
|
||||||
|
{
|
||||||
|
var workflowTypes = Workflows is IWorkflowTypeRegistry workflowTypeRegistry
|
||||||
|
? WorkflowTypes.Concat(workflowTypeRegistry.WorkflowTypes)
|
||||||
|
: WorkflowTypes;
|
||||||
|
|
||||||
|
WorkflowRuntimeTypeAliasRegistrar.Register(options, workflowTypes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows;
|
||||||
|
using Elsa.Workflows.Runtime.Bookmarks;
|
||||||
|
using Elsa.Workflows.Runtime.Stimuli;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Runtime;
|
||||||
|
|
||||||
|
internal static class WorkflowRuntimeTypeAliasRegistrar
|
||||||
|
{
|
||||||
|
public static void Register(ExpressionOptions options, IEnumerable<Type> workflowTypes)
|
||||||
|
{
|
||||||
|
options.RegisterTypeAlias(typeof(EventBookmarkPayload), nameof(EventBookmarkPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(ExecuteWorkflowPayload), nameof(ExecuteWorkflowPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(RunTaskBookmarkPayload), nameof(RunTaskBookmarkPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(BookmarkTokenPayload), nameof(BookmarkTokenPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(EventTokenPayload), nameof(EventTokenPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(ExecuteWorkflowResult), nameof(ExecuteWorkflowResult));
|
||||||
|
options.RegisterTypeAlias(typeof(WorkflowInterruptedPayload), nameof(WorkflowInterruptedPayload));
|
||||||
|
options.RegisterTypeAlias(typeof(BackgroundActivityStimulus), nameof(BackgroundActivityStimulus));
|
||||||
|
options.RegisterTypeAlias(typeof(BulkDispatchWorkflowsStimulus), nameof(BulkDispatchWorkflowsStimulus));
|
||||||
|
options.RegisterTypeAlias(typeof(DispatchWorkflowStimulus), nameof(DispatchWorkflowStimulus));
|
||||||
|
options.RegisterTypeAlias(typeof(EventStimulus), nameof(EventStimulus));
|
||||||
|
options.RegisterTypeAlias(typeof(ExecuteWorkflowStimulus), nameof(ExecuteWorkflowStimulus));
|
||||||
|
options.RegisterTypeAlias(typeof(RunTaskStimulus), nameof(RunTaskStimulus));
|
||||||
|
|
||||||
|
foreach (var workflowType in workflowTypes.Where(IsConcreteWorkflowType).Distinct())
|
||||||
|
options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsConcreteWorkflowType(Type type)
|
||||||
|
{
|
||||||
|
return typeof(IWorkflow).IsAssignableFrom(type) && type is { IsAbstract: false, IsInterface: false, ContainsGenericParameters: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,25 +142,24 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
|
||||||
return (T?)variables.FirstOrDefault(v => v.Variable.Name == variableName)?.Value;
|
return (T?)variables.FirstOrDefault(v => v.Variable.Name == variableName)?.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(TestWorkflowExecutionResult Result, List<WorkflowState> CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync(
|
private async Task<(TestWorkflowExecutionResult Result, List<WorkflowInstance> CompletedChildWorkflows)> RunWorkflowAndWaitForChildWorkflowsAsync(
|
||||||
string parentWorkflowDefinitionId,
|
string parentWorkflowDefinitionId,
|
||||||
string childWorkflowDefinitionId,
|
string childWorkflowDefinitionId,
|
||||||
int expectedChildCount)
|
int expectedChildCount)
|
||||||
{
|
{
|
||||||
var result = await RunWorkflowAsync(parentWorkflowDefinitionId);
|
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);
|
return (result, completedChildWorkflows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<WorkflowState>> WaitForCompletedChildWorkflowsAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount)
|
private async Task<List<WorkflowInstance>> WaitForCompletedChildWorkflowInstancesAsync(string parentWorkflowInstanceId, string childWorkflowDefinitionId, int expectedChildCount)
|
||||||
{
|
{
|
||||||
var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService<IWorkflowInstanceStore>();
|
|
||||||
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(ChildWorkflowTimeoutSeconds);
|
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(ChildWorkflowTimeoutSeconds);
|
||||||
|
|
||||||
while (DateTimeOffset.UtcNow < timeoutAt)
|
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)
|
if (completedChildWorkflows.Count >= expectedChildCount)
|
||||||
return completedChildWorkflows;
|
return completedChildWorkflows;
|
||||||
|
|
@ -168,13 +167,12 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
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}"));
|
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}");
|
throw new TimeoutException($"Expected {expectedChildCount} completed child workflows with definition ID {childWorkflowDefinitionId}, but observed {childWorkflows.Count(x => x.Status == WorkflowStatus.Finished)}. Observed child workflow states: {observedStates}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<WorkflowState>> FindChildWorkflowStatesAsync(
|
private async Task<List<WorkflowInstance>> FindChildWorkflowInstancesAsync(
|
||||||
IWorkflowInstanceStore workflowInstanceStore,
|
|
||||||
string parentWorkflowInstanceId,
|
string parentWorkflowInstanceId,
|
||||||
string childWorkflowDefinitionId,
|
string childWorkflowDefinitionId,
|
||||||
WorkflowStatus? workflowStatus = null)
|
WorkflowStatus? workflowStatus = null)
|
||||||
|
|
@ -185,8 +183,8 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
|
||||||
ParentWorkflowInstanceIds = new[] { parentWorkflowInstanceId },
|
ParentWorkflowInstanceIds = new[] { parentWorkflowInstanceId },
|
||||||
WorkflowStatus = workflowStatus
|
WorkflowStatus = workflowStatus
|
||||||
};
|
};
|
||||||
var instances = await workflowInstanceStore.FindManyAsync(filter);
|
var instances = await _workflowInstanceStore.FindManyAsync(filter);
|
||||||
return instances.Select(x => x.WorkflowState).ToList();
|
return instances.OrderBy(x => x.CreatedAt).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyCollection<WorkflowInstance>> WaitForChildWorkflowInstancesAsync(
|
private async Task<IReadOnlyCollection<WorkflowInstance>> WaitForChildWorkflowInstancesAsync(
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,9 @@ public class PublishEventTests : AppComponentTest
|
||||||
|
|
||||||
// Verify the payload structure and content
|
// Verify the payload structure and content
|
||||||
var payloadJson = JsonSerializer.Serialize(receivedPayload);
|
var payloadJson = JsonSerializer.Serialize(receivedPayload);
|
||||||
Assert.Contains("\"Status\"", payloadJson);
|
using var payloadDocument = JsonDocument.Parse(payloadJson);
|
||||||
Assert.Contains("\"Shipped\"", payloadJson);
|
var statusProperty = Assert.Single(payloadDocument.RootElement.EnumerateObject(), x => string.Equals(x.Name, "Status", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Equal("Shipped", statusProperty.Value.GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WorkflowInstance> GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000)
|
private async Task<WorkflowInstance> GetSingleWorkflowInstanceAsync(string definitionId, string correlationId, int timeoutMs = 5000)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Elsa.Expressions.JavaScript.ShellFeatures;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
|
using Jint.Runtime;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Elsa.JavaScript.IntegrationTests;
|
||||||
|
|
||||||
|
public class JavaScriptFeatureTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConfigureServices_RegistersUniqueWrapperExceptionAlias()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var feature = new JavaScriptFeature();
|
||||||
|
var wrapperExceptionType = typeof(JavaScriptException).GetNestedType("JavaScriptErrorWrapperException", BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
feature.ConfigureServices(services);
|
||||||
|
|
||||||
|
using var serviceProvider = services.BuildServiceProvider();
|
||||||
|
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
|
||||||
|
var registry = new WellKnownTypeRegistry(expressionOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(wrapperExceptionType);
|
||||||
|
Assert.True(registry.TryGetType("Jint.JavaScriptErrorWrapperException", out var type));
|
||||||
|
Assert.Equal(wrapperExceptionType, type);
|
||||||
|
Assert.False(registry.TryGetType(wrapperExceptionType.Name, out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"StatusCode": "Created",
|
"StatusCode": "Created",
|
||||||
"Content": {
|
"Content": {
|
||||||
"_island": "[\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n]",
|
"_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"
|
"_type": "ObjectMap"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"StatusCode": "Created",
|
"StatusCode": "Created",
|
||||||
"Content": {
|
"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}",
|
"_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"
|
"_type": "ObjectMap"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"StatusCode": "Created",
|
"StatusCode": "Created",
|
||||||
"Content": {
|
"Content": {
|
||||||
"_island": "[\r\n {\r\n \u0022path\u0022: \u0022folder1\u0022,\r\n \u0022command\u0022: \u0022add\u0022\r\n }\r\n]",
|
"_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"
|
"_type": "ObjectMap"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"StatusCode": "Created",
|
"StatusCode": "Created",
|
||||||
"Content": {
|
"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}",
|
"_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"
|
"_type": "ObjectMap"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
using Elsa.Testing.Shared;
|
using Elsa.Testing.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
@ -9,7 +10,9 @@ namespace Elsa.Workflows.IntegrationTests.Serialization.JsonSerialization;
|
||||||
|
|
||||||
public class SerializationTests(ITestOutputHelper testOutputHelper)
|
public class SerializationTests(ITestOutputHelper testOutputHelper)
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper).Build();
|
private readonly IServiceProvider _services = new TestApplicationBuilder(testOutputHelper)
|
||||||
|
.ConfigureServices(services => services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(typeof(TestObject), nameof(TestObject))))
|
||||||
|
.Build();
|
||||||
|
|
||||||
[Theory(DisplayName = "write")]
|
[Theory(DisplayName = "write")]
|
||||||
[InlineData(typeof(JsonObject), "JsonObjectIsland")]
|
[InlineData(typeof(JsonObject), "JsonObjectIsland")]
|
||||||
|
|
@ -182,4 +185,4 @@ public class SerializationTests(ITestOutputHelper testOutputHelper)
|
||||||
public class TestObject
|
public class TestObject
|
||||||
{
|
{
|
||||||
public string? Data { get; set; }
|
public string? Data { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public class Tests
|
||||||
public void Test1()
|
public void Test1()
|
||||||
{
|
{
|
||||||
var model = CreateModel();
|
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());
|
var actualJson = JsonSerializer.Serialize(model, GetSerializerOptions());
|
||||||
Assert.Equal(expectedJson, actualJson);
|
Assert.Equal(expectedJson, actualJson);
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +74,10 @@ public class Tests
|
||||||
private JsonSerializerOptions GetSerializerOptions()
|
private JsonSerializerOptions GetSerializerOptions()
|
||||||
{
|
{
|
||||||
var referenceHandler = new CrossScopedReferenceHandler();
|
var referenceHandler = new CrossScopedReferenceHandler();
|
||||||
|
var wellKnownTypeRegistry = new WellKnownTypeRegistry();
|
||||||
|
wellKnownTypeRegistry.RegisterType(typeof(Model), nameof(Model));
|
||||||
|
wellKnownTypeRegistry.RegisterType(typeof(CustomDictionary), nameof(CustomDictionary));
|
||||||
|
|
||||||
var options = new JsonSerializerOptions
|
var options = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
ReferenceHandler = referenceHandler,
|
ReferenceHandler = referenceHandler,
|
||||||
|
|
@ -84,7 +88,7 @@ public class Tests
|
||||||
|
|
||||||
options.Converters.Add(new JsonStringEnumConverter());
|
options.Converters.Add(new JsonStringEnumConverter());
|
||||||
options.Converters.Add(JsonMetadataServices.TimeSpanConverter);
|
options.Converters.Add(JsonMetadataServices.TimeSpanConverter);
|
||||||
options.Converters.Add(new PolymorphicObjectConverterFactory(new WellKnownTypeRegistry()));
|
options.Converters.Add(new PolymorphicObjectConverterFactory(wellKnownTypeRegistry));
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,4 +117,4 @@ public class Tests
|
||||||
public override void Write(Utf8JsonWriter writer, MyNumber value, JsonSerializerOptions options)
|
public override void Write(Utf8JsonWriter writer, MyNumber value, JsonSerializerOptions options)
|
||||||
=> writer.WriteNumberValue(value.Number);
|
=> writer.WriteNumberValue(value.Number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.CustomDictionary, Elsa.Workflows.IntegrationTests"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"System.Collections.Generic.List\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"System.Collections.Generic.HashSet\u00601[[Elsa.Workflows.IntegrationTests.Serialization.Polymorphism.Model, Elsa.Workflows.IntegrationTests]], System.Private.CoreLib"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}}
|
{"$id":"1","text":"Hello World","number":123,"flag":true,"items":{"$id":"2","$values":[{"$id":"3","text":"Hello","number":1,"flag":true,"items":{"$id":"4","$values":[{"$id":"5","number":0,"flag":false,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$id":"6","$values":[{"$id":"7","text":"Hello","number":1,"flag":true,"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$id":"8","content-type":["application/json"],"_type":"CustomDictionary"}}},{"$id":"9","text":"World","number":2,"flag":false}],"_type":"List\u003CModel\u003E"},"CustomDictionary":{"$ref":"8"}}}]}},{"$id":"10","text":"World","number":2,"flag":false}]},"metadata":{"Foo":"Bar","Number":123,"Flag":true,"Models":{"$ref":"6"},"CustomDictionary":{"$ref":"8"}},"payload":{"$id":"11","text":"Payload","number":0,"flag":false,"_type":"Model"},"hashSet":{"$id":"12","$values":[{"$id":"13","text":"I\u0027m a model in a set!","number":0,"flag":false}],"_type":"HashSet\u003CModel\u003E"},"properties":{"$id":"14","Hello":{"$id":"15","text":"Hello","number":1,"flag":true},"World":{"$id":"16","text":"World","number":2,"flag":false}}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Elsa.Common.Entities;
|
||||||
|
|
||||||
|
namespace Elsa.Common.UnitTests.Entities;
|
||||||
|
|
||||||
|
public class OrderDefinitionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Serialize_DoesNotIncludeKeySelectorText()
|
||||||
|
{
|
||||||
|
var order = new OrderDefinition<TestEntity, string>(x => x.Name, OrderDirection.Ascending);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(order);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(nameof(OrderDefinition<TestEntity, string>.KeySelectorText), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestEntity
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
|
using Elsa.Http.Bookmarks;
|
||||||
|
using Elsa.Http.ShellFeatures;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Elsa.Http.UnitTests.ShellFeatures;
|
||||||
|
|
||||||
|
public class HttpFeatureTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConfigureServices_RegistersHttpTypeAliases()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var feature = new HttpFeature();
|
||||||
|
|
||||||
|
feature.ConfigureServices(services);
|
||||||
|
|
||||||
|
using var serviceProvider = services.BuildServiceProvider();
|
||||||
|
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
|
||||||
|
var registry = new WellKnownTypeRegistry(expressionOptions);
|
||||||
|
|
||||||
|
Assert.True(registry.TryGetType(nameof(HttpEndpointBookmarkPayload), out var type));
|
||||||
|
Assert.Equal(typeof(HttpEndpointBookmarkPayload), type);
|
||||||
|
Assert.True(registry.TryGetType(nameof(HttpRequestException), out var exceptionType));
|
||||||
|
Assert.Equal(typeof(HttpRequestException), exceptionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Elsa.Workflows.Attributes;
|
||||||
|
using Elsa.Workflows.Serialization.Converters;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters;
|
||||||
|
|
||||||
|
public class ExcludeFromHashConverterTests
|
||||||
|
{
|
||||||
|
private readonly JsonSerializerOptions _options = new()
|
||||||
|
{
|
||||||
|
Converters = { new ExcludeFromHashConverterFactory() }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_IncludesConditionallyIgnoredProperty_WhenValueIsNotNull()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new ConditionalIgnoreModel { Name = "Alice" }, _options);
|
||||||
|
|
||||||
|
Assert.Contains("\"Name\":\"Alice\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_ExcludesConditionallyIgnoredProperty_WhenValueIsNull()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new ConditionalIgnoreModel(), _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Name\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_ExcludesAlwaysIgnoredProperty()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new AlwaysIgnoreModel { Secret = "hidden" }, _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Secret\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_SkipsIndexerProperties()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new IndexerModel(), _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Item\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_DoesNotEvaluateExcludedProperty()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new ExcludedThrowingModel(), _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Secret\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_DoesNotEvaluateAlwaysIgnoredProperty()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new AlwaysIgnoredThrowingModel(), _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"Secret\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_IncludesNullableValueTypeWithDefaultUnderlyingValue_WhenIgnoringDefaults()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new DefaultIgnoreNullableModel { Count = 0 }, _options);
|
||||||
|
|
||||||
|
Assert.Contains("\"Count\":0", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_IncludesProperty_WhenJsonIgnoreConditionIsUnknown()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new UnknownIgnoreConditionModel { Name = "Alice" }, _options);
|
||||||
|
|
||||||
|
Assert.Contains("\"Name\":\"Alice\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_ExcludesStaticProperties()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new StaticPropertyModel { Name = "Alice" }, _options);
|
||||||
|
|
||||||
|
Assert.Contains("\"Name\":\"Alice\"", json);
|
||||||
|
Assert.DoesNotContain("\"Secret\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_PreservesDeclarationOrder()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new OrderedModel { B = "second", A = "first" }, _options);
|
||||||
|
|
||||||
|
Assert.True(json.IndexOf("\"B\"", StringComparison.Ordinal) < json.IndexOf("\"A\"", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_OrdersInheritedProperties_ByDeclaringTypeThenDeclarationOrder()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new DerivedOrderedModel
|
||||||
|
{
|
||||||
|
BaseB = "base second",
|
||||||
|
BaseA = "base first",
|
||||||
|
DerivedB = "derived second",
|
||||||
|
DerivedA = "derived first"
|
||||||
|
}, _options);
|
||||||
|
|
||||||
|
Assert.True(json.IndexOf("\"DerivedB\"", StringComparison.Ordinal) < json.IndexOf("\"DerivedA\"", StringComparison.Ordinal));
|
||||||
|
Assert.True(json.IndexOf("\"DerivedA\"", StringComparison.Ordinal) < json.IndexOf("\"BaseB\"", StringComparison.Ordinal));
|
||||||
|
Assert.True(json.IndexOf("\"BaseB\"", StringComparison.Ordinal) < json.IndexOf("\"BaseA\"", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ConditionalIgnoreModel
|
||||||
|
{
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlwaysIgnoreModel
|
||||||
|
{
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class IndexerModel
|
||||||
|
{
|
||||||
|
public string this[int index] => index.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExcludedThrowingModel
|
||||||
|
{
|
||||||
|
[ExcludeFromHash]
|
||||||
|
public string Secret => throw new InvalidOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlwaysIgnoredThrowingModel
|
||||||
|
{
|
||||||
|
[JsonIgnore]
|
||||||
|
public string Secret => throw new InvalidOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DefaultIgnoreNullableModel
|
||||||
|
{
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
|
public int? Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UnknownIgnoreConditionModel
|
||||||
|
{
|
||||||
|
[JsonIgnore(Condition = (JsonIgnoreCondition)999)]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StaticPropertyModel
|
||||||
|
{
|
||||||
|
public static string Secret => throw new InvalidOperationException();
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OrderedModel
|
||||||
|
{
|
||||||
|
public string? B { get; set; }
|
||||||
|
|
||||||
|
public string? A { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BaseOrderedModel
|
||||||
|
{
|
||||||
|
public string? BaseB { get; set; }
|
||||||
|
|
||||||
|
public string? BaseA { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DerivedOrderedModel : BaseOrderedModel
|
||||||
|
{
|
||||||
|
public string? DerivedB { get; set; }
|
||||||
|
|
||||||
|
public string? DerivedA { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows.Exceptions;
|
||||||
|
using Elsa.Workflows.Memory;
|
||||||
|
using Elsa.Workflows.Serialization.Converters;
|
||||||
|
using Elsa.Workflows.Serialization.Helpers;
|
||||||
|
using Elsa.Workflows.State;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters;
|
||||||
|
|
||||||
|
public sealed class WorkflowJsonTypeResolverTests
|
||||||
|
{
|
||||||
|
private static readonly string UnsafeAssemblyQualifiedTypeAlias = typeof(System.Text.StringBuilder).AssemblyQualifiedName!;
|
||||||
|
private readonly WellKnownTypeRegistry _wellKnownTypeRegistry = new(Microsoft.Extensions.Options.Options.Create(new ExpressionOptions()));
|
||||||
|
private readonly JsonSerializerOptions _options;
|
||||||
|
|
||||||
|
public WorkflowJsonTypeResolverTests()
|
||||||
|
{
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(ExceptionState), nameof(ExceptionState));
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(FaultException), nameof(FaultException));
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(RegisteredPayload), "RegisteredPayload");
|
||||||
|
_options = CreateOptions(_wellKnownTypeRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("String", typeof(string))]
|
||||||
|
[InlineData("String[]", typeof(string[]))]
|
||||||
|
[InlineData("String[][]", typeof(string[][]))]
|
||||||
|
[InlineData("List<String>", typeof(List<string>))]
|
||||||
|
[InlineData("List<String[]>", typeof(List<string[]>))]
|
||||||
|
[InlineData("List<List<String>>", typeof(List<List<string>>))]
|
||||||
|
[InlineData("ExceptionState", typeof(ExceptionState))]
|
||||||
|
[InlineData("FaultException", typeof(FaultException))]
|
||||||
|
[InlineData("ObjectDictionary", typeof(IDictionary<string, object>))]
|
||||||
|
public void When_DeserializeRegisteredTypeAlias_Then_ReturnsExpectedType(string typeAlias, Type expectedType)
|
||||||
|
{
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(JsonSerializer.Serialize(typeAlias), _options);
|
||||||
|
|
||||||
|
Assert.Equal(expectedType, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeRegisteredLegacyAssemblyQualifiedTypeAlias_Then_ReturnsExpectedType()
|
||||||
|
{
|
||||||
|
var typeAlias = typeof(RegisteredPayload).GetSimpleAssemblyQualifiedName();
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options);
|
||||||
|
|
||||||
|
Assert.Equal(typeof(RegisteredPayload), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeRegisteredLegacyGenericCollectionTypeAlias_Then_ReturnsExpectedType()
|
||||||
|
{
|
||||||
|
var typeAlias = typeof(List<RegisteredPayload>).GetSimpleAssemblyQualifiedName();
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options);
|
||||||
|
|
||||||
|
Assert.Equal(typeof(List<RegisteredPayload>), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(string), "String")]
|
||||||
|
[InlineData(typeof(string[]), "String[]")]
|
||||||
|
[InlineData(typeof(string[][]), "String[][]")]
|
||||||
|
[InlineData(typeof(List<string>), "List<String>")]
|
||||||
|
[InlineData(typeof(List<string[]>), "List<String[]>")]
|
||||||
|
[InlineData(typeof(List<List<string>>), "List<List<String>>")]
|
||||||
|
[InlineData(typeof(ExceptionState), "ExceptionState")]
|
||||||
|
[InlineData(typeof(FaultException), "FaultException")]
|
||||||
|
public void When_SerializeSupportedType_Then_EmitsAliasThatCanBeDeserialized(Type type, string expectedAlias)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(type, _options);
|
||||||
|
var alias = JsonSerializer.Deserialize<string>(json);
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(json, _options);
|
||||||
|
|
||||||
|
Assert.Equal(expectedAlias, alias);
|
||||||
|
Assert.Equal(type, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(IEnumerable<string>), "List<String>", typeof(List<string>))]
|
||||||
|
[InlineData(typeof(ICollection<string>), "List<String>", typeof(List<string>))]
|
||||||
|
[InlineData(typeof(IList<string>), "List<String>", typeof(List<string>))]
|
||||||
|
[InlineData(typeof(IReadOnlyCollection<string>), "List<String>", typeof(List<string>))]
|
||||||
|
[InlineData(typeof(IReadOnlyList<string>), "List<String>", typeof(List<string>))]
|
||||||
|
[InlineData(typeof(ISet<string>), "HashSet<String>", typeof(HashSet<string>))]
|
||||||
|
public void When_SerializeInterfaceCollectionType_Then_EmitsInstantiableAlias(Type type, string expectedAlias, Type expectedRoundTripType)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(type, _options);
|
||||||
|
var alias = JsonSerializer.Deserialize<string>(json);
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(json, _options);
|
||||||
|
|
||||||
|
Assert.Equal(expectedAlias, alias);
|
||||||
|
Assert.Equal(expectedRoundTripType, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(JsonIslandValues))]
|
||||||
|
public void When_SerializeSpecialJsonIslandType_Then_CanBeDeserialized(object value, Type expectedType)
|
||||||
|
{
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(JObject), nameof(JObject));
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(JArray), nameof(JArray));
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(value, _options);
|
||||||
|
var result = JsonSerializer.Deserialize<object>(json, _options);
|
||||||
|
|
||||||
|
Assert.IsType(expectedType, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(System.Text.StringBuilder))]
|
||||||
|
[InlineData(typeof(System.Text.StringBuilder[]))]
|
||||||
|
[InlineData(typeof(List<System.Text.StringBuilder>))]
|
||||||
|
public void When_SerializeUnsupportedType_Then_EmitsSafeUnregisteredTypeAlias(Type type)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(type, _options);
|
||||||
|
var alias = JsonSerializer.Deserialize<string>(json);
|
||||||
|
var result = JsonSerializer.Deserialize<Type>(json, _options);
|
||||||
|
|
||||||
|
Assert.StartsWith("UnregisteredClrType:", alias);
|
||||||
|
Assert.Equal(typeof(Exception), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_SerializeExceptionStateWithUnregisteredExceptionType_Then_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var exceptionState = ExceptionState.FromException(new NullReferenceException("Test"));
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(exceptionState, _options);
|
||||||
|
var result = JsonSerializer.Deserialize<ExceptionState>(json, _options)!;
|
||||||
|
|
||||||
|
Assert.Contains("UnregisteredClrType:", json);
|
||||||
|
Assert.Equal(typeof(Exception), result.Type);
|
||||||
|
Assert.Equal("Test", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_ConfigureWorkflowsFeature_Then_RegistersCoreAliases()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
var module = services.CreateModule();
|
||||||
|
module.UseWorkflows();
|
||||||
|
module.Apply();
|
||||||
|
using var serviceProvider = services.BuildServiceProvider();
|
||||||
|
var expressionOptions = serviceProvider.GetRequiredService<IOptions<ExpressionOptions>>();
|
||||||
|
var registry = new WellKnownTypeRegistry(expressionOptions);
|
||||||
|
|
||||||
|
var aliasRegistered = registry.TryGetAlias(typeof(NullReferenceException), out var alias);
|
||||||
|
var typeRegistered = registry.TryGetType(nameof(NullReferenceException), out var type);
|
||||||
|
|
||||||
|
Assert.True(aliasRegistered);
|
||||||
|
Assert.Equal(nameof(NullReferenceException), alias);
|
||||||
|
Assert.True(typeRegistered);
|
||||||
|
Assert.Equal(typeof(NullReferenceException), type);
|
||||||
|
Assert.True(registry.TryGetAlias(typeof(MemoryStorageDriver), out var memoryStorageDriverAlias));
|
||||||
|
Assert.Equal(nameof(MemoryStorageDriver), memoryStorageDriverAlias);
|
||||||
|
Assert.True(registry.TryGetType(nameof(MemoryStorageDriver), out var memoryStorageDriverType));
|
||||||
|
Assert.Equal(typeof(MemoryStorageDriver), memoryStorageDriverType);
|
||||||
|
Assert.True(registry.TryGetType(typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName(), out var legacyMemoryStorageDriverType));
|
||||||
|
Assert.Equal(typeof(MemoryStorageDriver), legacyMemoryStorageDriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_SerializePolymorphicObjectWithUnregisteredType_Then_OmitsTypeMetadata()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize<object>(new UnregisteredPayload { Name = "Alice" }, _options);
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<object>(json, _options);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"_type\"", json);
|
||||||
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object>>(result);
|
||||||
|
Assert.Equal("Alice", payload["name"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeUnknownAssemblyQualifiedTypeAlias_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<Type>(JsonString(UnsafeAssemblyQualifiedTypeAlias), _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeUnknownGenericElementTypeAlias_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
var typeAlias = $"List<{UnsafeAssemblyQualifiedTypeAlias}>";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<Type>(JsonString(typeAlias), _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializePolymorphicObjectWithRegisteredTypeAlias_Then_ReturnsTypedObject()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"name": "Alice",
|
||||||
|
"_type": "RegisteredPayload"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<object>(json, _options);
|
||||||
|
|
||||||
|
var payload = Assert.IsType<RegisteredPayload>(result);
|
||||||
|
Assert.Equal("Alice", payload.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("IEnumerable<String>", typeof(List<string>))]
|
||||||
|
[InlineData("ICollection<String>", typeof(List<string>))]
|
||||||
|
[InlineData("IList<String>", typeof(List<string>))]
|
||||||
|
[InlineData("IReadOnlyCollection<String>", typeof(List<string>))]
|
||||||
|
[InlineData("IReadOnlyList<String>", typeof(List<string>))]
|
||||||
|
[InlineData("ISet<String>", typeof(HashSet<string>))]
|
||||||
|
public void When_DeserializePolymorphicCollectionInterface_Then_ReturnsConcreteCollection(string typeAlias, Type expectedType)
|
||||||
|
{
|
||||||
|
var json = $$"""
|
||||||
|
{
|
||||||
|
"_items": ["Alice"],
|
||||||
|
"_type": "{{typeAlias}}"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<object>(json, _options);
|
||||||
|
|
||||||
|
Assert.IsType(expectedType, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializePolymorphicObjectWithNonInstantiableType_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(AbstractPayload), "AbstractPayload");
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"_type": "AbstractPayload"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializePolymorphicObjectWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
var json = $$"""
|
||||||
|
{
|
||||||
|
"capacity": 16,
|
||||||
|
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializePolymorphicObjectWithoutTypeJsonConverterAndUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
var options = CreatePolymorphicOnlyOptions(_wellKnownTypeRegistry);
|
||||||
|
var json = $$"""
|
||||||
|
{
|
||||||
|
"capacity": 16,
|
||||||
|
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<object>(json, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeDictionaryObjectPayloadWithRegisteredTypeAlias_Then_ReturnsTypedObjectValue()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"name": "Alice",
|
||||||
|
"_type": "RegisteredPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = JsonSerializer.Deserialize<IDictionary<string, object>>(json, _options)!;
|
||||||
|
|
||||||
|
var payload = Assert.IsType<RegisteredPayload>(result["payload"]);
|
||||||
|
Assert.Equal("Alice", payload.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_DeserializeDictionaryObjectPayloadWithUnknownAssemblyQualifiedType_Then_ThrowsJsonException()
|
||||||
|
{
|
||||||
|
var json = $$"""
|
||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"capacity": 16,
|
||||||
|
"_type": {{JsonString(UnsafeAssemblyQualifiedTypeAlias)}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<IDictionary<string, object>>(json, _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void When_RegistryChangesAfterLegacyResolutionAttempt_Then_LegacyResolutionUsesCurrentRegistry()
|
||||||
|
{
|
||||||
|
var typeAlias = typeof(LateRegisteredPayload).GetSimpleAssemblyQualifiedName();
|
||||||
|
Assert.False(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out _));
|
||||||
|
|
||||||
|
_wellKnownTypeRegistry.RegisterType(typeof(LateRegisteredPayload), "LateRegisteredPayload");
|
||||||
|
|
||||||
|
Assert.True(WorkflowJsonTypeResolver.TryResolveType(_wellKnownTypeRegistry, typeAlias, out var result));
|
||||||
|
Assert.Equal(typeof(LateRegisteredPayload), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonSerializerOptions CreateOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new PolymorphicObjectConverterFactory(wellKnownTypeRegistry),
|
||||||
|
new TypeJsonConverter(wellKnownTypeRegistry)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonSerializerOptions CreatePolymorphicOnlyOptions(WellKnownTypeRegistry wellKnownTypeRegistry) => new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new PolymorphicObjectConverterFactory(wellKnownTypeRegistry)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string JsonString(string value) => JsonSerializer.Serialize(value);
|
||||||
|
|
||||||
|
public static TheoryData<object, Type> JsonIslandValues() => new()
|
||||||
|
{
|
||||||
|
{ new JObject { ["name"] = "Alice" }, typeof(JObject) },
|
||||||
|
{ new JArray("Alice", "Bob"), typeof(JArray) },
|
||||||
|
{ new JsonObject { ["name"] = "Alice" }, typeof(JsonObject) },
|
||||||
|
{ new JsonArray("Alice", "Bob"), typeof(JsonArray) }
|
||||||
|
};
|
||||||
|
|
||||||
|
public sealed class RegisteredPayload
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UnregisteredPayload
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class AbstractPayload
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LateRegisteredPayload
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows.Memory;
|
||||||
|
using Elsa.Workflows.Models;
|
||||||
|
using Elsa.Workflows.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Core.UnitTests.Services;
|
||||||
|
|
||||||
|
public class VariableMapperTests
|
||||||
|
{
|
||||||
|
private readonly WellKnownTypeRegistry _registry = new();
|
||||||
|
private readonly VariableMapper _mapper;
|
||||||
|
|
||||||
|
public VariableMapperTests()
|
||||||
|
{
|
||||||
|
_registry.RegisterType(typeof(string), "String");
|
||||||
|
_registry.RegisterType(typeof(WorkflowStorageDriver), nameof(WorkflowStorageDriver));
|
||||||
|
_registry.RegisterType(typeof(MemoryStorageDriver), typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName());
|
||||||
|
_mapper = new(_registry, NullLogger<VariableMapper>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_ResolvesRegisteredVariableTypeAlias()
|
||||||
|
{
|
||||||
|
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", null));
|
||||||
|
|
||||||
|
Assert.IsType<Variable<string>>(variable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_ResolvesRegisteredStorageDriverAlias()
|
||||||
|
{
|
||||||
|
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", nameof(WorkflowStorageDriver)));
|
||||||
|
|
||||||
|
Assert.Equal(typeof(WorkflowStorageDriver), variable.StorageDriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_ResolvesRegisteredMemoryStorageDriverAssemblyQualifiedName()
|
||||||
|
{
|
||||||
|
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", typeof(MemoryStorageDriver).GetSimpleAssemblyQualifiedName()));
|
||||||
|
|
||||||
|
Assert.Equal(typeof(MemoryStorageDriver), variable.StorageDriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_DoesNotLoadUnregisteredStorageDriverAssemblyQualifiedName()
|
||||||
|
{
|
||||||
|
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", typeof(VariableMapperTests).AssemblyQualifiedName));
|
||||||
|
|
||||||
|
Assert.Null(variable.StorageDriverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_DoesNotUseRegisteredNonStorageDriverAliasAsStorageDriver()
|
||||||
|
{
|
||||||
|
_registry.RegisterType(typeof(string), "NotAStorageDriver");
|
||||||
|
|
||||||
|
var variable = _mapper.Map(new VariableModel("id", "name", "String", "value", "NotAStorageDriver"));
|
||||||
|
|
||||||
|
Assert.Null(variable.StorageDriverType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Elsa.Expressions.Contracts;
|
||||||
|
using Elsa.Expressions.Services;
|
||||||
using Elsa.Workflows.Helpers;
|
using Elsa.Workflows.Helpers;
|
||||||
using Elsa.Workflows.Runtime.Comparers;
|
using Elsa.Workflows.Runtime.Comparers;
|
||||||
using Elsa.Workflows.Runtime.Entities;
|
using Elsa.Workflows.Runtime.Entities;
|
||||||
|
|
@ -25,6 +27,8 @@ public class WorkflowTriggerEqualityComparerTests
|
||||||
/// A simple payload class that mimics real trigger payloads like HttpEndpointBookmarkPayload.
|
/// A simple payload class that mimics real trigger payloads like HttpEndpointBookmarkPayload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private record TestPayload(string Path, string Method);
|
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")]
|
[Fact(DisplayName = "Fresh and round-tripped triggers with identical logical content should be considered equal")]
|
||||||
public void FreshAndRoundTrippedTriggers_ShouldBeEqual()
|
public void FreshAndRoundTrippedTriggers_ShouldBeEqual()
|
||||||
|
|
@ -37,7 +41,7 @@ public class WorkflowTriggerEqualityComparerTests
|
||||||
var roundTrippedPayload = SimulatePayloadRoundTrip(freshPayload);
|
var roundTrippedPayload = SimulatePayloadRoundTrip(freshPayload);
|
||||||
var loadedTrigger = CreateTrigger("trigger-1", roundTrippedPayload);
|
var loadedTrigger = CreateTrigger("trigger-1", roundTrippedPayload);
|
||||||
|
|
||||||
var comparer = new WorkflowTriggerEqualityComparer();
|
var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var areEqual = comparer.Equals(freshTrigger, loadedTrigger);
|
var areEqual = comparer.Equals(freshTrigger, loadedTrigger);
|
||||||
|
|
@ -68,7 +72,7 @@ public class WorkflowTriggerEqualityComparerTests
|
||||||
var newTriggers = new List<StoredTrigger> { freshTrigger };
|
var newTriggers = new List<StoredTrigger> { freshTrigger };
|
||||||
|
|
||||||
// Act: this is exactly what TriggerIndexer.IndexTriggersInternalAsync does
|
// 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.
|
// Assert: the diff should find no changes.
|
||||||
// Before the fix, it reported Removed=[existingTrigger] and Added=[freshTrigger]
|
// 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\"}", freshJson);
|
||||||
Assert.Equal("{\"path\":\"/api/test\",\"method\":\"GET\"}", roundTrippedJson);
|
Assert.Equal("{\"path\":\"/api/test\",\"method\":\"GET\"}", roundTrippedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Comparer serializes typed payload properties using the registered type aliases")]
|
||||||
|
public void TypedPayloadProperties_ShouldSerializeWithRegisteredAliases()
|
||||||
|
{
|
||||||
|
var payload = new TypedPayload(typeof(NestedPayload), new NestedPayload("orders"));
|
||||||
|
var comparer = new WorkflowTriggerEqualityComparer(CreateTypeRegistry());
|
||||||
|
var trigger = CreateTrigger("trigger-1", payload);
|
||||||
|
var expectedAliasPayload = JsonSerializer.Deserialize<object>(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"payloadType": "NestedPayload",
|
||||||
|
"value": {
|
||||||
|
"name": "orders",
|
||||||
|
"_type": "NestedPayload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
PayloadSerializerOptions);
|
||||||
|
var assemblyQualifiedPayload = JsonSerializer.Deserialize<object>(
|
||||||
|
$$"""
|
||||||
|
{
|
||||||
|
"payloadType": "{{typeof(NestedPayload).AssemblyQualifiedName}}",
|
||||||
|
"value": {
|
||||||
|
"name": "orders",
|
||||||
|
"_type": "{{typeof(NestedPayload).AssemblyQualifiedName}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
PayloadSerializerOptions);
|
||||||
|
var missingTypePayload = JsonSerializer.Deserialize<object>(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"payloadType": "NestedPayload",
|
||||||
|
"value": {
|
||||||
|
"name": "orders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
PayloadSerializerOptions);
|
||||||
|
|
||||||
|
Assert.True(comparer.Equals(trigger, CreateTrigger("trigger-2", expectedAliasPayload!)));
|
||||||
|
Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-3", assemblyQualifiedPayload!)));
|
||||||
|
Assert.False(comparer.Equals(trigger, CreateTrigger("trigger-4", missingTypePayload!)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// IPayloadSerializer options: camelCase with case-insensitive deserialization.
|
/// IPayloadSerializer options: camelCase with case-insensitive deserialization.
|
||||||
|
|
@ -142,6 +190,15 @@ public class WorkflowTriggerEqualityComparerTests
|
||||||
return deserialized!;
|
return deserialized!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IWellKnownTypeRegistry CreateTypeRegistry()
|
||||||
|
{
|
||||||
|
var registry = WellKnownTypeRegistry.CreateDefault();
|
||||||
|
registry.RegisterType(typeof(TestPayload), nameof(TestPayload));
|
||||||
|
registry.RegisterType(typeof(TypedPayload), nameof(TypedPayload));
|
||||||
|
registry.RegisterType(typeof(NestedPayload), nameof(NestedPayload));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a StoredTrigger with default values that can be overridden.
|
/// Creates a StoredTrigger with default values that can be overridden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -163,4 +220,3 @@ public class WorkflowTriggerEqualityComparerTests
|
||||||
Payload = payload
|
Payload = payload
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Elsa.Expressions.Options;
|
||||||
|
using Elsa.Features.Attributes;
|
||||||
|
using Elsa.Features.Services;
|
||||||
|
using Elsa.Extensions;
|
||||||
|
using Elsa.Workflows;
|
||||||
|
using Elsa.Workflows.Activities;
|
||||||
|
using Elsa.Workflows.Features;
|
||||||
|
using Elsa.Workflows.Runtime.Options;
|
||||||
|
using Elsa.Workflows.Runtime.Providers;
|
||||||
|
using NSubstitute;
|
||||||
|
using RuntimeFeature = Elsa.Workflows.Runtime.Features.WorkflowRuntimeFeature;
|
||||||
|
using ShellRuntimeFeature = Elsa.Workflows.Runtime.ShellFeatures.WorkflowRuntimeFeature;
|
||||||
|
|
||||||
|
namespace Elsa.Workflows.Runtime.UnitTests.Features;
|
||||||
|
|
||||||
|
public class WorkflowRuntimeFeatureTests
|
||||||
|
{
|
||||||
|
private readonly RuntimeFeature _feature = new(Substitute.For<IModule>());
|
||||||
|
private readonly ShellRuntimeFeature _shellFeature = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => _feature.AddWorkflow(typeof(NotAWorkflow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(NonInstantiableWorkflowTypes))]
|
||||||
|
public void AddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => _feature.AddWorkflow(workflowType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShellAddWorkflow_Throws_WhenTypeDoesNotImplementWorkflow()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => _shellFeature.AddWorkflow(typeof(NotAWorkflow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(NonInstantiableWorkflowTypes))]
|
||||||
|
public void ShellAddWorkflow_Throws_WhenWorkflowTypeIsNotInstantiable(Type workflowType)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => _shellFeature.AddWorkflow(workflowType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddWorkflow_AllowsClosedGenericWorkflowType()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
|
||||||
|
_feature.AddWorkflow(workflowType);
|
||||||
|
|
||||||
|
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys);
|
||||||
|
Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShellAddWorkflow_AllowsClosedGenericWorkflowType()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
|
||||||
|
_shellFeature.AddWorkflow(workflowType);
|
||||||
|
|
||||||
|
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _shellFeature.Workflows.Keys);
|
||||||
|
Assert.Contains(workflowType.FullName!, _shellFeature.Workflows.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkflowsAdd_RegistersWorkflowTypeAlias()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
var options = new ExpressionOptions();
|
||||||
|
|
||||||
|
_feature.Workflows.Add(workflowType);
|
||||||
|
|
||||||
|
RegisterWorkflowTypeAliases(_feature, options);
|
||||||
|
|
||||||
|
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkflowsAdd_DoesNotThrow_WhenLegacyKeyAlreadyExists()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
_feature.Workflows.Add(workflowType.FullName!, _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>()));
|
||||||
|
|
||||||
|
_feature.Workflows.Add(workflowType);
|
||||||
|
|
||||||
|
Assert.Contains(workflowType.GetSimpleAssemblyQualifiedName(), _feature.Workflows.Keys);
|
||||||
|
Assert.Contains(workflowType.FullName!, _feature.Workflows.Keys);
|
||||||
|
Assert.Same(_feature.Workflows[workflowType.FullName!], _feature.Workflows[workflowType.GetSimpleAssemblyQualifiedName()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClrWorkflowsProvider_MaterializesWorkflowOnce_WhenCanonicalAndLegacyKeysExist()
|
||||||
|
{
|
||||||
|
CountingWorkflow.CreatedCount = 0;
|
||||||
|
var builder = Substitute.For<IWorkflowBuilder>();
|
||||||
|
var builderFactory = Substitute.For<IWorkflowBuilderFactory>();
|
||||||
|
var provider = new ClrWorkflowsProvider(
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new RuntimeOptions { Workflows = _feature.Workflows }),
|
||||||
|
builderFactory,
|
||||||
|
Substitute.For<IServiceProvider>());
|
||||||
|
builderFactory.CreateBuilder().Returns(builder);
|
||||||
|
builder.BuildWorkflowAsync(Arg.Any<CancellationToken>()).Returns(new Workflow());
|
||||||
|
|
||||||
|
_feature.Workflows.Add(typeof(CountingWorkflow));
|
||||||
|
var workflows = await provider.GetWorkflowsAsync();
|
||||||
|
|
||||||
|
Assert.Single(workflows);
|
||||||
|
Assert.Equal(1, CountingWorkflow.CreatedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShellWorkflowsAdd_RegistersWorkflowTypeAlias()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
var options = new ExpressionOptions();
|
||||||
|
|
||||||
|
_shellFeature.Workflows.Add(workflowType);
|
||||||
|
|
||||||
|
RegisterWorkflowTypeAliases(_shellFeature, options);
|
||||||
|
|
||||||
|
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkflowsAdd_Throws_WhenTypeDoesNotImplementWorkflow()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => _feature.Workflows.Add(typeof(NotAWorkflow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RuntimeFeature_DependsOnWorkflowsFeature()
|
||||||
|
{
|
||||||
|
var dependencyTypes = typeof(RuntimeFeature)
|
||||||
|
.GetCustomAttributes<DependsOnAttribute>()
|
||||||
|
.Select(x => x.Type);
|
||||||
|
|
||||||
|
Assert.Contains(typeof(WorkflowsFeature), dependencyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
var options = new ExpressionOptions();
|
||||||
|
_feature.AddWorkflow(workflowType);
|
||||||
|
_feature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>());
|
||||||
|
|
||||||
|
RegisterWorkflowTypeAliases(_feature, options);
|
||||||
|
|
||||||
|
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
|
||||||
|
Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShellRegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes()
|
||||||
|
{
|
||||||
|
var workflowType = typeof(GenericWorkflow<int>);
|
||||||
|
var options = new ExpressionOptions();
|
||||||
|
_shellFeature.AddWorkflow(workflowType);
|
||||||
|
_shellFeature.Workflows[typeof(NotAWorkflow).AssemblyQualifiedName!] = _ => new ValueTask<IWorkflow>(new GenericWorkflow<int>());
|
||||||
|
|
||||||
|
RegisterWorkflowTypeAliases(_shellFeature, options);
|
||||||
|
|
||||||
|
Assert.Equal(workflowType, options.AliasTypeDictionary[workflowType.GetSimpleAssemblyQualifiedName()]);
|
||||||
|
Assert.DoesNotContain(typeof(NotAWorkflow).AssemblyQualifiedName!, options.AliasTypeDictionary.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<Type> NonInstantiableWorkflowTypes() => new()
|
||||||
|
{
|
||||||
|
typeof(IWorkflow),
|
||||||
|
typeof(WorkflowBase),
|
||||||
|
typeof(GenericWorkflow<>)
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class NotAWorkflow
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GenericWorkflow<T> : IWorkflow
|
||||||
|
{
|
||||||
|
public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CountingWorkflow : IWorkflow
|
||||||
|
{
|
||||||
|
public static int CreatedCount { get; set; }
|
||||||
|
|
||||||
|
public CountingWorkflow()
|
||||||
|
{
|
||||||
|
CreatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask BuildAsync(IWorkflowBuilder builder, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterWorkflowTypeAliases(object feature, ExpressionOptions options)
|
||||||
|
{
|
||||||
|
feature.GetType()
|
||||||
|
.GetMethod("RegisterWorkflowTypeAliases", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(feature, new object[] { options });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue