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 399fd4096..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 SendNotFoundAsync(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 SendOkAsync(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 SendOkAsync(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/GetEndpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs
index 8f0f8e148..4bd54724a 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/GetEndpoint.cs
@@ -1,3 +1,4 @@
+using Elsa.Abstractions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
@@ -12,13 +13,27 @@ internal class GetEndpoint(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
- IApiSerializer apiSerializer)
- : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
+ IApiSerializer apiSerializer)
+ : ElsaEndpoint
{
///
public override void Configure()
{
- base.Configure();
+ Routes("/workflow-definitions/{definitionId}/execute");
+ ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.GET);
}
+
+ ///
+ public override async Task HandleAsync(GetRequest request, CancellationToken cancellationToken)
+ {
+ await WorkflowExecutionHelper.ExecuteWorkflowAsync(
+ request,
+ workflowDefinitionService,
+ workflowRuntime,
+ workflowStarter,
+ apiSerializer,
+ HttpContext,
+ cancellationToken);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs
index 468ded31c..2ec0598cd 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/Models.cs
@@ -1,5 +1,4 @@
using System.Dynamic;
-using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Common.Models;
using Elsa.Expressions.Helpers;
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 2727e32a0..796fe7b32 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/PostEndpoint.cs
@@ -1,3 +1,5 @@
+using System.Text.Json;
+using Elsa.Abstractions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
@@ -13,12 +15,55 @@ internal class PostEndpoint(
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer)
- : EndpointBase(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
+ : ElsaEndpointWithoutRequest
{
///
public override void Configure()
{
- base.Configure();
+ Routes("/workflow-definitions/{definitionId}/execute");
+ ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.POST);
}
+
+ ///
+ public override async Task HandleAsync(CancellationToken cancellationToken)
+ {
+ PostRequest? request = null;
+
+ if (HttpContext.Request is { ContentLength: > 0, ContentType: "application/json" or null })
+ {
+ try
+ {
+ request = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body, cancellationToken: cancellationToken);
+ }
+ catch
+ {
+ AddError("Invalid request body.");
+ }
+ }
+
+ request ??= new();
+
+ var definitionId = Route("definitionId");
+
+ if (string.IsNullOrWhiteSpace(definitionId))
+ AddError("Missing workflow definition ID.");
+ else
+ request.DefinitionId = definitionId;
+
+ if (ValidationFailed)
+ {
+ await SendErrorsAsync(cancellation: cancellationToken);
+ return;
+ }
+
+ await WorkflowExecutionHelper.ExecuteWorkflowAsync(
+ request,
+ workflowDefinitionService,
+ workflowRuntime,
+ workflowStarter,
+ apiSerializer,
+ HttpContext,
+ cancellationToken);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs
new file mode 100644
index 000000000..359c1cb13
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs
@@ -0,0 +1,91 @@
+using System.Net.Mime;
+using Elsa.Common.Models;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Runtime;
+using Elsa.Workflows.State;
+using FastEndpoints;
+using Microsoft.AspNetCore.Http;
+
+namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
+
+public static class WorkflowExecutionHelper
+{
+ public static async Task ExecuteWorkflowAsync(
+ IExecutionRequest request,
+ IWorkflowDefinitionService workflowDefinitionService,
+ IWorkflowRuntime workflowRuntime,
+ IWorkflowStarter workflowStarter,
+ IApiSerializer apiSerializer,
+ HttpContext httpContext,
+ CancellationToken cancellationToken)
+ {
+ var definitionId = request.DefinitionId;
+ var versionOptions = request.VersionOptions ?? VersionOptions.Published;
+ var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken);
+
+ if (workflowGraph == null)
+ {
+ await httpContext.Response.SendNotFoundAsync(cancellation: 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)
+ {
+ httpContext.Response.StatusCode = StatusCodes.Status200OK;
+ await httpContext.Response.SendOkAsync(cancellationToken);
+ return;
+ }
+
+ var instanceId = startResponse.WorkflowInstanceId!;
+
+ if(!httpContext.Response.HasStarted)
+ httpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId);
+
+ var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken);
+
+ if (startResponse.SubStatus == WorkflowSubStatus.Faulted)
+ {
+ var workflowState = await workflowClient.ExportStateAsync(cancellationToken);
+ await HandleFaultAsync(workflowState, apiSerializer, httpContext, cancellationToken);
+ }
+ else
+ {
+ if (!httpContext.Response.HasStarted)
+ {
+ httpContext.Response.Headers.Append("x-elsa-response", "true");
+ if (httpContext.Response.StatusCode == StatusCodes.Status200OK)
+ {
+ var workflowState = await workflowClient.ExportStateAsync(cancellationToken);
+ var response = apiSerializer.Serialize(new Response(workflowState));
+ httpContext.Response.ContentType = MediaTypeNames.Application.Json;
+ await httpContext.Response.WriteAsync(response, cancellationToken);
+ }
+ }
+ }
+ }
+
+ private static async Task HandleFaultAsync(WorkflowState workflowState, IApiSerializer apiSerializer, HttpContext httpContext, 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);
+ }
+}
+
diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
index fc4ba4f47..bbca0a0bd 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
+++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
@@ -87,6 +87,9 @@
Always
+
+ Always
+
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
index 67f5b73d8..275c0f450 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
@@ -34,11 +34,19 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
public TClient CreateApiClient()
{
var client = CreateClient();
- client.BaseAddress = new(client.BaseAddress!, "/elsa/api");
+ client.BaseAddress = new(client.BaseAddress!, "/elsa/api/");
client.Timeout = TimeSpan.FromMinutes(1);
return RestService.For(client, CreateRefitSettings(Services));
}
+ public HttpClient CreateHttpClient()
+ {
+ var client = CreateClient();
+ client.BaseAddress = new(client.BaseAddress!, "/elsa/api/");
+ client.Timeout = TimeSpan.FromMinutes(1);
+ return client;
+ }
+
public HttpClient CreateHttpWorkflowClient()
{
var client = CreateClient();
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs
new file mode 100644
index 000000000..aa3f5e656
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs
@@ -0,0 +1,44 @@
+using System.Net;
+using Elsa.Testing.Shared.Extensions;
+using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute;
+
+public class GetTests(App app) : AppComponentTest(app)
+{
+ private const string DefinitionId = "3790068018ac4f02";
+ private const string Url = "workflow-definitions/{0}/execute";
+
+ [Fact]
+ public async Task Get_WithCorrelationId_ShouldReturnOk()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var url = string.Format(Url, DefinitionId) + "?correlationId=" + Guid.NewGuid();
+ using var response = await client.GetAsync(url);
+ var model = await response.ReadAsJsonAsync(WorkflowServer.Services);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
+ }
+
+ [Fact]
+ public async Task Get_WithoutCorrelationId_ShouldReturnOk()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var url = string.Format(Url, DefinitionId);
+ using var response = await client.GetAsync(url);
+ var model = await response.ReadAsJsonAsync(WorkflowServer.Services);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
+ }
+
+ [Fact]
+ public async Task Get_MissingDefinitionId_ShouldReturnValidationError()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var url = "/workflow-definitions//execute";
+ using var response = await client.GetAsync(url);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs
new file mode 100644
index 000000000..8def3a819
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Elsa.Testing.Shared.Extensions;
+using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute;
+
+public class PostTests(App app) : AppComponentTest(app)
+{
+ private const string DefinitionId = "3790068018ac4f02";
+ private const string Url = "workflow-definitions/{0}/execute";
+
+ [Fact]
+ public async Task Post_WithValidJsonBody_ShouldReturnOk()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var requestBody = JsonSerializer.Serialize(new PostRequest
+ {
+ CorrelationId = Guid.NewGuid().ToString()
+ });
+ var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
+ using var response = await client.PostAsync(string.Format(Url, DefinitionId), content);
+ var model = await response.ReadAsJsonAsync(WorkflowServer.Services);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
+ }
+
+ [Fact]
+ public async Task Post_WithoutBodyAndWithoutContentType_ShouldReturnOk()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId));
+ // No content, no content-type
+ using var response = await client.SendAsync(request);
+ var model = await response.ReadAsJsonAsync(WorkflowServer.Services);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
+ }
+
+ [Fact]
+ public async Task Post_WithoutBodyButWithContentType_ShouldReturnOk()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId));
+ request.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
+ using var response = await client.SendAsync(request);
+ var model = await response.ReadAsJsonAsync(WorkflowServer.Services);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
+ }
+
+ [Fact]
+ public async Task Post_MissingDefinitionId_ShouldReturnValidationError()
+ {
+ var client = WorkflowServer.CreateHttpClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/workflow-definitions//execute");
+ using var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json
new file mode 100644
index 000000000..307ecb8b6
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json
@@ -0,0 +1,42 @@
+{
+ "id": "2790068018ac4f01",
+ "definitionId": "3790068018ac4f02",
+ "name": "Hello World",
+ "isLatest": true,
+ "isPublished": true,
+ "root": {
+ "type": "Elsa.Flowchart",
+ "version": 1,
+ "id": "969b0703a9379c3b",
+ "nodeId": "Workflow1:969b0703a9379c3b",
+ "activities": [
+ {
+ "text": {
+ "typeName": "String",
+ "expression": {
+ "type": "Literal",
+ "value": "Hello World!"
+ }
+ },
+ "id": "b039045bb7443e57",
+ "nodeId": "Workflow1:969b0703a9379c3b:b039045bb7443e57",
+ "name": "WriteLine1",
+ "type": "Elsa.WriteLine",
+ "version": 1,
+ "metadata": {
+ "designer": {
+ "position": {
+ "x": -231.796875,
+ "y": 269
+ },
+ "size": {
+ "width": 139.296875,
+ "height": 50
+ }
+ }
+ }
+ }
+ ],
+ "connections": []
+ }
+}
\ No newline at end of file