Merge remote-tracking branch 'origin/enh/5601'

This commit is contained in:
Sipke Schoorstra 2024-06-15 18:17:59 +02:00
commit 72c3d2ca84
24 changed files with 593 additions and 61 deletions

View file

@ -6,7 +6,7 @@
<ItemGroup>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1" />
<PackageVersion Include="AutoMapper" Version="13.0.1" />
<PackageVersion Include="Azure.Identity" Version="1.11.3" />
<PackageVersion Include="Azure.Identity" Version="1.11.4" />
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.17.5" />
<PackageVersion Include="Azure.ResourceManager" Version="1.12.0" />
<PackageVersion Include="Azure.ResourceManager.AppContainers" Version="1.1.1" />
@ -23,9 +23,9 @@
<PackageVersion Include="DistributedLock.Postgres" Version="1.1.0" />
<PackageVersion Include="DistributedLock.Redis" Version="1.0.3" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="8.14.0" />
<PackageVersion Include="Elsa.Studio" Version="3.2.0-preview.310" />
<PackageVersion Include="Elsa.Studio.Core.BlazorWasm" Version="3.2.0-preview.310" />
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="3.2.0-preview.310" />
<PackageVersion Include="Elsa.Studio" Version="3.2.0-rc2.351" />
<PackageVersion Include="Elsa.Studio.Core.BlazorWasm" Version="3.2.0-rc2.351" />
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="3.2.0-rc2.351" />
<PackageVersion Include="FastEndpoints" Version="5.26.0" />
<PackageVersion Include="FastEndpoints.Security" Version="5.26.0" />
<PackageVersion Include="FastEndpoints.Swagger" Version="5.26.0" />

View file

@ -0,0 +1,293 @@
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.CompilerServices;
using Elsa.Extensions;
using Elsa.Http.ContentWriters;
using Elsa.Http.Models;
using Elsa.Http.UIHints;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Contracts;
using Elsa.Workflows.UIHints;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Logging;
using HttpHeaders = Elsa.Http.Models.HttpHeaders;
namespace Elsa.Http;
/// <summary>
/// An activity that downloads a file from a given URL.
/// </summary>
[Activity("Elsa", "HTTP", "Downloads a file from a given URL.", DisplayName = "Download File", Kind = ActivityKind.Task)]
[Output(IsSerializable = false)]
public class DownloadHttpFile : Activity<HttpFile>, IActivityPropertyDefaultValueProvider
{
/// <inheritdoc />
public DownloadHttpFile([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line)
{
}
/// <summary>
/// The URL to download the file from.
/// </summary>
[Input(DisplayName = "URL", Description = "The URL to download the file from.")]
public Input<Uri?> Url { get; set; } = default!;
/// <summary>
/// The HTTP method to use when sending the request.
/// </summary>
[Input(
Description = "The HTTP method to use when sending the request.",
Options = new[]
{
"GET", "POST", "PUT"
},
DefaultValue = "GET",
UIHint = InputUIHints.DropDown
)]
public Input<string> Method { get; set; } = new("GET");
/// <summary>
/// A list of expected status codes to handle.
/// </summary>
[Input(
Description = "A list of expected status codes to handle.",
UIHint = InputUIHints.MultiText,
DefaultValueProvider = typeof(FlowSendHttpRequest)
)]
public Input<ICollection<int>> ExpectedStatusCodes { get; set; } = default!;
/// <summary>
/// The content to send with the request. Can be a string, an object, a byte array or a stream.
/// </summary>
[Input(Name = "Content", Description = "The content to send with the request. Can be a string, an object, a byte array or a stream.")]
public Input<object?> RequestContent { get; set; } = default!;
/// <summary>
/// The content type to use when sending the request.
/// </summary>
[Input(
DisplayName = "Content Type",
Description = "The content type to use when sending the request.",
UIHandler = typeof(HttpContentTypeOptionsProvider),
UIHint = InputUIHints.DropDown
)]
public Input<string?> RequestContentType { get; set; } = default!;
/// <summary>
/// The Authorization header value to send with the request.
/// </summary>
/// <example>Bearer {some-access-token}</example>
[Input(Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", Category = "Security")]
public Input<string?> Authorization { get; set; } = default!;
/// <summary>
/// A value that allows to add the Authorization header without validation.
/// </summary>
[Input(Description = "A value that allows to add the Authorization header without validation.", Category = "Security")]
public Input<bool> DisableAuthorizationHeaderValidation { get; set; } = default!;
/// <summary>
/// The headers to send along with the request.
/// </summary>
[Input(
Description = "The headers to send along with the request.",
UIHint = InputUIHints.JsonEditor,
Category = "Advanced"
)]
public Input<HttpHeaders?> RequestHeaders { get; set; } = new(new HttpHeaders());
/// <summary>
/// The HTTP response.
/// </summary>
[Output(IsSerializable = false)]
public Output<HttpResponseMessage> Response { get; set; } = default!;
/// <summary>
/// The HTTP response status code
/// </summary>
[Output(Description = "The HTTP response status code")]
public Output<int> StatusCode { get; set; } = default!;
/// <summary>
/// The downloaded content stream, if any.
/// </summary>
[Output(Description = "The downloaded content stream, if any.", IsSerializable = false)]
public Output<Stream?> ResponseContentStream { get; set; } = default!;
/// <summary>
/// The downloaded content bytes, if any.
/// </summary>
[Output(Description = "The downloaded content bytes, if any.", IsSerializable = false)]
public Output<byte[]?> ResponseContentBytes { get; set; } = default!;
/// <summary>
/// The response headers that were received.
/// </summary>
[Output(Description = "The response headers that were received.")]
public Output<HttpHeaders?> ResponseHeaders { get; set; } = default!;
/// <summary>
/// The response content headers that were received.
/// </summary>
[Output(DisplayName = "Content Headers", Description = "The response content headers that were received.")]
public Output<HttpHeaders?> ResponseContentHeaders { get; set; } = default!;
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
await TrySendAsync(context);
}
private async Task TrySendAsync(ActivityExecutionContext context)
{
var request = PrepareRequest(context);
var logger = (ILogger)context.GetRequiredService(typeof(ILogger<>).MakeGenericType(GetType()));
var httpClientFactory = context.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequestBase));
var cancellationToken = context.CancellationToken;
try
{
var response = await httpClient.SendAsync(request, cancellationToken);
var file = await GetFileFromResponse(context, response, request);
var statusCode = (int)response.StatusCode;
var responseHeaders = new HttpHeaders(response.Headers);
var responseContentHeaders = new HttpHeaders(response.Content.Headers);
context.Set(Response, response);
context.Set(ResponseContentStream, file?.Stream);
context.Set(Result, file);
context.Set(StatusCode, statusCode);
context.Set(ResponseHeaders, responseHeaders);
context.Set(ResponseContentHeaders, responseContentHeaders);
if (ResponseContentBytes.HasTarget(context)) context.Set(ResponseContentBytes, file?.GetBytes());
await HandleResponseAsync(context, response);
}
catch (HttpRequestException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
StackTrace = e.StackTrace
});
context.JournalData.Add("Error", e.Message);
await HandleRequestExceptionAsync(context, e);
}
catch (TaskCanceledException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
StackTrace = e.StackTrace
});
context.JournalData.Add("Cancelled", true);
await HandleTaskCanceledExceptionAsync(context, e);
}
}
/// <summary>
/// Handles the response.
/// </summary>
private async Task HandleResponseAsync(ActivityExecutionContext context, HttpResponseMessage response)
{
var expectedStatusCodes = ExpectedStatusCodes.GetOrDefault(context) ?? new List<int>(0);
var statusCode = (int)response.StatusCode;
var hasMatchingStatusCode = expectedStatusCodes.Contains(statusCode);
var outcome = expectedStatusCodes.Any() ? hasMatchingStatusCode ? statusCode.ToString() : "Unmatched status code" : default;
var outcomes = new List<string>();
if (outcome != null)
outcomes.Add(outcome);
outcomes.Add("Done");
await context.CompleteActivityWithOutcomesAsync(outcomes.ToArray());
}
/// <summary>
/// Handles an exception that occurred while sending the request.
/// </summary>
private async Task HandleRequestExceptionAsync(ActivityExecutionContext context, HttpRequestException exception)
{
await context.CompleteActivityWithOutcomesAsync("Failed to connect");
}
/// <summary>
/// Handles <see cref="TaskCanceledException"/> that occurred while sending the request.
/// </summary>
private async Task HandleTaskCanceledExceptionAsync(ActivityExecutionContext context, TaskCanceledException exception)
{
await context.CompleteActivityWithOutcomesAsync("Timeout");
}
private async Task<HttpFile?> GetFileFromResponse(ActivityExecutionContext context, HttpResponseMessage httpResponse, HttpRequestMessage httpRequestMessage)
{
var httpContent = httpResponse.Content;
if (!HasContent(httpContent))
return null;
var cancellationToken = context.CancellationToken;
var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken);
var responseHeaders = httpResponse.Headers;
var contentHeaders = httpContent.Headers;
var contentType = contentHeaders.ContentType?.MediaType!;
var filename = contentHeaders.ContentDisposition?.FileName ?? httpRequestMessage.RequestUri!.Segments.LastOrDefault() ?? "file.dat";
var eTag = responseHeaders.ETag?.Tag;
return new HttpFile(contentStream, filename, contentType, eTag);
}
private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0;
private HttpRequestMessage PrepareRequest(ActivityExecutionContext context)
{
var method = Method.GetOrDefault(context) ?? "GET";
var url = Url.Get(context);
var request = new HttpRequestMessage(new HttpMethod(method), url);
var headers = context.GetHeaders(RequestHeaders);
var authorization = Authorization.GetOrDefault(context);
var addAuthorizationWithoutValidation = DisableAuthorizationHeaderValidation.GetOrDefault(context);
if (!string.IsNullOrWhiteSpace(authorization))
if (addAuthorizationWithoutValidation)
request.Headers.TryAddWithoutValidation("Authorization", authorization);
else
request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization);
foreach (var header in headers)
request.Headers.Add(header.Key, header.Value.AsEnumerable());
var contentType = RequestContentType.GetOrDefault(context);
var content = RequestContent.GetOrDefault(context);
if (contentType != null && content != null)
{
var factories = context.GetServices<IHttpContentFactory>();
var factory = SelectContentWriter(contentType, factories);
request.Content = factory.CreateHttpContent(content, contentType);
}
return request;
}
private IHttpContentFactory SelectContentWriter(string? contentType, IEnumerable<IHttpContentFactory> factories)
{
if (string.IsNullOrWhiteSpace(contentType))
return new JsonContentFactory();
var parsedContentType = new System.Net.Mime.ContentType(contentType);
return factories.FirstOrDefault(httpContentFactory => httpContentFactory.SupportedContentTypes.Any(c => c == parsedContentType.MediaType)) ?? new JsonContentFactory();
}
object IActivityPropertyDefaultValueProvider.GetDefaultValue(PropertyInfo property)
{
if (property.Name == nameof(ExpectedStatusCodes))
return new List<int>
{
200
};
return default!;
}
}

View file

@ -468,8 +468,9 @@ public class HttpEndpoint : Trigger<HttpRequest>
var targetType = ParsedContent.GetTargetType(context);
var contentStream = httpRequest.Body;
var contentType = httpRequest.ContentType!;
var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray());
return await context.ParseContentAsync(contentStream, contentType, targetType, cancellationToken);
return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken);
}
private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0;

View file

@ -33,7 +33,10 @@ public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
/// </summary>
[Input(
Description = "The HTTP method to use when sending the request.",
Options = new[] { "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD" },
Options = new[]
{
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"
},
DefaultValue = "GET",
UIHint = InputUIHints.DropDown
)]
@ -83,7 +86,7 @@ public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
/// </summary>
[Output(Description = "The HTTP response status code")]
public Output<int> StatusCode { get; set; } = default!;
/// <summary>
/// The parsed content, if any.
/// </summary>
@ -128,10 +131,10 @@ public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
try
{
var response = await httpClient.SendAsync(request, cancellationToken);
var parsedContent = await ParseContentAsync(context, response.Content);
var parsedContent = await ParseContentAsync(context, response);
var statusCode = (int)response.StatusCode;
var responseHeaders = new HttpHeaders(response.Headers);
context.Set(Result, response);
context.Set(ParsedContent, parsedContent);
context.Set(StatusCode, statusCode);
@ -142,28 +145,37 @@ public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
catch (HttpRequestException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new { StackTrace = e.StackTrace });
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
StackTrace = e.StackTrace
});
context.JournalData.Add("Error", e.Message);
await HandleRequestExceptionAsync(context, e);
}
catch (TaskCanceledException e)
{
logger.LogWarning(e, "An error occurred while sending an HTTP request");
context.AddExecutionLogEntry("Error", e.Message, payload: new { StackTrace = e.StackTrace });
context.AddExecutionLogEntry("Error", e.Message, payload: new
{
StackTrace = e.StackTrace
});
context.JournalData.Add("Cancelled", true);
await HandleTaskCanceledExceptionAsync(context, e);
}
}
private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpContent httpContent)
private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpResponseMessage httpResponse)
{
var httpContent = httpResponse.Content;
if (!HasContent(httpContent))
return null;
var cancellationToken = context.CancellationToken;
var targetType = ParsedContent.GetTargetType(context);
var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken);
var contentType = httpContent.Headers.ContentType?.MediaType!;
var responseHeaders = httpResponse.Headers;
var contentHeaders = httpContent.Headers;
var contentType = contentHeaders.ContentType?.MediaType!;
targetType ??= contentType switch
{
@ -171,7 +183,10 @@ public abstract class SendHttpRequestBase : Activity<HttpResponseMessage>
_ => typeof(string)
};
return await context.ParseContentAsync(contentStream, contentType, targetType, cancellationToken);
var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationToken);
}
private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0;

View file

@ -0,0 +1,6 @@
namespace Elsa.Http.Contexts;
/// <summary>
/// Represents the context in which an HTTP response is being parsed.
/// </summary>
public record HttpResponseParserContext(Stream Content, string ContentType, Type? ReturnType, IDictionary<string, string[]> Headers, CancellationToken CancellationToken);

View file

@ -1,3 +1,5 @@
using Elsa.Http.Contexts;
namespace Elsa.Http.Contracts;
/// <summary>
@ -14,10 +16,10 @@ public interface IHttpContentParser
/// <summary>
/// Returns a value indicating whether this reader supports the specified content type.
/// </summary>
bool GetSupportsContentType(string contentType);
bool GetSupportsContentType(HttpResponseParserContext context);
/// <summary>
/// Reads the specified <c>stream</c> and returns a parsed object of the specified type. If no type is specified, a string is returned.
/// </summary>
Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken);
Task<object> ReadAsync(HttpResponseParserContext context);
}

View file

@ -0,0 +1,24 @@
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.DownloadableContentHandlers;
/// <summary>
/// Handles content that represents a downloadable stream.
/// </summary>
public class HttpFileDownloadableContentHandler : DownloadableContentHandlerBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is HttpFile;
/// <inheritdoc />
protected override Downloadable GetDownloadable(DownloadableContext context)
{
var file = (HttpFile)context.Content;
var stream = file.Stream;
var fileName = file.Filename;
var contentType = file.ContentType;
return new Downloadable(stream, fileName, contentType);
}
}

View file

@ -11,7 +11,7 @@ namespace Elsa.Http.DownloadableContentHandlers;
public class MultiDownloadableContentHandler : DownloadableContentHandlerBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is IEnumerable enumerable and not string;
public override bool GetSupportsContent(object content) => content is IEnumerable and not string and not byte[];
/// <inheritdoc />
protected override IEnumerable<Func<ValueTask<Downloadable>>> GetDownloadablesAsync(DownloadableContext context)

View file

@ -0,0 +1,30 @@
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods for HTTP headers.
/// </summary>
public static class HeadersExtensions
{
/// <summary>
/// Gets the filename from the Content-Disposition header.
/// </summary>
public static string? GetFilename(this HttpResponseMessage response)
{
var dictionary = response.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase);
return dictionary.GetFilename();
}
/// <summary>
/// Gets the filename from the Content-Disposition header.
/// </summary>
public static string? GetFilename(this IDictionary<string, string[]> headers)
{
if (!headers.TryGetValue("Content-Disposition", out var values))
return null;
var contentDispositionString = string.Join("", values);
var contentDisposition = new System.Net.Mime.ContentDisposition(contentDispositionString);
return contentDisposition.FileName;
}
}

View file

@ -1,3 +1,4 @@
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Workflows;
using Elsa.Workflows.Models;
@ -7,15 +8,16 @@ namespace Elsa.Extensions;
internal static class HttpActivityExecutionContextExtensions
{
public static async Task<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, CancellationToken cancellationToken)
public static async Task<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary<string, string?[]> headers, CancellationToken cancellationToken)
{
var parsers = context.GetServices<IHttpContentParser>().OrderByDescending(x => x.Priority).ToList();
var contentParser = parsers.FirstOrDefault(x => x.GetSupportsContentType(contentType));
var httpResponseParserContext = new HttpResponseParserContext(content, contentType, returnType, headers, cancellationToken);
var contentParser = parsers.FirstOrDefault(x => x.GetSupportsContentType(httpResponseParserContext));
if (contentParser == null)
return null;
return await contentParser.ReadAsync(content, returnType, cancellationToken);
return await contentParser.ReadAsync(httpResponseParserContext);
}
public static IEnumerable<KeyValuePair<string, string[]>> GetHeaders(this ActivityExecutionContext context, Input input)

View file

@ -116,7 +116,9 @@ public class HttpFeature : FeatureBase
typeof(HttpResponse),
typeof(HttpResponseMessage),
typeof(HttpHeaders),
typeof(IFormFile)
typeof(IFormFile),
typeof(HttpFile),
typeof(Downloadable)
}, "HTTP");
management.AddActivitiesFrom<HttpFeature>();
@ -162,8 +164,9 @@ public class HttpFeature : FeatureBase
// Content parsers.
.AddSingleton<IHttpContentParser, JsonHttpContentParser>()
.AddSingleton<IHttpContentParser, XmlHttpContentParser>()
.AddSingleton<IHttpContentParser, PlainTextHttpContentParser>()
.AddSingleton<IHttpContentParser, PlainTextHttpContentParser>()
.AddSingleton<IHttpContentParser, TextHtmlHttpContentParser>()
.AddSingleton<IHttpContentParser, FileHttpContentParser>()
// HTTP content factories.
.AddScoped<IHttpContentFactory, TextContentFactory>()
@ -193,6 +196,7 @@ public class HttpFeature : FeatureBase
.AddScoped<IDownloadableContentHandler, DownloadableDownloadableContentHandler>()
.AddScoped<IDownloadableContentHandler, UrlDownloadableContentHandler>()
.AddScoped<IDownloadableContentHandler, StringDownloadableContentHandler>()
.AddScoped<IDownloadableContentHandler, HttpFileDownloadableContentHandler>()
// File caches.
.AddScoped(FileCache)
@ -220,6 +224,10 @@ public class HttpFeature : FeatureBase
{
options.AddTypeAlias<IFormFile>("FormFile");
options.AddTypeAlias<IFormFile[]>("FormFile[]");
options.AddTypeAlias<HttpFile>("HttpFile");
options.AddTypeAlias<HttpFile[]>("HttpFile[]");
options.AddTypeAlias<Downloadable>("Downloadable");
options.AddTypeAlias<Downloadable[]>("Downloadable[]");
});
}
}

View file

@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace Elsa.Http.Models;
/// <summary>
/// Represents a downloadable object.
/// </summary>
public class HttpFile
{
/// <summary>
/// Initializes a new instance of the <see cref="Downloadable"/> class.
/// </summary>
[JsonConstructor]
public HttpFile()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="HttpFile"/> class.
/// </summary>
/// <param name="stream">The stream to download.</param>
/// <param name="filename">The filename to use when downloading the stream.</param>
/// <param name="contentType">The content type to use when downloading the stream.</param>
/// <param name="eTag">The ETag to use when downloading the stream.</param>
public HttpFile(Stream stream, string? filename = default, string? contentType = default, string? eTag = default)
{
Stream = stream;
Filename = filename;
ContentType = contentType;
ETag = eTag;
}
/// <summary>
/// The file stream.
/// </summary>
public Stream Stream { get; set; } = default!;
/// <summary>
/// The filename.
/// </summary>
public string? Filename { get; set; }
/// <summary>
/// The content type.
/// </summary>
public string? ContentType { get; set; }
/// <summary>
/// The ETag.
/// </summary>
public string? ETag { get; set; }
/// <summary>
/// Gets the file bytes.
/// </summary>
public byte[] GetBytes()
{
using var memoryStream = new MemoryStream();
if (Stream.CanSeek) Stream.Seek(0, SeekOrigin.Begin);
Stream.CopyTo(memoryStream);
return memoryStream.ToArray();
}
}

View file

@ -30,6 +30,13 @@ public class HttpHeaders : Dictionary<string, string[]>
Add(item.Key, item.Value.ToArray());
}
/// <inheritdoc />
public HttpHeaders(HttpContentHeaders source)
{
foreach (var item in source)
Add(item.Key, item.Value.ToArray());
}
/// <summary>
/// Gets the content type.
/// </summary>

View file

@ -0,0 +1,34 @@
using Elsa.Extensions;
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
namespace Elsa.Http.Parsers;
/// <summary>
/// Reads received file from the HTTP response, if any.
/// </summary>
public class FileHttpContentParser : IHttpContentParser
{
/// <inheritdoc />
// Lower priority than other parsers, so that they can be tried first.
// If none of them can parse the content, this parser will be tried and interpret the content as a file.
public int Priority => -100;
/// <inheritdoc />
public bool GetSupportsContentType(HttpResponseParserContext context)
{
return true;
}
/// <inheritdoc />
public Task<object> ReadAsync(HttpResponseParserContext context)
{
var stream = context.Content;
var filename = context.Headers.GetFilename() ?? "file.dat";
var contentType = context.ContentType;
var eTag = context.Headers["ETag"].FirstOrDefault();
var file = new HttpFile(stream, filename, contentType, eTag);
return Task.FromResult<object>(file);
}
}

View file

@ -2,6 +2,7 @@ using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Expressions.Helpers;
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Workflows.Serialization.Converters;
@ -16,10 +17,10 @@ public class JsonHttpContentParser : IHttpContentParser
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("json", StringComparison.InvariantCultureIgnoreCase);
public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("json", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken)
public async Task<object> ReadAsync(HttpResponseParserContext context)
{
var options = new JsonSerializerOptions
{
@ -28,8 +29,10 @@ public class JsonHttpContentParser : IHttpContentParser
options.Converters.Add(new JsonStringEnumConverter());
var content = context.Content;
using var reader = new StreamReader(content, leaveOpen: true);
var json = await reader.ReadToEndAsync();
var returnType = context.ReturnType;
if(returnType == typeof(string))
return json;

View file

@ -1,3 +1,4 @@
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
namespace Elsa.Http.Parsers;
@ -11,11 +12,12 @@ public class PlainTextHttpContentParser : IHttpContentParser
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("text/plain", StringComparison.InvariantCultureIgnoreCase);
public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("text/plain", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken)
public async Task<object> ReadAsync(HttpResponseParserContext context)
{
var content = context.Content;
using var reader = new StreamReader(content, leaveOpen: true);
var stringContent = await reader.ReadToEndAsync();
return stringContent;

View file

@ -1,22 +1,24 @@
using Elsa.Http.Contracts;
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
namespace Elsa.Http.Parsers;
namespace Elsa.Http.Parsers;
/// <summary>
/// Reads text/html content type streams.
/// </summary>
// TODO: found a library to use a Html Content Parser and use a complexe object Type, until this, this class allow to accept request send using text/html content-type
/// </summary>
// TODO: found a library to use a Html Content Parser and use a complexe object Type, until this, this class allow to accept request send using text/html content-type
public class TextHtmlHttpContentParser : IHttpContentParser
{
/// <inheritdoc />
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("text/html", StringComparison.InvariantCultureIgnoreCase);
public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("text/html", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken)
{
public async Task<object> ReadAsync(HttpResponseParserContext context)
{
var content = context.Content;
using var reader = new StreamReader(content, leaveOpen: true);
var stringContent = await reader.ReadToEndAsync();
return stringContent;

View file

@ -1,4 +1,5 @@
using System.Xml.Serialization;
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
namespace Elsa.Http.Parsers;
@ -12,17 +13,19 @@ public class XmlHttpContentParser : IHttpContentParser
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase);
public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken)
public async Task<object> ReadAsync(HttpResponseParserContext context)
{
var content = context.Content;
using var reader = new StreamReader(content, leaveOpen: true);
var xml = await reader.ReadToEndAsync();
var returnType = context.ReturnType;
if (returnType == null || returnType == typeof(string))
return xml;
var serializer = new XmlSerializer(returnType);
return serializer.Deserialize(reader)!;
}

View file

@ -24,7 +24,7 @@ public class DefaultDownloadableManager : IDownloadableManager
var provider = _providers.FirstOrDefault(x => x.GetSupportsContent(content));
if (provider == null)
return Enumerable.Empty<Func<ValueTask<Downloadable>>>();
return [];
options ??= new();
var context = new DownloadableContext(this, content, options, cancellationToken);

View file

@ -0,0 +1,26 @@
using System.Diagnostics.CodeAnalysis;
using Jint;
using Jint.Native;
using Jint.Runtime.Interop;
namespace Elsa.JavaScript.ObjectConverters;
/// <summary>
/// Converts a byte array to a <see cref="JsValue"/> instance representing a Uint8Array.
/// </summary>
internal class ByteArrayConverter : IObjectConverter
{
public bool TryConvert(Engine engine, object value, [NotNullWhen(true)] out JsValue? result)
{
if (value is byte[] bytes)
{
// TODO: Temporary: Uint8Array creates a copy of the byte array. Instead, we want to create a view or a buffer referencing the byte array.
// See also: https://github.com/sebastienros/jint/pull/1590
result = engine.Intrinsics.Uint8Array.Construct(bytes);
return true;
}
result = JsValue.Null;
return false;
}
}

View file

@ -12,10 +12,13 @@ using Elsa.Extensions;
using Elsa.JavaScript.Contracts;
using Elsa.JavaScript.Helpers;
using Elsa.JavaScript.Notifications;
using Elsa.JavaScript.ObjectConverters;
using Elsa.JavaScript.Options;
using Elsa.Mediator.Contracts;
using Humanizer;
using Jint;
using Jint.Native;
using Jint.Native.TypedArray;
using Jint.Runtime.Interop;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
@ -27,23 +30,11 @@ namespace Elsa.JavaScript.Services;
/// <summary>
/// Provides a JavaScript evaluator using Jint.
/// </summary>
public class JintJavaScriptEvaluator : IJavaScriptEvaluator
public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions<JintOptions> scriptOptions, IMemoryCache memoryCache)
: IJavaScriptEvaluator
{
private readonly INotificationSender _mediator;
private readonly IMemoryCache _memoryCache;
private readonly JintOptions _jintOptions;
private readonly IConfiguration _configuration;
/// <summary>
/// Constructor.
/// </summary>
public JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions<JintOptions> scriptOptions, IMemoryCache memoryCache)
{
_mediator = mediator;
_memoryCache = memoryCache;
_jintOptions = scriptOptions.Value;
_configuration = configuration;
}
private readonly JintOptions _jintOptions = scriptOptions.Value;
private readonly JsonSerializerOptions _jsonSerializerOptions = CreateJsonSerializerOptions();
/// <inheritdoc />
[RequiresUnreferencedCode("The Jint library uses reflection and can't be statically analyzed.")]
@ -79,6 +70,8 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
return instance;
});
engineOptions.Interop.ObjectConverters.Add(new ByteArrayConverter());
await _mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken);
var engine = new Engine(engineOptions);
@ -126,7 +119,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
// Create configuration value accessor
if (_jintOptions.AllowConfigurationAccess)
engine.SetValue("getConfig", (Func<string, object?>)(name => _configuration.GetSection(name).Value));
engine.SetValue("getConfig", (Func<string, object?>)(name => configuration.GetSection(name).Value));
// Add common .NET types.
engine.RegisterType<DateTime>();
@ -138,7 +131,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
_jintOptions.ConfigureEngineCallback(engine, context);
// Allow listeners invoked by the mediator to configure the engine.
await _mediator.SendAsync(new EvaluatingJavaScript(engine, context), cancellationToken);
await mediator.SendAsync(new EvaluatingJavaScript(engine, context), cancellationToken);
return engine;
}
@ -162,6 +155,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
}
}
[RequiresUnreferencedCode("Calls Jint.Engine.SetValue<T>(String, T)")]
private static void CreateVariableAccessors(Engine engine, ExpressionExecutionContext context)
{
var variableNames = context.GetVariableNamesInScope().ToList();
@ -187,7 +181,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
{
var cacheKey = "jint:script:" + Hash(expression);
var parsedScript = _memoryCache.GetOrCreate(cacheKey, entry =>
var parsedScript = memoryCache.GetOrCreate(cacheKey, entry =>
{
if (_jintOptions.ScriptCacheTimeout.HasValue)
entry.SetAbsoluteExpiration(_jintOptions.ScriptCacheTimeout.Value);
@ -207,15 +201,19 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator
}
[RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
private static string Serialize(object value)
private string Serialize(object value)
{
return JsonSerializer.Serialize(value, _jsonSerializerOptions);
}
private static JsonSerializerOptions CreateJsonSerializerOptions()
{
var options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
};
options.Converters.Add(new JsonStringEnumConverter());
return JsonSerializer.Serialize(value, options);
return options;
}
private string Hash(string input)

View file

@ -24,10 +24,14 @@ public class TypeAliasRegistry : ITypeAliasRegistry
this.RegisterType<decimal>("Decimal");
this.RegisterType<float>("Single");
this.RegisterType<double>("Double");
this.RegisterType<byte[]>("Buffer");
this.RegisterType<Stream>("Stream");
this.RegisterType<Guid>("Guid");
this.RegisterType<DateTime>("Date");
this.RegisterType<DateTimeOffset>("Date");
this.RegisterType<DateOnly>("Date");
this.RegisterType<TimeOnly>("Date");
this.RegisterType<IDictionary<string, object>>("ObjectDictionary");
}
/// <inheritdoc />

View file

@ -53,4 +53,13 @@ public static class OutputExtensions
var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType();
return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault();
}
/// <summary>
/// Returns a value indicating whether the output has a target.
/// </summary>
public static bool HasTarget(this Output? output, ActivityExecutionContext context)
{
var memoryBlockReference = output?.MemoryBlockReference();
return memoryBlockReference is not null && context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out _);
}
}

View file

@ -120,7 +120,7 @@ public class WorkflowDefinitionPublisher : IWorkflowDefinitionPublisher
var consumingWorkflows = new List<WorkflowDefinition>();
if (definition.Options.UsableAsActivity == true)
if (definition.Options is { UsableAsActivity: true, AutoUpdateConsumingWorkflows: true })
{
consumingWorkflows.AddRange(await UpdateReferencesInConsumingWorkflows(definition, cancellationToken));
}