Implemented HTTP Request Action activity (#24)

This commit is contained in:
Sipke Schoorstra 2019-02-17 11:44:50 +01:00 committed by GitHub
parent a167799cd6
commit 3ec5c5ae84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 231 additions and 23 deletions

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using Elsa.Expressions;
using Elsa.Models;
@ -7,29 +6,39 @@ namespace Elsa.Activities.Http.Activities
{
public class HttpRequestAction : Activity
{
public HttpRequestAction()
{
SupportedStatusCodes = new HashSet<int>{ 200 };
}
/// <summary>
/// The URL to invoke.
/// </summary>
public Uri Url { get; set; }
public WorkflowExpression<string> Url { get; set; }
/// <summary>
/// The HTTP method to use.
/// </summary>
public string Method { get; set; }
public string Method { get; set; } = "GET";
/// <summary>
/// The body to send along with the request.
/// </summary>
public WorkflowExpression<string> Body { get; set; }
/// <summary>
/// The Content Type header to send along with the request body.
/// </summary>
public WorkflowExpression<string> ContentType { get; set; }
/// <summary>
/// The headers to send along with the request.
/// </summary>
public IDictionary<string, string> RequestHeaders { get; set; }
public WorkflowExpression<string> RequestHeaders { get; set; }
/// <summary>
/// A list of HTTP status codes this activity cam handle.
/// A list of HTTP status codes this activity can handle.
/// </summary>
public ICollection<int> SupportedStatusCodes { get; set; }
public HashSet<int> SupportedStatusCodes { get; set; }
}
}

View file

@ -15,7 +15,7 @@ namespace Elsa.Activities.Http.Activities
/// The body to send along with the response
/// </summary>
public WorkflowExpression<string> Body { get; set; }
/// <summary>
/// The Content-Type header to send along with the response.
/// </summary>

View file

@ -1,24 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Elsa.Activities.Http.Activities;
using Elsa.Activities.Http.Models;
using Elsa.Expressions;
using Elsa.Handlers;
using Elsa.Models;
using Elsa.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace Elsa.Activities.Http.Drivers
{
public class HttpRequestActionDriver : ActivityDriver<HttpRequestAction>
{
private readonly IWorkflowExpressionEvaluator expressionEvaluator;
private readonly HttpClient httpClient;
private readonly IEnumerable<IContentFormatter> contentFormatters;
public HttpRequestActionDriver(IWorkflowExpressionEvaluator expressionEvaluator)
public HttpRequestActionDriver(
IWorkflowExpressionEvaluator expressionEvaluator,
IHttpClientFactory httpClientFactory,
IEnumerable<IContentFormatter> contentFormatters)
{
this.expressionEvaluator = expressionEvaluator;
httpClient = httpClientFactory.CreateClient(nameof(HttpRequestActionDriver));
this.contentFormatters = contentFormatters;
}
protected override async Task<ActivityExecutionResult> OnExecuteAsync(HttpRequestAction activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
return Endpoint("Done");
var request = await CreateRequestAsync(activity, workflowContext, cancellationToken);
var response = await httpClient.SendAsync(request, cancellationToken);
var content = response.Content != null ? await response.Content.ReadAsStringAsync() : default(string);
var contentType = response.Content?.Headers.ContentType.MediaType;
var formatter = SelectContentFormatter(contentType);
var responseModel = new HttpResponseModel
{
StatusCode = response.StatusCode,
Headers = new HeaderDictionary(response.Headers.ToDictionary(x => x.Key, x => new StringValues(x.Value.ToArray()))),
Content = content,
FormattedContent = await formatter.FormatAsync(content, contentType)
};
workflowContext.SetLastResult(responseModel);
var statusEndpoint = ((int) response.StatusCode).ToString();
return Endpoints(new[] { "Done", statusEndpoint });
}
private IContentFormatter SelectContentFormatter(string contentType)
{
var formatters = contentFormatters.OrderByDescending(x => x.Priority).ToList();
return formatters.FirstOrDefault(x => x.SupportedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase)) ?? formatters.Last();
}
private async Task<HttpRequestMessage> CreateRequestAsync(HttpRequestAction activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
var methodSupportsBody = GetMethodSupportsBody(activity.Method);
var uri = await expressionEvaluator.EvaluateAsync(activity.Url, workflowContext, cancellationToken);
var request = new HttpRequestMessage(new HttpMethod(activity.Method), uri);
var requestHeaders = await ParseRequestHeadersAsync(activity, workflowContext, cancellationToken);
if (methodSupportsBody)
{
var body = await expressionEvaluator.EvaluateAsync(activity.Body, workflowContext, cancellationToken);
var contentType = await expressionEvaluator.EvaluateAsync(activity.ContentType, workflowContext, cancellationToken);
if (!string.IsNullOrWhiteSpace(body))
{
request.Content = new StringContent(body, Encoding.UTF8, contentType);
}
}
foreach (var header in requestHeaders)
{
request.Headers.Add(header.Key, header.Value.AsEnumerable());
}
return request;
}
private async Task<IHeaderDictionary> ParseRequestHeadersAsync(HttpRequestAction activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
var headersText = await expressionEvaluator.EvaluateAsync(activity.RequestHeaders, workflowContext, cancellationToken);
var headers = new HeaderDictionary();
if (headersText != null)
{
var headersQuery =
from line in Regex.Split(headersText, "\\n", RegexOptions.Multiline)
let pair = line.Split(':', '=')
select new KeyValuePair<string, string>(pair[0], pair[1]);
foreach (var header in headersQuery)
{
var headerValueExpression = new WorkflowExpression<string>(activity.RequestHeaders.Syntax, header.Value);
var headerValue = await expressionEvaluator.EvaluateAsync(headerValueExpression, workflowContext, cancellationToken);
headers.Add(header.Key, headerValue);
}
}
return headers;
}
private bool GetMethodSupportsBody(string method)
{
var methods = new[] { "POST", "PUT", "PATCH" };
return methods.Contains(method, StringComparer.InvariantCultureIgnoreCase);
}
}
}

View file

@ -23,6 +23,7 @@
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="2.2.0" />
</ItemGroup>
</Project>

View file

@ -1,4 +1,5 @@
using Elsa.Activities.Http.Drivers;
using Elsa.Activities.Http.Formatters;
using Elsa.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@ -18,10 +19,16 @@ namespace Elsa.Activities.Http.Extensions
services.AddHttpWorkflowDescriptors();
services.AddAsyncInitialization();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddHttpClient(nameof(HttpRequestActionDriver));
services
.AddActivityDriver<HttpRequestTriggerDriver>()
.AddActivityDriver<HttpResponseActionDriver>();
.AddActivityDriver<HttpResponseActionDriver>()
.AddActivityDriver<HttpRequestActionDriver>();
services
.AddSingleton<IContentFormatter, NullContentFormatter>()
.AddSingleton<IContentFormatter, JsonContentFormatter>();
return services;
}

View file

@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Elsa.Activities.Http.Formatters
{
public class JsonContentFormatter : IContentFormatter
{
public int Priority => 0;
public IEnumerable<string> SupportedContentTypes => new[] { "application/json", "text/json" };
public Task<object> FormatAsync(string content, string contentType)
{
return Task.FromResult<object>(JToken.Parse(content));
}
}
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Elsa.Activities.Http.Formatters
{
public class NullContentFormatter : IContentFormatter
{
public int Priority => -1;
public IEnumerable<string> SupportedContentTypes => new[] { "", default(string) };
public Task<object> FormatAsync(string content, string contentType)
{
return null;
}
}
}

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace Elsa.Activities.Http
{
public interface IContentFormatter
{
int Priority { get; }
IEnumerable<string> SupportedContentTypes { get; }
Task<object> FormatAsync(string content, string contentType);
}
}

View file

@ -0,0 +1,17 @@
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Elsa.Activities.Http.Models
{
public class HttpResponseModel
{
public HttpResponseModel()
{
}
public HttpStatusCode StatusCode { get; set; }
public IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
public string Content { get; set; }
public object FormattedContent { get; set; }
}
}

View file

@ -1,5 +1,7 @@
using Elsa.Activities.Http.Activities;
using Elsa.Expressions;
using System;
using System.Collections.Generic;
using System.Linq;
using Elsa.Activities.Http.Activities;
using Elsa.Web.Activities.Http.ViewModels;
using Elsa.Web.Components.ViewModels;
using Elsa.Web.Drivers;
@ -12,14 +14,24 @@ namespace Elsa.Web.Activities.Http.Display
{
model.Method = activity.Method;
model.Body = new ExpressionViewModel(activity.Body);
model.Url = activity.Url;
model.Url = new ExpressionViewModel(activity.Url);
model.RequestHeaders = new ExpressionViewModel(activity.RequestHeaders);
model.ContentType = new ExpressionViewModel(activity.ContentType);
model.SupportedStatusCodes = string.Join(", ", activity.SupportedStatusCodes);
}
protected override void UpdateActivity(HttpRequestActionViewModel model, HttpRequestAction activity)
{
activity.Method = model.Method;
activity.Body = model.Body.ToWorkflowExpression<string>();
activity.Url = model.Url;
activity.Url = model.Url.ToWorkflowExpression<string>();
activity.RequestHeaders = model.RequestHeaders.ToWorkflowExpression<string>();
activity.ContentType = model.ContentType.ToWorkflowExpression<string>();
activity.SupportedStatusCodes = new HashSet<int>(model.SupportedStatusCodes
.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(int.Parse));
}
}
}

View file

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Elsa.Expressions;
using Elsa.Web.Components.ViewModels;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -10,14 +8,19 @@ namespace Elsa.Web.Activities.Http.ViewModels
{
public class HttpRequestActionViewModel
{
[Required]
public Uri Url { get; set; }
public ExpressionViewModel Url { get; set; }
[Required]
public string Method { get; set; }
public ExpressionViewModel Body { get; set; }
public ExpressionViewModel ContentType { get; set; }
public ExpressionViewModel RequestHeaders { get; set; }
[Required]
public string SupportedStatusCodes { get; set; }
public ICollection<SelectListItem> GetAvailableHttpMethods()
{
var availableHttpMethods = new[] { "GET", "POST", "PUT", "DELETE", "OPTIONS" };

View file

@ -1,8 +1,7 @@
@using OrchardCore.Mvc.Core.Utilities
@model HttpRequestActionViewModel
<div class="form-group">
<label asp-for="Url">@T["Url"]</label>
<input asp-for="Url" class="form-control"/>
<vc:expression-editor model="@Model.Url" prefix="@Html.NameFor(m => m.Url)"></vc:expression-editor>
<span asp-validation-for="Url" class="text-danger"></span>
<small class="form-text text-muted">@T["The relative path that will trigger this activity. Example: '/webhooks/my'"]</small>
</div>
@ -17,4 +16,22 @@
<vc:expression-editor model="@Model.Body" prefix="@Html.NameFor(m => m.Body)"></vc:expression-editor>
<span asp-validation-for="Body" class="text-danger"></span>
<small class="form-text text-muted">@T["The HTTP request body to send along with the request."]</small>
</div>
<div class="form-group">
<label asp-for="ContentType">@T["Content Type"]</label>
<vc:expression-editor model="@Model.ContentType" prefix="@Html.NameFor(m => m.ContentType)"></vc:expression-editor>
<span asp-validation-for="ContentType" class="text-danger"></span>
<small class="form-text text-muted">@T["The Content-Type header send along with the request."]</small>
</div>
<div class="form-group">
<label asp-for="RequestHeaders">@T["Request Headers"]</label>
<vc:expression-editor model="@Model.RequestHeaders" prefix="@Html.NameFor(m => m.RequestHeaders)"></vc:expression-editor>
<span asp-validation-for="RequestHeaders" class="text-danger"></span>
<small class="form-text text-muted">@T["Any additional headers to send along with the request, one per line. For example: my-header-one=foo\r\nmy-header-two=bar"]</small>
</div>
<div class="form-group">
<label asp-for="SupportedStatusCodes">@T["Handle Status Codes"]</label>
<input asp-for="SupportedStatusCodes" class="form-control"/>
<span asp-validation-for="SupportedStatusCodes" class="text-danger"></span>
<small class="form-text text-muted">@T["A comma-separated list of HTTP status codes to handle. These will be used to emit the activity's endpoints."]</small>
</div>