Part 6 of Building Workflow Driven .NET Applications with Elsa 2

Custom Activities

Sipke Schoorstra
CodeX

--

In the previous part, we did something really cool: we invoked a workflow in response to a domain event. And although that workflow was rather harmless, now that we have the important pieces in play we can do a whole lot more damage :)

With Elsa’s increasingly rich set of activity libraries coming to a NuGet feed near you, there’s already a lot you can do without writing custom activities. But in many custom workflow driven applications, you are likely to need the ability to write custom activities. That’s what we will be doing in this part.

In the introduction, I mentioned the following custom activities:

  • Get Document
  • Archive Document
  • Zip File
  • Update Blockchain

We will go over each activity and implement them one by one.

Get Document

The purpose of this activity is to get a document and its associated uploaded file into memory so that the workflow can do something with it. For that, it needs only one input: the ID of the document to load. Its output will be a an object of a new record type that carries both the document and the file stream.

Since this is the first activity being created, I will go over various aspects of it in more detail.

Start by creating a new Activities folder in the DocumentManagement.Workflows project. Then create a new class called GetDocument.

using Elsa.Attributes;
using Elsa.Services;

namespace DocumentManagement.Workflows.Activities
{
public class GetDocument : Activity
{
}
}

The above code has the absolute minimum requirement to implement an activity by implementing the IActivity interface which is inherited from the Activity base class.

In order for this activity to be easier to understand by users of the workflow designer, it is recommended to provide a category and a description as metadata by applying the ActivityAttributeon the class:

[Action(Category = "Document Management", Description = "Gets the specified document from the database.")]
public class GetDocument : Activity
{
}

Within the same file, add the following record type above or after the GetDocument class (include the necessary namespaces as well):

+ using System.IO;
+ using DocumentManagement.Core.Models;
public record DocumentFile(Document Document, Stream FileStream);

IsExternalInit

At this point, you might receive a complaint from your IDE that “[t]he predefined type ‘System.Runtime.CompilerServices.IsExternalInit’ must be defined or imported in order to declare init-only setter.”. We have seen this before so we know what to do: just copy the IsExternalInit.cs file from the DocumentManagement.Core project into the root of the DocumentManagement.Workflows project.

Input

With that out of the way, let’s continue by adding the activity’s input property called DocumentId:

+ using Elsa.Expressions;[ActivityInput(
Label = "Document ID",
Hint = "The ID of the document to load",
SupportedSyntaxes = new[] {SyntaxNames.JavaScript, SyntaxNames.Liquid}
)]
public string DocumentId { get; set; } = default!;

The ActivityInputAttribute is necessary for both the engine as well as the designer. The engine uses the attribute as a marker to determine what properties to serialize, while the designer uses the additional metadata such as Label and Hintto display the activity in a user-friendly manner. The SupportedSyntaxes property is leveraged by the activity editor when displaying this property and allows the user to switch from the property’s default input editor to a JavaScript or Liquid editor.

Output

Next, add the following output property:

[ActivityOutput(
Hint = "The document + file.",
DefaultWorkflowStorageProvider = TransientWorkflowStorageProvider.ProviderName)]
public DocumentFile Output { get; set; } = default!;

The ActivityOutputAttribute is also used by the workflow engine as well as the designer in a similar way to ActivityInputAttribute. TheHint property isn’t currently leveraged by the designer, but this may change in a future version so it’s good to be prepared.

The DefaultWorkflowStorageProvider property tells the engine what workflow storage provider to use to persist the value of DocumentFile.

By default, all activity properties are serialized as part of the workflow instance. But this may not be desirable for all types. For example, our DocumentFile record contains a property of type Stream. Which is usually an indication that something large might be stored here. If we were to serialize it to base64, the workflow instance might become quite large, which would negatively impact performance every time the instance is loaded from the database, which also means it needs to be deserialized.

By using a different storage provider such as the TransientWorkflowStorageProvider, the object will be stored in-memory only.

The user can control what storage provider to use on a per-property basis. The DefaultWorkflowStorageProvider merely provides the default setting. If you want to prevent the user from having an option at all, set the DisableWorkflowProviderSelection property to true.

Execute

When the activity executes, we want it to do the following things:

  1. Load the document by ID from the database.
  2. Read the associated file as a stream.
  3. Return these things as output.

To load documents, we need to inject IDocumentStore. And to read files, we need to inject IFileStorage . Update the class with the following fields and constructor:

+ using DocumentManagement.Core.Services;private readonly IDocumentStore _documentStore;
private readonly IFileStorage _fileStorage;

public GetDocument(IDocumentStore documentStore, IFileStorage fileStorage)
{
_documentStore = documentStore;
_fileStorage = fileStorage;
}

Next, override the OnExecuteAsync method as follows:

+ using System.Threading.Tasks;
+ using Elsa.ActivityResults;
+ using Elsa.Providers.WorkflowStorage;
+ using Elsa.Services.Models;
protected override async ValueTask<IActivityExecutionResult> OnExecuteAsync(ActivityExecutionContext context)
{
var document = await _documentStore.GetAsync(DocumentId, context.CancellationToken);
var fileStream = await _fileStorage.ReadAsync(document!.FileName, context.CancellationToken);

Output = new DocumentFile(document, fileStream);
return Done();
}

The complete file should look like this:

Register Activity

Before we can actually use the activity on a workflow, we need to register it with Elsa. To do that, update the ServiceCollectionExtensions class and add the following code right after the .AddHttpActivities() invocation:

// Add custom activities
.AddActivitiesFrom<GetDocument>()

This will register not only the GetDocument activity, but also any additional activities we create in this project.

Try it out

Although we won’t implement any of the document handling workflows until the next part, let’s make sure the activity works by following these steps:

  1. Start the web application.
  2. Start Elsa Studio.
  3. Create a new workflow.
  4. Add the new Get Document activity and set its Document ID setting to the following JavaScript syntax: correlationId.
  5. Connect a new HTTP Response activity with the following Liquid content: The document’s file name is {{ Input.Document.FileName }}
  6. Publish the workflow and take a note of its ID (navigate back to the Workflow Definitions screen, click on the newly created workflow and copy the workflow ID from the browser’s address bar).
  7. Go to the home page of the web application and upload a document. Take note of the generated document ID.
  8. Open Postman or your own favorite HTTP client and execute the following request to invoke the workflow using the Elsa API (substitute “{your-workflow-id}” and “{your-document-id}” with your own values) :
curl --location --request POST 'https://localhost:5001/v1/workflows/{your-workflow-id}/execute' \
--header 'Content-Type: application/json' \
--data-raw '{
"correlationId": "{your-document-id}"
}'

The response should look like this:

The document's file name is

This is probably not what you expected. Although we clearly & correctly applied a Liquid expression of: The document’s file name is {{ Input.Document.FileName }} , and we know the document is stored in the database with an actual file name, for some reason it does not render the file name.

Liquid & Allow-Listing

To understand why, we need to know that Elsa uses the Fluid library to evaluate Liquid expressions. And although Fluid is perfectly capable of accessing .NET objects, it needs to be told what types the user is allowed to access. This makes Liquid a secure templating language.

Since our liquid expression access both theDocumentFileand Document types (since Document is a property of it), all this means is that we need to allow-list them with Liquid. Let’s do that right away.

Within the DocumentManagement.Workflows project, create a new folder called Scripting and a subfolder called Liquid. Then create the following class:

Notice that this is a notification handler that handles the EvaluatingLiquidExpression event. This event is published every time the engine is about to evaluate a liquid expression for a given activity property.

In the event handler, we access Fluid’s TemplateContext and configure its MemberAccessStrategy by registering our DocumentFile type.

Since we already call .AddNotificationHandlersFrom<StartDocumentWorkflows>() from the ServiceCollectionExtensions class, this event handler will automatically be registered with the service container.

When we execute the HTTP request again, we now see something like this:

The document's file name is a3fe4f2b-8ef1-4984-ac98-1b571171a6d6.docx

Glorious. Let’s take a moment to reflect on what we just learned:

  • We created a custom activity that receives the document ID as an input and provides the loaded document as well as a file stream as an output.
  • We could have hardcoded the document ID value, but instead we provided input in the form of a JavaScript expression.
  • We then connected the HTTP Response activity to write back the loaded document’s file name by leveraging Liquid.
  • We learned that we had to configure Liquid and allow-list our custom types if we want to access objects of these types.
  • We used the Elsa API to execute our workflow.

Next, let’s work on the Archive Document activity.

Archive Document

The Archive Document activity is a very simple one with only two tasks it needs to perform:

  1. Set the Status property of a given Document to Archived.
  2. Update the document in the database.

The activity will take one input, which is the Document to update.

The complete file, ArchiveDocument.cs, to create looks like this:

Although this activity is quite boring, it does showcase how easy it can be to quickly implement a custom activity that does something useful.

To try out this activity, connect it to the Get Document activity of the workflow we created earlier and use the following JS expression for its Document property:

input.Document

This works because the output of the Get Document activity returns an object of type DocumentFile, which contains a Document and a FileStream property. Since the Archive Document activity executes immediately after the Get Document activity, the output of the former will be available as the input to the latter.

However, there’s a big caveat: the HTTP Response activity is now no longer connected directly to the Get Document activity, so it can no longer use the Input variable.

To fix this, we need to give the Get Document activity a name. Giving activities a name allows you to access these activities’ output from any other activity in the workflow.

So here’s what we will do:

  1. Give the Get Document a name of GetDocument1.
  2. Update the HTTP Response activity with the following liquid content:
The document's file name is {{ Activities.GetDocument1.Output.Document.FileName }} and the document's status is {{ Activities.GetDocument1.Output.Document.Status }}

The workflow should look like this:

The response should be something like:

The document's file name is b01bd191-2891-44d2-84e5-c8d4bfbb6917.docx and the document's status is 1

Let’s look at a more interesting activity next.

Zip File

This activity is more fun because it does something I’m not doing everyday. The sole purpose of this one is to take a stream and returning a compressed version of it.

It takes two input properties:

  1. The stream to compress.
  2. The filename to use when writing an entry to the zip archive.

Here’s the code to create:

Nothing really special going on, but the application is awesome. Try it out by updating the workflow and connect the Zip File activity to the Archive Document using the following settings:

  • Stream (JS): activities.GetDocument1.Output().FileStream
  • File Name (JS): activities.GetDocument1.Output().Document.FileName

Then update the HTTP Response to write back the zipped stream as a download by configuring it with the following settings:

  • Content (JS): input
  • Content Type (Literal): application/zip
  • Response Headers (Advanced tab): { \"Content-Disposition\": \"attachment; filename=download.zip\" }

The complete workflow should look like this:

When you now upload a document and execute the workflow using Postman, you should receive binary content. Use Postman to save the response as a zip file to make sure you can open the file and checkout the contents.

If you think this is cool, then I agree!

Let’s move on to the final activity to implement: Update Blockchain.

Update Blockchain

Although the name of the activity might be very exciting, we aren’t actually writing anything to any blockchain. It‘s not just a ruse either; I can totally imagine someone writing an activity that writes a document signature to a blockchain as part of a document management system to make it tamper proof.

The more important point here is this: there may be activities that are better run in the background while the workflow goes to sleep and awaits completion of the background task.

So that’s what we will be showcasing next: writing an activity that starts a task, suspends the workflow while the task is running, and then let the task resume the workflow once it’s finished.

For this, we will leverage Hangfire as the engine for running a task in the background.

As a first step, let’s define the activity skeleton like this:

The activity receives as an input a File. This can either by a Stream or a byte[]. It then computes a hash and suspends the workflow.

At this point, there’s nothing that will ever resume this activity. Let’s take care of that next.

First, let’s inject Hangfire’s IBackgroundJobClient service and define a new record type that will carry some context for the background job to work on:

public record UpdateBlockchainContext(string WorkflowInstanceId, string ActivityId, string FileSignature);

Then replace the TODO comment in OnExecuteAsync with the following line:

// Schedule background work using Hangfire.
_backgroundJobClient.Enqueue(() => SubmitToBlockChainAsync(new UpdateBlockchainContext(context.WorkflowInstance.Id, context.ActivityId, fileSignature), CancellationToken.None));

This will enqueue a new job that is implemented in the form of the SubmitToBlockChainAsync method, which we’ll define next (as a public method within the activity class) as:

/// <summary>
/// Invoked by Hangfire as a background job.
/// </summary>
public async Task SubmitToBlockChainAsync(UpdateBlockchainContext context, CancellationToken cancellationToken)
{
// Simulate storing it on an imaginary blockchain out there.
await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken);
}

Once the job is done (waiting for 15 seconds), we need to resume the workflow. A good way to do is is to leverage IWorkflowInstanceDispatcher, which lets us schedule an existing workflow instance by ID for execution.

Go ahead and inject IWorkflowInstanceDispatcher and then add the following line to the SubmitToBlockChainAsync method:

// Resume the suspended workflow.
await _workflowInstanceDispatcher.DispatchAsync(new ExecuteWorkflowInstanceRequest(context.WorkflowInstanceId, context.ActivityId, new WorkflowInput(context.FileSignature)), cancellationToken);

Now you also see why our little UpdateBlockchainContext record type carries both the workflow instance ID as well as our activity ID — we need both these values to resume the workflow that will become blocked by our activity.

We also simply echo back the FileSignature by sending it as workflow input.

When the workflow is resumed, the workflow engine will invoke our activitiy’s OnResumeAsync method. Let’s update that now to receive the file signature and store it as output of our activity. Replace the TODO comment with the following:

var fileSignature = context.GetInput<string>();
Output = fileSignature;

The complete activity should look like this:

Try it

Let’s try it out!

Update the workflow by connecting the Update Blockchain activity to the Zip File activity and configure it as follows:

  • File (JS): input

Let’s also make sure we replace the HTTP Response activity, since the blockchain activity will put the workflow to sleep and resume it in the background where there’s no longer any HTTP context available.

So let’s do this:

  1. Delete the HTTP Response activity.
  2. Connect a new Write Line activity with the following settings:
  • Text (Liquid): File signature: {{ Input }}

Publish the changes and invoke the workflow. Notice that the workflow will take about 15 seconds to complete now. Notice also that while you wait, the workflow is in the Suspended state:

After about 15 seconds, the console window should print something like the following output:

File signature: ojPRr24fjiSUC0rQdVI4ieYoyy7Uhb1QfChtTKgStr4=

Next

I don’t know about you, but I think writing custom activities is a lot of fun!

In the next part, we will be putting it all together and create a workflow for each document type and make good use of these custom activities, as well as some of the built-in activities.

See you there!

--

--