< Summary

Information
Class: Elsa.Http.SendHttpRequestBase
Assembly: Elsa.Http
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
Line coverage
70%
Covered lines: 97
Uncovered lines: 40
Coverable lines: 137
Total lines: 332
Line coverage: 70.8%
Branch coverage
35%
Covered branches: 19
Total branches: 54
Branch coverage: 35.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Url()100%11100%
get_Method()100%11100%
get_Content()100%11100%
get_ContentType()100%11100%
get_Authorization()100%11100%
get_DisableAuthorizationHeaderValidation()100%11100%
get_RequestHeaders()100%11100%
get_EnableResiliency()100%11100%
get_StatusCode()100%11100%
get_ParsedContent()100%11100%
get_ResponseHeaders()100%11100%
ExecuteAsync()100%11100%
CollectRetryDetails(...)50%8890%
TrySendAsync()100%11100%
SendRequestAsync()50%2275%
<TrySendAsync()100%210%
<TrySendAsync()100%11100%
SendRequestAsyncCore()100%11100%
ParseContentAsync()70%101093.75%
HasContent(...)100%11100%
PrepareRequest(...)58.33%151273.68%
SelectContentWriter(...)0%4260%
BuildResiliencyPipeline(...)100%210%
IsTransientStatusCode(...)0%272160%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http.Headers;
 3using Elsa.Extensions;
 4using Elsa.Http.ContentWriters;
 5using Elsa.Http.UIHints;
 6using Elsa.Resilience;
 7using Elsa.Resilience.Models;
 8using Elsa.Workflows;
 9using Elsa.Workflows.Attributes;
 10using Elsa.Workflows.UIHints;
 11using Elsa.Workflows.Models;
 12using Microsoft.Extensions.Logging;
 13using Polly;
 14
 15namespace Elsa.Http;
 16
 17/// <summary>
 18/// Base class for activities that send HTTP requests.
 19/// </summary>
 20[Output(IsSerializable = false)]
 21[ResilienceCategory("HTTP")]
 8322public abstract class SendHttpRequestBase(string? source = null, int? line = null) : Activity<HttpResponseMessage>(sourc
 23{
 24    /// <summary>
 25    /// The URL to send the request to.
 26    /// </summary>
 10627    [Input(Order = 0)] public Input<Uri?> Url { get; set; } = null!;
 28
 29    /// <summary>
 30    /// The HTTP method to use when sending the request.
 31    /// </summary>
 32    [Input(
 33        Description = "The HTTP method to use when sending the request.",
 34        Options = new[]
 35        {
 36            "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"
 37        },
 38        DefaultValue = "GET",
 39        UIHint = InputUIHints.DropDown,
 40        Order = 1
 41    )]
 18942    public Input<string> Method { get; set; } = new("GET");
 43
 44    /// <summary>
 45    /// The content to send with the request. Can be a string, an object, a byte array or a stream.
 46    /// </summary>
 47    [Input(
 48        Description = "The content to send with the request. Can be a string, an object, a byte array or a stream.",
 49        Order = 2
 50        )]
 10051    public Input<object?> Content { get; set; } = null!;
 52
 53    /// <summary>
 54    /// The content type to use when sending the request.
 55    /// </summary>
 56    [Input(
 57        Description = "The content type to use when sending the request.",
 58        UIHandler = typeof(HttpContentTypeOptionsProvider),
 59        UIHint = InputUIHints.DropDown,
 60        Order = 3
 61    )]
 10062    public Input<string?> ContentType { get; set; } = null!;
 63
 64    /// <summary>
 65    /// The Authorization header value to send with the request.
 66    /// </summary>
 67    /// <example>Bearer {some-access-token}</example>
 68    [Input(
 69        Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}"
 70        Category = "Security",
 71        CanContainSecrets = true,
 72        Order = 4
 73    )]
 10074    public Input<string?> Authorization { get; set; } = null!;
 75
 76    /// <summary>
 77    /// A value that allows to add the Authorization header without validation.
 78    /// </summary>
 79    [Input(
 80        Description = "A value that allows to add the Authorization header without validation.",
 81        Category = "Security",
 82        Order = 5
 83    )]
 8084    public Input<bool> DisableAuthorizationHeaderValidation { get; set; } = null!;
 85
 86    /// <summary>
 87    /// The headers to send along with the request.
 88    /// </summary>
 89    [Input(
 90        Description = "The headers to send along with the request.",
 91        UIHint = InputUIHints.JsonEditor,
 92        Category = "Advanced",
 93        Order = 6
 94    )]
 16395    public Input<HttpHeaders?> RequestHeaders { get; set; } = new(new HttpHeaders());
 96
 97    /// <summary>
 98    /// Indicates whether resiliency mechanisms should be enabled for the HTTP request.
 99    /// </summary>
 100    [Obsolete("Use the common Resilience Strategy setting instead.")]
 101    [Input(Description = "Obsolete. Use the common Resilience Strategy setting instead.")]
 78102    public Input<bool> EnableResiliency { get; set; } = null!;
 103
 104    /// <summary>
 105    /// The HTTP response status code
 106    /// </summary>
 107    [Output(Description = "The HTTP response status code")]
 48108    public Output<int> StatusCode { get; set; } = null!;
 109
 110    /// <summary>
 111    /// The parsed content, if any.
 112    /// </summary>
 113    [Output(Description = "The parsed content, if any.")]
 55114    public Output<object?> ParsedContent { get; set; } = null!;
 115
 116    /// <summary>
 117    /// The response headers that were received.
 118    /// </summary>
 119    [Output(Description = "The response headers that were received.")]
 48120    public Output<HttpHeaders?> ResponseHeaders { get; set; } = null!;
 121
 122    /// <inheritdoc />
 123    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
 124    {
 26125        await TrySendAsync(context);
 26126    }
 127
 128    public IDictionary<string, string?> CollectRetryDetails(ActivityExecutionContext context, RetryAttempt attempt)
 129    {
 2130        if (attempt.Result is not HttpResponseMessage response)
 0131            return new Dictionary<string, string?>();
 132
 2133        return new Dictionary<string, string?>
 2134        {
 2135            ["StatusCode"] = response.StatusCode.ToString(),
 2136            ["ReasonPhrase"] = response.ReasonPhrase,
 2137            ["Content-Type"] = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream",
 2138            ["Date"] = response.Headers.Date.ToString(),
 2139            ["Retry-After"] = response.Headers.RetryAfter?.ToString()
 2140        };
 141    }
 142
 143    /// <summary>
 144    /// Handles the response.
 145    /// </summary>
 146    protected abstract ValueTask HandleResponseAsync(ActivityExecutionContext context, HttpResponseMessage response);
 147
 148    /// <summary>
 149    /// Handles an exception that occurred while sending the request.
 150    /// </summary>
 151    protected abstract ValueTask HandleRequestExceptionAsync(ActivityExecutionContext context, HttpRequestException exce
 152
 153    /// <summary>
 154    /// Handles <see cref="TaskCanceledException"/> that occurred while sending the request.
 155    /// </summary>
 156    protected abstract ValueTask HandleTaskCanceledExceptionAsync(ActivityExecutionContext context, TaskCanceledExceptio
 157
 158    private async Task TrySendAsync(ActivityExecutionContext context)
 159    {
 26160        var logger = (ILogger)context.GetRequiredService(typeof(ILogger<>).MakeGenericType(GetType()));
 26161        var httpClientFactory = context.GetRequiredService<IHttpClientFactory>();
 26162        var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequestBase));
 26163        var cancellationToken = context.CancellationToken;
 26164        var resiliencyEnabled = EnableResiliency.GetOrDefault(context, () => false);
 165
 166        try
 167        {
 26168            var response = await SendRequestAsync(context);
 22169            var parsedContent = await ParseContentAsync(context, response);
 22170            var statusCode = (int)response.StatusCode;
 22171            var responseHeaders = new HttpHeaders(response.Headers);
 172
 22173            context.Set(Result, response);
 22174            context.Set(ParsedContent, parsedContent);
 22175            context.Set(StatusCode, statusCode);
 22176            context.Set(ResponseHeaders, responseHeaders);
 177
 22178            await HandleResponseAsync(context, response);
 22179        }
 2180        catch (HttpRequestException e)
 181        {
 2182            logger.LogWarning(e, "An error occurred while sending an HTTP request");
 2183            context.AddExecutionLogEntry("Error", e.Message, payload: new
 2184            {
 2185                e.StackTrace
 2186            });
 2187            context.JournalData.Add("Error", e.Message);
 2188            await HandleRequestExceptionAsync(context, e);
 2189        }
 2190        catch (TaskCanceledException e)
 191        {
 2192            logger.LogWarning(e, "An error occurred while sending an HTTP request");
 2193            context.AddExecutionLogEntry("Error", e.Message, payload: new
 2194            {
 2195                e.StackTrace
 2196            });
 2197            context.JournalData.Add("Cancelled", true);
 2198            await HandleTaskCanceledExceptionAsync(context, e);
 199        }
 200
 26201        return;
 202
 203        async Task<HttpResponseMessage> SendRequestAsync(ActivityExecutionContext activityExecutionContext)
 204        {
 205            // Keep this for backward compatibility.
 26206            if (resiliencyEnabled)
 207            {
 0208                var pipeline = BuildResiliencyPipeline(context);
 0209                return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken);
 210            }
 211
 26212            var resilienceService = activityExecutionContext.GetRequiredService<IResilientActivityInvoker>();
 54213            return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyn
 22214        }
 215
 216        async Task<HttpResponseMessage> SendRequestAsyncCore(CancellationToken ct = default)
 217        {
 28218            var request = PrepareRequest(context);
 219
 28220            return await httpClient.SendAsync(request, ct);
 24221        }
 26222    }
 223
 224    private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpResponseMessage httpResponse)
 225    {
 22226        var httpContent = httpResponse.Content;
 22227        if (!HasContent(httpContent))
 16228            return null;
 229
 6230        var cancellationToken = context.CancellationToken;
 6231        var targetType = ParsedContent.GetTargetType(context);
 6232        var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken);
 6233        var responseHeaders = httpResponse.Headers;
 6234        var contentHeaders = httpContent.Headers;
 6235        var contentType = contentHeaders.ContentType?.MediaType ?? "application/octet-stream";
 236
 6237        targetType ??= contentType switch
 6238        {
 6239            "application/json" => typeof(object),
 0240            _ => typeof(string)
 6241        };
 242
 30243        var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.Or
 6244        var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.
 30245        var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x =>
 6246        return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationTo
 22247    }
 248
 22249    private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0;
 250
 251    private HttpRequestMessage PrepareRequest(ActivityExecutionContext context)
 252    {
 28253        var method = Method.GetOrDefault(context) ?? "GET";
 28254        var url = Url.Get(context);
 28255        var request = new HttpRequestMessage(new HttpMethod(method), url);
 28256        var headers = context.GetHeaders(RequestHeaders);
 28257        var authorization = Authorization.GetOrDefault(context);
 28258        var addAuthorizationWithoutValidation = DisableAuthorizationHeaderValidation.GetOrDefault(context);
 259
 28260        if (!string.IsNullOrWhiteSpace(authorization))
 6261            if (addAuthorizationWithoutValidation)
 0262                request.Headers.TryAddWithoutValidation("Authorization", authorization);
 263            else
 6264                request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);
 265
 56266        foreach (var header in headers)
 0267            request.Headers.Add(header.Key, header.Value.AsEnumerable());
 268
 28269        var contentType = ContentType.GetOrDefault(context);
 28270        var content = Content.GetOrDefault(context);
 271
 28272        if (contentType != null && content != null)
 273        {
 0274            var factories = context.GetServices<IHttpContentFactory>();
 0275            var factory = SelectContentWriter(contentType, factories);
 0276            request.Content = factory.CreateHttpContent(content, contentType);
 277        }
 278
 28279        return request;
 280    }
 281
 282    private IHttpContentFactory SelectContentWriter(string? contentType, IEnumerable<IHttpContentFactory> factories)
 283    {
 0284        if (string.IsNullOrWhiteSpace(contentType))
 0285            return new JsonContentFactory();
 286
 0287        var parsedContentType = new System.Net.Mime.ContentType(contentType);
 0288        return factories.FirstOrDefault(httpContentFactory => httpContentFactory.SupportedContentTypes.Any(c => c == par
 289    }
 290
 291    private ResiliencePipeline<HttpResponseMessage> BuildResiliencyPipeline(ActivityExecutionContext context)
 292    {
 293        // Docs: https://www.pollydocs.org/strategies/retry
 0294        var pipelineBuilder = new ResiliencePipelineBuilder<HttpResponseMessage>()
 0295            .AddRetry(new()
 0296            {
 0297                ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
 0298                    .Handle<TimeoutException>() // Specific timeout exception
 0299                    .Handle<HttpRequestException>() // Any HTTP exception
 0300                    .HandleResult(response => IsTransientStatusCode(response.StatusCode)),
 0301                MaxRetryAttempts = 8,
 0302                UseJitter = false, // If enabled, adds a random value between -25% and +25% of the calculated Delay, exc
 0303                Delay = TimeSpan.FromSeconds(1),
 0304                BackoffType = DelayBackoffType.Exponential // Delay * 2^AttemptNumber, e.g. [ 2s, 4s, 8s, 16s ]. Total s
 0305                // If BackoffType is Exponential, then the calculated Delay is multiplied by a random value between -25%
 0306            });
 307
 0308        return pipelineBuilder.Build();
 309    }
 310
 311    // Helper method to identify transient status codes.
 312    private static bool IsTransientStatusCode(HttpStatusCode? statusCode)
 313    {
 0314        if (statusCode is null)
 315        {
 316            // No status code -> Assume network failure, worth retrying.
 0317            return true;
 318        }
 319
 0320        return statusCode.Value switch
 0321        {
 0322            HttpStatusCode.RequestTimeout => true, // 408
 0323            HttpStatusCode.TooManyRequests => true, // 429 (if no Retry-After header is respected)
 0324            HttpStatusCode.InternalServerError => true, // 500
 0325            HttpStatusCode.BadGateway => true, // 502
 0326            HttpStatusCode.ServiceUnavailable => true, // 503
 0327            HttpStatusCode.GatewayTimeout => true, // 504
 0328            HttpStatusCode.Conflict => true, // 409 - Can be transient in concurrency cases
 0329            _ => false // Other errors are not transient
 0330        };
 331    }
 332}