From 7e11a58d143db75116504b9f1fa23d48bd148247 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 12 Jun 2024 20:37:36 +0200 Subject: [PATCH 1/8] Update build pipeline for RC2 --- .github/workflows/packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 399ee97dd..bae203a0e 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -62,7 +62,7 @@ jobs: TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV else - echo "VERSION=3.2.0-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV + echo "VERSION=3.2.0-rc2.${{github.run_number}}" >> $GITHUB_ENV fi - name: Set up JDK 17 uses: actions/setup-java@v2 From bbc7d502d5203f374614457c127f770051b9a410 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 12 Jun 2024 23:38:47 +0200 Subject: [PATCH 2/8] Move HTTP OutputExtensions to a shared library for reuse --- .../Elsa.Http/Extensions/OutputExtensions.cs | 26 ------------------- .../Extensions/OutputExtensions.cs | 17 ++++++++++++ 2 files changed, 17 insertions(+), 26 deletions(-) delete mode 100644 src/modules/Elsa.Http/Extensions/OutputExtensions.cs diff --git a/src/modules/Elsa.Http/Extensions/OutputExtensions.cs b/src/modules/Elsa.Http/Extensions/OutputExtensions.cs deleted file mode 100644 index b25ff770c..000000000 --- a/src/modules/Elsa.Http/Extensions/OutputExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Elsa.Workflows; -using Elsa.Workflows.Memory; -using Elsa.Workflows.Models; - -// ReSharper disable once CheckNamespace -namespace Elsa.Extensions; - -internal static class OutputExtensions -{ - /// - /// Gets the target type of the specified variable type. - /// - public static Type? GetTargetType(this Output? output, ActivityExecutionContext context) - { - var memoryBlockReference = output?.MemoryBlockReference(); - - if (memoryBlockReference is null) - return default; - - if(!context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out var memoryBlock)) - return default; - - var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType(); - return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs index ebcb8a488..c54bff7e7 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/OutputExtensions.cs @@ -36,4 +36,21 @@ public static class OutputExtensions /// Sets the output to the specified value. /// public static void Set(this Output? output, ExpressionExecutionContext context, Variable value) => context.Set(output, value.Get(context)); + + /// + /// Gets the target type of the specified variable type, if any, linked to the output. + /// + public static Type? GetTargetType(this Output? output, ActivityExecutionContext context) + { + var memoryBlockReference = output?.MemoryBlockReference(); + + if (memoryBlockReference is null) + return default; + + if(!context.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out var memoryBlock)) + return default; + + var parsedContentVariableType = (memoryBlock.Metadata as VariableBlockMetadata)?.Variable.GetType(); + return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); + } } \ No newline at end of file From c88e5a846df144598b4a16baf82158044e3cf816 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 13 Jun 2024 07:11:02 +0200 Subject: [PATCH 3/8] Add 'Data' category to Workflow Management Feature The 'Data' category has been added to the Workflow Management Feature. This includes support for byte arrays and Stream types, extending the type capabilities of the feature. --- .../Features/WorkflowManagementFeature.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 055e37cf9..cfb45929c 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -44,6 +44,7 @@ public class WorkflowManagementFeature : FeatureBase private const string PrimitivesCategory = "Primitives"; private const string LookupsCategory = "Lookups"; private const string DynamicCategory = "Dynamic"; + private const string DataCategory = "Data"; private string CompressionAlgorithm { get; set; } = nameof(None); private LogPersistenceMode LogPersistenceMode { get; set; } = LogPersistenceMode.Include; @@ -78,7 +79,9 @@ public class WorkflowManagementFeature : FeatureBase new(typeof(TimeSpan), PrimitivesCategory, "Represents a duration of time."), new(typeof(IDictionary), LookupsCategory, "A dictionary with string key and values."), new(typeof(IDictionary), LookupsCategory, "A dictionary with string key and object values."), - new(typeof(ExpandoObject), DynamicCategory, "A dictionary that can be typed as dynamic to access members using dot notation.") + new(typeof(ExpandoObject), DynamicCategory, "A dictionary that can be typed as dynamic to access members using dot notation."), + new(typeof(byte[]), DataCategory, "A byte array."), + new(typeof(Stream), DataCategory, "A stream.") ]; /// From 5cfd6d98604d0db557bb9a7af68764dbdfda23e6 Mon Sep 17 00:00:00 2001 From: raymonddenhaan <155616759+raymonddenhaan@users.noreply.github.com> Date: Thu, 13 Jun 2024 11:15:25 +0200 Subject: [PATCH 4/8] Extended check when to update dependent workflows (#5598) --- Directory.Packages.props | 8 ++++---- .../Services/WorkflowDefinitionPublisher.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3049689cb..ff7338d94 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -23,9 +23,9 @@ - - - + + + diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index fb3dd4a51..38d49ecf6 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -121,7 +121,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)); } From d7320e1a5d0b0bcafb476c556a13ee29a85329b0 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 13 Jun 2024 22:13:53 +0200 Subject: [PATCH 5/8] Add HTTP file download activity and supporting classes This commit adds a new activity, DownloadHttpFile, which provides the capability to download a file from a specified URL. It introduces supporting classes like HttpFile, FileHttpContentParser, and several extensions methods related to handling HTTP headers and file content. It also contains necessary updates in existing classes to accommodate the new file-download feature. --- .../Elsa.Http/Activities/DownloadHttpFile.cs | 282 ++++++++++++++++++ .../Elsa.Http/Activities/HttpEndpoint.cs | 3 +- .../Activities/SendHttpRequestBase.cs | 33 +- .../Contexts/HttpResponseParserContext.cs | 6 + .../Elsa.Http/Contracts/IHttpContentParser.cs | 6 +- .../HttpFileDownloadableContentHandler.cs | 24 ++ .../Elsa.Http/Extensions/HeadersExtensions.cs | 30 ++ .../HttpActivityExecutionContextExtensions.cs | 8 +- src/modules/Elsa.Http/Features/HttpFeature.cs | 8 +- src/modules/Elsa.Http/Models/HttpFile.cs | 63 ++++ src/modules/Elsa.Http/Models/HttpHeaders.cs | 7 + .../Parsers/FileHttpContentParser.cs | 34 +++ .../Parsers/JsonHttpContentParser.cs | 7 +- .../Parsers/PlainTextHttpContentParser.cs | 6 +- .../Parsers/TextHtmlHttpContentParser.cs | 18 +- .../Elsa.Http/Parsers/XmlHttpContentParser.cs | 9 +- 16 files changed, 512 insertions(+), 32 deletions(-) create mode 100644 src/modules/Elsa.Http/Activities/DownloadHttpFile.cs create mode 100644 src/modules/Elsa.Http/Contexts/HttpResponseParserContext.cs create mode 100644 src/modules/Elsa.Http/DownloadableContentHandlers/HttpFileDownloadableContentHandler.cs create mode 100644 src/modules/Elsa.Http/Extensions/HeadersExtensions.cs create mode 100644 src/modules/Elsa.Http/Models/HttpFile.cs create mode 100644 src/modules/Elsa.Http/Parsers/FileHttpContentParser.cs diff --git a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs new file mode 100644 index 000000000..c4988a99d --- /dev/null +++ b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs @@ -0,0 +1,282 @@ +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 parsed content, if any. + /// + [Output(Description = "The parsed content, if any.", IsSerializable = false)] + public Output ResponseContent { 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(Result, file); + context.Set(StatusCode, statusCode); + context.Set(ResponseHeaders, responseHeaders); + context.Set(ResponseContentHeaders, responseContentHeaders); + + 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/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 441e5a900..2ec04580b 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -114,7 +114,9 @@ public class HttpFeature : FeatureBase typeof(HttpResponse), typeof(HttpResponseMessage), typeof(HttpHeaders), - typeof(IFormFile) + typeof(IFormFile), + typeof(HttpFile), + typeof(Downloadable) }, "HTTP"); management.AddActivitiesFrom(); @@ -161,8 +163,9 @@ public class HttpFeature : FeatureBase // Content parsers. .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() + .AddSingleton() // HTTP content factories. .AddScoped() @@ -192,6 +195,7 @@ public class HttpFeature : FeatureBase .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // File caches. .AddScoped(FileCache) 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)!; } From 43432cb70d70fc7fc20b1554e12dcc7c8d72440c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 13 Jun 2024 23:10:10 +0200 Subject: [PATCH 6/8] Add response stream to context in DownloadHttpFile In DownloadHttpFile.cs, context data now includes response content's file stream. Changes to DefaultDownloadableManager.cs simplify the return statement when no provider is found, directly returning an empty array. --- src/modules/Elsa.Http/Activities/DownloadHttpFile.cs | 1 + src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs index c4988a99d..8e62f56fd 100644 --- a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs +++ b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs @@ -150,6 +150,7 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu var responseContentHeaders = new HttpHeaders(response.Content.Headers); context.Set(Response, response); + context.Set(ResponseContent, file?.Stream); context.Set(Result, file); context.Set(StatusCode, statusCode); context.Set(ResponseHeaders, responseHeaders); 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); From 615f30f0eb9e7046b8bced9800b0f807ca5de9be Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 14 Jun 2024 22:46:47 +0200 Subject: [PATCH 7/8] Add byte array converter and enhance HTTP file download A new ByteArrayConverter class has been added to Elsa.JavaScript that converts byte arrays to Uint8Arrays. In Elsa.Http, the DownloadHttpFile activity now also stores the downloaded file's content in bytes. In addition, various new type aliases have been added to the Elsa.Http and Elsa.JavaScript modules, making it easier to handle non-string responses and the transfer of data. --- .../Elsa.Http/Activities/DownloadHttpFile.cs | 30 ++++++++----- .../MultiDownloadableContentHandler.cs | 2 +- src/modules/Elsa.Http/Features/HttpFeature.cs | 4 ++ .../ObjectConverters/ByteArrayConverter.cs | 26 +++++++++++ .../Services/JintJavaScriptEvaluator.cs | 44 +++++++++---------- .../Services/TypeAliasRegistry.cs | 4 ++ .../Extensions/OutputExtensions.cs | 9 ++++ 7 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 src/modules/Elsa.JavaScript/ObjectConverters/ByteArrayConverter.cs diff --git a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs index 8e62f56fd..fd8c9d0cd 100644 --- a/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs +++ b/src/modules/Elsa.Http/Activities/DownloadHttpFile.cs @@ -46,7 +46,7 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu UIHint = InputUIHints.DropDown )] public Input Method { get; set; } = new("GET"); - + /// /// A list of expected status codes to handle. /// @@ -102,7 +102,7 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu /// [Output(IsSerializable = false)] public Output Response { get; set; } = default!; - + /// /// The HTTP response status code /// @@ -110,17 +110,23 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu public Output StatusCode { get; set; } = default!; /// - /// The parsed content, if any. + /// The downloaded content stream, if any. /// - [Output(Description = "The parsed content, if any.", IsSerializable = false)] - public Output ResponseContent { get; set; } = default!; + [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. /// @@ -150,11 +156,12 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu var responseContentHeaders = new HttpHeaders(response.Content.Headers); context.Set(Response, response); - context.Set(ResponseContent, file?.Stream); + 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); } @@ -179,7 +186,7 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu await HandleTaskCanceledExceptionAsync(context, e); } } - + /// /// Handles the response. /// @@ -272,11 +279,14 @@ public class DownloadHttpFile : Activity, IActivityPropertyDefaultValu 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 new List + { + 200 + }; return default!; } 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/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 2ec04580b..57e76dc51 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -219,6 +219,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.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 be9cbae41..ac9cd3908 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(); /// public async Task EvaluateAsync(string expression, @@ -71,13 +62,15 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator // Wrap objects in ObjectWrapper instances and set their prototype to Array.prototype if they are array-like. opts.SetWrapObjectHandler((engine, target, type) => { - var instance = new ObjectWrapper(engine, target); + var instance = ObjectWrapper.Create(engine, target); if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType())) instance.Prototype = engine.Intrinsics.Array.PrototypeObject; return instance; }); + + opts.Interop.ObjectConverters.Add(new ByteArrayConverter()); }); configureEngine?.Invoke(engine); @@ -123,7 +116,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(); @@ -135,7 +128,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; } @@ -159,6 +152,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(); @@ -184,7 +178,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); @@ -204,15 +198,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 From 7b5e1d72fef801e1a536534b0200594f28d88a7c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 15 Jun 2024 18:04:58 +0200 Subject: [PATCH 8/8] Update Elsa.Studio package versions The package versions for Elsa.Studio, Elsa.Studio.Core.BlazorWasm, and Elsa.Studio.Login.BlazorWasm have been updated from 3.2.0-preview.346 to 3.2.0 --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ff7338d94..f8216b2d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,9 +23,9 @@ - - - + + +