diff --git a/Directory.Packages.props b/Directory.Packages.props index 452482edc..4ed98d627 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -23,9 +23,9 @@ - - - + + + diff --git a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs new file mode 100644 index 000000000..fd8c9d0cd --- /dev/null +++ b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs @@ -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; + +/// +/// An activity that downloads a file from a given URL. +/// +[Activity("Elsa", "HTTP", "Downloads a file from a given URL.", DisplayName = "Download File", Kind = ActivityKind.Task)] +[Output(IsSerializable = false)] +public class DownloadHttpFile : Activity, IActivityPropertyDefaultValueProvider +{ + /// + public DownloadHttpFile([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + { + } + + /// + /// The URL to download the file from. + /// + [Input(DisplayName = "URL", Description = "The URL to download the file from.")] + public Input Url { get; set; } = default!; + + /// + /// The HTTP method to use when sending the request. + /// + [Input( + Description = "The HTTP method to use when sending the request.", + Options = new[] + { + "GET", "POST", "PUT" + }, + DefaultValue = "GET", + UIHint = InputUIHints.DropDown + )] + public Input Method { get; set; } = new("GET"); + + /// + /// A list of expected status codes to handle. + /// + [Input( + Description = "A list of expected status codes to handle.", + UIHint = InputUIHints.MultiText, + DefaultValueProvider = typeof(FlowSendHttpRequest) + )] + public Input> ExpectedStatusCodes { get; set; } = default!; + + /// + /// The content to send with the request. Can be a string, an object, a byte array or a stream. + /// + [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 RequestContent { get; set; } = default!; + + /// + /// The content type to use when sending the request. + /// + [Input( + DisplayName = "Content Type", + Description = "The content type to use when sending the request.", + UIHandler = typeof(HttpContentTypeOptionsProvider), + UIHint = InputUIHints.DropDown + )] + public Input RequestContentType { get; set; } = default!; + + /// + /// The Authorization header value to send with the request. + /// + /// Bearer {some-access-token} + [Input(Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", Category = "Security")] + public Input Authorization { get; set; } = default!; + + /// + /// A value that allows to add the Authorization header without validation. + /// + [Input(Description = "A value that allows to add the Authorization header without validation.", Category = "Security")] + public Input DisableAuthorizationHeaderValidation { get; set; } = default!; + + /// + /// The headers to send along with the request. + /// + [Input( + Description = "The headers to send along with the request.", + UIHint = InputUIHints.JsonEditor, + Category = "Advanced" + )] + public Input RequestHeaders { get; set; } = new(new HttpHeaders()); + + /// + /// The HTTP response. + /// + [Output(IsSerializable = false)] + public Output Response { get; set; } = default!; + + /// + /// The HTTP response status code + /// + [Output(Description = "The HTTP response status code")] + public Output StatusCode { get; set; } = default!; + + /// + /// The downloaded content stream, if any. + /// + [Output(Description = "The downloaded content stream, if any.", IsSerializable = false)] + public Output ResponseContentStream { get; set; } = default!; + + /// + /// The downloaded content bytes, if any. + /// + [Output(Description = "The downloaded content bytes, if any.", IsSerializable = false)] + public Output ResponseContentBytes { get; set; } = default!; + + /// + /// The response headers that were received. + /// + [Output(Description = "The response headers that were received.")] + public Output ResponseHeaders { get; set; } = default!; + + /// + /// The response content headers that were received. + /// + [Output(DisplayName = "Content Headers", Description = "The response content headers that were received.")] + public Output ResponseContentHeaders { get; set; } = default!; + + /// + 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(); + 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); + } + } + + /// + /// Handles the response. + /// + private async Task HandleResponseAsync(ActivityExecutionContext context, HttpResponseMessage response) + { + var expectedStatusCodes = ExpectedStatusCodes.GetOrDefault(context) ?? new List(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(); + + if (outcome != null) + outcomes.Add(outcome); + + outcomes.Add("Done"); + await context.CompleteActivityWithOutcomesAsync(outcomes.ToArray()); + } + + /// + /// Handles an exception that occurred while sending the request. + /// + private async Task HandleRequestExceptionAsync(ActivityExecutionContext context, HttpRequestException exception) + { + await context.CompleteActivityWithOutcomesAsync("Failed to connect"); + } + + /// + /// Handles that occurred while sending the request. + /// + private async Task HandleTaskCanceledExceptionAsync(ActivityExecutionContext context, TaskCanceledException exception) + { + await context.CompleteActivityWithOutcomesAsync("Timeout"); + } + + private async Task 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(); + var factory = SelectContentWriter(contentType, factories); + request.Content = factory.CreateHttpContent(content, contentType); + } + + return request; + } + + private IHttpContentFactory SelectContentWriter(string? contentType, IEnumerable 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 + { + 200 + }; + + return default!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index ac14dbaba..9f1e3c5f9 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -468,8 +468,9 @@ public class HttpEndpoint : Trigger 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; diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs index 8f1845ac6..04085dbd8 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs @@ -33,7 +33,10 @@ public abstract class SendHttpRequestBase : Activity /// [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 /// [Output(Description = "The HTTP response status code")] public Output StatusCode { get; set; } = default!; - + /// /// The parsed content, if any. /// @@ -128,10 +131,10 @@ public abstract class SendHttpRequestBase : Activity 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 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 ParseContentAsync(ActivityExecutionContext context, HttpContent httpContent) + private async Task 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 _ => typeof(string) }; - return await context.ParseContentAsync(contentStream, contentType, targetType, cancellationToken); + var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().ToArray(), StringComparer.OrdinalIgnoreCase); + var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().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; diff --git a/src/modules/Elsa.Http/Contexts/HttpResponseParserContext.cs b/src/modules/Elsa.Http/Contexts/HttpResponseParserContext.cs new file mode 100644 index 000000000..bd872f91d --- /dev/null +++ b/src/modules/Elsa.Http/Contexts/HttpResponseParserContext.cs @@ -0,0 +1,6 @@ +namespace Elsa.Http.Contexts; + +/// +/// Represents the context in which an HTTP response is being parsed. +/// +public record HttpResponseParserContext(Stream Content, string ContentType, Type? ReturnType, IDictionary Headers, CancellationToken CancellationToken); \ No newline at end of file diff --git a/src/modules/Elsa.Http/Contracts/IHttpContentParser.cs b/src/modules/Elsa.Http/Contracts/IHttpContentParser.cs index 2e3dc3d41..52562cf7e 100644 --- a/src/modules/Elsa.Http/Contracts/IHttpContentParser.cs +++ b/src/modules/Elsa.Http/Contracts/IHttpContentParser.cs @@ -1,3 +1,5 @@ +using Elsa.Http.Contexts; + namespace Elsa.Http.Contracts; /// @@ -14,10 +16,10 @@ public interface IHttpContentParser /// /// Returns a value indicating whether this reader supports the specified content type. /// - bool GetSupportsContentType(string contentType); + bool GetSupportsContentType(HttpResponseParserContext context); /// /// Reads the specified stream and returns a parsed object of the specified type. If no type is specified, a string is returned. /// - Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken); + Task ReadAsync(HttpResponseParserContext context); } \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableContentHandlers/HttpFileDownloadableContentHandler.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/HttpFileDownloadableContentHandler.cs new file mode 100644 index 000000000..dc1b52592 --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableContentHandlers/HttpFileDownloadableContentHandler.cs @@ -0,0 +1,24 @@ +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableContentHandlers; + +/// +/// Handles content that represents a downloadable stream. +/// +public class HttpFileDownloadableContentHandler : DownloadableContentHandlerBase +{ + /// + public override bool GetSupportsContent(object content) => content is HttpFile; + + /// + 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); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs b/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs index 346b721cf..944e7c776 100644 --- a/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs +++ b/src/modules/Elsa.Http/DownloadableContentHandlers/MultiDownloadableContentHandler.cs @@ -11,7 +11,7 @@ namespace Elsa.Http.DownloadableContentHandlers; public class MultiDownloadableContentHandler : DownloadableContentHandlerBase { /// - 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[]; /// protected override IEnumerable>> GetDownloadablesAsync(DownloadableContext context) diff --git a/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs new file mode 100644 index 000000000..5c144c002 --- /dev/null +++ b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs @@ -0,0 +1,30 @@ +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Provides extension methods for HTTP headers. +/// +public static class HeadersExtensions +{ + /// + /// Gets the filename from the Content-Disposition header. + /// + public static string? GetFilename(this HttpResponseMessage response) + { + var dictionary = response.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase); + return dictionary.GetFilename(); + } + + /// + /// Gets the filename from the Content-Disposition header. + /// + public static string? GetFilename(this IDictionary 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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs index e0612f72c..a51a16f60 100644 --- a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs @@ -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 ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, CancellationToken cancellationToken) + public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary headers, CancellationToken cancellationToken) { var parsers = context.GetServices().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> GetHeaders(this ActivityExecutionContext context, Input input) diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 3b5245e51..defb7df0b 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -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(); @@ -162,8 +164,9 @@ public class HttpFeature : FeatureBase // Content parsers. .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() + .AddSingleton() // HTTP content factories. .AddScoped() @@ -193,6 +196,7 @@ public class HttpFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // File caches. .AddScoped(FileCache) @@ -220,6 +224,10 @@ public class HttpFeature : FeatureBase { options.AddTypeAlias("FormFile"); options.AddTypeAlias("FormFile[]"); + options.AddTypeAlias("HttpFile"); + options.AddTypeAlias("HttpFile[]"); + options.AddTypeAlias("Downloadable"); + options.AddTypeAlias("Downloadable[]"); }); } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpFile.cs b/src/modules/Elsa.Http/Models/HttpFile.cs new file mode 100644 index 000000000..9a5cf92fd --- /dev/null +++ b/src/modules/Elsa.Http/Models/HttpFile.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace Elsa.Http.Models; + +/// +/// Represents a downloadable object. +/// +public class HttpFile +{ + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public HttpFile() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The stream to download. + /// The filename to use when downloading the stream. + /// The content type to use when downloading the stream. + /// The ETag to use when downloading the stream. + public HttpFile(Stream stream, string? filename = default, string? contentType = default, string? eTag = default) + { + Stream = stream; + Filename = filename; + ContentType = contentType; + ETag = eTag; + } + + /// + /// The file stream. + /// + public Stream Stream { get; set; } = default!; + + /// + /// The filename. + /// + public string? Filename { get; set; } + + /// + /// The content type. + /// + public string? ContentType { get; set; } + + /// + /// The ETag. + /// + public string? ETag { get; set; } + + /// + /// Gets the file bytes. + /// + public byte[] GetBytes() + { + using var memoryStream = new MemoryStream(); + if (Stream.CanSeek) Stream.Seek(0, SeekOrigin.Begin); + Stream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpHeaders.cs b/src/modules/Elsa.Http/Models/HttpHeaders.cs index e1f61b26d..bcf4a160d 100644 --- a/src/modules/Elsa.Http/Models/HttpHeaders.cs +++ b/src/modules/Elsa.Http/Models/HttpHeaders.cs @@ -30,6 +30,13 @@ public class HttpHeaders : Dictionary Add(item.Key, item.Value.ToArray()); } + /// + public HttpHeaders(HttpContentHeaders source) + { + foreach (var item in source) + Add(item.Key, item.Value.ToArray()); + } + /// /// Gets the content type. /// diff --git a/src/modules/Elsa.Http/Parsers/FileHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/FileHttpContentParser.cs new file mode 100644 index 000000000..60a9e652d --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/FileHttpContentParser.cs @@ -0,0 +1,34 @@ +using Elsa.Extensions; +using Elsa.Http.Contexts; +using Elsa.Http.Contracts; +using Elsa.Http.Models; + +namespace Elsa.Http.Parsers; + +/// +/// Reads received file from the HTTP response, if any. +/// +public class FileHttpContentParser : IHttpContentParser +{ + /// + // 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; + + /// + public bool GetSupportsContentType(HttpResponseParserContext context) + { + return true; + } + + /// + public Task 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(file); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs index 4f2ee9bba..b55beca38 100644 --- a/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs +++ b/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs @@ -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; /// - public bool GetSupportsContentType(string contentType) => contentType.Contains("json", StringComparison.InvariantCultureIgnoreCase); + public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("json", StringComparison.InvariantCultureIgnoreCase); /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + public async Task 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; diff --git a/src/modules/Elsa.Http/Parsers/PlainTextHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/PlainTextHttpContentParser.cs index 343034087..782646131 100644 --- a/src/modules/Elsa.Http/Parsers/PlainTextHttpContentParser.cs +++ b/src/modules/Elsa.Http/Parsers/PlainTextHttpContentParser.cs @@ -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; /// - public bool GetSupportsContentType(string contentType) => contentType.Contains("text/plain", StringComparison.InvariantCultureIgnoreCase); + public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("text/plain", StringComparison.InvariantCultureIgnoreCase); /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + public async Task ReadAsync(HttpResponseParserContext context) { + var content = context.Content; using var reader = new StreamReader(content, leaveOpen: true); var stringContent = await reader.ReadToEndAsync(); return stringContent; diff --git a/src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs index 9557a69d4..f33b92d63 100644 --- a/src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs +++ b/src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs @@ -1,22 +1,24 @@ -using Elsa.Http.Contracts; +using Elsa.Http.Contexts; +using Elsa.Http.Contracts; + +namespace Elsa.Http.Parsers; -namespace Elsa.Http.Parsers; - /// /// Reads text/html content type streams. -/// -// 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 +/// +// 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 { /// public int Priority => 0; /// - public bool GetSupportsContentType(string contentType) => contentType.Contains("text/html", StringComparison.InvariantCultureIgnoreCase); + public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("text/html", StringComparison.InvariantCultureIgnoreCase); /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) - { + public async Task ReadAsync(HttpResponseParserContext context) + { + var content = context.Content; using var reader = new StreamReader(content, leaveOpen: true); var stringContent = await reader.ReadToEndAsync(); return stringContent; diff --git a/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs index d890753e6..51a82c214 100644 --- a/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs +++ b/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs @@ -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; /// - public bool GetSupportsContentType(string contentType) => contentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase); + public bool GetSupportsContentType(HttpResponseParserContext context) => context.ContentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase); /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + public async Task 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)!; } diff --git a/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs index 5e7ad3fe8..dcd954a72 100644 --- a/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs +++ b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs @@ -24,7 +24,7 @@ public class DefaultDownloadableManager : IDownloadableManager var provider = _providers.FirstOrDefault(x => x.GetSupportsContent(content)); if (provider == null) - return Enumerable.Empty>>(); + return []; options ??= new(); var context = new DownloadableContext(this, content, options, cancellationToken); diff --git a/src/modules/Elsa.JavaScript/ObjectConverters/ByteArrayConverter.cs b/src/modules/Elsa.JavaScript/ObjectConverters/ByteArrayConverter.cs new file mode 100644 index 000000000..a2092d1a4 --- /dev/null +++ b/src/modules/Elsa.JavaScript/ObjectConverters/ByteArrayConverter.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using Jint; +using Jint.Native; +using Jint.Runtime.Interop; + +namespace Elsa.JavaScript.ObjectConverters; + +/// +/// Converts a byte array to a instance representing a Uint8Array. +/// +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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index a8040cb32..4598582a5 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -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; /// /// Provides a JavaScript evaluator using Jint. /// -public class JintJavaScriptEvaluator : IJavaScriptEvaluator +public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions scriptOptions, IMemoryCache memoryCache) + : IJavaScriptEvaluator { - private readonly INotificationSender _mediator; - private readonly IMemoryCache _memoryCache; - private readonly JintOptions _jintOptions; - private readonly IConfiguration _configuration; - - /// - /// Constructor. - /// - public JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions scriptOptions, IMemoryCache memoryCache) - { - _mediator = mediator; - _memoryCache = memoryCache; - _jintOptions = scriptOptions.Value; - _configuration = configuration; - } + private readonly JintOptions _jintOptions = scriptOptions.Value; + private readonly JsonSerializerOptions _jsonSerializerOptions = CreateJsonSerializerOptions(); /// [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)(name => _configuration.GetSection(name).Value)); + engine.SetValue("getConfig", (Func)(name => configuration.GetSection(name).Value)); // Add common .NET types. engine.RegisterType(); @@ -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(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, 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) diff --git a/src/modules/Elsa.JavaScript/Services/TypeAliasRegistry.cs b/src/modules/Elsa.JavaScript/Services/TypeAliasRegistry.cs index c345b6402..77af847c9 100644 --- a/src/modules/Elsa.JavaScript/Services/TypeAliasRegistry.cs +++ b/src/modules/Elsa.JavaScript/Services/TypeAliasRegistry.cs @@ -24,10 +24,14 @@ public class TypeAliasRegistry : ITypeAliasRegistry this.RegisterType("Decimal"); this.RegisterType("Single"); this.RegisterType("Double"); + this.RegisterType("Buffer"); + this.RegisterType("Stream"); + this.RegisterType("Guid"); this.RegisterType("Date"); this.RegisterType("Date"); this.RegisterType("Date"); this.RegisterType("Date"); + this.RegisterType>("ObjectDictionary"); } /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs index c54bff7e7..70573c4d2 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs @@ -53,4 +53,13 @@ public static class OutputExtensions var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType(); return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); } + + /// + /// Returns a value indicating whether the output has a target. + /// + public static bool HasTarget(this Output? output, ActivityExecutionContext context) + { + var memoryBlockReference = output?.MemoryBlockReference(); + return memoryBlockReference is not null && context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out _); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index e7571af91..8fa9c9dfa 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -120,7 +120,7 @@ public class WorkflowDefinitionPublisher : IWorkflowDefinitionPublisher var consumingWorkflows = new List(); - if (definition.Options.UsableAsActivity == true) + if (definition.Options is { UsableAsActivity: true, AutoUpdateConsumingWorkflows: true }) { consumingWorkflows.AddRange(await UpdateReferencesInConsumingWorkflows(definition, cancellationToken)); }