< 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
68%
Covered lines: 134
Uncovered lines: 63
Coverable lines: 197
Total lines: 373
Line coverage: 68%
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%181893.02%
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]
 130public class HttpWorkflowsMiddleware(RequestDelegate next, IOptions<HttpActivityOptions> options)
 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(HttpContext httpContext, IServiceProvider serviceProvider)
 37    {
 23638        var path = httpContext.Request.Path.Value!.NormalizeRoute();
 23639        var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
 23640        var basePath = options.Value.BasePath?.ToString().NormalizeRoute();
 41
 42        // If the request path does not match the configured base path to handle workflows, then skip.
 23643        if (!string.IsNullOrWhiteSpace(basePath))
 44        {
 23645            if (!path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
 46            {
 2147                await next(httpContext);
 2148                return;
 49            }
 50
 51            // Strip the base path.
 21552            matchingPath = matchingPath[basePath.Length..];
 53        }
 54
 21555        matchingPath = matchingPath.NormalizeRoute();
 56
 21557        var input = new Dictionary<string, object>
 21558        {
 21559            [HttpEndpoint.HttpContextInputKey] = true,
 21560            [HttpEndpoint.PathInputKey] = path
 21561        };
 62
 21563        var cancellationToken = httpContext.RequestAborted;
 21564        var request = httpContext.Request;
 21565        var method = request.Method.ToLowerInvariant();
 21566        var httpWorkflowLookupService = serviceProvider.GetRequiredService<IHttpWorkflowLookupService>();
 21567        var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken);
 21568        var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken);
 21569        var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method);
 21570        var lookupResult = await httpWorkflowLookupService.FindWorkflowAsync(bookmarkHash, cancellationToken);
 71
 21572        if (lookupResult != null)
 73        {
 21274            var triggers = lookupResult.Triggers;
 75
 21276            if (triggers.Count > 1)
 77            {
 078                await HandleMultipleWorkflowsFoundAsync(httpContext, () => triggers.Select(x => new
 079                {
 080                    x.WorkflowDefinitionId
 081                }), cancellationToken);
 082                return;
 83            }
 84
 21285            var trigger = triggers.FirstOrDefault();
 21286            if (trigger != null)
 87            {
 21288                var workflowGraph = lookupResult.WorkflowGraph!;
 21289                await StartWorkflowAsync(httpContext, trigger, workflowGraph, input, workflowInstanceId, correlationId);
 21290                return;
 91            }
 92        }
 93
 394        var bookmarks = await FindBookmarksAsync(serviceProvider, bookmarkHash, workflowInstanceId, correlationId, cance
 95
 396        if (bookmarks.Count > 1)
 97        {
 098            await HandleMultipleWorkflowsFoundAsync(httpContext, () => bookmarks.Select(x => new
 099            {
 0100                x.WorkflowInstanceId
 0101            }), cancellationToken);
 0102            return;
 103        }
 104
 3105        var bookmark = bookmarks.SingleOrDefault();
 106
 3107        if (bookmark != null)
 108        {
 2109            await ResumeWorkflowAsync(httpContext, bookmark, input, correlationId);
 2110            return;
 111        }
 112
 113        // If a base path was configured, the requester tried to execute a workflow that doesn't exist.
 1114        if (basePath != null)
 115        {
 1116            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 1117            return;
 118        }
 119
 120        // If no base path was configured, the request should be handled by subsequent middlewares.
 0121        await next(httpContext);
 236122    }
 123
 124    private async Task<WorkflowGraph?> FindWorkflowGraphAsync(IServiceProvider serviceProvider, StoredTrigger trigger, C
 125    {
 0126        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 0127        var workflowDefinitionId = trigger.WorkflowDefinitionVersionId;
 0128        return await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, cancellationToken);
 0129    }
 130
 131    private async Task<IEnumerable<StoredTrigger>> FindTriggersAsync(IServiceProvider serviceProvider, string bookmarkHa
 132    {
 0133        var triggerStore = serviceProvider.GetRequiredService<ITriggerStore>();
 0134        var triggerFilter = new TriggerFilter
 0135        {
 0136            Hash = bookmarkHash
 0137        };
 0138        return await triggerStore.FindManyAsync(triggerFilter, cancellationToken);
 0139    }
 140
 141    private async Task<IEnumerable<StoredBookmark>> FindBookmarksAsync(IServiceProvider serviceProvider, string bookmark
 142    {
 3143        var bookmarkStore = serviceProvider.GetRequiredService<IBookmarkStore>();
 3144        var bookmarkFilter = new BookmarkFilter
 3145        {
 3146            Hash = bookmarkHash,
 3147            WorkflowInstanceId = workflowInstanceId,
 3148            CorrelationId = correlationId,
 3149            TenantAgnostic = true
 3150        };
 3151        return await bookmarkStore.FindManyAsync(bookmarkFilter, cancellationToken);
 3152    }
 153
 154    private async Task StartWorkflowAsync(HttpContext httpContext, StoredTrigger trigger, WorkflowGraph workflowGraph, I
 155    {
 212156        var bookmarkPayload = trigger.GetPayload<HttpEndpointBookmarkPayload>();
 212157        var workflowOptions = new RunWorkflowOptions
 212158        {
 212159            Input = input,
 212160            CorrelationId = correlationId,
 212161            TriggerActivityId = trigger.ActivityId,
 212162            WorkflowInstanceId = workflowInstanceId
 212163        };
 164
 212165        await ExecuteWorkflowAsync(httpContext, workflowGraph, workflowOptions, bookmarkPayload, null, input);
 212166    }
 167
 168    private async Task ResumeWorkflowAsync(HttpContext httpContext, StoredBookmark bookmark, IDictionary<string, object>
 169    {
 2170        var serviceProvider = httpContext.RequestServices;
 2171        var cancellationToken = httpContext.RequestAborted;
 2172        var bookmarkPayload = bookmark.GetPayload<HttpEndpointBookmarkPayload>();
 2173        var workflowInstanceStore = serviceProvider.GetRequiredService<IWorkflowInstanceStore>();
 2174        var workflowInstance = await workflowInstanceStore.FindAsync(bookmark.WorkflowInstanceId, cancellationToken);
 175
 2176        if (workflowInstance == null)
 177        {
 0178            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0179            return;
 180        }
 181
 2182        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 2183        var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowInstance.DefinitionVersionId,
 184
 2185        if (workflowGraph == null)
 186        {
 0187            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0188            return;
 189        }
 190
 2191        var runWorkflowParams = new RunWorkflowOptions
 2192        {
 2193            WorkflowInstanceId = workflowInstance.Id,
 2194            Input = input,
 2195            CorrelationId = correlationId,
 2196            ActivityHandle = bookmark.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(bookmark.Activi
 2197            BookmarkId = bookmark.Id
 2198        };
 199
 2200        await ExecuteWorkflowAsync(httpContext, workflowGraph, runWorkflowParams, bookmarkPayload, workflowInstance, inp
 2201    }
 202
 203    private async Task ExecuteWorkflowAsync(HttpContext httpContext, WorkflowGraph workflowGraph, RunWorkflowOptions wor
 204    {
 214205        var serviceProvider = httpContext.RequestServices;
 214206        var cancellationToken = httpContext.RequestAborted;
 214207        var workflow = workflowGraph.Workflow;
 208
 214209        if (!await AuthorizeAsync(serviceProvider, httpContext, workflow, bookmarkPayload, cancellationToken))
 210        {
 0211            httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
 0212            return;
 213        }
 214
 214215        var workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
 214216        var result = await ExecuteWithinTimeoutAsync(async ct =>
 214217        {
 214218            if (workflowInstance == null)
 212219                return await workflowRunner.RunAsync(workflowGraph, workflowOptions, ct);
 2220            return await workflowRunner.RunAsync(workflow, workflowInstance.WorkflowState, workflowOptions, ct);
 428221        }, bookmarkPayload.RequestTimeout, httpContext);
 214222        await HandleWorkflowFaultAsync(serviceProvider, httpContext, result, cancellationToken);
 214223    }
 224
 225    private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout
 226    {
 227        // If no request timeout is specified, execute the action without any timeout.
 214228        if (requestTimeout == null)
 214229            return await action(httpContext.RequestAborted);
 230
 231        // Create a combined cancellation token that cancels when the request is aborted or when the request timeout is 
 0232        using var requestTimeoutCancellationTokenSource = new CancellationTokenSource();
 0233        requestTimeoutCancellationTokenSource.CancelAfter(requestTimeout.Value);
 0234        using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted, requ
 0235        var originalCancellationToken = httpContext.RequestAborted;
 236
 237        // Replace the original cancellation token with the combined one.
 0238        httpContext.RequestAborted = combinedTokenSource.Token;
 239
 240        // Execute the action.
 0241        var result = await action(httpContext.RequestAborted);
 242
 243        // Restore the original cancellation token.
 0244        httpContext.RequestAborted = originalCancellationToken;
 245
 0246        return result;
 214247    }
 248
 249    private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path)
 250    {
 236251        var routeMatcher = serviceProvider.GetRequiredService<IRouteMatcher>();
 236252        var routeTable = serviceProvider.GetRequiredService<IRouteTable>();
 253
 236254        var matchingRouteQuery =
 236255            from routeData in routeTable
 857256            let routeValues = routeMatcher.Match(routeData.Route, path)
 857257            where routeValues != null
 451258            select new
 451259            {
 451260                route = routeData,
 451261                routeValues
 451262            };
 263
 236264        var matchingRoute = matchingRouteQuery.FirstOrDefault();
 236265        var routeTemplate = matchingRoute?.route ?? new HttpRouteData(path);
 266
 236267        return routeTemplate;
 268    }
 269
 270    private async Task<string?> GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Cancell
 271    {
 215272        var correlationIdSelectors = serviceProvider.GetServices<IHttpCorrelationIdSelector>();
 273
 215274        var correlationId = default(string);
 275
 1716276        foreach (var selector in correlationIdSelectors.OrderByDescending(x => x.Priority))
 277        {
 430278            correlationId = await selector.GetCorrelationIdAsync(httpContext, cancellationToken);
 279
 430280            if (correlationId != null)
 281                break;
 282        }
 283
 215284        return correlationId;
 215285    }
 286
 287    private async Task<string?> GetWorkflowInstanceIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Ca
 288    {
 215289        var workflowInstanceIdSelectors = serviceProvider.GetServices<IHttpWorkflowInstanceIdSelector>();
 290
 215291        var workflowInstanceId = default(string);
 292
 1716293        foreach (var selector in workflowInstanceIdSelectors.OrderByDescending(x => x.Priority))
 294        {
 430295            workflowInstanceId = await selector.GetWorkflowInstanceIdAsync(httpContext, cancellationToken);
 296
 430297            if (workflowInstanceId != null)
 298                break;
 299        }
 300
 215301        return workflowInstanceId;
 215302    }
 303
 304    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 305    private static async Task WriteResponseAsync(HttpContext httpContext, CancellationToken cancellationToken)
 306    {
 0307        var response = httpContext.Response;
 308
 0309        if (!response.HasStarted)
 310        {
 0311            response.ContentType = MediaTypeNames.Application.Json;
 0312            response.StatusCode = StatusCodes.Status200OK;
 313
 0314            var model = new
 0315            {
 0316                workflowInstanceIds = Array.Empty<string>(),
 0317            };
 318
 0319            var json = JsonSerializer.Serialize(model);
 0320            await response.WriteAsync(json, cancellationToken);
 321        }
 0322    }
 323
 324    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 325    private async Task<bool> HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, Func<IEnumerable<object>> workfl
 326    {
 0327        httpContext.Response.ContentType = "application/json";
 0328        httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
 329
 0330        var responseContent = JsonSerializer.Serialize(new
 0331        {
 0332            errorMessage = "The call is ambiguous and matches multiple workflows.",
 0333            workflows = workflowMatches().ToArray()
 0334        });
 335
 0336        await httpContext.Response.WriteAsync(responseContent, cancellationToken);
 0337        return true;
 0338    }
 339
 340    private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWork
 341    {
 214342        if (!workflowExecutionResult.WorkflowState.Incidents.Any() || httpContext.Response.HasStarted)
 214343            return false;
 344
 0345        var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
 0346        var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
 0347        var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cance
 0348        await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState.WorkflowState, cancellationToken));
 0349        return true;
 214350    }
 351
 352    private async Task<bool> AuthorizeAsync(
 353        IServiceProvider serviceProvider,
 354        HttpContext httpContext,
 355        Workflow workflow,
 356        HttpEndpointBookmarkPayload bookmarkPayload,
 357        CancellationToken cancellationToken)
 358    {
 214359        var httpEndpointAuthorizationHandler = serviceProvider.GetRequiredService<IHttpEndpointAuthorizationHandler>();
 360
 214361        if (bookmarkPayload.Authorize == false)
 214362            return true;
 363
 0364        return await httpEndpointAuthorizationHandler.AuthorizeAsync(new(httpContext, workflow, bookmarkPayload.Policy))
 214365    }
 366
 367    private string ComputeBookmarkHash(IServiceProvider serviceProvider, string path, string method)
 368    {
 215369        var bookmarkPayload = new HttpEndpointBookmarkPayload(path, method);
 215370        var bookmarkHasher = serviceProvider.GetRequiredService<IStimulusHasher>();
 215371        return bookmarkHasher.Hash(HttpStimulusNames.HttpEndpoint, bookmarkPayload);
 372    }
 373}