< 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
71%
Covered lines: 105
Uncovered lines: 41
Coverable lines: 146
Total lines: 351
Line coverage: 71.9%
Branch coverage
40%
Covered branches: 24
Total branches: 60
Branch coverage: 40%
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%141275%
InjectTraceContext(...)83.33%6691.66%
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")]
 15422public 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>
 11427    [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    )]
 26842    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        )]
 10851    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    )]
 10862    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    )]
 10874    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    )]
 8684    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    )]
 24095    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.")]
 84102    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")]
 52108    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.")]
 61114    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.")]
 52120    public Output<HttpHeaders?> ResponseHeaders { get; set; } = null!;
 121
 122    /// <inheritdoc />
 123    protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
 124    {
 28125        await TrySendAsync(context);
 28126    }
 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    {
 28160        var logger = (ILogger)context.GetRequiredService(typeof(ILogger<>).MakeGenericType(GetType()));
 28161        var httpClientFactory = context.GetRequiredService<IHttpClientFactory>();
 28162        var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequestBase));
 28163        var cancellationToken = context.CancellationToken;
 164        var resiliencyEnabled = EnableResiliency.GetOrDefault(context, () => false);
 165
 166        try
 167        {
 28168            var response = await SendRequestAsync(context);
 24169            var parsedContent = await ParseContentAsync(context, response);
 24170            var statusCode = (int)response.StatusCode;
 24171            var responseHeaders = new HttpHeaders(response.Headers);
 172
 24173            context.Set(Result, response);
 24174            context.Set(ParsedContent, parsedContent);
 24175            context.Set(StatusCode, statusCode);
 24176            context.Set(ResponseHeaders, responseHeaders);
 177
 24178            await HandleResponseAsync(context, response);
 24179        }
 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
 28201        return;
 202
 203        async Task<HttpResponseMessage> SendRequestAsync(ActivityExecutionContext activityExecutionContext)
 204        {
 205            // Keep this for backward compatibility.
 28206            if (resiliencyEnabled)
 207            {
 0208                var pipeline = BuildResiliencyPipeline(context);
 0209                return await pipeline.ExecuteAsync(async ct => await SendRequestAsyncCore(ct), cancellationToken);
 210            }
 211
 28212            var resilienceService = activityExecutionContext.GetRequiredService<IResilientActivityInvoker>();
 58213            return await resilienceService.InvokeAsync(this, activityExecutionContext, async () => await SendRequestAsyn
 24214        }
 215
 216        async Task<HttpResponseMessage> SendRequestAsyncCore(CancellationToken ct = default)
 217        {
 30218            var request = PrepareRequest(context);
 219
 30220            return await httpClient.SendAsync(request, ct);
 26221        }
 28222    }
 223
 224    private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpResponseMessage httpResponse)
 225    {
 24226        var httpContent = httpResponse.Content;
 24227        if (!HasContent(httpContent))
 16228            return null;
 229
 8230        var cancellationToken = context.CancellationToken;
 8231        var targetType = ParsedContent.GetTargetType(context);
 8232        var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken);
 8233        var responseHeaders = httpResponse.Headers;
 8234        var contentHeaders = httpContent.Headers;
 8235        var contentType = contentHeaders.ContentType?.MediaType ?? "application/octet-stream";
 236
 8237        targetType ??= contentType switch
 8238        {
 8239            "application/json" => typeof(object),
 0240            _ => typeof(string)
 8241        };
 242
 243        var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.Or
 244        var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.
 245        var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x =>
 8246        return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationTo
 24247    }
 248
 24249    private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0;
 250
 251    private HttpRequestMessage PrepareRequest(ActivityExecutionContext context)
 252    {
 30253        var method = Method.GetOrDefault(context) ?? "GET";
 30254        var url = Url.Get(context);
 30255        var request = new HttpRequestMessage(new HttpMethod(method), url);
 30256        var headers = context.GetHeaders(RequestHeaders);
 30257        var authorization = Authorization.GetOrDefault(context);
 30258        var addAuthorizationWithoutValidation = DisableAuthorizationHeaderValidation.GetOrDefault(context);
 259
 30260        if (!string.IsNullOrWhiteSpace(authorization))
 6261            if (addAuthorizationWithoutValidation)
 0262                request.Headers.TryAddWithoutValidation("Authorization", authorization);
 263            else
 6264                request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);
 265
 60266        foreach (var header in headers)
 0267            request.Headers.Add(header.Key, header.Value.AsEnumerable());
 268
 30269        InjectTraceContext(request);
 270
 30271        var contentType = ContentType.GetOrDefault(context);
 30272        var content = Content.GetOrDefault(context);
 273
 30274        if (contentType != null && content != null)
 275        {
 0276            var factories = context.GetServices<IHttpContentFactory>();
 0277            var factory = SelectContentWriter(contentType, factories);
 0278            request.Content = factory.CreateHttpContent(content, contentType);
 279        }
 280
 30281        return request;
 282    }
 283
 284    private static void InjectTraceContext(HttpRequestMessage request)
 285    {
 30286        var activity = System.Diagnostics.Activity.Current;
 287
 30288        if (activity == null)
 28289            return;
 290
 2291        System.Diagnostics.DistributedContextPropagator.Current.Inject(activity, request, static (carrier, key, value) =
 2292        {
 2293            if (carrier is not HttpRequestMessage requestMessage)
 0294                return;
 2295
 2296            if (!requestMessage.Headers.Contains(key))
 2297                requestMessage.Headers.TryAddWithoutValidation(key, value);
 4298        });
 2299    }
 300
 301    private IHttpContentFactory SelectContentWriter(string? contentType, IEnumerable<IHttpContentFactory> factories)
 302    {
 0303        if (string.IsNullOrWhiteSpace(contentType))
 0304            return new JsonContentFactory();
 305
 0306        var parsedContentType = new System.Net.Mime.ContentType(contentType);
 0307        return factories.FirstOrDefault(httpContentFactory => httpContentFactory.SupportedContentTypes.Any(c => c == par
 308    }
 309
 310    private ResiliencePipeline<HttpResponseMessage> BuildResiliencyPipeline(ActivityExecutionContext context)
 311    {
 312        // Docs: https://www.pollydocs.org/strategies/retry
 0313        var pipelineBuilder = new ResiliencePipelineBuilder<HttpResponseMessage>()
 0314            .AddRetry(new()
 0315            {
 0316                ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
 0317                    .Handle<TimeoutException>() // Specific timeout exception
 0318                    .Handle<HttpRequestException>() // Any HTTP exception
 0319                    .HandleResult(response => IsTransientStatusCode(response.StatusCode)),
 0320                MaxRetryAttempts = 8,
 0321                UseJitter = false, // If enabled, adds a random value between -25% and +25% of the calculated Delay, exc
 0322                Delay = TimeSpan.FromSeconds(1),
 0323                BackoffType = DelayBackoffType.Exponential // Delay * 2^AttemptNumber, e.g. [ 2s, 4s, 8s, 16s ]. Total s
 0324                // If BackoffType is Exponential, then the calculated Delay is multiplied by a random value between -25%
 0325            });
 326
 0327        return pipelineBuilder.Build();
 328    }
 329
 330    // Helper method to identify transient status codes.
 331    private static bool IsTransientStatusCode(HttpStatusCode? statusCode)
 332    {
 0333        if (statusCode is null)
 334        {
 335            // No status code -> Assume network failure, worth retrying.
 0336            return true;
 337        }
 338
 0339        return statusCode.Value switch
 0340        {
 0341            HttpStatusCode.RequestTimeout => true, // 408
 0342            HttpStatusCode.TooManyRequests => true, // 429 (if no Retry-After header is respected)
 0343            HttpStatusCode.InternalServerError => true, // 500
 0344            HttpStatusCode.BadGateway => true, // 502
 0345            HttpStatusCode.ServiceUnavailable => true, // 503
 0346            HttpStatusCode.GatewayTimeout => true, // 504
 0347            HttpStatusCode.Conflict => true, // 409 - Can be transient in concurrency cases
 0348            _ => false // Other errors are not transient
 0349        };
 350    }
 351}