From 5f11026ee7c97c9c51e8443ebde17ecab0ddbfec Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 14 Aug 2024 17:43:35 +0200 Subject: [PATCH] Add JS functions for encoding/decoding byte arrays from and to strings (#5902) * Add handlers and improve JavaScript engine configuration Added several new handlers for configuring the JavaScript engine with common types, functions, variable accessors, and input/output accessors. Refactored the JintJavaScriptEvaluator for better clarity and modularity by breaking down configuration steps into separate methods. Also replaced `IsInsideCompositeActivity` with `IsContainedWithinCompositeActivity` for better semantic consistency. * Refactor to use primary constructor syntax for TypeDefinitionProviders Updated CommonTypeDefinitionProvider, VariableTypeDefinitionProvider, and ActivityOutputFunctionsDefinitionProvider to use primary constructors for dependency injection. This change reduces redundancy and simplifies the code structure for better readability and maintainability. * Add byte conversion functions to JavaScript engine Introduced functions for converting bytes to/from strings and Base64. Updated CommonFunctionsDefinitionProvider and ConfigureEngineWithCommonFunctions to incorporate these new functions. This enhances the JavaScript engine's ability to handle byte array manipulations. * Add tests for JavaScript byte and string conversions. Introduce integration tests to verify byte array to string, string to byte array, byte array to Base64, and Base64 to byte array conversions using JavaScript functions. Ensure accurate transformation of data within different encoding scenarios. * Add meaningful summaries to Jint engine configuration handlers Updated comment summaries in four handler classes to provide clear and concise descriptions of their purpose. This helps improve code readability and understanding for future developers. * Rename and merge variable and input/output handlers Merged the variable accessor logic into the input/output handler and renamed the class to reflect its broader functionality. This consolidation ensures the accessors are registered in the right order. * Add string base64 conversion functions Introduced `stringToBase64` and `stringFromBase64` functions to handle base64 encoding and decoding of strings. Updated corresponding provider, handler, and added tests to ensure functionality. --- .../ConfigureEngineWithCommonFunctions.cs | 74 +++++++ .../ConfigureEngineWithCommonTypes.cs | 25 +++ ...ineWithVariablesAndInputOutputAccessors.cs | 68 +++++++ .../InputFunctionsDefinitionProvider.cs | 4 +- .../JavaScriptExpressionSyntaxProvider.cs | 2 + .../Services/JintJavaScriptEvaluator.cs | 184 +++++------------- ...tivityOutputFunctionsDefinitionProvider.cs | 22 +-- .../CommonFunctionsDefinitionProvider.cs | 38 +++- .../CommonTypesDefinitionProvider.cs | 13 +- .../VariableTypeDefinitionProvider.cs | 11 +- .../ExpressionExecutionContextExtensions.cs | 4 +- .../BytesAndStringEncodingTests.cs | 100 ++++++++++ 12 files changed, 367 insertions(+), 178 deletions(-) create mode 100644 src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonFunctions.cs create mode 100644 src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonTypes.cs create mode 100644 src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/JavaScriptFunctions/BytesAndStringEncodingTests.cs diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonFunctions.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonFunctions.cs new file mode 100644 index 000000000..544a1894a --- /dev/null +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonFunctions.cs @@ -0,0 +1,74 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Text.Unicode; +using Elsa.Extensions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using JetBrains.Annotations; + +namespace Elsa.JavaScript.Handlers; + +/// A handler that configures the Jint engine with common functions. +[UsedImplicitly] +public class ConfigureEngineWithCommonFunctions : INotificationHandler +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = CreateJsonSerializerOptions(); + + /// + public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + var context = notification.Context; + + // Add common functions. + engine.SetValue("getWorkflowDefinitionId", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.DefinitionId)); + engine.SetValue("getWorkflowDefinitionVersionId", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.Id)); + engine.SetValue("getWorkflowDefinitionVersion", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.Version)); + engine.SetValue("getWorkflowInstanceId", (Func)(() => context.GetActivityExecutionContext().WorkflowExecutionContext.Id)); + engine.SetValue("setCorrelationId", (Action)(value => context.GetActivityExecutionContext().WorkflowExecutionContext.CorrelationId = value)); + engine.SetValue("getCorrelationId", (Func)(() => context.GetActivityExecutionContext().WorkflowExecutionContext.CorrelationId)); + engine.SetValue("setVariable", (Action)((name, value) => context.SetVariableInScope(name, value))); + engine.SetValue("getVariable", (Func)(name => context.GetVariableInScope(name))); + engine.SetValue("getInput", (Func)(name => context.GetInput(name))); + engine.SetValue("getOutputFrom", (Func)((activityIdName, outputName) => context.GetOutput(activityIdName, outputName))); + engine.SetValue("getLastResult", (Func)(() => context.GetLastResult())); + engine.SetValue("isNullOrWhiteSpace", (Func)(value => string.IsNullOrWhiteSpace(value))); + engine.SetValue("isNullOrEmpty", (Func)(value => string.IsNullOrEmpty(value))); + engine.SetValue("toJson", (Func)(value => Serialize(value))); + engine.SetValue("parseGuid", (Func)(value => Guid.Parse(value))); + engine.SetValue("newGuid", (Func)(() => Guid.NewGuid())); + engine.SetValue("newGuidString", (Func)(() => Guid.NewGuid().ToString())); + engine.SetValue("newShortGuid", (Func)(() => Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", ""))); + engine.SetValue("bytesToString", (Func)(value => Encoding.UTF8.GetString(value))); + engine.SetValue("bytesFromString", (Func)(value => Encoding.UTF8.GetBytes(value))); + engine.SetValue("bytesToBase64", (Func)(value => Convert.ToBase64String(value))); + engine.SetValue("bytesFromBase64", (Func)(value => Convert.FromBase64String(value))); + engine.SetValue("stringToBase64", (Func)(value => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)))); + engine.SetValue("stringFromBase64", (Func)(value => Encoding.UTF8.GetString(Convert.FromBase64String(value)))); + + // Deprecated, use newGuidString instead. + engine.SetValue("getGuidString", (Func)(() => Guid.NewGuid().ToString())); + + // Deprecated, use newShortGuid instead. + engine.SetValue("getShortGuid", (Func)(() => Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", ""))); + return Task.CompletedTask; + } + + 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 options; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonTypes.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonTypes.cs new file mode 100644 index 000000000..3443dcb60 --- /dev/null +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithCommonTypes.cs @@ -0,0 +1,25 @@ +using Elsa.Extensions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using JetBrains.Annotations; + +namespace Elsa.JavaScript.Handlers; + +/// A handler that configures the Jint engine with common types. +[UsedImplicitly] +public class ConfigureEngineWithCommonTypes : INotificationHandler +{ + /// + public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + + // Add common .NET types. + engine.RegisterType(); + engine.RegisterType(); + engine.RegisterType(); + engine.RegisterType(); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs new file mode 100644 index 000000000..f7faa1913 --- /dev/null +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariablesAndInputOutputAccessors.cs @@ -0,0 +1,68 @@ +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using Humanizer; +using JetBrains.Annotations; +using Jint; + +namespace Elsa.JavaScript.Handlers; + +/// A handler that configures the Jint engine with workflow input and output accessors. +[UsedImplicitly] +public class ConfigureEngineWithVariablesAndInputOutputAccessors : INotificationHandler +{ + /// + public async Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + var context = notification.Context; + + // The order of the next 3 lines is important. + CreateVariableAccessors(engine, context); + CreateWorkflowInputAccessors(engine, context); + await CreateActivityOutputAccessorsAsync(engine, context); + } + + private void CreateVariableAccessors(Engine engine, ExpressionExecutionContext context) + { + var variableNames = context.GetVariableNamesInScope().ToList(); + + foreach (var variableName in variableNames) + { + var pascalName = variableName.Pascalize(); + engine.SetValue($"get{pascalName}", (Func)(() => context.GetVariableInScope(variableName))); + engine.SetValue($"set{pascalName}", (Action)(value => context.SetVariableInScope(variableName, value))); + } + } + + private void CreateWorkflowInputAccessors(Engine engine, ExpressionExecutionContext context) + { + // Create workflow input accessors - only if the current activity is not part of a composite activity definition. + // Otherwise, the workflow input accessors will hide the composite activity input accessors which rely on variable accessors. + if (context.IsContainedWithinCompositeActivity()) + return; + + var inputs = context.GetWorkflowInputs().ToDictionary(x => x.Name); + + if (!context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) + return; + + var inputDefinitions = workflowExecutionContext.Workflow.Inputs; + + foreach (var inputDefinition in inputDefinitions) + { + var input = inputs.GetValueOrDefault(inputDefinition.Name); + engine.SetValue($"get{inputDefinition.Name}", (Func)(() => input?.Value)); + } + } + + private static async Task CreateActivityOutputAccessorsAsync(Engine engine, ExpressionExecutionContext context) + { + var activityOutputs = context.GetActivityOutputs(); + + await foreach (var activityOutput in activityOutputs) + foreach (var outputName in activityOutput.OutputNames) + engine.SetValue($"get{outputName}From{activityOutput.ActivityName}", (Func)(() => context.GetOutput(activityOutput.ActivityId, outputName))); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Providers/InputFunctionsDefinitionProvider.cs b/src/modules/Elsa.JavaScript/Providers/InputFunctionsDefinitionProvider.cs index 91f2a1026..080b488dc 100644 --- a/src/modules/Elsa.JavaScript/Providers/InputFunctionsDefinitionProvider.cs +++ b/src/modules/Elsa.JavaScript/Providers/InputFunctionsDefinitionProvider.cs @@ -6,12 +6,12 @@ using Elsa.Workflows.Management; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Entities; using Humanizer; +using JetBrains.Annotations; namespace Elsa.JavaScript.Providers; -/// /// Produces s for common functions. -/// +[UsedImplicitly] internal class InputFunctionsDefinitionProvider(ITypeAliasRegistry typeAliasRegistry, IWorkflowDefinitionService workflowDefinitionService) : FunctionDefinitionProvider { diff --git a/src/modules/Elsa.JavaScript/Providers/JavaScriptExpressionSyntaxProvider.cs b/src/modules/Elsa.JavaScript/Providers/JavaScriptExpressionSyntaxProvider.cs index bddfdd997..6be4ed523 100644 --- a/src/modules/Elsa.JavaScript/Providers/JavaScriptExpressionSyntaxProvider.cs +++ b/src/modules/Elsa.JavaScript/Providers/JavaScriptExpressionSyntaxProvider.cs @@ -2,10 +2,12 @@ using Elsa.Expressions.Contracts; using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.JavaScript.Expressions; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; namespace Elsa.JavaScript.Providers; +[UsedImplicitly] internal class JavaScriptExpressionDescriptorProvider : IExpressionDescriptorProvider { private const string TypeName = "JavaScript"; diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index c185bb8e4..3d2cbea84 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -1,24 +1,15 @@ -using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using System.Text.Unicode; using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; -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 Esprima.Ast; using Jint; -using Jint.Native; -using Jint.Native.TypedArray; using Jint.Runtime.Interop; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; @@ -34,7 +25,6 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification : IJavaScriptEvaluator { private readonly JintOptions _jintOptions = scriptOptions.Value; - private readonly JsonSerializerOptions _jsonSerializerOptions = CreateJsonSerializerOptions(); /// public async Task EvaluateAsync(string expression, @@ -56,164 +46,86 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification var engine = new Engine(opts => { - if (_jintOptions.AllowClrAccess) - opts.AllowClr(); - - // Wrap objects in ObjectWrapper instances and set their prototype to Array.prototype if they are array-like. - opts.SetWrapObjectHandler((engine, target, type) => - { - var instance = ObjectWrapper.Create(engine, target); - - if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType())) - instance.Prototype = engine.Intrinsics.Array.PrototypeObject; - - return instance; - }); - - opts.Interop.ObjectConverters.AddRange([new ByteArrayConverter(), new ExpandoObjectConverter()]); + ConfigureClrAccess(opts); + ConfigureObjectWrapper(opts); + ConfigureObjectConverters(opts); }); configureEngine?.Invoke(engine); - - // Add common functions. - engine.SetValue("getWorkflowDefinitionId", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.DefinitionId)); - engine.SetValue("getWorkflowDefinitionVersionId", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.Id)); - engine.SetValue("getWorkflowDefinitionVersion", (Func)(() => context.GetWorkflowExecutionContext().Workflow.Identity.Version)); - engine.SetValue("getWorkflowInstanceId", (Func)(() => context.GetActivityExecutionContext().WorkflowExecutionContext.Id)); - engine.SetValue("setCorrelationId", (Action)(value => context.GetActivityExecutionContext().WorkflowExecutionContext.CorrelationId = value)); - engine.SetValue("getCorrelationId", (Func)(() => context.GetActivityExecutionContext().WorkflowExecutionContext.CorrelationId)); - engine.SetValue("setVariable", (Action)((name, value) => context.SetVariableInScope(name, value))); - engine.SetValue("getVariable", (Func)(name => context.GetVariableInScope(name))); - engine.SetValue("getInput", (Func)(name => context.GetInput(name))); - engine.SetValue("getOutputFrom", (Func)((activityIdName, outputName) => context.GetOutput(activityIdName, outputName))); - engine.SetValue("getLastResult", (Func)(() => context.GetLastResult())); - - // Create variable getters and setters for each variable. - CreateVariableAccessors(engine, context); - - // Create workflow input accessors - only if the current activity is not part of a composite activity definition. - // Otherwise, the workflow input accessors will hide the composite activity input accessors which rely on variable accessors created above. - CreateWorkflowInputAccessors(engine, context); - - // Create output getters for each activity. - await CreateActivityOutputAccessorsAsync(engine, context); - - // Create argument getters for each argument. - foreach (var argument in options.Arguments) - engine.SetValue($"get{argument.Key}", (Func)(() => argument.Value)); - - // Add common functions. - engine.SetValue("isNullOrWhiteSpace", (Func)(value => string.IsNullOrWhiteSpace(value))); - engine.SetValue("isNullOrEmpty", (Func)(value => string.IsNullOrEmpty(value))); - engine.SetValue("toJson", (Func)(value => Serialize(value))); - engine.SetValue("parseGuid", (Func)(value => Guid.Parse(value))); - engine.SetValue("newGuid", (Func)(() => Guid.NewGuid())); - engine.SetValue("newGuidString", (Func)(() => Guid.NewGuid().ToString())); - engine.SetValue("newShortGuid", (Func)(() => Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", ""))); - - // Deprecated, use newGuidString instead. - engine.SetValue("getGuidString", (Func)(() => Guid.NewGuid().ToString())); - - // Deprecated, use newShortGuid instead. - engine.SetValue("getShortGuid", (Func)(() => Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", ""))); - - // Create configuration value accessor - if (_jintOptions.AllowConfigurationAccess) - engine.SetValue("getConfig", (Func)(name => configuration.GetSection(name).Value)); - - // Add common .NET types. - engine.RegisterType(); - engine.RegisterType(); - engine.RegisterType(); - engine.RegisterType(); - - // Invoke registered configuration callback. + ConfigureArgumentGetters(engine, options); + ConfigureConfigurationAccess(engine); _jintOptions.ConfigureEngineCallback(engine, context); - - // Allow listeners invoked by the mediator to configure the engine. await mediator.SendAsync(new EvaluatingJavaScript(engine, context), cancellationToken); return engine; } - private void CreateWorkflowInputAccessors(Engine engine, ExpressionExecutionContext context) + private void ConfigureClrAccess(Jint.Options options) { - if (context.IsInsideCompositeActivity()) - return; - - var inputs = context.GetWorkflowInputs().ToDictionary(x => x.Name); - - if (!context.TryGetWorkflowExecutionContext(out var workflowExecutionContext)) - return; - - var inputDefinitions = workflowExecutionContext.Workflow.Inputs; - - foreach (var inputDefinition in inputDefinitions) + if (_jintOptions.AllowClrAccess) + options.AllowClr(); + } + + private void ConfigureObjectWrapper(Jint.Options options) + { + options.SetWrapObjectHandler((engine, target, type) => { - var input = inputs.GetValueOrDefault(inputDefinition.Name); - engine.SetValue($"get{inputDefinition.Name}", (Func)(() => input?.Value)); - } + var instance = ObjectWrapper.Create(engine, target); + + if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType())) + instance.Prototype = engine.Intrinsics.Array.PrototypeObject; + + return instance; + }); } - [RequiresUnreferencedCode("Calls Jint.Engine.SetValue(String, T)")] - private static void CreateVariableAccessors(Engine engine, ExpressionExecutionContext context) + private void ConfigureObjectConverters(Jint.Options options) { - var variableNames = context.GetVariableNamesInScope().ToList(); - - foreach (var variableName in variableNames) - { - var pascalName = variableName.Pascalize(); - engine.SetValue($"get{pascalName}", (Func)(() => context.GetVariableInScope(variableName))); - engine.SetValue($"set{pascalName}", (Action)(value => context.SetVariableInScope(variableName, value))); - } + options.Interop.ObjectConverters.AddRange([new ByteArrayConverter(), new ExpandoObjectConverter()]); } - private static async Task CreateActivityOutputAccessorsAsync(Engine engine, ExpressionExecutionContext context) + private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options) { - var activityOutputs = context.GetActivityOutputs(); - - await foreach (var activityOutput in activityOutputs) - foreach (var outputName in activityOutput.OutputNames) - engine.SetValue($"get{outputName}From{activityOutput.ActivityName}", (Func)(() => context.GetOutput(activityOutput.ActivityId, outputName))); + foreach (var argument in options.Arguments) + engine.SetValue($"get{argument.Key}", (Func)(() => argument.Value)); + } + + private void ConfigureConfigurationAccess(Engine engine) + { + if (_jintOptions.AllowConfigurationAccess) + engine.SetValue("getConfig", (Func)(name => configuration.GetSection(name).Value)); } private object? ExecuteExpressionAndGetResult(Engine engine, string expression) + { + var preparedScript = GetOrCreatePrepareScript(expression); + var result = engine.Evaluate(preparedScript); + return result.ToObject(); + } + + private Prepared