Add support for array variables and fix serialization issue with proto

This commit is contained in:
Sipke Schoorstra 2023-01-23 23:30:00 +01:00
parent 0584dc0bdf
commit 98d0920cfc
14 changed files with 99 additions and 45 deletions

View file

@ -112,6 +112,7 @@ export namespace Components {
"addActivity": (args: AddActivityArgs) => Promise<Activity>;
"autoLayout": (direction: LayoutDirection) => Promise<void>;
"export": () => Promise<Activity>;
"getActivity": (id: string) => Promise<Activity>;
"getGraph": () => Promise<Graph>;
"interactiveMode": boolean;
"newRoot": () => Promise<Activity>;

View file

@ -1,7 +1,7 @@
import {Component, h, Prop, Event, EventEmitter, Method} from "@stencil/core";
import {groupBy} from 'lodash';
import {StorageDriverDescriptor, Variable} from "../../../models";
import {FormEntry} from "../../shared/forms/form-entry";
import {CheckboxFormEntry, FormEntry} from "../../shared/forms/form-entry";
import {isNullOrWhitespace} from "../../../utils";
import descriptorsStore from '../../../data/descriptors-store';
import {VariableDescriptor} from "../../../services/api-client/variable-descriptors-api";
@ -22,7 +22,7 @@ export class VariableEditorDialogContent {
}
render() {
const variable: Variable = this.variable ?? {name: '', typeName: 'Object'};
const variable: Variable = this.variable ?? {name: '', typeName: 'Object', isArray: false};
const variableTypeName = variable.typeName;
const availableTypes: Array<VariableDescriptor> = descriptorsStore.variableDescriptors;
const groupedVariableTypes = groupBy(availableTypes, x => x.category);
@ -50,6 +50,10 @@ export class VariableEditorDialogContent {
</select>
</FormEntry>
<CheckboxFormEntry fieldId="variableIsArray" label="This variable is an array" hint="Check if the variable holds an array of the selected type.">
<input type="checkbox" name="variableIsArray" id="variableIsArray" value="true" checked={variable.isArray}/>
</CheckboxFormEntry>
<FormEntry fieldId="variableValue" label="Value" hint="The value of the variable.">
<input type="text" name="variableValue" id="variableValue" value={variable.value}/>
</FormEntry>
@ -84,12 +88,14 @@ export class VariableEditorDialogContent {
const name = formData.get('variableName') as string;
const value = formData.get('variableValue') as string;
const type = formData.get('variableTypeName') as string;
const isArray = formData.get('variableIsArray') as string == 'true';
const driverTypeName = formData.get('variableStorageDriverTypeName') as string;
const variable = this.variable;
variable.name = name;
variable.typeName = type;
variable.value = value;
variable.isArray = isArray;
variable.storageDriverTypeName = isNullOrWhitespace(driverTypeName) ? null : driverTypeName;
return variable;

View file

@ -109,7 +109,7 @@ export class VariablesEditor {
private onAddVariableClick = async () => {
const newVariableName = this.generateNewVariableName();
const variable = {name: newVariableName, typeName: 'Object', value: null};
const variable: Variable = {name: newVariableName, typeName: 'Object', value: null, isArray: false};
this.modalDialogInstance = this.modalDialogService.show(() => <elsa-variable-editor-dialog-content variable={variable}/>, {actions: [this.saveAction]})
};

View file

@ -1,8 +1,7 @@
import 'reflect-metadata';
import {Service} from "typedi";
import state from "./state";
import {ModalActionDefinition, ModalDialogInstance, ShowModalDialogOptions} from "./models";
import {ModalType} from "../../../components/shared/modal-dialog";
import {ModalDialogInstance, ShowModalDialogOptions} from "./models";
@Service()
export class ModalDialogService {

View file

@ -38,6 +38,8 @@ export interface Workflow extends Activity {
export interface Variable {
name: string;
typeName: string;
isArray: boolean;
value?: any;
storageDriverTypeName?: string;
}

View file

@ -209,6 +209,11 @@ export class FlowchartComponent {
this.updateLookups();
}
@Method()
async getActivity(id: string): Promise<Activity> {
return this.activityLookup[id];
}
@Method()
public async renameActivity(args: RenameActivityArgs) {
const nodeId = args.originalId;

View file

@ -1,3 +1,4 @@
using System.Dynamic;
using Elsa.Expressions.Extensions;
using Elsa.Expressions.Services;
@ -28,6 +29,7 @@ public class WellKnownTypeRegistry : IWellKnownTypeRegistry
this.RegisterType<TimeSpan>("TimeSpan");
this.RegisterType<DateOnly>("DateOnly");
this.RegisterType<TimeOnly>("TimeOnly");
this.RegisterType<ExpandoObject>("ExpandoObject");
}
/// <inheritdoc />

View file

@ -74,30 +74,42 @@ public class HttpEndpoint : Trigger<HttpRequest>
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// If we did not receive external input, it means we are just now encountering this activity and we need to block execution by creating a bookmark.
if (!context.TryGetInput<HttpContext>(HttpContextInputKey, out var httpContext))
if (!context.TryGetInput<bool>(HttpContextInputKey, out var isHttpContext))
{
// Create bookmarks for when we receive the expected HTTP request.
context.CreateBookmarks(GetBookmarkPayloads(context.ExpressionExecutionContext));
return;
}
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
// Provide the received HTTP request as output.
var request = httpContext.Request;
context.Set(Result, request);
// Read route data, if any.
var path = context.GetInput<PathString>(RequestPathInputKey);
var routeData = GetRouteData(httpContext, path);
context.Set(RouteData, routeData);
// Read content, if any.
var content = await ParseContentAsync(context, request);
context.Set(ParsedContent, content);
// Complete.
await context.CompleteActivityAsync();
if (httpContext == null)
{
// We're executing in a non-HTTP context (e.g. in a virtual actor).
// Create a bookmark to allow the invoker to export the state and resume execution from there.
context.CreateBookmark(OnResumeAsync);
return;
}
await HandleRequestAsync(context, httpContext);
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// We're not in an HTTP context, so let's fail.
throw new Exception("Cannot execute in a non-HTTP context");
}
await HandleRequestAsync(context, httpContext);
}
private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpRequest httpRequest)
{
if (!HasContent(httpRequest))
@ -121,6 +133,25 @@ public class HttpEndpoint : Trigger<HttpRequest>
return methods!.Select(x => new HttpEndpointBookmarkPayload(path!, x.ToLowerInvariant())).Cast<object>().ToArray();
}
private async Task HandleRequestAsync(ActivityExecutionContext context, HttpContext httpContext)
{
// Provide the received HTTP request as output.
var request = httpContext.Request;
context.Set(Result, request);
// Read route data, if any.
var path = context.GetInput<PathString>(RequestPathInputKey);
var routeData = GetRouteData(httpContext, path);
context.Set(RouteData, routeData);
// Read content, if any.
var content = await ParseContentAsync(context, request);
context.Set(ParsedContent, content);
// Complete.
await context.CompleteActivityAsync();
}
private static RouteData GetRouteData(HttpContext httpContext, string path)
{
var routeData = httpContext.GetRouteData();

View file

@ -59,17 +59,7 @@ public class WriteHttpResponse : CodeActivity
return;
}
var response = httpContext.Response;
response.StatusCode = (int)context.Get(StatusCode);
var headers = ResponseHeaders.TryGet(context) ?? new HttpResponseHeaders();
foreach (var header in headers)
response.Headers.Add(header.Key, header.Value);
response.ContentType = ContentType.TryGet(context);
var content = context.Get(Content);
if (content != null)
await response.WriteAsync(content, context.CancellationToken);
await WriteResponseAsync(context, httpContext.Response);
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
@ -83,10 +73,17 @@ public class WriteHttpResponse : CodeActivity
throw new Exception("Cannot execute in a non-HTTP context");
}
var response = httpContext.Response;
await WriteResponseAsync(context, httpContext.Response);
}
private async Task WriteResponseAsync(ActivityExecutionContext context, HttpResponse response)
{
response.StatusCode = (int)context.Get(StatusCode);
var headers = ResponseHeaders.TryGet(context) ?? new HttpResponseHeaders();
foreach (var header in headers)
response.Headers.Add(header.Key, header.Value);
var content = context.Get(Content);
if (content != null)

View file

@ -60,8 +60,8 @@ public static class RouteTableExtensions
routeTable.RemoveRange(paths);
}
private static IEnumerable<StoredTrigger> Filter(IEnumerable<StoredTrigger> triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>());
private static IEnumerable<Bookmark> Filter(IEnumerable<Bookmark> triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>());
private static IEnumerable<StoredTrigger> Filter(IEnumerable<StoredTrigger> triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>() && x.Data != null);
private static IEnumerable<Bookmark> Filter(IEnumerable<Bookmark> triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>() && x.Data != null);
private static HttpEndpointBookmarkPayload Deserialize(StoredTrigger trigger) => Deserialize(trigger.Data!);
private static HttpEndpointBookmarkPayload Deserialize(Bookmark bookmark) => Deserialize(bookmark.Data!);
private static HttpEndpointBookmarkPayload Deserialize(string model) => JsonSerializer.Deserialize<HttpEndpointBookmarkPayload>(model, SerializerOptions)!;

View file

@ -66,7 +66,7 @@ public class WorkflowsMiddleware
var input = new Dictionary<string, object>
{
[HttpEndpoint.HttpContextInputKey] = httpContext,
[HttpEndpoint.HttpContextInputKey] = true,
[HttpEndpoint.RequestPathInputKey] = path
};
@ -86,14 +86,15 @@ public class WorkflowsMiddleware
cancellationToken);
// We must assume that the workflow executed in a different process (when e.g. using Proto.Actor)
// and check if we received any `WriteHttpResponse` activity bookmarks.
// and check if we received any `HttpEndpoint` or `WriteHttpResponse` activity bookmarks.
// If we did, acquire a lock on the workflow instance and resume it from here within an actual HTTP context so that the activity can complete its HTTP response.
var httpEndpointTypeName = ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>();
var writeHttpResponseTypeName = ActivityTypeNameHelper.GenerateTypeName<WriteHttpResponse>();
var query =
from triggeredWorkflow in triggerResult.TriggeredWorkflows
from bookmark in triggeredWorkflow.Bookmarks
where bookmark.Name == writeHttpResponseTypeName
where bookmark.Name == writeHttpResponseTypeName || bookmark.Name == httpEndpointTypeName
select (triggeredWorkflow.InstanceId, bookmark.Id);
var workflowExecutionResults = new Stack<(string InstanceId, string BookmarkId)>(query);
@ -127,7 +128,7 @@ public class WorkflowsMiddleware
cancellationToken);
var workflowHost = await _workflowHostFactory.CreateAsync(workflow, workflowState, cancellationToken);
var options = new ResumeWorkflowHostOptions(correlationId, result.BookmarkId);
var options = new ResumeWorkflowHostOptions(correlationId, result.BookmarkId, Input: input);
await workflowHost.ResumeWorkflowAsync(options, cancellationToken);
// Import the updated workflow state into the runtime.

View file

@ -44,7 +44,13 @@ public class LiquidTemplateManager : ILiquidTemplateManager
e =>
{
if (!TryParse(source, out var parsed, out var error))
{
error = "{% raw %}\n" + error + "\n{% endraw %}";
TryParse(error, out parsed, out error);
e.SetSlidingExpiration(TimeSpan.FromMilliseconds(100));
return parsed;
}
// TODO: add signal based cache invalidation.
e.SetSlidingExpiration(TimeSpan.FromSeconds(30));

View file

@ -31,11 +31,12 @@ public class VariableDefinitionMapper
if (!_wellKnownTypeRegistry.TryGetTypeOrDefault(source.TypeName, out var type))
return null;
var variableGenericType = typeof(Variable<>).MakeGenericType(type);
var valueType = source.IsArray ? typeof(ICollection<>).MakeGenericType(type) : type;
var variableGenericType = typeof(Variable<>).MakeGenericType(valueType);
var variable = (Variable)Activator.CreateInstance(variableGenericType)!;
variable.Name = source.Name;
variable.Value = source.Value.ConvertTo(type);
variable.Value = source.Value.ConvertTo(valueType);
variable.StorageDriverType = !string.IsNullOrEmpty(source.StorageDriverTypeName) ? Type.GetType(source.StorageDriverTypeName) : default;
return variable;
@ -57,13 +58,16 @@ public class VariableDefinitionMapper
public VariableDefinition Map(Variable source)
{
var variableType = source.GetType();
var value = source.Value;
var valueType = variableType.IsConstructedGenericType ? variableType.GetGenericArguments().FirstOrDefault() ?? typeof(object) : typeof(object);
var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(valueType);
var isArray = valueType.IsGenericType && typeof(ICollection<>).IsAssignableFrom(valueType.GetGenericTypeDefinition());
var elementValueType = isArray ? valueType.GenericTypeArguments[0] : valueType;
var value = source.Value;
var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(elementValueType);
var storageDriverTypeName = source.StorageDriverType?.GetSimpleAssemblyQualifiedName();
var serializedValue = value.Format();
return new VariableDefinition(source.Name, valueTypeAlias, serializedValue, storageDriverTypeName);
return new VariableDefinition(source.Name, valueTypeAlias, isArray, serializedValue, storageDriverTypeName);
}
/// <summary>

View file

@ -3,4 +3,4 @@ namespace Elsa.Workflows.Management.Models;
/// <summary>
/// Stores information about a workflow variable.
/// </summary>
public record VariableDefinition(string Name, string TypeName, string? Value, string? StorageDriverTypeName);
public record VariableDefinition(string Name, string TypeName, bool IsArray, string? Value, string? StorageDriverTypeName);