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.
This commit is contained in:
Sipke Schoorstra 2024-06-03 08:38:18 +02:00 committed by GitHub
parent 038d29b792
commit 100ece8278
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 252 additions and 23 deletions

View file

@ -14,4 +14,8 @@
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="App_Data\locks\" />
</ItemGroup>
</Project>

View file

@ -40,6 +40,9 @@ builder.Services.AddElsa(elsa =>
// Register custom activities.
elsa.AddActivitiesFrom<Program>();
// Register custom workflows.
elsa.AddWorkflowsFrom<Program>();
});
// Configure CORS to allow designer app hosted on a different origin to invoke the APIs.

View file

@ -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;
/// <summary>
/// Represents a mysterious pond workflow.
/// </summary>
public class MysteriousPondWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
var investment = builder.WithVariable<Investment>();
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);
};

View file

@ -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
}

View file

@ -59,6 +59,7 @@
<ItemGroup>
<Folder Include="App_Data\"/>
<Folder Include="Workflows\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,31 @@
using System.Text.Json;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Parses a <see cref="JsonElement"/> into a .NET object.
/// </summary>
public static class JsonElementExtensions
{
/// <summary>
/// Parses a <see cref="JsonElement"/> into a .NET object.
/// </summary>
/// <param name="jsonElement">The JSON element to parse.</param>
/// <returns>The parsed object.</returns>
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()
};
}
}

View file

@ -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<Expression>((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;
};
}

View file

@ -217,6 +217,7 @@ public class ActivityJsonConverter : JsonConverter<IActivity>
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;
}
}

View file

@ -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;
/// <summary>
/// Serializes <see cref="Expression"/> objects.
/// </summary>
public class ExpressionJsonConverter : JsonConverter<Expression>
{
private readonly IExpressionDescriptorRegistry _expressionDescriptorRegistry;
/// <inheritdoc />
public ExpressionJsonConverter(IExpressionDescriptorRegistry expressionDescriptorRegistry)
{
_expressionDescriptorRegistry = expressionDescriptorRegistry;
}
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert) => typeof(Input).IsAssignableFrom(typeToConvert);
/// <inheritdoc />
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;
}
/// <inheritdoc />
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);
}
}

View file

@ -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;
/// <summary>
/// A JSON converter factory that creates <see cref="ExpressionJsonConverter"/> instances.
/// </summary>
public class ExpressionJsonConverterFactory : JsonConverterFactory
{
private readonly IExpressionDescriptorRegistry _expressionDescriptorRegistry;
/// <inheritdoc />
public ExpressionJsonConverterFactory(IExpressionDescriptorRegistry expressionDescriptorRegistry)
{
_expressionDescriptorRegistry = expressionDescriptorRegistry;
}
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert) => typeof(Expression).IsAssignableFrom(typeToConvert);
/// <inheritdoc />
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return new ExpressionJsonConverter(_expressionDescriptorRegistry);
}
}

View file

@ -49,7 +49,6 @@ public class InputJsonConverter<T> : JsonConverter<Input<T>>
var memoryBlockType = memoryBlockReference.GetType();
var context = new ExpressionSerializationContext(expressionTypeName!, expressionElement, options, memoryBlockType);
var expression = expressionDescriptor!.Deserialize(context);
return (Input<T>)Activator.CreateInstance(typeof(Input<T>), expression, memoryBlockReference)!;
}
@ -61,14 +60,14 @@ public class InputJsonConverter<T> : JsonConverter<Input<T>>
public override void Write(Utf8JsonWriter writer, Input<T> 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)

View file

@ -39,6 +39,8 @@ public class JsonActivitySerializer(IServiceProvider serviceProvider) : Configur
{
options.Converters.Add(CreateInstance<TypeJsonConverter>());
options.Converters.Add(CreateInstance<InputJsonConverterFactory>());
options.Converters.Add(CreateInstance<OutputJsonConverterFactory>());
options.Converters.Add(CreateInstance<ExpressionJsonConverterFactory>());
}
private JsonSerializerOptions GetOptionsInternal()

View file

@ -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
/// <inheritdoc />
protected override void AddConverters(JsonSerializerOptions options)
{
var expressionDescriptorRegistry = ServiceProvider.GetRequiredService<IExpressionDescriptorRegistry>();
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));
}
}

View file

@ -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)

View file

@ -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);