From 3702fcfc7cee42b2c08e5617c14212cc7f20b618 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 1 Sep 2025 09:49:07 +0200 Subject: [PATCH] Restore ability to send no body and no content type to Execute endpoint (#6882) * Refactor workflow execution endpoints and introduce helper utility Replaced `EndpointBase` class with lighter `WorkflowExecutionHelper` for handling workflow execution logic. Simplified Get & Post endpoints and added comprehensive component tests for enhanced validation. * Update GetTests to expect NotFound instead of BadRequest --- .../Execute/EndpointBase.cs | 106 ------------------ .../Execute/GetEndpoint.cs | 21 +++- .../WorkflowDefinitions/Execute/Models.cs | 1 - .../Execute/PostEndpoint.cs | 49 +++++++- .../Execute/WorkflowExecutionHelper.cs | 91 +++++++++++++++ .../Elsa.Workflows.ComponentTests.csproj | 3 + .../Helpers/Fixtures/WorkflowServer.cs | 10 +- .../WorkflowDefinitions/Execute/GetTests.cs | 44 ++++++++ .../WorkflowDefinitions/Execute/PostTests.cs | 63 +++++++++++ .../RestApis/Workflows/hello-world.json | 42 +++++++ 10 files changed, 317 insertions(+), 113 deletions(-) delete mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/EndpointBase.cs create mode 100644 src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Execute/WorkflowExecutionHelper.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/GetTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/WorkflowDefinitions/Execute/PostTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Workflows/hello-world.json 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