diff --git a/Elsa.sln b/Elsa.sln index da59e9a5a..98441e50b 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -397,6 +397,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A7 doc\bounty\bounty-quality-standards.md = doc\bounty\bounty-quality-standards.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resilience", "resilience", "{CD7DC0D1-FFDC-417A-89BE-7F32408F583E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core", "src\modules\Elsa.Resilience.Core\Elsa.Resilience.Core.csproj", "{70593549-8B26-4D63-9857-6BA8BB3E31DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience", "src\modules\Elsa.Resilience\Elsa.Resilience.csproj", "{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -821,6 +827,14 @@ Global {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Debug|Any CPU.Build.0 = Debug|Any CPU {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB20B168-CD90-41F2-BE08-3D8C6F142459}.Release|Any CPU.Build.0 = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70593549-8B26-4D63-9857-6BA8BB3E31DB}.Release|Any CPU.Build.0 = Release|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -966,6 +980,9 @@ Global {8A050229-DB79-4E0B-9AFF-7565E87F2954} = {B08B4E00-C2AB-48F3-8389-449F42AEF179} {0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} {9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6} + {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index c3e315e26..8c9b023fe 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -30,6 +30,7 @@ using Elsa.MongoDb.Modules.Management; using Elsa.MongoDb.Modules.Runtime; using Elsa.MongoDb.Modules.Tenants; using Elsa.OpenTelemetry.Middleware; +using Elsa.Resilience.Extensions; using Elsa.Retention.Extensions; using Elsa.Retention.Models; using Elsa.Secrets.Extensions; @@ -105,9 +106,9 @@ const bool useTenantsFromConfiguration = true; const bool useSecrets = false; const bool disableVariableWrappers = false; const bool disableVariableCopying = false; -const bool useManualOtelInstrumentation = true; +const bool useManualOtelInstrumentation = false; -ObjectConverter.StrictMode = false; +ObjectConverter.StrictMode = true; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 91735a81b..306c906a5 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -271,5 +271,23 @@ "Url": "https://localhost:6001/webhooks/run-task" } ] + }, + "Resilience": { + "Strategies": [ + { + "$type": "HttpResilienceStrategy", + "Id": "1", + "DisplayName": "Retry 3 times", + "RetryCount": 3, + "BackoffFactor": 2.0 + }, + { + "$type": "HttpResilienceStrategy", + "Id": "2", + "DisplayName": "Retry 10 times", + "RetryCount": 10, + "BackoffFactor": 2.0 + } + ] } } diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index 4990ea81a..c16488325 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.ResilienceStrategies.Models; using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; using Elsa.Api.Client.Shared.Models; @@ -193,4 +194,23 @@ public static class ActivityExtensions /// Sets the commit state behavior for the specified activity. /// public static void SetCommitStrategy(this JsonObject activity, string? name) => activity.SetProperty(JsonValue.Create(name), "customProperties", "commitStrategyName"); + + + /// + /// Gets the resilience strategy for the specified activity. + /// + public static ResilienceStrategyConfig? GetResilienceStrategy(this JsonObject activity) + { + var node = activity.GetProperty("customProperties", "resilienceStrategy"); + return ResilienceStrategyConfig.Deserialize(node); + } + + /// + /// Sets the resilience strategy for the specified activity. + /// + public static void SetResilienceStrategy(this JsonObject activity, ResilienceStrategyConfig? config) + { + var node = config?.SerializeToNode(); + activity.SetProperty(node, "customProperties", "resilienceStrategy"); + } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index e6d846919..3446d0637 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -8,6 +8,7 @@ using Elsa.Api.Client.Resources.Features.Contracts; using Elsa.Api.Client.Resources.Identity.Contracts; using Elsa.Api.Client.Resources.IncidentStrategies.Contracts; using Elsa.Api.Client.Resources.LogPersistenceStrategies; +using Elsa.Api.Client.Resources.ResilienceStrategies.Contracts; using Elsa.Api.Client.Resources.Scripting.Contracts; using Elsa.Api.Client.Resources.StorageDrivers.Contracts; using Elsa.Api.Client.Resources.Tasks.Contracts; @@ -76,6 +77,7 @@ public static class DependencyInjectionExtensions services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); diff --git a/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs new file mode 100644 index 000000000..b198f054b --- /dev/null +++ b/src/clients/Elsa.Api.Client/Extensions/ResilienceStrategyExtensions.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Nodes; + +namespace Elsa.Api.Client.Extensions; + +public static class ResilienceStrategyExtensions +{ + public static string GetResilienceStrategyId(this JsonObject strategyJsonObject) + { + return strategyJsonObject.GetProperty("id")!; + } + + public static string GetResilienceStrategyDisplayName(this JsonObject strategyJsonObject) + { + return strategyJsonObject.GetProperty("displayName")!; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs new file mode 100644 index 000000000..9e02f85ae --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Contracts/IIncidentStrategiesApi.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; +using Elsa.Api.Client.Shared.Models; +using Refit; + +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Contracts; + +/// +/// Provides operations for managing and retrieving resilience strategies within the application. +/// +public interface IResilienceStrategiesApi +{ + /// + /// Retrieves a list of resilience strategies from the resilience strategies API endpoint. + /// + /// The category to filter the strategies by. + /// An optional token to cancel the operation. + /// A with the list of resilience strategies. + [Get("/resilience/strategies")] + Task> LisAsync(string category, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs new file mode 100644 index 000000000..136f3e2bd --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.Api.Client.Resources.Scripting.Models; +using Elsa.Api.Client.Serialization; + +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models; + +public class ResilienceStrategyConfig +{ + public ResilienceStrategyConfigMode Mode { get; set; } + public string? StrategyId { get; set; } + public Expression? Expression { get; set; } + + public JsonNode SerializeToNode() + { + return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } + + public static ResilienceStrategyConfig? Deserialize(JsonNode? node) + { + return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs new file mode 100644 index 000000000..489730c14 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ResilienceStrategies/Models/ResilienceStrategyConfigMode.cs @@ -0,0 +1,7 @@ +namespace Elsa.Api.Client.Resources.ResilienceStrategies.Models; + +public enum ResilienceStrategyConfigMode +{ + Identifier, + Expression, +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs index 130134880..718c7463b 100644 --- a/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs +++ b/src/clients/Elsa.Api.Client/Resources/Scripting/Models/Expression.cs @@ -20,7 +20,7 @@ public class Expression /// /// The type of the expression. /// The expression. - public Expression(string type, string? value = default) + public Expression(string type, string? value = null) { Type = type; Value = value; @@ -29,7 +29,7 @@ public class Expression /// /// Gets or sets the expression type. /// - public string Type { get; set; } = default!; + public string Type { get; set; } = null!; /// /// Gets or sets the value representing the expression. diff --git a/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs new file mode 100644 index 000000000..41b044930 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Serialization/SerializerOptions.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Api.Client.Serialization; + +/// +/// Provides options for serializing and deserializing objects. +/// +public static class SerializerOptions +{ + public static JsonSerializerOptions CommonSerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + /// + /// Gets the serializer options for the log persistence configuration. + /// + public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions); + + public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions); +} \ No newline at end of file diff --git a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs index 44f0fc533..afc859d91 100644 --- a/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs +++ b/src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs @@ -18,7 +18,7 @@ public static class DependencyInjectionExtensions /// /// Installs and configures the specified feature. If the feature was already installed, it is not added twice, which means it is safe to call this method multiple times. /// - public static IModule Use(this IModule module, Action? configure = default) where T: class, IFeature + public static IModule Use(this IModule module, Action? configure = null) where T: class, IFeature { module.Configure(configure); return module; diff --git a/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs new file mode 100644 index 000000000..8297401e7 --- /dev/null +++ b/src/modules/Elsa.Common/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +public static class ConfigurationExtensions +{ + public static string GetSectionAsJson(this IConfiguration configuration, string sectionKey, JsonSerializerOptions? options = null) + { + var section = configuration.GetSection(sectionKey); + var element = ConvertToJsonElement(section); + return JsonSerializer.Serialize(element, options); + } + + private static JsonElement ConvertToJsonElement(IConfigurationSection section) + { + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(ToObject(section))); + return doc.RootElement.Clone(); + } + + private static object? ToObject(IConfigurationSection section) + { + var children = section.GetChildren().ToList(); + if (!children.Any()) + return section.Value; + + if (children.All(c => int.TryParse(c.Key, out _))) + { + // Treat as array + return children + .OrderBy(c => int.Parse(c.Key)) + .Select(ToObject) + .ToList(); + } + + var dict = new Dictionary(); + foreach (var child in children) + { + dict[child.Key] = ToObject(child); + } + + return dict; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs index f55c1e6ca..13357ccc6 100644 --- a/src/modules/Elsa.Common/Serialization/SerializerOptions.cs +++ b/src/modules/Elsa.Common/Serialization/SerializerOptions.cs @@ -1,11 +1,26 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace Elsa.Common.Serialization; public static class SerializerOptions { + public static JsonSerializerOptions CommonSerializerOptions { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + public static JsonSerializerOptions ConfigurationJsonSerializerOptions { get; } = new() { Converters = { new ConfigurationJsonConverter() } }; + + /// + /// Gets the serializer options for the log persistence configuration. + /// + public static JsonSerializerOptions LogPersistenceConfigSerializerOptions { get; } = new(CommonSerializerOptions); + + public static JsonSerializerOptions ResilienceStrategyConfigSerializerOptions { get; } = new(CommonSerializerOptions); } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs index 698e1412c..7cc94cc36 100644 --- a/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs +++ b/src/modules/Elsa.Expressions/Helpers/ObjectConverter.cs @@ -178,7 +178,7 @@ public static class ObjectConverter var internalSerializerOptions = InternalSerializerOptions; - if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && underlyingTargetType.IsClass) + if (typeof(IDictionary).IsAssignableFrom(underlyingSourceType) && (underlyingTargetType.IsClass || underlyingTargetType.IsInterface)) { if (typeof(ExpandoObject) == underlyingTargetType) { diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs index 4f9fe747a..7b27571c4 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs @@ -11,7 +11,7 @@ namespace Elsa.Http; public class SendHttpRequest : SendHttpRequestBase { /// - public SendHttpRequest([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + public SendHttpRequest([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) { } diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 5140e8c2b..d922d1965 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using Elsa.Extensions; using Elsa.Http.ContentWriters; using Elsa.Http.UIHints; +using Elsa.Resilience; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.UIHints; @@ -16,7 +17,8 @@ namespace Elsa.Http; /// Base class for activities that send HTTP requests. /// [Output(IsSerializable = false)] -public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line) +[ResilienceCategory("HTTP")] +public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity(source, line), IResilientActivity { /// /// The URL to send the request to. @@ -94,6 +96,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul /// /// Indicates whether resiliency mechanisms should be enabled for the HTTP request. /// + [Obsolete("Use the common Resilience Strategy setting instead.")] + [Input(Description = "Obsolete. Use the common Resilience Strategy setting instead.")] public Input EnableResiliency { get; set; } = null!; /// @@ -145,7 +149,7 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul try { - var response = await SendRequestAsync(); + var response = await SendRequestAsync(context); var parsedContent = await ParseContentAsync(context, response); var statusCode = (int)response.StatusCode; var responseHeaders = new HttpHeaders(response.Headers); @@ -180,15 +184,17 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul return; - async Task SendRequestAsync() + async Task SendRequestAsync(ActivityExecutionContext activityExecutionContext) { + // Keep this for backward compatibility. if (resiliencyEnabled) { var pipeline = BuildResiliencyPipeline(context); return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken); } - - return await SendRequestAsyncCore(); + + var resilienceService = activityExecutionContext.GetRequiredService(); + return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyncCore(cancellationToken), cancellationToken); } async Task SendRequestAsyncCore(CancellationToken ct = default) diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index f190f061d..7901bd00d 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -11,10 +11,13 @@ + + + diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 085a5c4ab..80e0e5120 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -10,10 +10,13 @@ using Elsa.Http.Handlers; using Elsa.Http.Options; using Elsa.Http.Parsers; using Elsa.Http.PortResolvers; +using Elsa.Http.Resilience; using Elsa.Http.Selectors; using Elsa.Http.Services; using Elsa.Http.Tasks; using Elsa.Http.UIHints; +using Elsa.Resilience.Extensions; +using Elsa.Resilience.Features; using Elsa.Workflows; using FluentStorage; using Microsoft.AspNetCore.Http; @@ -28,6 +31,7 @@ namespace Elsa.Http.Features; /// Installs services related to HTTP services and activities. /// [DependsOn(typeof(HttpJavaScriptFeature))] +[DependsOn(typeof(ResilienceFeature))] public class HttpFeature(IModule module) : FeatureBase(module) { private Func _httpEndpointRouteProvider = sp => sp.GetRequiredService(); @@ -137,6 +141,8 @@ public class HttpFeature(IModule module) : FeatureBase(module) management.AddActivitiesFrom(); }); + + Module.UseResilience(resilience => resilience.AddResilienceStrategyType()); } /// diff --git a/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs new file mode 100644 index 000000000..c346a5773 --- /dev/null +++ b/src/modules/Elsa.Http/Resilience/HttpResilienceStrategy.cs @@ -0,0 +1,26 @@ +using Elsa.Resilience; +using Polly; +using Polly.Retry; + +namespace Elsa.Http.Resilience; + +[ResilienceCategory("HTTP")] +public class HttpResilienceStrategy : IResilienceStrategy +{ + public string Id { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public int RetryCount { get; set; } = 3; + public double BackoffFactor { get; set; } = 2.0; + + public async Task ExecuteAsync(Func> action) + { + AsyncRetryPolicy policy = Policy + .Handle() + .WaitAndRetryAsync( + RetryCount, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(BackoffFactor, retryAttempt)) + ); + + return await policy.ExecuteAsync(action); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs index c71bfb661..ed51cd4d2 100644 --- a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs +++ b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs @@ -1,9 +1,11 @@ using Elsa.Extensions; +using Elsa.Http.Resilience; using Elsa.JavaScript.Notifications; using Elsa.JavaScript.TypeDefinitions.Builders; using Elsa.JavaScript.TypeDefinitions.Contracts; using Elsa.JavaScript.TypeDefinitions.Models; using Elsa.Mediator.Contracts; +using Elsa.Workflows; using JetBrains.Annotations; namespace Elsa.Http.Scripting.JavaScript; @@ -30,6 +32,7 @@ public class HttpJavaScriptHandler : INotificationHandler, var engine = notification.Engine; engine.RegisterType(); engine.RegisterType(); + engine.RegisterType(); var activityExecutionContext = notification.Context; @@ -63,6 +66,7 @@ public class HttpJavaScriptHandler : INotificationHandler, { yield return _typeDescriber.DescribeType(typeof(HttpHeaders)); yield return _typeDescriber.DescribeType(typeof(Downloadable)); + yield return _typeDescriber.DescribeType(typeof(HttpResilienceStrategy)); } private IEnumerable GetFunctionDefinitions(TypeDefinitionContext context) diff --git a/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs new file mode 100644 index 000000000..4858669c0 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Attributes/ResilienceCategoryAttribute.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience; + +[AttributeUsage(AttributeTargets.Class)] +public class ResilienceCategoryAttribute(string category) : Attribute +{ + public string Category { get; } = category; +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs new file mode 100644 index 000000000..599f146bb --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategy.cs @@ -0,0 +1,8 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategy +{ + string Id { get; set; } + string DisplayName { get; set; } + Task ExecuteAsync(Func> action); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs new file mode 100644 index 000000000..51725949c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyCatalog.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategyCatalog +{ + Task> ListAsync(CancellationToken cancellationToken = default); + Task GetAsync(string id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs new file mode 100644 index 000000000..ef1d7a40c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategyConfigEvaluator.cs @@ -0,0 +1,9 @@ +using Elsa.Expressions.Models; +using Elsa.Resilience.Models; + +namespace Elsa.Resilience; + +public interface IResilienceStrategyConfigEvaluator +{ + Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs new file mode 100644 index 000000000..35cc05288 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilienceStrategySource.cs @@ -0,0 +1,6 @@ +namespace Elsa.Resilience; + +public interface IResilienceStrategySource +{ + Task> GetStrategiesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs new file mode 100644 index 000000000..d8b07e376 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivity.cs @@ -0,0 +1,5 @@ +using Elsa.Workflows; + +namespace Elsa.Resilience; + +public interface IResilientActivity : IActivity; \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs new file mode 100644 index 000000000..fd92fb28a --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Contracts/IResilientActivityInvoker.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows; + +namespace Elsa.Resilience; + +/// +/// Provides functionality to execute an activity's logic with a specified resilience strategy, or execute the action directly if no resilience configuration is applicable. +/// +public interface IResilientActivityInvoker +{ + /// + /// Invokes a resilient activity execution with the provided action using the activity's selected resilience strategy configuration. + /// If no resilience configuration is provided or otherwise does not evaluate to a strategy, the action is called as-is. + /// + /// The type of the result produced by the activity. + /// The resilient activity being invoked. + /// The execution context in which the activity runs. + /// The action representing the activity's execution logic. + /// A token to monitor for cancellation requests. + /// The result of the invoked action. + Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj new file mode 100644 index 000000000..d36195aae --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj @@ -0,0 +1,15 @@ + + + + + Provides core services and types for implementing resilient services and activities. + + elsa module resilience core + Elsa.Resilience + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings new file mode 100644 index 000000000..78381e8d4 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Elsa.Resilience.Core.csproj.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/FodyWeavers.xml b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs new file mode 100644 index 000000000..12e97902e --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.Common.Serialization; +using Elsa.Expressions.Models; + +namespace Elsa.Resilience.Models; + +public class ResilienceStrategyConfig +{ + public ResilienceStrategyConfigMode Mode { get; set; } + public string? StrategyId { get; set; } + public Expression? Expression { get; set; } + + public JsonNode SerializeToNode() + { + return JsonSerializer.SerializeToNode(this, SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } + + public static ResilienceStrategyConfig? Deserialize(JsonNode? node) + { + return node.Deserialize(SerializerOptions.ResilienceStrategyConfigSerializerOptions)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs new file mode 100644 index 000000000..e82490780 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Models/ResilienceStrategyConfigMode.cs @@ -0,0 +1,7 @@ +namespace Elsa.Resilience.Models; + +public enum ResilienceStrategyConfigMode +{ + Identifier, + Expression, +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs new file mode 100644 index 000000000..c7f8e9b0b --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Options/ResilienceOptions.cs @@ -0,0 +1,6 @@ +namespace Elsa.Resilience.Options; + +public class ResilienceOptions +{ + public ICollection StrategyTypes { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs new file mode 100644 index 000000000..451a791fa --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Serialization/ResilienceStrategySerializer.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Elsa.Common.Converters; +using Elsa.Resilience.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Resilience.Serialization; + +public class ResilienceStrategySerializer +{ + private readonly JsonSerializerOptions _serializerOptions; + + public ResilienceStrategySerializer(IOptions options) + { + var serializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo => + { + if (typeInfo.Type != typeof(IResilienceStrategy)) + return; + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + var polymorphismOptions = new JsonPolymorphismOptions + { + TypeDiscriminatorPropertyName = "$type" + }; + + foreach (var type in options.Value.StrategyTypes.ToList()) + polymorphismOptions.DerivedTypes.Add(new(type, type.Name)); + + typeInfo.PolymorphismOptions = polymorphismOptions; + }), + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + _serializerOptions = serializerOptions; + } + + public JsonSerializerOptions SerializerOptions => _serializerOptions; + public string Serialize(IResilienceStrategy strategy) => JsonSerializer.Serialize(strategy, _serializerOptions); + public string SerializeMany(IEnumerable strategies) => JsonSerializer.Serialize(strategies, _serializerOptions); + public IResilienceStrategy Deserialize(string json) => JsonSerializer.Deserialize(json, _serializerOptions)!; + public IEnumerable DeserializeMany(string json) => JsonSerializer.Deserialize>(json, _serializerOptions)!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs new file mode 100644 index 000000000..9ea52ccb7 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyCatalog.cs @@ -0,0 +1,31 @@ +namespace Elsa.Resilience; + +public class ResilienceStrategyCatalog : IResilienceStrategyCatalog +{ + private readonly Lazy>> _strategies; + private readonly IEnumerable _providers; + + public ResilienceStrategyCatalog(IEnumerable providers) + { + _providers = providers; + _strategies = new(GetStrategiesInternalAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } + + public Task> ListAsync(CancellationToken cancellationToken = default) + { + return _strategies.Value; + } + + public async Task GetAsync(string id, CancellationToken cancellationToken = default) + { + var strategies = await ListAsync(cancellationToken); + return strategies.FirstOrDefault(x => x.Id == id); + } + + private async Task> GetStrategiesInternalAsync() + { + var strategies = new List(); + foreach (var provider in _providers) strategies.AddRange(await provider.GetStrategiesAsync()); + return strategies; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs new file mode 100644 index 000000000..6d4e6f362 --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilienceStrategyConfigEvaluator.cs @@ -0,0 +1,51 @@ +using System.Dynamic; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Resilience.Models; +using Elsa.Resilience.Serialization; + +namespace Elsa.Resilience; + +public class ResilienceStrategyConfigEvaluator(IResilienceStrategyCatalog catalog, IExpressionEvaluator expressionEvaluator, ResilienceStrategySerializer strategySerializer) : IResilienceStrategyConfigEvaluator +{ + public async Task EvaluateAsync(ResilienceStrategyConfig? config, ExpressionExecutionContext context, CancellationToken cancellationToken = default) + { + if (config == null) return null; + + var mode = config.Mode; + + return mode switch + { + ResilienceStrategyConfigMode.Identifier => await ResolveByIdentifierAsync(config, cancellationToken), + ResilienceStrategyConfigMode.Expression => await ResolveByExpressionAsync(config, context, cancellationToken), + _ => null + }; + } + + private async Task ResolveByIdentifierAsync(ResilienceStrategyConfig config, CancellationToken cancellationToken = default) + { + var strategyId = config.StrategyId; + return string.IsNullOrWhiteSpace(strategyId) + ? null + : await catalog.GetAsync(strategyId, cancellationToken); + } + + private async Task ResolveByExpressionAsync(ResilienceStrategyConfig config, ExpressionExecutionContext context, CancellationToken cancellationToken = default) + { + var expression = config.Expression; + + if (expression == null) + return null; + + var result = await expressionEvaluator.EvaluateAsync(expression, context, ExpressionEvaluatorOptions.Empty); + + return result switch + { + string strategyId => await catalog.GetAsync(strategyId, cancellationToken), + IResilienceStrategy strategy => strategy, + ExpandoObject expando => expando.ConvertTo(new(strategySerializer.SerializerOptions)), + _ => null + }; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs new file mode 100644 index 000000000..cc1dcaade --- /dev/null +++ b/src/modules/Elsa.Resilience.Core/Services/ResilientActivityInvoker.cs @@ -0,0 +1,24 @@ +using Elsa.Expressions.Helpers; +using Elsa.Resilience.Models; +using Elsa.Workflows; + +namespace Elsa.Resilience; + +public class ResilientActivityInvoker(IResilienceStrategyConfigEvaluator configEvaluator) : IResilientActivityInvoker +{ + private const string ResilienceStrategyIdPropKey = "resilienceStrategy"; + + public async Task InvokeAsync(IResilientActivity activity, ActivityExecutionContext context, Func> action, CancellationToken cancellationToken = default) + { + var strategyConfig = GetStrategyConfig(activity); + var strategy = await configEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken); + return strategy == null ? await action() : await strategy.ExecuteAsync(action); + } + + private ResilienceStrategyConfig? GetStrategyConfig(IResilientActivity resilientActivity) + { + return !resilientActivity.CustomProperties.TryGetValue(ResilienceStrategyIdPropKey, out var value) + ? null + : value.ConvertTo(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Elsa.Resilience.csproj b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj new file mode 100644 index 000000000..6a2f5cf12 --- /dev/null +++ b/src/modules/Elsa.Resilience/Elsa.Resilience.csproj @@ -0,0 +1,15 @@ + + + + + Provides features and services for implementing resilient services and activities. + + elsa module resilience + + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs new file mode 100644 index 000000000..5398e10e0 --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/ResilienceStrategies/List/Endpoint.cs @@ -0,0 +1,23 @@ +using Elsa.Abstractions; +using Elsa.Models; +using Elsa.Resilience.Serialization; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Resilience.Endpoints.ResilienceStrategies.List; + +public class Endpoint(IResilienceStrategyCatalog catalog, ResilienceStrategySerializer serializer) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/resilience/strategies"); + ConfigurePermissions("read:*", "read:resilience-strategies"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var strategies = (await catalog.ListAsync(ct)).ToList(); + var response = new ListResponse(strategies); + + await HttpContext.Response.WriteAsJsonAsync(response, serializer.SerializerOptions, ct); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..e10e7c383 --- /dev/null +++ b/src/modules/Elsa.Resilience/Extensions/ModuleExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Resilience.Features; + +namespace Elsa.Resilience.Extensions; + +public static class ModuleExtensions +{ + public static IModule UseResilience(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs new file mode 100644 index 000000000..740d42e9d --- /dev/null +++ b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs @@ -0,0 +1,43 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Resilience.Modifiers; +using Elsa.Resilience.Options; +using Elsa.Resilience.Serialization; +using Elsa.Resilience.StrategySources; +using Elsa.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Resilience.Features; + +public class ResilienceFeature(IModule module) : FeatureBase(module) +{ + public ResilienceFeature AddResilienceStrategyType() where T : IResilienceStrategy + { + return AddResilienceStrategyType(typeof(T)); + } + + public ResilienceFeature AddResilienceStrategyType(Type strategyType) + { + Services.Configure(options => options.StrategyTypes.Add(strategyType)); + return this; + } + + public override void Configure() + { + Module.AddFastEndpointsAssembly(); + } + + public override void Apply() + { + Services.AddOptions(); + + Services + .AddSingleton() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/FodyWeavers.xml b/src/modules/Elsa.Resilience/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Resilience/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs new file mode 100644 index 000000000..b2a279fe5 --- /dev/null +++ b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs @@ -0,0 +1,29 @@ +using Elsa.Workflows; +using Elsa.Workflows.Models; + +namespace Elsa.Resilience.Modifiers; + +/// +/// Modifies the of the activity. +/// +public class ResilientActivityDescriptorModifier : IActivityDescriptorModifier +{ + /// + public void Modify(ActivityDescriptor descriptor) + { + if (!descriptor.CustomProperties.TryGetValue("Type", out var typeObj)) + return; + + if(typeObj is not Type type) + return; + + // Check if this type implements IResilientActivity. + if (!typeof(IResilientActivity).IsAssignableFrom(type)) + return; + + var categoryAttr = descriptor.Attributes.FirstOrDefault(x => x is ResilienceCategoryAttribute) as ResilienceCategoryAttribute; + var category = categoryAttr?.Category ?? "Default"; + descriptor.CustomProperties["Resilient"] = true; + descriptor.CustomProperties["ResilienceCategory"] = category; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs b/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs new file mode 100644 index 000000000..a73a9cb43 --- /dev/null +++ b/src/modules/Elsa.Resilience/StrategySources/ConfigurationResilienceStrategySource.cs @@ -0,0 +1,19 @@ +using Elsa.Extensions; +using Elsa.Resilience.Serialization; +using Microsoft.Extensions.Configuration; + +namespace Elsa.Resilience.StrategySources; + +public class ConfigurationResilienceStrategySource(IConfiguration configuration, ResilienceStrategySerializer serializer) : IResilienceStrategySource +{ + public Task> GetStrategiesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(GetStrategies()); + } + + private IEnumerable GetStrategies() + { + var json = configuration.GetSectionAsJson("Resilience:Strategies"); + return string.IsNullOrWhiteSpace(json) ? [] : serializer.DeserializeMany(json); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs index a80fabe55..dbf28921c 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityDescriptor.cs @@ -13,17 +13,17 @@ public class ActivityDescriptor /// /// The fully qualified name of the activity type. /// - public string TypeName { get; set; } = default!; + public string TypeName { get; set; } = null!; /// /// The namespace of the activity type. /// - public string Namespace { get; set; } = default!; + public string Namespace { get; set; } = null!; /// /// The name of the activity type. /// - public string Name { get; set; } = default!; + public string Name { get; set; } = null!; /// /// The version of the activity type. @@ -33,7 +33,7 @@ public class ActivityDescriptor /// /// The category of the activity type. /// - public string Category { get; set; } = default!; + public string Category { get; set; } = null!; /// /// The display name of the activity type. @@ -64,7 +64,7 @@ public class ActivityDescriptor /// Instantiates a concrete instance of an . /// [JsonIgnore] - public Func Constructor { get; set; } = default!; + public Func Constructor { get; set; } = null!; /// /// The kind of activity. diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs index f874bbeac..9533f4652 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs @@ -81,6 +81,10 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve IsStart = isStart, IsTerminal = isTerminal, Attributes = attributes, + CustomProperties = + { + ["Type"] = activityType + }, Constructor = context => { var activity = activityFactory.Create(activityType, context); @@ -143,7 +147,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve if (wrappedPropertyType.IsNullableType()) wrappedPropertyType = wrappedPropertyType.GetTypeOfNullable(); - + var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken); return new InputDescriptor @@ -183,7 +187,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve { return await DescribeOutputPropertiesAsync(GetOutputProperties(activityType), cancellationToken); } - + public static string GetUIHint(Type wrappedPropertyType, InputAttribute? inputAttribute = null) { if (inputAttribute?.UIHint != null) diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs index e35e71634..dbf2cd3d8 100644 --- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivityProvider.cs @@ -73,7 +73,7 @@ public class WorkflowDefinitionActivityProvider(IWorkflowDefinitionStore store, ports.Insert(0, rootPort); - return new ActivityDescriptor + return new() { TypeName = typeName, Name = typeName,