diff --git a/Elsa.sln b/Elsa.sln index 34a01ba3a..f1c26d414 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -64,6 +64,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}" + ProjectSection(SolutionItems) = preProject + test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}" ProjectSection(SolutionItems) = preProject diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs deleted file mode 100644 index 70432fd97..000000000 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Net.Mime; -using Elsa.Abstractions; -using Elsa.Common.Models; -using Elsa.Workflows.Management; -using Elsa.Workflows.Runtime; -using Elsa.Workflows.State; -using Microsoft.AspNetCore.Http; - -namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute; - -/// -/// This abstract class provides the necessary infrastructure to handle the execution of workflows, including setup of routes, permissions, -/// and processing of HTTP requests to execute workflows. -/// -internal abstract class EndpointBase( - IWorkflowDefinitionService workflowDefinitionService, - IWorkflowRuntime workflowRuntime, - IWorkflowStarter workflowStarter, - IApiSerializer apiSerializer) - : ElsaEndpoint where T : IExecutionRequest, new() -{ - /// - public override void Configure() - { - Routes("/workflow-definitions/{definitionId}/execute"); - ConfigurePermissions("exec:workflow-definitions"); - } - - /// - public override async Task HandleAsync(T request, CancellationToken cancellationToken) - { - var definitionId = request.DefinitionId; - var versionOptions = request.VersionOptions ?? VersionOptions.Published; - var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken); - - if (workflowGraph == null) - { - await Send.NotFoundAsync(cancellationToken); - return; - } - - var startRequest = new StartWorkflowRequest - { - Workflow = workflowGraph.Workflow, - CorrelationId = request.CorrelationId, - Name = request.Name, - Input = request.GetInputAsDictionary(), - Variables = request.GetVariablesAsDictionary(), - TriggerActivityId = request.TriggerActivityId, - ActivityHandle = request.ActivityHandle - }; - - var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken); - - if(!HttpContext.Response.HasStarted) - HttpContext.Response.Headers.Append("x-elsa-workflow-cannot-start", startResponse.CannotStart.ToString()); - - if (startResponse.CannotStart) - { - await Send.OkAsync(cancellationToken); - return; - } - - var instanceId = startResponse.WorkflowInstanceId!; - - // Write the workflow instance ID to the response header. - // This allows clients to read the header even if the workflow writes a response body - // (in which case, we can't transmit a JSON body that includes the instance ID). - if(!HttpContext.Response.HasStarted) - HttpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId); - - var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken); - - // If a workflow fault occurred, respond appropriately with a 500 internal server error. - if (startResponse.SubStatus == WorkflowSubStatus.Faulted) - { - var workflowState = await workflowClient.ExportStateAsync(cancellationToken); - await HandleFaultAsync(workflowState, cancellationToken); - } - else - { - if (!HttpContext.Response.HasStarted) - { - // Write a response header to indicate that the response is a workflow state response. - // This is used by tools like Elsa Studio to determine if the response is in response to a workflow execution manually triggered by the user. - HttpContext.Response.Headers.Append("x-elsa-response", "true"); - - // Only write a response if the workflow didn't change the HTTP status code. - if (HttpContext.Response.StatusCode == StatusCodes.Status200OK) - { - var workflowState = await workflowClient.ExportStateAsync(cancellationToken); - await Send.OkAsync(new(workflowState), cancellationToken); - } - } - } - } - - private async Task HandleFaultAsync(WorkflowState workflowState, CancellationToken cancellationToken) - { - var faultedResponse = apiSerializer.Serialize(new Response(workflowState)); - - HttpContext.Response.ContentType = MediaTypeNames.Application.Json; - HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; - await HttpContext.Response.WriteAsync(faultedResponse, cancellationToken); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs index 24d6168e3..6148de9e6 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs @@ -24,7 +24,6 @@ internal class PostEndpoint( ConfigurePermissions("exec:workflow-definitions"); Verbs(FastEndpoints.Http.POST); } - /// public override async Task HandleAsync(CancellationToken cancellationToken) { diff --git a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs index fb48a93b8..b30d7669e 100644 --- a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs @@ -63,6 +63,12 @@ public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphSer DefinitionId = definitionId; return this; } + + public IWorkflowBuilder WithId(string id) + { + Id = id; + return this; + } /// public IWorkflowBuilder WithTenantId(string tenantId) diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs index 363fcbf11..fc9a78cdb 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowBuilder.cs @@ -96,6 +96,13 @@ public interface IWorkflowBuilder /// The definition ID to use for the workflow being built. IWorkflowBuilder WithDefinitionId(string definitionId); + /// + /// A fluent method for setting the property. + /// + /// The unique identifier to use for the workflow being built. + /// The current instance for method chaining. + IWorkflowBuilder WithId(string id); + /// /// A fluent method for setting the property. /// diff --git a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs index 79a1c65ee..726ea230a 100644 --- a/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs +++ b/src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs @@ -106,6 +106,12 @@ public class InputDescriptor : PropertyDescriptor /// True if the expression should be evaluated automatically, false otherwise. Defaults to true. /// public bool AutoEvaluate { get; set; } = true; + + /// + /// Specifies the type of a custom evaluator to use for evaluating the input property value. + /// The evaluator type determines how the value for the property is resolved at runtime. + /// + public Type? EvaluatorType { get; set; } /// /// Specifies the type of a custom evaluator to use for evaluating the input property value. diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs index 024e5c492..bbcd5f0ef 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs @@ -15,7 +15,7 @@ namespace Elsa.Workflows.Runtime.Activities; [UsedImplicitly] public class Event : Trigger { - internal const string EventInputWorkflowInputKey = "__EventPayloadWorkflowInput"; + public const string EventInputWorkflowInputKey = "__EventPayloadWorkflowInput"; /// internal Event([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs index c7354560e..5fb45c5a7 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/DependencyInjectionExtensions.cs @@ -11,13 +11,11 @@ namespace Microsoft.Extensions.DependencyInjection; public static class DependencyInjectionExtensions { /// - /// Adds the to the service collection. + /// Adds the specified workflow provider type to the service collection. /// - /// The service collection. - /// The type of the workflow definition provider. - /// The service collection. + /// The type of the workflow provider to add. Must implement . + [Obsolete("Use AddWorkflowsProvider instead.", false)] public static IServiceCollection AddWorkflowDefinitionProvider(this IServiceCollection services) where T : class, IWorkflowsProvider => services.AddScoped(); - /// /// Registers a with the service container. /// @@ -29,4 +27,10 @@ public static class DependencyInjectionExtensions { return services.AddScoped, TValidator>(); } + + /// + /// Adds the specified workflows provider type to the service collection. + /// + /// The type of the workflow provider to add. Must implement . + public static IServiceCollection AddWorkflowsProvider(this IServiceCollection services) where T : class, IWorkflowsProvider => services.AddScoped(); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs index 909f2e502..4feafd362 100644 --- a/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Models/MaterializedWorkflow.cs @@ -9,4 +9,4 @@ namespace Elsa.Workflows.Runtime; /// The name of the provider that provided the workflow definition. /// The name of the materializer that materialized the workflow. /// The context of the materializer that materialized the workflow. -public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = default); \ No newline at end of file +public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs index 96997e284..d21d298fe 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowDefinitionStorePopulator.cs @@ -56,7 +56,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP { var providers = _workflowDefinitionProviders(); var workflowDefinitions = new List(); - + foreach (var provider in providers) { var results = await provider.GetWorkflowsAsync(cancellationToken).AsTask().ToList(); @@ -84,7 +84,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP var workflowDefinition = await AddOrUpdateAsync(materializedWorkflow, cancellationToken); if (indexTriggers) - await IndexTriggersAsync(materializedWorkflow, cancellationToken); + await IndexTriggersAsync(workflowDefinition, cancellationToken); return workflowDefinition; } @@ -260,7 +260,7 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP } } - private async Task IndexTriggersAsync(MaterializedWorkflow materializedWorkflow, CancellationToken cancellationToken) => await _triggerIndexer.IndexTriggersAsync(materializedWorkflow.Workflow, cancellationToken); + private async Task IndexTriggersAsync(WorkflowDefinition workflowDefinition, CancellationToken cancellationToken) => await _triggerIndexer.IndexTriggersAsync(workflowDefinition, cancellationToken); /// /// Syncs the items in the primary list with existing items in the secondary list, even when the object instances are not the same (but their IDs are). diff --git a/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs b/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs index 641223fa2..a760accbe 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/EventPublisher.cs @@ -29,16 +29,17 @@ public class EventPublisher(IStimulusSender stimulusSender, IStimulusDispatcher WorkflowInstanceId = workflowInstanceId, Input = workflowInput }; + var triggerName = ActivityTypeNameHelper.GenerateTypeName(); if (asynchronous) { await stimulusDispatcher.SendAsync(new() { - ActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(), + ActivityTypeName = triggerName, Stimulus = stimulus, Metadata = metadata }, cancellationToken); } else - await stimulusSender.SendAsync(stimulus, metadata, cancellationToken); + await stimulusSender.SendAsync(triggerName, stimulus, metadata, cancellationToken); } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs new file mode 100644 index 000000000..461831eac --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowMaterializer.cs @@ -0,0 +1,20 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +public class InMemoryWorkflowMaterializer(Workflow workflow) : IWorkflowMaterializer +{ + public string Name => "InMemory"; + + public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) + { + var materializedWorkflow = new Workflow + { + Identity = new(definition.DefinitionId, definition.Version, definition.Id), + Root = workflow.Root + }; + return new(materializedWorkflow); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs new file mode 100644 index 000000000..1dc95db61 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/InMemoryWorkflowProvider.cs @@ -0,0 +1,23 @@ +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +public class InMemoryWorkflowsProvider(Workflow workflow) : IWorkflowsProvider +{ + public string Name => "InMemory"; + + public ValueTask> GetWorkflowsAsync(CancellationToken cancellationToken = default) + { + var materializedWorkflow = new MaterializedWorkflow( + Workflow: workflow, + ProviderName: "InMemory", + MaterializerName: "InMemory", + MaterializerContext: null + ); + + return new([materializedWorkflow]); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs new file mode 100644 index 000000000..a4cdd4859 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/WorkflowDefinitionStorePopulation/Tests.cs @@ -0,0 +1,73 @@ +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Management; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Activities; +using Elsa.Workflows.Runtime.Stimuli; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.WorkflowDefinitionStorePopulation; + +/// +/// Represents a test class for integration testing various scenarios related to the +/// `WorkflowDefinitionStorePopulation`. This class primarily focuses +/// on ensuring correct behavior when workflow definitions are published or updated, and their effects +/// on consuming workflows and triggers. +/// +public class Tests +{ + private readonly IServiceProvider _services; + private readonly Workflow _shiftyWorkflow; + + public Tests(ITestOutputHelper testOutputHelper) + { + _shiftyWorkflow = new() + { + Identity = new( + DefinitionId: "WorkflowWithTrigger", + Version: 1, + Id: "1", + TenantId: "default" + ), + Root = new Event("Foo") + { + CanStartWorkflow = true + } + }; + + _services = new TestApplicationBuilder(testOutputHelper) + .ConfigureServices(services => services + .AddScoped(_ => new InMemoryWorkflowsProvider(_shiftyWorkflow)) + .AddScoped(_ => new InMemoryWorkflowMaterializer(_shiftyWorkflow))) + .Build(); + } + + /// + /// When a dependency workflow is published, all consuming workflows are updated to point to the new version of the dependency. + /// + [Fact(DisplayName = "When a workflow definition from a given source has a different Id than the one in the store, the trigger should still point to the workflow definition version ID in the store.")] + public async Task Test1() + { + // Initial population of the store from workflow providers. + await _services.PopulateRegistriesAsync(); + + // Artificially change the workflow definition version ID. + _shiftyWorkflow.Identity = _shiftyWorkflow.Identity with + { + Id = ":1" + }; + + // Emulate reloading of workflow definitions. + await _services.PopulateRegistriesAsync(); + + // Triggering the workflow should still work. + var stimulusSender = _services.GetRequiredService(); + var stimulus = new EventStimulus("Foo"); + var triggerName = ActivityTypeNameHelper.GenerateTypeName(); + var result = await stimulusSender.SendAsync(triggerName, stimulus); + + Assert.NotEmpty(result.WorkflowInstanceResponses); + } +} \ No newline at end of file