From 100ece827891c9a8cc317e73e83cd2d973bee4f2 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 3 Jun 2024 08:38:18 +0200 Subject: [PATCH] Add JSON Serialization for Elsa expression (#5490) * Add JSON Serialization for Elsa expression Extended the expression serialization. Added new classes ExpressionJsonConverter and ExpressionJsonConverterFactory implementing serialization of expression objects. Also, made respective changes in different serializers and related files for seamless integration. * Fix XML comments * Set initial builder Id in ClrWorkflowProvider This commit involves a modification in ClrWorkflowProvider.cs where an Id was set for the builder. The Id was set with the format `workflowBuilderType.Name`:1.0, providing a deterministic identifier for each builder instance. * Add MysteriousPondWorkflow and associated HTTP endpoints A new workflow, MysteriousPondWorkflow, has been introduced along with HTTP endpoints to interact with it. The workflow simulates throwing an arbitrary amount of rupees into a mysterious pond and getting a luck prediction for the day based on the amount. An integration of this workflow is registered in the main Program.cs file, and the necessary directories to handle this workflow have been added to relevant project files. --- .../Elsa.Samples.AspNet.WorkflowServer.csproj | 4 + .../Program.cs | 3 + .../Workflows/MysteriousPondWorkflow.cs | 56 ++++++++++++++ .../mysterious_pond.http | 31 ++++++++ .../Elsa.Server.Web/Elsa.Server.Web.csproj | 1 + .../Extensions/JsonElementExtensions.cs | 31 ++++++++ .../Models/ExpressionDescriptor.cs | 11 ++- .../Converters/ActivityJsonConverter.cs | 1 + .../Converters/ExpressionJsonConverter.cs | 74 +++++++++++++++++++ .../ExpressionJsonConverterFactory.cs | 29 ++++++++ .../Converters/InputJsonConverter.cs | 9 +-- .../Serializers/JsonActivitySerializer.cs | 2 + .../Serializers/SafeSerializer.cs | 5 ++ .../DefaultExpressionDescriptorProvider.cs | 17 +---- .../Providers/ClrWorkflowProvider.cs | 1 + 15 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Workflows/MysteriousPondWorkflow.cs create mode 100644 samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/mysterious_pond.http create mode 100644 src/modules/Elsa.Expressions/Extensions/JsonElementExtensions.cs create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverter.cs create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverterFactory.cs diff --git a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Elsa.Samples.AspNet.WorkflowServer.csproj b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Elsa.Samples.AspNet.WorkflowServer.csproj index 9c538db64..4a446863f 100644 --- a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Elsa.Samples.AspNet.WorkflowServer.csproj +++ b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Elsa.Samples.AspNet.WorkflowServer.csproj @@ -14,4 +14,8 @@ + + + + \ No newline at end of file diff --git a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Program.cs b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Program.cs index a093abdf5..ead62e546 100644 --- a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Program.cs +++ b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Program.cs @@ -40,6 +40,9 @@ builder.Services.AddElsa(elsa => // Register custom activities. elsa.AddActivitiesFrom(); + + // Register custom workflows. + elsa.AddWorkflowsFrom(); }); // Configure CORS to allow designer app hosted on a different origin to invoke the APIs. diff --git a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Workflows/MysteriousPondWorkflow.cs b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Workflows/MysteriousPondWorkflow.cs new file mode 100644 index 000000000..fd7cd1c78 --- /dev/null +++ b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/Workflows/MysteriousPondWorkflow.cs @@ -0,0 +1,56 @@ +using System.Net.Mime; +using Elsa.Expressions.Models; +using Elsa.Http; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; + +namespace Elsa.Samples.AspNet.WorkflowServer.Workflows; + +/// +/// Represents a mysterious pond workflow. +/// +public class MysteriousPondWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var investment = builder.WithVariable(); + + builder.Root = new Sequence + { + Activities = + { + new HttpEndpoint + { + Path = new("mysterious_pond"), + SupportedMethods = new([HttpMethod.Post.Method]), + ParsedContent = new(investment), + CanStartWorkflow = true + }, + new WriteLine(context => $"Received {GetRupees(context)} rupees"), + new Switch + { + Cases = + { + new SwitchCase("Great Luck", context => GetRupees(context) >= 1000, WriteHttpResponse("For today, you will have great luck.")), + new SwitchCase("Good Luck", context => GetRupees(context) >= 500, WriteHttpResponse("For today, you will have good luck.")), + new SwitchCase("A Little Luck", context => GetRupees(context) >= 250, WriteHttpResponse("For today, you will have a little luck.")), + new SwitchCase("Bad Luck", context => GetRupees(context) < 250, WriteHttpResponse("For today, you will have bad luck.")), + + } + } + } + }; + return; + + int GetRupees(ExpressionExecutionContext context) => investment.Get(context)!.Rupees; + } + + private static WriteHttpResponse WriteHttpResponse(string message) => new() + { + Content = new(message), + ContentType = new(MediaTypeNames.Text.Plain), + }; + + private record Investment(int Rupees); +}; \ No newline at end of file diff --git a/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/mysterious_pond.http b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/mysterious_pond.http new file mode 100644 index 000000000..1a5c9f72c --- /dev/null +++ b/samples/aspnet/Elsa.Samples.AspNet.WorkflowServer/mysterious_pond.http @@ -0,0 +1,31 @@ +### POST 1000 rupees to the mysterious pond. +POST https://localhost:5001/workflows/mysterious_pond +Content-Type: application/json + +{ + "rupees": 1000 +} + +### POST 500 rupees to the mysterious pond. +POST https://localhost:5001/workflows/mysterious_pond +Content-Type: application/json + +{ + "rupees": 500 +} + +### POST 250 rupees to the mysterious pond. +POST https://localhost:5001/workflows/mysterious_pond +Content-Type: application/json + +{ + "rupees": 250 +} + +### POST 100 rupees to the mysterious pond. +POST https://localhost:5001/workflows/mysterious_pond +Content-Type: application/json + +{ + "rupees": 100 +} diff --git a/src/bundles/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/bundles/Elsa.Server.Web/Elsa.Server.Web.csproj index 1b9a8a9bc..189840cc2 100644 --- a/src/bundles/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/bundles/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -59,6 +59,7 @@ + diff --git a/src/modules/Elsa.Expressions/Extensions/JsonElementExtensions.cs b/src/modules/Elsa.Expressions/Extensions/JsonElementExtensions.cs new file mode 100644 index 000000000..e1975c2d8 --- /dev/null +++ b/src/modules/Elsa.Expressions/Extensions/JsonElementExtensions.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Parses a into a .NET object. +/// +public static class JsonElementExtensions +{ + /// + /// Parses a into a .NET object. + /// + /// The JSON element to parse. + /// The parsed object. + public static object? GetValue(this JsonElement jsonElement) + { + return jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetDecimal(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Undefined => null, + JsonValueKind.Null => null, + JsonValueKind.Object => jsonElement.GetRawText(), + JsonValueKind.Array => jsonElement.GetRawText(), + _ => jsonElement.GetRawText() + }; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Models/ExpressionDescriptor.cs b/src/modules/Elsa.Expressions/Models/ExpressionDescriptor.cs index b63ac9d7a..084c3612e 100644 --- a/src/modules/Elsa.Expressions/Models/ExpressionDescriptor.cs +++ b/src/modules/Elsa.Expressions/Models/ExpressionDescriptor.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Elsa.Expressions.Contracts; +using Elsa.Extensions; namespace Elsa.Expressions.Models; @@ -16,9 +17,13 @@ public class ExpressionDescriptor // Default deserialization function. Deserialize = context => { - return context.JsonElement.ValueKind == JsonValueKind.Object - ? context.JsonElement.Deserialize((JsonSerializerOptions?)context.Options)! - : new Expression(context.ExpressionType, null!); + var expression = new Expression(context.ExpressionType, null); + + if (context.JsonElement.ValueKind == JsonValueKind.Object) + if (context.JsonElement.TryGetProperty("value", out var expressionValueElement)) + expression.Value = expressionValueElement.GetValue(); + + return expression; }; } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 4859f023f..62b6a2223 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -217,6 +217,7 @@ public class ActivityJsonConverter : JsonConverter var newOptions = new JsonSerializerOptions(options); newOptions.Converters.Add(new InputJsonConverterFactory(_serviceProvider)); newOptions.Converters.Add(new OutputJsonConverterFactory(_serviceProvider)); + newOptions.Converters.Add(new ExpressionJsonConverterFactory(_expressionDescriptorRegistry)); return _options = newOptions; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverter.cs new file mode 100644 index 000000000..70fe6ddf5 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverter.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.Serialization.Converters; + +/// +/// Serializes objects. +/// +public class ExpressionJsonConverter : JsonConverter +{ + private readonly IExpressionDescriptorRegistry _expressionDescriptorRegistry; + + /// + public ExpressionJsonConverter(IExpressionDescriptorRegistry expressionDescriptorRegistry) + { + _expressionDescriptorRegistry = expressionDescriptorRegistry; + } + + /// + public override bool CanConvert(Type typeToConvert) => typeof(Input).IsAssignableFrom(typeToConvert); + + /// + public override Expression Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (!JsonDocument.TryParseValue(ref reader, out var doc)) + return default!; + + var expressionElement = doc.RootElement; + var expressionTypeNameElement = expressionElement.TryGetProperty("type", out var expressionTypeNameElementValue) ? expressionTypeNameElementValue : default; + var expressionTypeName = expressionTypeNameElement.ValueKind != JsonValueKind.Undefined ? expressionTypeNameElement.GetString() ?? "Literal" : default; + var expressionDescriptor = expressionTypeName != null ? _expressionDescriptorRegistry.Find(expressionTypeName) : default; + var memoryBlockReference = expressionDescriptor?.MemoryBlockReferenceFactory?.Invoke(); + + if (memoryBlockReference == null) + return default!; + + var memoryBlockType = memoryBlockReference.GetType(); + var context = new ExpressionSerializationContext(expressionTypeName!, expressionElement, options, memoryBlockType); + var expression = expressionDescriptor!.Deserialize(context); + + return expression; + } + + /// + public override void Write(Utf8JsonWriter writer, Expression value, JsonSerializerOptions options) + { + var expression = value; + + if(expression == null) + { + writer.WriteNullValue(); + return; + } + + var expressionType = expression.Type; + var expressionDescriptor = expressionType != null ? _expressionDescriptorRegistry.Find(expressionType) : null; + + if (expressionDescriptor == null) + throw new JsonException($"Could not find an expression descriptor for expression type '{expressionType}'."); + + var expressionValue = expressionDescriptor.IsSerializable ? expression.Value : null; + + var model = new + { + Type = expressionType, + Value = expressionValue + }; + + JsonSerializer.Serialize(writer, model, options); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverterFactory.cs new file mode 100644 index 000000000..0af35b397 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpressionJsonConverterFactory.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; + +namespace Elsa.Workflows.Serialization.Converters; + +/// +/// A JSON converter factory that creates instances. +/// +public class ExpressionJsonConverterFactory : JsonConverterFactory +{ + private readonly IExpressionDescriptorRegistry _expressionDescriptorRegistry; + + /// + public ExpressionJsonConverterFactory(IExpressionDescriptorRegistry expressionDescriptorRegistry) + { + _expressionDescriptorRegistry = expressionDescriptorRegistry; + } + + /// + public override bool CanConvert(Type typeToConvert) => typeof(Expression).IsAssignableFrom(typeToConvert); + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new ExpressionJsonConverter(_expressionDescriptorRegistry); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/InputJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/InputJsonConverter.cs index f0f58f70f..f1c6c80d9 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/InputJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/InputJsonConverter.cs @@ -49,7 +49,6 @@ public class InputJsonConverter : JsonConverter> var memoryBlockType = memoryBlockReference.GetType(); var context = new ExpressionSerializationContext(expressionTypeName!, expressionElement, options, memoryBlockType); var expression = expressionDescriptor!.Deserialize(context); - return (Input)Activator.CreateInstance(typeof(Input), expression, memoryBlockReference)!; } @@ -61,14 +60,14 @@ public class InputJsonConverter : JsonConverter> public override void Write(Utf8JsonWriter writer, Input value, JsonSerializerOptions options) { var expression = value.Expression; - - if(expression == null) + + if (expression == null) { writer.WriteNullValue(); return; } - - var expressionType = expression?.Type; + + var expressionType = expression.Type; var expressionDescriptor = expressionType != null ? _expressionDescriptorRegistry.Find(expressionType) : default; if (expressionDescriptor == null) diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonActivitySerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonActivitySerializer.cs index c206fefd6..704b48bca 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonActivitySerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonActivitySerializer.cs @@ -39,6 +39,8 @@ public class JsonActivitySerializer(IServiceProvider serviceProvider) : Configur { options.Converters.Add(CreateInstance()); options.Converters.Add(CreateInstance()); + options.Converters.Add(CreateInstance()); + options.Converters.Add(CreateInstance()); } private JsonSerializerOptions GetOptionsInternal() diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs index 12a79f64b..a9437dba0 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs @@ -2,9 +2,11 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; using Elsa.Common.Serialization; +using Elsa.Expressions.Contracts; using Elsa.Expressions.Services; using Elsa.Workflows.Contracts; using Elsa.Workflows.Serialization.Converters; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.Serialization.Serializers; @@ -51,8 +53,11 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer /// protected override void AddConverters(JsonSerializerOptions options) { + var expressionDescriptorRegistry = ServiceProvider.GetRequiredService(); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); options.Converters.Add(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault())); options.Converters.Add(new SafeValueConverterFactory()); + options.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry)); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs index c0d738ba5..e85742a7c 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs @@ -32,20 +32,7 @@ public class DefaultExpressionDescriptorProvider : IExpressionDescriptorProvider deserialize: context => { var elementValue = context.JsonElement.TryGetProperty("value", out var v) ? v : default; - - var value = (object?)(elementValue.ValueKind switch - { - JsonValueKind.String => elementValue.GetString(), - JsonValueKind.Number => elementValue.GetDecimal(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Undefined => null, - JsonValueKind.Null => null, - JsonValueKind.Object => elementValue.GetRawText(), - JsonValueKind.Array => elementValue.GetRawText(), - _ => v.GetRawText() - }); - + var value = elementValue.GetValue(); return new Expression("Literal", value); }); } @@ -93,7 +80,7 @@ public class DefaultExpressionDescriptorProvider : IExpressionDescriptorProvider MemoryBlockReferenceFactory = memoryBlockReferenceFactory ?? (() => new MemoryBlockReference()) }; - if (deserialize != null) + if (deserialize != null) descriptor.Deserialize = deserialize; if (monacoLanguage != null) diff --git a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowProvider.cs b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowProvider.cs index c20d8cbdd..7125fabcf 100644 --- a/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowProvider.cs +++ b/src/modules/Elsa.Workflows.Runtime/Providers/ClrWorkflowProvider.cs @@ -51,6 +51,7 @@ public class ClrWorkflowProvider : IWorkflowProvider var workflowBuilderType = workflowBuilder.GetType(); builder.DefinitionId = workflowBuilderType.Name; + builder.Id = $"{workflowBuilderType.Name}:1.0"; await workflowBuilder.BuildAsync(builder, cancellationToken); var workflow = await builder.BuildWorkflowAsync(cancellationToken);