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:
parent
6764d4acc4
commit
3702fcfc7c
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Expressions.Helpers;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue