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
This commit is contained in:
Sipke Schoorstra 2025-09-01 09:49:07 +02:00 committed by GitHub
parent 6764d4acc4
commit 3702fcfc7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 317 additions and 113 deletions

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
internal abstract class EndpointBase<T>(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer)
: ElsaEndpoint<T, Response> where T : IExecutionRequest, new()
{
/// <inheritdoc />
public override void Configure()
{
Routes("/workflow-definitions/{definitionId}/execute");
ConfigurePermissions("exec:workflow-definitions");
}
/// <inheritdoc />
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);
}
}

View file

@ -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<GetRequest>(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
IApiSerializer apiSerializer)
: ElsaEndpoint<GetRequest>
{
/// <inheritdoc />
public override void Configure()
{
base.Configure();
Routes("/workflow-definitions/{definitionId}/execute");
ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.GET);
}
/// <inheritdoc />
public override async Task HandleAsync(GetRequest request, CancellationToken cancellationToken)
{
await WorkflowExecutionHelper.ExecuteWorkflowAsync(
request,
workflowDefinitionService,
workflowRuntime,
workflowStarter,
apiSerializer,
HttpContext,
cancellationToken);
}
}

View file

@ -1,5 +1,4 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Common.Models;
using Elsa.Expressions.Helpers;

View file

@ -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<PostRequest>(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
: ElsaEndpointWithoutRequest<Response>
{
/// <inheritdoc />
public override void Configure()
{
base.Configure();
Routes("/workflow-definitions/{definitionId}/execute");
ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.POST);
}
/// <inheritdoc />
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<PostRequest>(HttpContext.Request.Body, cancellationToken: cancellationToken);
}
catch
{
AddError("Invalid request body.");
}
}
request ??= new();
var definitionId = Route<string>("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);
}
}

View file

@ -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);
}
}

View file

@ -87,6 +87,9 @@
<None Update="Scenarios\InputOutput\Workflows\provider.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\RestApis\Workflows\hello-world.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -34,11 +34,19 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
public TClient CreateApiClient<TClient>()
{
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<TClient>(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();

View file

@ -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<Response>(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<Response>(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);
}
}

View file

@ -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<Response>(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<Response>(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<Response>(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);
}
}

View file

@ -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": []
}
}