< 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: 152
Uncovered lines: 70
Coverable lines: 222
Total lines: 430
Line coverage: 68.4%
Branch coverage
68%
Covered branches: 52
Total branches: 76
Branch coverage: 68.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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]
 1530public 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    {
 30742        var path = httpContext.Request.Path.Value!.NormalizeRoute();
 30743        var basePath = options.Value.BasePath?.ToString().NormalizeRoute();
 44
 30745        if (!string.IsNullOrWhiteSpace(basePath) && !IsBasePathMatch(path, basePath))
 46        {
 3947            await next(httpContext);
 3948            return;
 49        }
 50
 26851        var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
 26852        matchingPath = TryStripBasePath(matchingPath, basePath) ?? matchingPath;
 53
 54        // Graceful-shutdown gate: when the runtime is paused or draining, we don't accept new HTTP-triggered work.
 55        // The ingress source registry visibility is provided by HttpTriggerIngressSource — this is the actual mechanism
 26856        var quiescenceSignal = serviceProvider.GetService<IQuiescenceSignal>();
 26857        if (quiescenceSignal is not null && !quiescenceSignal.IsAcceptingNewWork)
 58        {
 059            httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
 60            // Retry-After is reason-aware: drain is short (host is exiting and will be replaced shortly), but an
 61            // administrative pause is indefinite, so a longer back-off avoids a tight retry loop while operators
 62            // perform maintenance.
 063            httpContext.Response.Headers.RetryAfter = quiescenceSignal.CurrentState.Reason.HasFlag(QuiescenceReason.Drai
 064            return;
 65        }
 66
 26867        matchingPath = matchingPath.NormalizeRoute();
 68
 26869        var input = new Dictionary<string, object>
 26870        {
 26871            [HttpEndpoint.HttpContextInputKey] = true,
 26872            [HttpEndpoint.PathInputKey] = path
 26873        };
 74
 26875        var cancellationToken = httpContext.RequestAborted;
 26876        var request = httpContext.Request;
 26877        var method = request.Method.ToLowerInvariant();
 26878        var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken);
 26879        var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken);
 26880        var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method);
 26881        var lookupResult = await httpWorkflowLookupService.FindWorkflowAsync(bookmarkHash, cancellationToken);
 82
 26883        if (lookupResult != null)
 84        {
 25885            var triggers = lookupResult.Triggers;
 86
 25887            if (triggers.Count > 1)
 88            {
 089                await HandleMultipleWorkflowsFoundAsync(httpContext, () => triggers.Select(x => new
 090                {
 091                    x.WorkflowDefinitionId
 092                }), cancellationToken);
 093                return;
 94            }
 95
 25896            var trigger = triggers.FirstOrDefault();
 25897            if (trigger != null)
 98            {
 25899                var workflowGraph = lookupResult.WorkflowGraph!;
 258100                await StartWorkflowAsync(httpContext, trigger, workflowGraph, input, workflowInstanceId, correlationId);
 258101                return;
 102            }
 103        }
 104
 10105        var bookmarks = await FindBookmarksAsync(serviceProvider, bookmarkHash, workflowInstanceId, correlationId, cance
 106
 10107        if (bookmarks.Count > 1)
 108        {
 0109            await HandleMultipleWorkflowsFoundAsync(httpContext, () => bookmarks.Select(x => new
 0110            {
 0111                x.WorkflowInstanceId
 0112            }), cancellationToken);
 0113            return;
 114        }
 115
 10116        var bookmark = bookmarks.SingleOrDefault();
 117
 10118        if (bookmark != null)
 119        {
 2120            await ResumeWorkflowAsync(httpContext, bookmark, input, correlationId);
 2121            return;
 122        }
 123
 124        // If a base path was configured, the requester tried to execute a workflow that doesn't exist.
 8125        if (basePath != null)
 126        {
 7127            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 7128            return;
 129        }
 130
 131        // If no base path was configured, the request should be handled by subsequent middlewares.
 1132        await next(httpContext);
 307133    }
 134
 135    private async Task<WorkflowGraph?> FindWorkflowGraphAsync(IServiceProvider serviceProvider, StoredTrigger trigger, C
 136    {
 0137        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 0138        var workflowDefinitionId = trigger.WorkflowDefinitionVersionId;
 0139        return await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, cancellationToken);
 0140    }
 141
 142    private async Task<IEnumerable<StoredTrigger>> FindTriggersAsync(IServiceProvider serviceProvider, string bookmarkHa
 143    {
 0144        var triggerStore = serviceProvider.GetRequiredService<ITriggerStore>();
 0145        var triggerFilter = new TriggerFilter
 0146        {
 0147            Hash = bookmarkHash
 0148        };
 0149        return await triggerStore.FindManyAsync(triggerFilter, cancellationToken);
 0150    }
 151
 152    private async Task<IEnumerable<StoredBookmark>> FindBookmarksAsync(IServiceProvider serviceProvider, string bookmark
 153    {
 10154        var bookmarkStore = serviceProvider.GetRequiredService<IBookmarkStore>();
 10155        var bookmarkFilter = new BookmarkFilter
 10156        {
 10157            Hash = bookmarkHash,
 10158            WorkflowInstanceId = workflowInstanceId,
 10159            CorrelationId = correlationId
 10160        };
 10161        return await bookmarkStore.FindManyAsync(bookmarkFilter, cancellationToken);
 10162    }
 163
 164    private async Task StartWorkflowAsync(HttpContext httpContext, StoredTrigger trigger, WorkflowGraph workflowGraph, I
 165    {
 258166        var bookmarkPayload = trigger.GetPayload<HttpEndpointBookmarkPayload>();
 258167        var workflowOptions = new RunWorkflowOptions
 258168        {
 258169            Input = input,
 258170            CorrelationId = correlationId,
 258171            TriggerActivityId = trigger.ActivityId,
 258172            WorkflowInstanceId = workflowInstanceId
 258173        };
 174
 258175        await ExecuteWorkflowAsync(httpContext, workflowGraph, workflowOptions, bookmarkPayload, null, input);
 258176    }
 177
 178    private async Task ResumeWorkflowAsync(HttpContext httpContext, StoredBookmark bookmark, IDictionary<string, object>
 179    {
 2180        var serviceProvider = httpContext.RequestServices;
 2181        var cancellationToken = httpContext.RequestAborted;
 2182        var bookmarkPayload = bookmark.GetPayload<HttpEndpointBookmarkPayload>();
 2183        var workflowInstanceStore = serviceProvider.GetRequiredService<IWorkflowInstanceStore>();
 2184        var workflowInstance = await workflowInstanceStore.FindAsync(bookmark.WorkflowInstanceId, cancellationToken);
 185
 2186        if (workflowInstance == null)
 187        {
 0188            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0189            return;
 190        }
 191
 2192        var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
 2193        var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowInstance.DefinitionVersionId,
 194
 2195        if (workflowGraph == null)
 196        {
 0197            await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
 0198            return;
 199        }
 200
 2201        var runWorkflowParams = new RunWorkflowOptions
 2202        {
 2203            WorkflowInstanceId = workflowInstance.Id,
 2204            Input = input,
 2205            CorrelationId = correlationId,
 2206            ActivityHandle = bookmark.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(bookmark.Activi
 2207            BookmarkId = bookmark.Id
 2208        };
 209
 2210        await ExecuteWorkflowAsync(httpContext, workflowGraph, runWorkflowParams, bookmarkPayload, workflowInstance, inp
 2211    }
 212
 213    private async Task ExecuteWorkflowAsync(HttpContext httpContext, WorkflowGraph workflowGraph, RunWorkflowOptions wor
 214    {
 260215        var serviceProvider = httpContext.RequestServices;
 260216        var cancellationToken = httpContext.RequestAborted;
 260217        var workflow = workflowGraph.Workflow;
 218
 260219        if (!await AuthorizeAsync(serviceProvider, httpContext, workflow, bookmarkPayload, cancellationToken))
 220        {
 0221            httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
 0222            return;
 223        }
 224
 260225        var workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
 260226        var result = await ExecuteWithinTimeoutAsync(async ct =>
 260227        {
 260228            if (workflowInstance == null)
 258229                return await workflowRunner.RunAsync(workflowGraph, workflowOptions, ct);
 2230            return await workflowRunner.RunAsync(workflow, workflowInstance.WorkflowState, workflowOptions, ct);
 520231        }, bookmarkPayload.RequestTimeout, httpContext);
 260232        await HandleWorkflowFaultAsync(serviceProvider, httpContext, result, cancellationToken);
 260233    }
 234
 235    private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout
 236    {
 237        // If no request timeout is specified, execute the action without any timeout.
 260238        if (requestTimeout == null)
 260239            return await action(httpContext.RequestAborted);
 240
 241        // Create a combined cancellation token that cancels when the request is aborted or when the request timeout is 
 0242        using var requestTimeoutCancellationTokenSource = new CancellationTokenSource();
 0243        requestTimeoutCancellationTokenSource.CancelAfter(requestTimeout.Value);
 0244        using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted, requ
 0245        var originalCancellationToken = httpContext.RequestAborted;
 246
 247        // Replace the original cancellation token with the combined one.
 0248        httpContext.RequestAborted = combinedTokenSource.Token;
 249
 250        // Execute the action.
 0251        var result = await action(httpContext.RequestAborted);
 252
 253        // Restore the original cancellation token.
 0254        httpContext.RequestAborted = originalCancellationToken;
 255
 0256        return result;
 260257    }
 258
 259    private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path)
 260    {
 268261        var routeMatcher = serviceProvider.GetRequiredService<IRouteMatcher>();
 268262        var routeTable = serviceProvider.GetRequiredService<IRouteTable>();
 263
 268264        var matchingRouteQuery =
 268265            from routeData in routeTable
 2684266            let routeValues = routeMatcher.Match(routeData.Route, path)
 2684267            where routeValues != null
 534268            select new
 534269            {
 534270                route = routeData,
 534271                routeValues
 534272            };
 273
 268274        var matchingRoute = matchingRouteQuery.FirstOrDefault();
 275
 268276        return matchingRoute?.route ?? new HttpRouteData(path);
 277    }
 278
 279    private static string? TryStripBasePath(string route, string? basePath)
 280    {
 268281        if (string.IsNullOrWhiteSpace(basePath) || basePath == "/")
 1282            return route;
 283
 267284        var routeSegments = GetRouteSegments(route);
 267285        var basePathSegments = GetRouteSegments(basePath);
 267286        var basePathIndex = FindSegmentSequence(routeSegments, basePathSegments);
 287
 267288        if (basePathIndex != 0)
 0289            return null;
 290
 267291        var remainingSegments = routeSegments.Skip(basePathIndex + basePathSegments.Length);
 267292        return remainingSegments.Any() ? $"/{string.Join('/', remainingSegments)}" : "/";
 293    }
 294
 295    private static bool IsBasePathMatch(string route, string basePath) =>
 306296        basePath == "/" ||
 306297        string.Equals(route, basePath, StringComparison.OrdinalIgnoreCase) ||
 306298        route.StartsWith($"{basePath}/", StringComparison.OrdinalIgnoreCase);
 299
 300    private static int FindSegmentSequence(string[] routeSegments, string[] candidateSegments)
 301    {
 267302        if (candidateSegments.Length == 0)
 0303            return 0;
 304
 534305        for (var startIndex = 0; startIndex <= routeSegments.Length - candidateSegments.Length; startIndex++)
 306        {
 267307            var isMatch = true;
 308
 1068309            for (var candidateIndex = 0; candidateIndex < candidateSegments.Length; candidateIndex++)
 310            {
 267311                if (!string.Equals(routeSegments[startIndex + candidateIndex], candidateSegments[candidateIndex], String
 312                {
 0313                    isMatch = false;
 0314                    break;
 315                }
 316            }
 317
 267318            if (isMatch)
 267319                return startIndex;
 320        }
 321
 0322        return -1;
 323    }
 324
 534325    private static string[] GetRouteSegments(string route) => route.Trim('/').Split('/', StringSplitOptions.RemoveEmptyE
 326
 327    private async Task<string?> GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Cancell
 328    {
 268329        var correlationIdSelectors = serviceProvider.GetServices<IHttpCorrelationIdSelector>();
 330
 268331        var correlationId = default(string);
 332
 2122333        foreach (var selector in correlationIdSelectors.OrderByDescending(x => x.Priority))
 334        {
 530335            correlationId = await selector.GetCorrelationIdAsync(httpContext, cancellationToken);
 336
 530337            if (correlationId != null)
 338                break;
 339        }
 340
 268341        return correlationId;
 268342    }
 343
 344    private async Task<string?> GetWorkflowInstanceIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, Ca
 345    {
 268346        var workflowInstanceIdSelectors = serviceProvider.GetServices<IHttpWorkflowInstanceIdSelector>();
 347
 268348        var workflowInstanceId = default(string);
 349
 2122350        foreach (var selector in workflowInstanceIdSelectors.OrderByDescending(x => x.Priority))
 351        {
 530352            workflowInstanceId = await selector.GetWorkflowInstanceIdAsync(httpContext, cancellationToken);
 353
 530354            if (workflowInstanceId != null)
 355                break;
 356        }
 357
 268358        return workflowInstanceId;
 268359    }
 360
 361    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 362    private static async Task WriteResponseAsync(HttpContext httpContext, CancellationToken cancellationToken)
 363    {
 0364        var response = httpContext.Response;
 365
 0366        if (!response.HasStarted)
 367        {
 0368            response.ContentType = MediaTypeNames.Application.Json;
 0369            response.StatusCode = StatusCodes.Status200OK;
 370
 0371            var model = new
 0372            {
 0373                workflowInstanceIds = Array.Empty<string>(),
 0374            };
 375
 0376            var json = JsonSerializer.Serialize(model);
 0377            await response.WriteAsync(json, cancellationToken);
 378        }
 0379    }
 380
 381    [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
 382    private async Task<bool> HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, Func<IEnumerable<object>> workfl
 383    {
 0384        httpContext.Response.ContentType = "application/json";
 0385        httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
 386
 0387        var responseContent = JsonSerializer.Serialize(new
 0388        {
 0389            errorMessage = "The call is ambiguous and matches multiple workflows.",
 0390            workflows = workflowMatches().ToArray()
 0391        });
 392
 0393        await httpContext.Response.WriteAsync(responseContent, cancellationToken);
 0394        return true;
 0395    }
 396
 397    private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWork
 398    {
 260399        if (!workflowExecutionResult.WorkflowState.Incidents.Any() || httpContext.Response.HasStarted)
 260400            return false;
 401
 0402        var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
 0403        var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
 0404        var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cance
 0405        await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState.WorkflowState, cancellationToken));
 0406        return true;
 260407    }
 408
 409    private async Task<bool> AuthorizeAsync(
 410        IServiceProvider serviceProvider,
 411        HttpContext httpContext,
 412        Workflow workflow,
 413        HttpEndpointBookmarkPayload bookmarkPayload,
 414        CancellationToken cancellationToken)
 415    {
 260416        var httpEndpointAuthorizationHandler = serviceProvider.GetRequiredService<IHttpEndpointAuthorizationHandler>();
 417
 260418        if (bookmarkPayload.Authorize == false)
 260419            return true;
 420
 0421        return await httpEndpointAuthorizationHandler.AuthorizeAsync(new(httpContext, workflow, bookmarkPayload.Policy))
 260422    }
 423
 424    private string ComputeBookmarkHash(IServiceProvider serviceProvider, string path, string method)
 425    {
 268426        var bookmarkPayload = new HttpEndpointBookmarkPayload(path, method);
 268427        var bookmarkHasher = serviceProvider.GetRequiredService<IStimulusHasher>();
 268428        return bookmarkHasher.Hash(HttpStimulusNames.HttpEndpoint, bookmarkPayload);
 429    }
 430}