< Summary

Information
Class: Elsa.Http.Middleware.HttpWorkflowsMiddleware
Assembly: Elsa.Http
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs
Line coverage
67%
Covered lines: 133
Uncovered lines: 63
Coverable lines: 196
Total lines: 376
Line coverage: 67.8%
Branch coverage
69%
Covered branches: 32
Total branches: 46
Branch coverage: 69.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
InvokeAsync()77.77%181892.85%
FindWorkflowGraphAsync()100%210%
FindTriggersAsync()100%210%
FindBookmarksAsync()100%11100%
StartWorkflowAsync()100%11100%
ResumeWorkflowAsync()50%6682.6%
ExecuteWorkflowAsync()50%2281.81%
<ExecuteWorkflowAsync()100%22100%
ExecuteWithinTimeoutAsync()50%4227.27%
GetMatchingRoute(...)100%44100%
GetCorrelationIdAsync()100%44100%
GetWorkflowInstanceIdAsync()100%44100%
WriteResponseAsync()0%620%
HandleMultipleWorkflowsFoundAsync()100%210%
HandleWorkflowFaultAsync()50%8437.5%
AuthorizeAsync()50%2280%
ComputeBookmarkHash(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs

#LineLine coverage
 1using Elsa.Extensions;
 2using Elsa.Http.Bookmarks;
 3using Elsa.Http.Options;
 4using Elsa.Workflows.Runtime.Filters;
 5using JetBrains.Annotations;
 6using Microsoft.AspNetCore.Http;
 7using Microsoft.Extensions.DependencyInjection;
 8using Microsoft.Extensions.Options;
 9using System.Net;
 10using System.Net.Mime;
 11using System.Text.Json;
 12using Elsa.Workflows.Activities;
 13using Elsa.Workflows.Runtime.Entities;
 14using FastEndpoints;
 15using System.Diagnostics.CodeAnalysis;
 16using Elsa.Workflows;
 17using Elsa.Workflows.Management;
 18using Elsa.Workflows.Management.Entities;
 19using Elsa.Workflows.Models;
 20using Elsa.Workflows.Options;
 21using Elsa.Workflows.Runtime;
 22using Open.Linq.AsyncExtensions;
 23
 24namespace Elsa.Http.Middleware;
 25
 26/// <summary>
 27/// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run 
 28/// </summary>
 29[PublicAPI]
 330public class HttpWorkflowsMiddleware(RequestDelegate next)
 31{
 32    /// <summary>
 33    /// Attempts to match the inbound request path to an associated workflow and then run that workflow.
 34    /// </summary>
 35    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 36    public async Task InvokeAsync(
 37        HttpContext httpContext,
 38        IServiceProvider serviceProvider,
 39        IOptions<HttpActivityOptions> options,
 40        IHttpWorkflowLookupService httpWorkflowLookupService)
 41    {
 30642        var path = httpContext.Request.Path.Value!.NormalizeRoute();
 30643        var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
 30644        var basePath = options.Value.BasePath?.ToString().NormalizeRoute();
 45
 46        // If the request path does not match the configured base path to handle workflows, then skip.
 30647        if (!string.IsNullOrWhiteSpace(basePath))
 48        {
 30649            if (!path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
 50            {
 3651                await next(httpContext);
 3652                return;
 53            }
 54
 55            // Strip the base path.
 27056            matchingPath = matchingPath[basePath.Length..];
 57        }
 58
 27059        matchingPath = matchingPath.NormalizeRoute();
 60
 27061        var input = new Dictionary<string, object>
 27062        {
 27063            [HttpEndpoint.HttpContextInputKey] = true,
 27064            [HttpEndpoint.PathInputKey] = path
 27065        };
 66
 27067        var cancellationToken = httpContext.RequestAborted;
 27068        var request = httpContext.Request;
 27069        var method = request.Method.ToLowerInvariant();
 27070        var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken);
 27071        var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken);
 27072        var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method);
 27073        var lookupResult = await httpWorkflowLookupService.FindWorkflowAsync(bookmarkHash, cancellationToken);
 74
 27075        if (lookupResult != null)
 76        {
 26377            var triggers = lookupResult.Triggers;
 78
 26379            if (triggers.Count > 1)
 80            {
 081                await HandleMultipleWorkflowsFoundAsync(httpContext, () => triggers.Select(x => new
 082                {
 083                    x.WorkflowDefinitionId
 084                }), cancellationToken);
 085                return;
 86            }
 87
 26388            var trigger = triggers.FirstOrDefault();
 26389            if (trigger != null)
 90            {
 26391                var workflowGraph = lookupResult.WorkflowGraph!;
 26392                await StartWorkflowAsync(httpContext, trigger, workflowGraph, input, workflowInstanceId, correlationId);
 26393                return;
 94            }
 95        }
 96
 797        var bookmarks = await FindBookmarksAsync(serviceProvider, bookmarkHash, workflowInstanceId, correlationId, cance
 98
 799        if (bookmarks.Count > 1)
 100        {
 0101            await HandleMultipleWorkflowsFoundAsync(httpContext, () => bookmarks.Select(x => new
 0102            {
 0103                x.WorkflowInstanceId
 0104            }), cancellationToken);
 0105            return;
 106        }
 107
 7108        var bookmark = bookmarks.SingleOrDefault();
 109
 7110        if (bookmark != null)
 111        {
 2112            await ResumeWorkflowAsync(httpContext, bookmark, input, correlationId);
 2113            return;
 114        }
 115
 116        // If a base path was configured, the requester tried to execute a workflow that doesn't exist.
 5117        if (basePath != null)
 118        {
 5119            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 5120            return;
 121        }
 122
 123        // If no base path was configured, the request should be handled by subsequent middlewares.
 0124        await next(httpContext);
 306125    }
 126
 127    private async Task<WorkflowGraph?> FindWorkflowGraphAsync(IServiceProvider serviceProvider, StoredTrigger trigger, C
 128    {
 0129        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 0130        var workflowDefinitionId = trigger.WorkflowDefinitionVersionId;
 0131        return await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, cancellationToken);
 0132    }
 133
 134    private async Task<IEnumerable<StoredTrigger>> FindTriggersAsync(IServiceProvider serviceProvider, string bookmarkHa
 135    {
 0136        var triggerStore = serviceProvider.GetRequiredService<ITriggerStore>();
 0137        var triggerFilter = new TriggerFilter
 0138        {
 0139            Hash = bookmarkHash
 0140        };
 0141        return await triggerStore.FindManyAsync(triggerFilter, cancellationToken);
 0142    }
 143
 144    private async Task<IEnumerable<StoredBookmark>> FindBookmarksAsync(IServiceProvider serviceProvider, string bookmark
 145    {
 7146        var bookmarkStore = serviceProvider.GetRequiredService<IBookmarkStore>();
 7147        var bookmarkFilter = new BookmarkFilter
 7148        {
 7149            Hash = bookmarkHash,
 7150            WorkflowInstanceId = workflowInstanceId,
 7151            CorrelationId = correlationId,
 7152            TenantAgnostic = true
 7153        };
 7154        return await bookmarkStore.FindManyAsync(bookmarkFilter, cancellationToken);
 7155    }
 156
 157    private async Task StartWorkflowAsync(HttpContext httpContext, StoredTrigger trigger, WorkflowGraph workflowGraph, I
 158    {
 263159        var bookmarkPayload = trigger.GetPayload<HttpEndpointBookmarkPayload>();
 263160        var workflowOptions = new RunWorkflowOptions
 263161        {
 263162            Input = input,
 263163            CorrelationId = correlationId,
 263164            TriggerActivityId = trigger.ActivityId,
 263165            WorkflowInstanceId = workflowInstanceId
 263166        };
 167
 263168        await ExecuteWorkflowAsync(httpContext, workflowGraph, workflowOptions, bookmarkPayload, null, input);
 263169    }
 170
 171    private async Task ResumeWorkflowAsync(HttpContext httpContext, StoredBookmark bookmark, IDictionary<string, object>
 172    {
 2173        var serviceProvider = httpContext.RequestServices;
 2174        var cancellationToken = httpContext.RequestAborted;
 2175        var bookmarkPayload = bookmark.GetPayload<HttpEndpointBookmarkPayload>();
 2176        var workflowInstanceStore = serviceProvider.GetRequiredService<IWorkflowInstanceStore>();
 2177        var workflowInstance = await workflowInstanceStore.FindAsync(bookmark.WorkflowInstanceId, cancellationToken);
 178
 2179        if (workflowInstance == null)
 180        {
 0181            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0182            return;
 183        }
 184
 2185        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 2186        var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowInstance.DefinitionVersionId,
 187
 2188        if (workflowGraph == null)
 189        {
 0190            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0191            return;
 192        }
 193
 2194        var runWorkflowParams = new RunWorkflowOptions
 2195        {
 2196            WorkflowInstanceId = workflowInstance.Id,
 2197            Input = input,
 2198            CorrelationId = correlationId,
 2199            ActivityHandle = bookmark.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(bookmark.Activi
 2200            BookmarkId = bookmark.Id
 2201        };
 202
 2203        await ExecuteWorkflowAsync(httpContext, workflowGraph, runWorkflowParams, bookmarkPayload, workflowInstance, inp
 2204    }
 205
 206    private async Task ExecuteWorkflowAsync(HttpContext httpContext, WorkflowGraph workflowGraph, RunWorkflowOptions wor
 207    {
 265208        var serviceProvider = httpContext.RequestServices;
 265209        var cancellationToken = httpContext.RequestAborted;
 265210        var workflow = workflowGraph.Workflow;
 211
 265212        if (!await AuthorizeAsync(serviceProvider, httpContext, workflow, bookmarkPayload, cancellationToken))
 213        {
 0214            httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
 0215            return;
 216        }
 217
 265218        var workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
 265219        var result = await ExecuteWithinTimeoutAsync(async ct =>
 265220        {
 265221            if (workflowInstance == null)
 263222                return await workflowRunner.RunAsync(workflowGraph, workflowOptions, ct);
 2223            return await workflowRunner.RunAsync(workflow, workflowInstance.WorkflowState, workflowOptions, ct);
 530224        }, bookmarkPayload.RequestTimeout, httpContext);
 265225        await HandleWorkflowFaultAsync(serviceProvider, httpContext, result, cancellationToken);
 265226    }
 227
 228    private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout
 229    {
 230        // If no request timeout is specified, execute the action without any timeout.
 265231        if (requestTimeout == null)
 265232            return await action(httpContext.RequestAborted);
 233
 234        // Create a combined cancellation token that cancels when the request is aborted or when the request timeout is 
 0235        using var requestTimeoutCancellationTokenSource = new CancellationTokenSource();
 0236        requestTimeoutCancellationTokenSource.CancelAfter(requestTimeout.Value);
 0237        using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted, requ
 0238        var originalCancellationToken = httpContext.RequestAborted;
 239
 240        // Replace the original cancellation token with the combined one.
 0241        httpContext.RequestAborted = combinedTokenSource.Token;
 242
 243        // Execute the action.
 0244        var result = await action(httpContext.RequestAborted);
 245
 246        // Restore the original cancellation token.
 0247        httpContext.RequestAborted = originalCancellationToken;
 248
 0249        return result;
 265250    }
 251
 252    private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path)
 253    {
 306254        var routeMatcher = serviceProvider.GetRequiredService<IRouteMatcher>();
 306255        var routeTable = serviceProvider.GetRequiredService<IRouteTable>();
 256
 306257        var matchingRouteQuery =
 306258            from routeData in routeTable
 3198259            let routeValues = routeMatcher.Match(routeData.Route, path)
 3198260            where routeValues != null
 574261            select new
 574262            {
 574263                route = routeData,
 574264                routeValues
 574265            };
 266
 306267        var matchingRoute = matchingRouteQuery.FirstOrDefault();
 306268        var routeTemplate = matchingRoute?.route ?? new HttpRouteData(path);
 269
 306270        return routeTemplate;
 271    }
 272
 273    private async Task<string?> GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Cancell
 274    {
 270275        var correlationIdSelectors = serviceProvider.GetServices<IHttpCorrelationIdSelector>();
 276
 270277        var correlationId = default(string);
 278
 2156279        foreach (var selector in correlationIdSelectors.OrderByDescending(x => x.Priority))
 280        {
 540281            correlationId = await selector.GetCorrelationIdAsync(httpContext, cancellationToken);
 282
 540283            if (correlationId != null)
 284                break;
 285        }
 286
 270287        return correlationId;
 270288    }
 289
 290    private async Task<string?> GetWorkflowInstanceIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Ca
 291    {
 270292        var workflowInstanceIdSelectors = serviceProvider.GetServices<IHttpWorkflowInstanceIdSelector>();
 293
 270294        var workflowInstanceId = default(string);
 295
 2156296        foreach (var selector in workflowInstanceIdSelectors.OrderByDescending(x => x.Priority))
 297        {
 540298            workflowInstanceId = await selector.GetWorkflowInstanceIdAsync(httpContext, cancellationToken);
 299
 540300            if (workflowInstanceId != null)
 301                break;
 302        }
 303
 270304        return workflowInstanceId;
 270305    }
 306
 307    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 308    private static async Task WriteResponseAsync(HttpContext httpContext, CancellationToken cancellationToken)
 309    {
 0310        var response = httpContext.Response;
 311
 0312        if (!response.HasStarted)
 313        {
 0314            response.ContentType = MediaTypeNames.Application.Json;
 0315            response.StatusCode = StatusCodes.Status200OK;
 316
 0317            var model = new
 0318            {
 0319                workflowInstanceIds = Array.Empty<string>(),
 0320            };
 321
 0322            var json = JsonSerializer.Serialize(model);
 0323            await response.WriteAsync(json, cancellationToken);
 324        }
 0325    }
 326
 327    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 328    private async Task<bool> HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, Func<IEnumerable<object>> workfl
 329    {
 0330        httpContext.Response.ContentType = "application/json";
 0331        httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
 332
 0333        var responseContent = JsonSerializer.Serialize(new
 0334        {
 0335            errorMessage = "The call is ambiguous and matches multiple workflows.",
 0336            workflows = workflowMatches().ToArray()
 0337        });
 338
 0339        await httpContext.Response.WriteAsync(responseContent, cancellationToken);
 0340        return true;
 0341    }
 342
 343    private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWork
 344    {
 265345        if (!workflowExecutionResult.WorkflowState.Incidents.Any() || httpContext.Response.HasStarted)
 265346            return false;
 347
 0348        var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
 0349        var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
 0350        var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cance
 0351        await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState.WorkflowState, cancellationToken));
 0352        return true;
 265353    }
 354
 355    private async Task<bool> AuthorizeAsync(
 356        IServiceProvider serviceProvider,
 357        HttpContext httpContext,
 358        Workflow workflow,
 359        HttpEndpointBookmarkPayload bookmarkPayload,
 360        CancellationToken cancellationToken)
 361    {
 265362        var httpEndpointAuthorizationHandler = serviceProvider.GetRequiredService<IHttpEndpointAuthorizationHandler>();
 363
 265364        if (bookmarkPayload.Authorize == false)
 265365            return true;
 366
 0367        return await httpEndpointAuthorizationHandler.AuthorizeAsync(new(httpContext, workflow, bookmarkPayload.Policy))
 265368    }
 369
 370    private string ComputeBookmarkHash(IServiceProvider serviceProvider, string path, string method)
 371    {
 270372        var bookmarkPayload = new HttpEndpointBookmarkPayload(path, method);
 270373        var bookmarkHasher = serviceProvider.GetRequiredService<IStimulusHasher>();
 270374        return bookmarkHasher.Hash(HttpStimulusNames.HttpEndpoint, bookmarkPayload);
 375    }
 376}