From 615f30f0eb9e7046b8bced9800b0f807ca5de9be Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 14 Jun 2024 22:46:47 +0200 Subject: [PATCH] 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