diff --git a/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs b/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs new file mode 100644 index 0000000000..5be75e7bd1 --- /dev/null +++ b/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs @@ -0,0 +1,50 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; + +namespace Elsa.Server.Web.ActivityHosts; + +/// +/// A sample activity host that represents a penguin. +/// Each of its public methods is an activity that can be executed. +/// Method arguments are interpreted as input values, except for ActivityExecutionContext and CancellationToken. +/// +/// +[UsedImplicitly] +public class Penguin(ILogger logger) +{ + [Activity(Description = "Wag the penguin")] + public void Wag() + { + logger.LogInformation("The penguin is wagging!"); + } + + public void Jump() + { + logger.LogInformation("The penguin is jumping!"); + } + + public void Swim() + { + logger.LogInformation("The penguin is swimming!"); + } + + public void Eat(string food) + { + logger.LogInformation($"The penguin is eating {food}!"); + } + + public string Sleep(ActivityExecutionContext context) + { + logger.LogInformation("The penguin is sleeping!"); + var bookmark = context.CreateBookmark(Wake); + return context.GenerateBookmarkTriggerToken(bookmark.Id); + } + + private ValueTask Wake(ActivityExecutionContext context) + { + logger.LogInformation("The penguin woke up!"); + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index d42fea8bce..45c7a17931 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -7,6 +7,8 @@ using Elsa.Persistence.EFCore.Extensions; using Elsa.Persistence.EFCore.Modules.Management; using Elsa.Persistence.EFCore.Modules.Runtime; +using Elsa.Server.Web.Activities; +using Elsa.Server.Web.ActivityHosts; using Elsa.Server.Web.Filters; using Elsa.Tenants.AspNetCore; using Elsa.Tenants.Extensions; @@ -44,6 +46,7 @@ { elsa .AddActivitiesFrom() + .AddActivityHost() .AddWorkflowsFrom() .UseIdentity(identity => { diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 3bdf4969d8..7fd3ed9157 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -4,7 +4,8 @@ "Default": "Warning", "Microsoft.Hosting.Lifetime": "Information", "Elsa": "Warning", - "Elsa.Workflows.ActivityRegistry": "Error" + "Elsa.Workflows.ActivityRegistry": "Error", + "Elsa.Server.Web.ActivityHosts": "Information" } }, "HostBuilder": { diff --git a/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs index c509b1f84d..489922fd1b 100644 --- a/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs @@ -14,66 +14,71 @@ namespace Elsa.Extensions; /// public static class BookmarkExecutionContextExtensions { - public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, TimeSpan lifetime) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, lifetime); - public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, DateTimeOffset expiresAt) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, expiresAt); - public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId); - - /// - /// Generates a URL that can be used to resume a bookmarked workflow. - /// - /// The expression execution context. - /// The ID of the bookmark to resume. - /// The lifetime of the bookmark trigger token. - /// A URL that can be used to resume a bookmarked workflow. - public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, TimeSpan lifetime) + extension(ActivityExecutionContext context) { - var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, lifetime); - return context.GenerateBookmarkTriggerUrlInternal(token); + public string GenerateBookmarkTriggerUrl(string bookmarkId, TimeSpan lifetime) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, lifetime); + public string GenerateBookmarkTriggerUrl(string bookmarkId, DateTimeOffset expiresAt) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, expiresAt); + public string GenerateBookmarkTriggerUrl(string bookmarkId) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId); + public string GenerateBookmarkTriggerToken(string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null) => context.ExpressionExecutionContext.GenerateBookmarkTriggerToken(bookmarkId, lifetime, expiresAt); } - /// - /// Generates a URL that can be used to resume a bookmarked workflow. - /// /// The expression execution context. - /// The ID of the bookmark to resume. - /// The expiration date of the bookmark trigger token. - /// A URL that can be used to resume a bookmarked workflow. - public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, DateTimeOffset expiresAt) + extension(ExpressionExecutionContext context) { - var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, expiresAt: expiresAt); - return context.GenerateBookmarkTriggerUrlInternal(token); - } + /// + /// Generates a URL that can be used to resume a bookmarked workflow. + /// + /// The ID of the bookmark to resume. + /// The lifetime of the bookmark trigger token. + /// A URL that can be used to resume a bookmarked workflow. + public string GenerateBookmarkTriggerUrl(string bookmarkId, TimeSpan lifetime) + { + var token = context.GenerateBookmarkTriggerToken(bookmarkId, lifetime); + return context.GenerateBookmarkTriggerUrlInternal(token); + } - /// - /// Generates a URL that can be used to resume a bookmarked workflow. - /// - /// The expression execution context. - /// The ID of the bookmark to resume. - /// A URL that can be used to trigger an event. - public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId) - { - var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId); - return context.GenerateBookmarkTriggerUrlInternal(token); - } + /// + /// Generates a URL that can be used to resume a bookmarked workflow. + /// + /// The ID of the bookmark to resume. + /// The expiration date of the bookmark trigger token. + /// A URL that can be used to resume a bookmarked workflow. + public string GenerateBookmarkTriggerUrl(string bookmarkId, DateTimeOffset expiresAt) + { + var token = context.GenerateBookmarkTriggerToken(bookmarkId, expiresAt: expiresAt); + return context.GenerateBookmarkTriggerUrlInternal(token); + } - private static string GenerateBookmarkTriggerUrlInternal(this ExpressionExecutionContext context, string token) - { - var options = context.GetRequiredService>().Value; - var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}"; - var absoluteUrlProvider = context.GetRequiredService(); - return absoluteUrlProvider.ToAbsoluteUrl(url).ToString(); - } + /// + /// Generates a URL that can be used to resume a bookmarked workflow. + /// + /// The ID of the bookmark to resume. + /// A URL that can be used to trigger an event. + public string GenerateBookmarkTriggerUrl(string bookmarkId) + { + var token = context.GenerateBookmarkTriggerToken(bookmarkId); + return context.GenerateBookmarkTriggerUrlInternal(token); + } - private static string GenerateBookmarkTriggerTokenInternal(this ExpressionExecutionContext context, string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null) - { - var workflowInstanceId = context.GetWorkflowExecutionContext().Id; - var payload = new BookmarkTokenPayload(bookmarkId, workflowInstanceId); - var tokenService = context.GetRequiredService(); + public string GenerateBookmarkTriggerToken(string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null) + { + var workflowInstanceId = context.GetWorkflowExecutionContext().Id; + var payload = new BookmarkTokenPayload(bookmarkId, workflowInstanceId); + var tokenService = context.GetRequiredService(); - return lifetime != null - ? tokenService.CreateToken(payload, lifetime.Value) - : expiresAt != null - ? tokenService.CreateToken(payload, expiresAt.Value) - : tokenService.CreateToken(payload); + return lifetime != null + ? tokenService.CreateToken(payload, lifetime.Value) + : expiresAt != null + ? tokenService.CreateToken(payload, expiresAt.Value) + : tokenService.CreateToken(payload); + } + + private string GenerateBookmarkTriggerUrlInternal(string token) + { + var options = context.GetRequiredService>().Value; + var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}"; + var absoluteUrlProvider = context.GetRequiredService(); + return absoluteUrlProvider.ToAbsoluteUrl(url).ToString(); + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs index cef1d0b41c..1e8ef2cd29 100644 --- a/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs +++ b/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs @@ -1,6 +1,6 @@ namespace Elsa.Workflows.Attributes; -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method)] public class ActivityAttribute : Attribute { public ActivityAttribute() diff --git a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs index 4b5e7520d7..651659ba15 100644 --- a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs +++ b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs @@ -5,35 +5,71 @@ namespace Elsa.Workflows.Models; /// /// A bookmark represents a location in a workflow where the workflow can be resumed at a later time. /// -/// The ID of the bookmark. -/// The name of the bookmark. -/// The hash of the bookmark. -/// The data associated with the bookmark. -/// The ID of the activity associated with the bookmark. -/// The ID of the activity node associated with the bookmark. -/// The ID of the activity instance associated with the bookmark. -/// The date and time the bookmark was created. -/// Whether or not the bookmark should be automatically burned. -/// The name of the method on the activity class to invoke when the bookmark is resumed. -/// Whether or not the activity should be automatically completed when the bookmark is resumed. -/// Custom properties associated with the bookmark. -public record Bookmark( - string Id, - string Name, - string Hash, - object? Payload, - string ActivityId, - string ActivityNodeId, - string? ActivityInstanceId, - DateTimeOffset CreatedAt, - bool AutoBurn = true, - string? CallbackMethodName = null, - bool AutoComplete = true, - IDictionary? Metadata = null) +/// The ID of the bookmark. +/// The name of the bookmark. +/// The hash of the bookmark. +/// The data associated with the bookmark. +/// The ID of the activity associated with the bookmark. +/// The ID of the activity node associated with the bookmark. +/// The ID of the activity instance associated with the bookmark. +/// The date and time the bookmark was created. +/// Whether or not the bookmark should be automatically burned. +/// The name of the method on the activity class to invoke when the bookmark is resumed. +/// Whether or not the activity should be automatically completed when the bookmark is resumed. +/// Custom properties associated with the bookmark. +public class Bookmark( + string id, + string name, + string hash, + object? payload, + string activityId, + string activityNodeId, + string? activityInstanceId, + DateTimeOffset createdAt, + bool autoBurn = true, + string? callbackMethodName = null, + bool autoComplete = true, + IDictionary? metadata = null) { /// [JsonConstructor] public Bookmark() : this("", "", "", null, "", "", "", default, false) { } + + /// The ID of the bookmark. + public string Id { get; set; } = id; + + /// The name of the bookmark. + public string Name { get; set; } = name; + + /// The hash of the bookmark. + public string Hash { get; set; } = hash; + + /// The data associated with the bookmark. + public object? Payload { get; set; } = payload; + + /// The ID of the activity associated with the bookmark. + public string ActivityId { get; set; } = activityId; + + /// The ID of the activity node associated with the bookmark. + public string ActivityNodeId { get; set; } = activityNodeId; + + /// The ID of the activity instance associated with the bookmark. + public string? ActivityInstanceId { get; set; } = activityInstanceId; + + /// The date and time the bookmark was created. + public DateTimeOffset CreatedAt { get; set; } = createdAt; + + /// Whether the bookmark should be automatically burned. + public bool AutoBurn { get; set; } = autoBurn; + + /// The name of the method on the activity class to invoke when the bookmark is resumed. + public string? CallbackMethodName { get; set; } = callbackMethodName; + + /// Whether the activity should be automatically completed when the bookmark is resumed. + public bool AutoComplete { get; set; } = autoComplete; + + /// Custom properties associated with the bookmark. + public IDictionary? Metadata { get; set; } = metadata; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs new file mode 100644 index 0000000000..3ed0306e95 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using System.Dynamic; +using System.Reflection; +using System.Text.Json.Serialization; +using Elsa.Expressions.Helpers; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.Management.Activities.HostMethod; + +/// +/// Executes a public async method on a configured CLR type. Internal activity used by . +/// +[Browsable(false)] +public class HostMethodActivity : Activity +{ + [JsonIgnore] internal Type HostType { get; set; } = null!; + [JsonIgnore] internal string MethodName { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var method = ResolveMethod(MethodName); + await ExecuteInternalAsync(context, method); + } + + private async ValueTask ResumeAsync(ActivityExecutionContext context) + { + var metadata = context.WorkflowExecutionContext.ResumedBookmarkContext?.Bookmark.Metadata; + if (metadata != null) + { + var callbackMethodName = metadata["HostMethodActivityResumeCallback"]; + var callbackMethod = ResolveMethod(callbackMethodName); + await ExecuteInternalAsync(context, callbackMethod); + } + } + + private async Task ExecuteInternalAsync(ActivityExecutionContext context, MethodInfo method) + { + var cancellationToken = context.CancellationToken; + var activityDescriptor = context.ActivityDescriptor; + var inputDescriptors = activityDescriptor.Inputs.ToList(); + var serviceProvider = context.GetRequiredService(); + var hostInstance = ActivatorUtilities.CreateInstance(serviceProvider, HostType); + var args = await BuildArgumentsAsync(method, inputDescriptors, context, serviceProvider, cancellationToken); + var currentBookmarks = context.Bookmarks.ToList(); + + ApplyPropertyInputs(hostInstance, inputDescriptors, context); + + var resultValue = await InvokeAndGetResultAsync(hostInstance, method, args); + SetOutput(activityDescriptor, resultValue, context); + + // By convention, if no bookmarks are created, complete the activity. This may change in the future when we expose more control to the host type. + var addedBookmarks = context.Bookmarks.Except(currentBookmarks).ToList(); + if (!addedBookmarks.Any()) + { + await context.CompleteActivityAsync(); + return; + } + + // If bookmarks were created, overwrite the resume callback. We need to invoke the callback provided by the host type. + foreach (var bookmark in addedBookmarks) + { + var callbackMethodName = bookmark.CallbackMethodName; + + if (string.IsNullOrWhiteSpace(callbackMethodName)) + continue; + + bookmark.CallbackMethodName = nameof(ResumeAsync); + var metadata = bookmark.Metadata ?? new Dictionary(); + + metadata["HostMethodActivityResumeCallback"] = callbackMethodName; + bookmark.Metadata = metadata; + } + } + + private MethodInfo ResolveMethod(string methodName) + { + var method = HostType.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + if (method == null) + throw new InvalidOperationException($"Method '{methodName}' not found on type '{HostType.Name}'."); + + return method; + } + + private async ValueTask BuildArgumentsAsync(MethodInfo method, IReadOnlyCollection inputDescriptors, ActivityExecutionContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var parameters = method.GetParameters(); + var args = new object?[parameters.Length]; + + // Allow multiple providers; call in order. + var providers = serviceProvider.GetServices().ToList(); + if (providers.Count == 0) + providers.Add(new DefaultHostMethodParameterValueProvider()); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + var providerContext = new HostMethodParameterValueProviderContext( + serviceProvider, + context, + inputDescriptors, + this, + parameter, + cancellationToken); + + var handled = false; + object? value = null; + + foreach (var provider in providers) + { + var result = await provider.GetValueAsync(providerContext); + if (!result.Handled) + continue; + + handled = true; + value = result.Value; + break; + } + + if (handled) + { + args[i] = value; + continue; + } + + // No provider handled it: fall back to parameter default value (if any). + args[i] = parameter.HasDefaultValue ? parameter.DefaultValue : null; + } + + return args; + } + + private void ApplyPropertyInputs(object hostInstance, IReadOnlyCollection inputDescriptors, ActivityExecutionContext context) + { + var hostPropertyLookup = HostType.GetProperties().ToDictionary(x => x.Name, x => x); + + foreach (var inputDescriptor in inputDescriptors) + { + if (!hostPropertyLookup.TryGetValue(inputDescriptor.Name, out var prop) || !prop.CanWrite) + continue; + + var input = (Input?)inputDescriptor.ValueGetter(this); + var inputValue = input != null ? context.Get(input.MemoryBlockReference()) : null; + inputValue = ConvertIfNeeded(inputValue, prop.PropertyType); + + prop.SetValue(hostInstance, inputValue); + } + } + + private async Task InvokeAndGetResultAsync(object hostInstance, MethodInfo method, object?[] args) + { + var invocationResult = method.Invoke(hostInstance, args); + + // Synchronous methods. + if (invocationResult is not Task task) + { + return method.ReturnType == typeof(void) ? null : invocationResult; + } + + await task; + + // Task. + if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var resultProperty = task.GetType().GetProperty("Result"); + return resultProperty?.GetValue(task); + } + + // Task. + return null; + } + + private void SetOutput(ActivityDescriptor activityDescriptor, object? resultValue, ActivityExecutionContext context) + { + var outputDescriptor = activityDescriptor.Outputs.SingleOrDefault(); + if (outputDescriptor == null) + return; + + var output = (Output?)outputDescriptor.ValueGetter(this); + context.Set(output, resultValue, outputDescriptor.Name); + } + + private static object? ConvertIfNeeded(object? value, Type targetType) + { + if (value is ExpandoObject expandoObject) + return expandoObject.ConvertTo(targetType); + + return value; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs new file mode 100644 index 0000000000..34641f2833 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs @@ -0,0 +1,30 @@ +using Elsa.Workflows.Management.Options; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace Elsa.Workflows.Management.Activities.HostMethod; + +/// +/// Provides activities for each configured host method type registered via . +/// Public instance methods of the configured types are exposed as activities (as determined by ). +/// Inputs come from public properties and method parameters. +/// +[UsedImplicitly] +public class HostMethodActivityProvider(IOptions options, IHostMethodActivityDescriber hostMethodActivityDescriber) : IActivityProvider +{ + public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + var descriptors = new List(); + + foreach (var kvp in options.Value.ActivityTypes) + { + var key = kvp.Key; + var type = kvp.Value; + var methodDescriptors = await hostMethodActivityDescriber.DescribeAsync(key, type, cancellationToken); + descriptors.AddRange(methodDescriptors); + } + + return descriptors; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs b/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs new file mode 100644 index 0000000000..415269f51d --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs @@ -0,0 +1,10 @@ +namespace Elsa.Workflows.Management.Attributes; + +/// +/// Indicates that a host method parameter should be resolved from the service provider instead of from workflow inputs. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class FromServicesAttribute : Attribute +{ +} + diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs new file mode 100644 index 0000000000..028124eb17 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.Management; + +/// +/// Provides descriptions of workflow activities that are backed by methods on a host type. +/// +/// +/// Implement this interface to translate methods on a host type into instances +/// that can be used by the workflow runtime and design-time tooling. Implementations are responsible for +/// inspecting the specified host type and methods, and returning descriptors that describe how they should be +/// represented and configured as workflow activities. +/// +public interface IHostMethodActivityDescriber +{ + /// + /// Describes all workflow activities exposed by the specified host type for the given key. + /// + /// A classifier used to select or group host methods (for example, a category or provider key). + /// The host type whose methods should be described as workflow activities. + /// A token that can be used to cancel the asynchronous operation. + /// + /// A task that, when completed, contains the collection of instances + /// describing the applicable host methods. + /// + Task> DescribeAsync(string key, Type hostType, CancellationToken cancellationToken = default); + + /// + /// Describes a single workflow activity backed by the specified host method. + /// + /// A classifier used to select or group host methods (for example, a category or provider key). + /// The host type that declares the method to describe. + /// The method that should be described as a workflow activity. + /// A token that can be used to cancel the asynchronous operation. + /// + /// A task that, when completed, contains the describing the specified method. + /// + Task DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs new file mode 100644 index 0000000000..2ff545ac15 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs @@ -0,0 +1,40 @@ +using System.Reflection; + +namespace Elsa.Workflows.Management; + +/// +/// Provides an extensibility hook to resolve values for host method parameters. +/// Implementations can decide to resolve from workflow inputs, DI, context, or any other source. +/// +public interface IHostMethodParameterValueProvider +{ + /// + /// Attempts to provide a value for the specified parameter. + /// Return a handled result when a value was provided (including null), otherwise return + /// to let other providers handle it. + /// + ValueTask GetValueAsync(HostMethodParameterValueProviderContext context); +} + +/// +/// Result returned by . +/// +public readonly record struct HostMethodParameterValueProviderResult(bool Handled, object? Value) +{ + public static HostMethodParameterValueProviderResult Unhandled { get; } = new(false, null); + public static HostMethodParameterValueProviderResult HandledValue(object? value) => new(true, value); +} + +/// +/// Context passed to . +/// +public record HostMethodParameterValueProviderContext( + IServiceProvider ServiceProvider, + ActivityExecutionContext ActivityExecutionContext, + IReadOnlyCollection InputDescriptors, + IActivity Activity, + ParameterInfo Parameter, + CancellationToken CancellationToken) +{ + public string ParameterName => Parameter.Name ?? string.Empty; +} diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs index ccb31472b4..55e72f630e 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs @@ -51,6 +51,11 @@ public static WorkflowManagementFeature UseWorkflowInstances(this WorkflowManage /// Adds the specified activity type to the system. /// public static IModule AddActivity(this IModule module) where T : IActivity => module.UseWorkflowManagement(management => management.AddActivity()); + + /// + /// Registers the specified activity host type to the workflow system. + /// + public static IModule AddActivityHost(this IModule module) where T : class => module.UseWorkflowManagement(management => management.AddActivityHost()); /// /// Removes the specified activity type from the system. diff --git a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs index d26e07ea46..5998de6358 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs @@ -1,6 +1,5 @@ using Elsa.Workflows.Management; using Elsa.Workflows.Management.Entities; -using Elsa.Workflows.Management.Filters; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; diff --git a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs index df939d6cc3..4d50bc97db 100644 --- a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs @@ -1,6 +1,5 @@ using Elsa.Features.Abstractions; using Elsa.Features.Services; -using Elsa.Workflows.Management.Handlers; using Elsa.Workflows.Management.Handlers.Notifications; using Elsa.Workflows.Management.Services; using Elsa.Workflows.Management.Stores; diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 589b2e8475..4740bf1e52 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -14,6 +14,7 @@ using Elsa.Features.Services; using Elsa.Workflows.Features; using Elsa.Workflows.LogPersistence; +using Elsa.Workflows.Management.Activities.HostMethod; using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Entities; @@ -139,6 +140,28 @@ public WorkflowManagementFeature RemoveActivity(Type activityType) return this; } + /// + /// Configures the system to add a specific activity host type to the workflow management feature. + /// + /// The type of the activity host to be added. + /// An optional unique key to associate with the activity host type. + public WorkflowManagementFeature AddActivityHost(string? key = null) where T : class + { + Module.Services.Configure(options => options.AddType(key)); + return this; + } + + /// + /// Configures the system to add a specific activity host type to the workflow management feature. + /// + /// The type of the activity host to be added. + /// An optional unique key to associate with the activity host type. + public WorkflowManagementFeature AddActivityHost(Type hostType, string? key = null) + { + Module.Services.Configure(options => options.AddType(hostType, key)); + return this; + } + /// /// Adds the specified variable type to the system. /// @@ -222,35 +245,38 @@ public override void Configure() public override void Apply() { Services - .AddMemoryStore() - .AddMemoryStore() - .AddActivityProvider() - .AddActivityProvider() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(_workflowReferenceQuery) - .AddScoped(_workflowDefinitionPublisher) - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddSingleton() - .AddSingleton() - .AddSerializationOptionsConfigurator() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddSingleton() - .AddSingleton() - ; + .AddMemoryStore() + .AddMemoryStore() + .AddActivityProvider() + .AddActivityProvider() + .AddActivityProvider() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(_workflowReferenceQuery) + .AddScoped(_workflowDefinitionPublisher) + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton() + .AddSingleton() + .AddSerializationOptionsConfigurator() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton() + .AddSingleton() + ; Services .AddNotificationHandler() @@ -271,5 +297,7 @@ public override void Apply() options.LogPersistenceMode = LogPersistenceMode; options.IsReadOnlyMode = IsReadOnlyMode; }); + + Services.Configure(_ => { }); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs b/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs new file mode 100644 index 0000000000..b9be50c386 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs @@ -0,0 +1,36 @@ +namespace Elsa.Workflows.Management.Options; + +/// +/// Represents the options for managing host method-based activities in workflows. +/// +public class HostMethodActivitiesOptions +{ + /// + /// Maps a registration key to a CLR type whose public async methods should be exposed as activities. + /// + public IDictionary ActivityTypes { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a new activity type to the collection of activity types. + /// + /// The type of the activity to add. + /// An optional key to associate with the activity type. If not provided, the type's name will be used. + public HostMethodActivitiesOptions AddType(string? key = null) where T : class + { + key ??= typeof(T).Name; + ActivityTypes[key] = typeof(T); + return this; + } + + /// + /// Adds a new activity type to the collection of activity types. + /// + /// The type of the activity to add. + /// An optional key to associate with the activity type. If not provided, the type's name will be used. + public HostMethodActivitiesOptions AddType(Type type, string? key = null) + { + key ??= type.Name; + ActivityTypes[key] = type; + return this; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs index 572692cc57..80c2a4e353 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using Elsa.Expressions; using Elsa.Expressions.Contracts; diff --git a/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs new file mode 100644 index 0000000000..00348ee9fd --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using Elsa.Expressions.Helpers; +using Elsa.Workflows.Management.Attributes; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.Management.Services; + +/// +/// Default parameter binding implementation for host method activities. +/// +public class DefaultHostMethodParameterValueProvider : IHostMethodParameterValueProvider +{ + public ValueTask GetValueAsync(HostMethodParameterValueProviderContext context) + { + var parameter = context.Parameter; + + // Provided by runtime. + if (parameter.ParameterType == typeof(CancellationToken)) + return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(context.CancellationToken)); + + if (parameter.ParameterType == typeof(ActivityExecutionContext)) + return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(context.ActivityExecutionContext)); + + // Resolve from DI if explicitly requested. + if (parameter.GetCustomAttribute() != null) + { + var service = context.ServiceProvider.GetRequiredService(parameter.ParameterType); + return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(service)); + } + + // Resolve from workflow inputs (default). + var inputDescriptor = context.InputDescriptors.FirstOrDefault(x => string.Equals(x.Name, parameter.Name, StringComparison.OrdinalIgnoreCase)); + if (inputDescriptor == null) + return ValueTask.FromResult(HostMethodParameterValueProviderResult.Unhandled); + + var input = (Input?)inputDescriptor.ValueGetter(context.Activity); + var inputValue = input != null ? context.ActivityExecutionContext.Get(input.MemoryBlockReference()) : null; + + if (inputValue is System.Dynamic.ExpandoObject expandoObject) + inputValue = expandoObject.ConvertTo(parameter.ParameterType); + + return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(inputValue)); + } +} diff --git a/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs new file mode 100644 index 0000000000..ffce2b8469 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs @@ -0,0 +1,11 @@ +namespace Elsa.Workflows.Management.Services; + +/// +/// Convenience implementation of that delegates to a user-provided function. +/// +public class DelegateHostMethodParameterValueProvider(Func> handler) + : IHostMethodParameterValueProvider +{ + public ValueTask GetValueAsync(HostMethodParameterValueProviderContext context) => handler(context); +} + diff --git a/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs new file mode 100644 index 0000000000..c96db73db5 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs @@ -0,0 +1,245 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Elsa.Extensions; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Management.Activities.HostMethod; +using Elsa.Workflows.Management.Attributes; +using Elsa.Workflows.Models; +using Humanizer; + +namespace Elsa.Workflows.Management.Services; + +public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) : IHostMethodActivityDescriber +{ + public async Task> DescribeAsync(string key, Type hostType, CancellationToken cancellationToken = default) + { + var methods = hostType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName) + .ToList(); + + var descriptors = new List(methods.Count); + foreach (var method in methods) + { + var descriptor = await DescribeMethodAsync(key, hostType, method, cancellationToken); + descriptors.Add(descriptor); + } + + return descriptors; + } + + public async Task DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default) + { + var descriptor = await activityDescriber.DescribeActivityAsync(typeof(HostMethodActivity), cancellationToken); + var activityAttribute = hostType.GetCustomAttribute() ?? method.GetCustomAttribute(); + + var methodName = method.Name; + var activityTypeName = BuildActivityTypeName(key, method, activityAttribute); + + var displayAttribute = method.GetCustomAttribute(); + var typeDisplayName = activityAttribute?.DisplayName ?? hostType.GetCustomAttribute()?.DisplayName; + var methodNameWithoutAsync = StripAsyncSuffix(methodName); + var methodDisplayName = displayAttribute?.Name ?? methodNameWithoutAsync.Humanize().Transform(To.TitleCase); + var displayName = !string.IsNullOrWhiteSpace(typeDisplayName) ? typeDisplayName : methodDisplayName; + if (!string.IsNullOrWhiteSpace(activityAttribute?.DisplayName)) + displayName = activityAttribute.DisplayName!; + + descriptor.Name = methodName; + descriptor.TypeName = activityTypeName; + descriptor.DisplayName = displayName; + descriptor.Description = activityAttribute?.Description ?? method.GetCustomAttribute()?.Description ?? hostType.GetCustomAttribute()?.Description; + descriptor.Category = activityAttribute?.Category ?? hostType.Name.Humanize().Transform(To.TitleCase); + descriptor.Kind = activityAttribute?.Kind ?? ActivityKind.Task; + descriptor.RunAsynchronously = activityAttribute?.RunAsynchronously ?? false; + descriptor.IsBrowsable = true; + descriptor.ClrType = typeof(HostMethodActivity); + + descriptor.Constructor = context => + { + var activity = context.CreateActivity(); + activity.Type = activityTypeName; + activity.HostType = hostType; + activity.MethodName = methodName; + activity.RunAsynchronously ??= descriptor.RunAsynchronously; + return activity; + }; + + descriptor.Inputs.Clear(); + foreach (var prop in hostType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!IsInputProperty(prop)) + continue; + + var inputDescriptor = CreatePropertyInputDescriptor(prop); + descriptor.Inputs.Add(inputDescriptor); + } + + foreach (var parameter in method.GetParameters()) + { + if (IsSpecialParameter(parameter)) + continue; + + // If FromServices is used, the parameter is not a workflow input unless explicitly forced via [Input]. + var isFromServices = parameter.GetCustomAttribute() != null; + var isExplicitInput = parameter.GetCustomAttribute() != null; + if (isFromServices && !isExplicitInput) + continue; + + var inputDescriptor = CreateParameterInputDescriptor(parameter); + descriptor.Inputs.Add(inputDescriptor); + } + + descriptor.Outputs.Clear(); + var outputDescriptor = CreateOutputDescriptor(method); + if (outputDescriptor != null) + descriptor.Outputs.Add(outputDescriptor); + + return descriptor; + } + + private string BuildActivityTypeName(string key, MethodInfo method, ActivityAttribute? activityAttribute) + { + var methodName = StripAsyncSuffix(method.Name); + + if (activityAttribute != null && !string.IsNullOrWhiteSpace(activityAttribute.Namespace)) + { + var typeSegment = activityAttribute.Type ?? methodName; + return $"{activityAttribute.Namespace}.{typeSegment}"; + } + + return $"Elsa.Dynamic.HostMethod.{key.Pascalize()}.{methodName}"; + } + + private static string StripAsyncSuffix(string name) + { + return name.EndsWith("Async", StringComparison.Ordinal) + ? name[..^5] + : name; + } + + private InputDescriptor CreatePropertyInputDescriptor(PropertyInfo prop) + { + var inputAttribute = prop.GetCustomAttribute(); + var displayNameAttribute = prop.GetCustomAttribute(); + var descriptionAttribute = prop.GetCustomAttribute(); + + var inputName = inputAttribute?.Name ?? prop.Name; + var displayName = inputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? prop.Name.Humanize(); + var description = inputAttribute?.Description ?? descriptionAttribute?.Description; + var nakedInputType = prop.PropertyType; + + return new() + { + Name = inputName, + DisplayName = displayName, + Description = description, + Type = nakedInputType, + ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(inputName), + ValueSetter = (activity, value) => activity.SyntheticProperties[inputName] = value!, + IsSynthetic = true, + IsWrapped = true, + UIHint = inputAttribute?.UIHint ?? ActivityDescriber.GetUIHint(nakedInputType), + Category = inputAttribute?.Category, + DefaultValue = inputAttribute?.DefaultValue, + Order = inputAttribute?.Order ?? 0, + IsBrowsable = inputAttribute?.IsBrowsable ?? true, + AutoEvaluate = inputAttribute?.AutoEvaluate ?? true, + IsSerializable = inputAttribute?.IsSerializable ?? true + }; + } + + private InputDescriptor CreateParameterInputDescriptor(ParameterInfo parameter) + { + var inputAttribute = parameter.GetCustomAttribute(); + var displayNameAttribute = parameter.GetCustomAttribute(); + + var inputName = inputAttribute?.Name ?? parameter.Name ?? "input"; + var displayName = inputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? inputName.Humanize(); + var description = inputAttribute?.Description; + var nakedInputType = parameter.ParameterType; + + return new() + { + Name = inputName, + DisplayName = displayName, + Description = description, + Type = nakedInputType, + ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(inputName), + ValueSetter = (activity, value) => activity.SyntheticProperties[inputName] = value!, + IsSynthetic = true, + IsWrapped = true, + UIHint = inputAttribute?.UIHint ?? ActivityDescriber.GetUIHint(nakedInputType), + Category = inputAttribute?.Category, + DefaultValue = inputAttribute?.DefaultValue, + Order = inputAttribute?.Order ?? 0, + IsBrowsable = inputAttribute?.IsBrowsable ?? true, + AutoEvaluate = inputAttribute?.AutoEvaluate ?? true, + IsSerializable = inputAttribute?.IsSerializable ?? true + }; + } + + private OutputDescriptor? CreateOutputDescriptor(MethodInfo method) + { + var returnType = method.ReturnType; + + // No output for void or Task. + if (returnType == typeof(void) || returnType == typeof(Task)) + return null; + + // Determine the "real" return type. + Type actualReturnType; + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + actualReturnType = returnType.GetGenericArguments()[0]; + else if (typeof(Task).IsAssignableFrom(returnType)) + return null; + else + actualReturnType = returnType; + + var outputAttribute = method.ReturnParameter.GetCustomAttribute() ?? + method.GetCustomAttribute() ?? + method.DeclaringType?.GetCustomAttribute(); + + var displayNameAttribute = method.ReturnParameter.GetCustomAttribute(); + var outputName = outputAttribute?.Name ?? "Output"; + var displayName = outputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? outputName.Humanize(); + var description = outputAttribute?.Description ?? "The method output."; + var nakedOutputType = actualReturnType; + + return new() + { + Name = outputName, + DisplayName = displayName, + Description = description, + Type = nakedOutputType, + IsSynthetic = true, + ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(outputName), + ValueSetter = (activity, value) => activity.SyntheticProperties[outputName] = value!, + IsBrowsable = outputAttribute?.IsBrowsable ?? true, + IsSerializable = outputAttribute?.IsSerializable ?? true + }; + } + + private static bool IsSpecialParameter(ParameterInfo parameter) + { + // These parameters are supplied by the runtime and should not become input descriptors. + if (parameter.ParameterType == typeof(CancellationToken)) + return true; + + if (parameter.ParameterType == typeof(ActivityExecutionContext)) + return true; + + return false; + } + + private static bool IsInputProperty(PropertyInfo prop) + { + if (!prop.CanRead || !prop.CanWrite) + return false; + + if (prop.GetIndexParameters().Length > 0) + return false; + + return true; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index 05242c9ebe..735bc6caa2 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -236,12 +236,12 @@ private async Task CompleteBackgroundActivityScheduledActivitiesAsync(ActivityEx { ExistingActivityExecutionContext = scheduledActivity.Options.ExistingActivityInstanceId != null ? context.WorkflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Id == scheduledActivity.Options.ExistingActivityInstanceId) : null, Variables = scheduledActivity.Options?.Variables, - CompletionCallback = !string.IsNullOrEmpty(scheduledActivity.Options?.CompletionCallback) && owner != null ? owner.Activity.GetActivityCompletionCallback(scheduledActivity.Options.CompletionCallback) : default, + CompletionCallback = !string.IsNullOrEmpty(scheduledActivity.Options?.CompletionCallback) && owner != null ? owner.Activity.GetActivityCompletionCallback(scheduledActivity.Options.CompletionCallback) : null, PreventDuplicateScheduling = scheduledActivity.Options?.PreventDuplicateScheduling ?? false, Input = scheduledActivity.Options?.Input, Tag = scheduledActivity.Options?.Tag } - : default; + : null; await context.ScheduleActivityAsync(activityNode, owner, options); } } diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index dbfd8dcec7..fd395bfdd8 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -100,4 +100,8 @@ + + + + diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 1a10ecba11..936ead5581 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -12,6 +12,7 @@ using Elsa.Testing.Shared.Services; using Elsa.Workflows.ComponentTests.Decorators; using Elsa.Workflows.ComponentTests.Materializers; +using Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities; using Elsa.Workflows.ComponentTests.WorkflowProviders; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime.Distributed.Extensions; @@ -66,6 +67,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { elsa.AddWorkflowsFrom(); elsa.AddActivitiesFrom(); + elsa.AddActivityHost(); elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseAdminApiKey()); elsa.UseFluentStorageProvider(sp => { diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs new file mode 100644 index 0000000000..74e2b80439 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs @@ -0,0 +1,207 @@ +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities; + +public class HostMethodActivityTests(App app) : AppComponentTest(app) +{ + [Fact(DisplayName = "All TestHostMethod public methods are registered as activities")] + public void AllPublicMethodsRegistered() + { + // Arrange + var allDescriptors = ActivityRegistry.ListAll().ToList(); + var hostMethodDescriptors = allDescriptors + .Where(d => d.TypeName.StartsWith("Elsa.Dynamic.HostMethod.TestHostMethod.")) + .ToList(); + + // Assert - We expect all public methods except ClearLog (which is static) and CustomAttributeMethod (which has custom namespace) + var expectedMethods = new[] + { + "SimpleAction", "GreetPerson", "AddNumbers", "GetMessage", "Calculate", + "GetAsyncMessage", "UseContext", "ProcessWithCancellation", "CreateBookmark", + "WithDefaultValue", "AsyncAction", "GetComplexData" + }; + + foreach (var expectedMethod in expectedMethods) + { + Assert.Contains(hostMethodDescriptors, d => d.Name == expectedMethod); + } + + // CustomAttributeMethod has a custom namespace, so check it separately + var customMethod = allDescriptors.FirstOrDefault(d => d.TypeName == "CustomNamespace.CustomType"); + Assert.NotNull(customMethod); + Assert.Equal("CustomAttributeMethod", customMethod.Name); + + // Should not include static methods + Assert.DoesNotContain(hostMethodDescriptors, d => d.Name == "ClearLog"); + } + + [Theory(DisplayName = "Method descriptor has correct basic properties")] + [InlineData("SimpleAction", "Performs a simple action")] + [InlineData("GreetPerson", null)] + [InlineData("GetMessage", null)] + public void DescriptorHasCorrectProperties(string methodName, string? expectedDescription) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(methodName, descriptor.Name); + Assert.Equal($"Elsa.Dynamic.HostMethod.TestHostMethod.{methodName}", descriptor.TypeName); + Assert.Equal("Test Host Method", descriptor.Category); + + if (expectedDescription != null) + { + Assert.Equal(expectedDescription, descriptor.Description); + } + } + + [Theory(DisplayName = "Method descriptor has expected input parameters")] + [InlineData("SimpleAction", 0)] + [InlineData("GreetPerson", 1)] + [InlineData("AddNumbers", 2)] + [InlineData("GetMessage", 1)] + [InlineData("UseContext", 1)] // ActivityExecutionContext excluded + [InlineData("ProcessWithCancellation", 1)] // CancellationToken excluded + [InlineData("AsyncAction", 1)] + [InlineData("GetComplexData", 2)] + [InlineData("WithDefaultValue", 1)] + public void DescriptorHasExpectedInputCount(string methodName, int expectedInputCount) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(expectedInputCount, descriptor.Inputs.Count); + } + + [Theory(DisplayName = "Method descriptor has expected output type")] + [InlineData("SimpleAction", null)] // void + [InlineData("GetMessage", typeof(string))] + [InlineData("Calculate", typeof(int))] + [InlineData("GetAsyncMessage", typeof(string))] + [InlineData("AsyncAction", null)] // Task (void) + [InlineData("GetComplexData", typeof(Dictionary))] + [InlineData("CreateBookmark", typeof(string))] + public void DescriptorHasExpectedOutputType(string methodName, Type? expectedOutputType) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + + if (expectedOutputType == null) + { + Assert.Empty(descriptor.Outputs); + } + else + { + Assert.Single(descriptor.Outputs); + var output = descriptor.Outputs.First(); + Assert.Equal("Output", output.Name); + Assert.Equal(expectedOutputType, output.Type); + } + } + + [Theory(DisplayName = "Method descriptor has correctly typed input parameters")] + [InlineData("GreetPerson", "name", typeof(string))] + [InlineData("GetMessage", "prefix", typeof(string))] + [InlineData("UseContext", "data", typeof(string))] + [InlineData("ProcessWithCancellation", "item", typeof(string))] + [InlineData("AsyncAction", "action", typeof(string))] + [InlineData("WithDefaultValue", "message", typeof(string))] + public void DescriptorHasCorrectInputParameterType(string methodName, string parameterName, Type expectedType) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + var input = descriptor.Inputs.FirstOrDefault(i => i.Name == parameterName); + Assert.NotNull(input); + Assert.Equal(expectedType, input.Type); + } + + [Theory(DisplayName = "Method with multiple parameters has all parameters correctly defined")] + [InlineData("AddNumbers", new[] { "a", "b" }, new[] { typeof(int), typeof(int) })] + [InlineData("GetComplexData", new[] { "key", "value" }, new[] { typeof(string), typeof(string) })] + [InlineData("Calculate", new[] { "x", "y" }, new[] { typeof(int), typeof(int) })] + public void DescriptorHasAllParametersCorrectlyDefined(string methodName, string[] parameterNames, Type[] parameterTypes) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(parameterNames.Length, descriptor.Inputs.Count); + + for (int i = 0; i < parameterNames.Length; i++) + { + var input = descriptor.Inputs.FirstOrDefault(p => p.Name == parameterNames[i]); + Assert.NotNull(input); + Assert.Equal(parameterTypes[i], input.Type); + } + } + + [Theory(DisplayName = "Special parameters are excluded from inputs")] + [InlineData("UseContext")] // Has ActivityExecutionContext parameter + [InlineData("ProcessWithCancellation")] // Has CancellationToken parameter + [InlineData("CreateBookmark")] // Has ActivityExecutionContext parameter + public void SpecialParametersExcludedFromInputs(string methodName) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + Assert.DoesNotContain(descriptor.Inputs, i => i.Type == typeof(ActivityExecutionContext)); + Assert.DoesNotContain(descriptor.Inputs, i => i.Type == typeof(CancellationToken)); + } + + [Fact(DisplayName = "CustomAttributeMethod uses custom Activity attribute values")] + public void CustomAttributeMethodUsesCustomValues() + { + // Act + var descriptor = ActivityRegistry.Find("CustomNamespace.CustomType"); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal("CustomAttributeMethod", descriptor.Name); + Assert.Equal("CustomNamespace.CustomType", descriptor.TypeName); + Assert.Equal("Custom Display Name", descriptor.DisplayName); + Assert.Equal("Custom description for this activity", descriptor.Description); + Assert.Equal("Custom Category", descriptor.Category); + } + + [Theory(DisplayName = "Async methods are correctly registered")] + [InlineData("GetAsyncMessage", 1, typeof(string))] // Task + [InlineData("AsyncAction", 1, null)] // Task (void) + [InlineData("ProcessWithCancellation", 1, null)] // Task (void) + public void AsyncMethodsCorrectlyRegistered(string methodName, int expectedInputs, Type? expectedOutputType) + { + // Act + var descriptor = FindDescriptor(methodName); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(expectedInputs, descriptor.Inputs.Count); + + if (expectedOutputType == null) + { + Assert.Empty(descriptor.Outputs); + } + else + { + Assert.Single(descriptor.Outputs); + Assert.Equal(expectedOutputType, descriptor.Outputs.First().Type); + } + } + + private IActivityRegistry ActivityRegistry => Scope.ServiceProvider.GetRequiredService(); + private ActivityDescriptor? FindDescriptor(string methodName) => ActivityRegistry.Find($"Elsa.Dynamic.HostMethod.TestHostMethod.{methodName}"); +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs new file mode 100644 index 0000000000..33741aa083 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs @@ -0,0 +1,181 @@ +using System.ComponentModel; +using Elsa.Extensions; +using Elsa.Workflows.Attributes; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities; + +/// +/// A test host class for HostMethod activity testing. +/// Each public method becomes an activity that can be executed. +/// +[UsedImplicitly] +[Description("Test Host Method")] +public class TestHostMethod(ILogger logger) +{ + // Tracks method invocations for test verification + public static readonly List InvocationLog = new(); + + /// + /// Simple method with no parameters or return value + /// + [Activity(Description = "Performs a simple action")] + public void SimpleAction() + { + logger.LogInformation("SimpleAction invoked"); + InvocationLog.Add("SimpleAction"); + } + + /// + /// Method with a single string parameter + /// + public void GreetPerson(string name) + { + logger.LogInformation($"Greeting {name}"); + InvocationLog.Add($"GreetPerson:{name}"); + } + + /// + /// Method with multiple parameters + /// + public void AddNumbers(int a, int b) + { + var sum = a + b; + logger.LogInformation($"Adding {a} + {b} = {sum}"); + InvocationLog.Add($"AddNumbers:{a}+{b}={sum}"); + } + + /// + /// Method that returns a value + /// + public string GetMessage(string prefix) + { + var message = $"{prefix}: Hello from HostMethod!"; + logger.LogInformation($"GetMessage returned: {message}"); + InvocationLog.Add($"GetMessage:{prefix}"); + return message; + } + + /// + /// Method that returns an integer + /// + public int Calculate(int x, int y) + { + var result = x * y; + logger.LogInformation($"Calculate: {x} * {y} = {result}"); + InvocationLog.Add($"Calculate:{x}*{y}={result}"); + return result; + } + + /// + /// Async method that returns a value + /// + public async Task GetAsyncMessage(string text) + { + await Task.Delay(10); + var message = $"Async: {text}"; + logger.LogInformation($"GetAsyncMessage returned: {message}"); + InvocationLog.Add($"GetAsyncMessage:{text}"); + return message; + } + + /// + /// Method that accepts ActivityExecutionContext + /// + public void UseContext(string data, ActivityExecutionContext context) + { + var workflowInstanceId = context.WorkflowExecutionContext.Id; + logger.LogInformation($"UseContext invoked with data={data}, workflowInstanceId={workflowInstanceId}"); + InvocationLog.Add($"UseContext:{data}:{workflowInstanceId}"); + } + + /// + /// Method that accepts CancellationToken + /// + public async Task ProcessWithCancellation(string item, CancellationToken cancellationToken) + { + await Task.Delay(10, cancellationToken); + logger.LogInformation($"ProcessWithCancellation completed for: {item}"); + InvocationLog.Add($"ProcessWithCancellation:{item}"); + } + + /// + /// Method that creates a bookmark for workflow suspension + /// + public string CreateBookmark(string bookmarkName, ActivityExecutionContext context) + { + logger.LogInformation($"Creating bookmark: {bookmarkName}"); + var bookmark = context.CreateBookmark(ResumeFromBookmark); + InvocationLog.Add($"CreateBookmark:{bookmarkName}"); + return context.GenerateBookmarkTriggerToken(bookmark.Id); + } + + /// + /// Private callback method invoked when bookmark is resumed + /// + private ValueTask ResumeFromBookmark(ActivityExecutionContext context) + { + logger.LogInformation("Bookmark resumed"); + InvocationLog.Add("ResumeFromBookmark"); + return ValueTask.CompletedTask; + } + + /// + /// Method with custom Activity attribute settings + /// + [Activity( + DisplayName = "Custom Display Name", + Description = "Custom description for this activity", + Category = "Custom Category", + Namespace = "CustomNamespace", + Type = "CustomType" + )] + public void CustomAttributeMethod() + { + logger.LogInformation("CustomAttributeMethod invoked"); + InvocationLog.Add("CustomAttributeMethod"); + } + + /// + /// Method with default parameter value + /// + public void WithDefaultValue(string message = "default message") + { + logger.LogInformation($"WithDefaultValue: {message}"); + InvocationLog.Add($"WithDefaultValue:{message}"); + } + + /// + /// Method that returns Task (void async) + /// + public async Task AsyncAction(string action) + { + await Task.Delay(10); + logger.LogInformation($"AsyncAction: {action}"); + InvocationLog.Add($"AsyncAction:{action}"); + } + + /// + /// Method with complex return type + /// + public Dictionary GetComplexData(string key, string value) + { + var data = new Dictionary + { + [key] = value, + ["timestamp"] = DateTime.UtcNow.ToString("O") + }; + logger.LogInformation($"GetComplexData: {key}={value}"); + InvocationLog.Add($"GetComplexData:{key}={value}"); + return data; + } + + /// + /// Static method to clear invocation log between tests + /// + public static void ClearLog() + { + InvocationLog.Clear(); + } +}