Merge 3.5.1 into 3.6.0 (#6907)

* Introduce `IWorkflowResumer` and `ActivityInputEvaluatorContext`, refactor endpoint handling, extend logging, and improve bookmark queue processing.

* Remove deprecated WorkflowContexts module and optimize project.

Deleted the Elsa.Studio.WorkflowContexts module and references from solution files. Corrected minor errors in remaining code and updated project configurations to align with the new structure.```

* Update GitHub workflows to track `develop/3.6.0` branch instead of `patch/3.5.1`.

* Fix inconsistent formatting in `InputDescriptor` constructor and properties.

* Add XML documentation for `DictionaryValueEvaluator` in `UIHints/Dictionary` module

* Refactor `DictionaryValueEvaluator` to improve readability and simplify dictionary evaluation logic.
This commit is contained in:
Sipke Schoorstra 2025-09-13 13:05:52 +02:00 committed by GitHub
parent b1d9d04150
commit 72ad243ed4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 959 additions and 164 deletions

View file

@ -3,7 +3,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- develop/3.6.0
jobs:
push_to_registry:

View file

@ -3,7 +3,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- develop/3.6.0
jobs:
push_to_registry:

View file

@ -3,7 +3,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- develop/3.6.0
jobs:
push_to_registry:

View file

@ -48,7 +48,7 @@ jobs:
run: |
if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then
git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/*
git branch --remote --contains | grep origin/main
git branch --remote --contains | grep origin/develop/3.6.0
else
git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/*
git branch --remote --contains | grep origin/${BRANCH_NAME}
@ -60,7 +60,7 @@ jobs:
TAG_NAME=${TAG_NAME#refs/tags/} # remove the refs/tags/ prefix
echo "VERSION=${TAG_NAME}" >> $GITHUB_ENV
else
echo "VERSION=${{env.base_version}}-${PACKAGE_PREFIX}.${{github.run_number}}" >> $GITHUB_ENV
echo "VERSION=${{env.base_version}}-preview.${{github.run_number}}" >> $GITHUB_ENV
fi
# - name: Set up JDK 17
# uses: actions/setup-java@v2

View file

@ -9,7 +9,7 @@
<ResilienceVersion>9.7.0</ResilienceVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1"/>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1" PrivateAssets="All"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.MySql" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1"/>
@ -50,11 +50,11 @@
<PackageVersion Include="FluentStorage" Version="5.6.0"/>
<PackageVersion Include="FluentStorage.Azure.Blobs" Version="5.3.0"/>
<PackageVersion Include="Fluid.Core" Version="2.25.0"/>
<PackageVersion Include="Fody" Version="6.9.2"/>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1"/>
<PackageVersion Include="Fody" Version="6.9.2" PrivateAssets="All"/>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All"/>
<PackageVersion Include="Google.Protobuf" Version="3.31.1"/>
<PackageVersion Include="Grpc.Net.Client" Version="2.71.0"/>
<PackageVersion Include="Grpc.Tools" Version="2.72.0"/>
<PackageVersion Include="Grpc.Tools" Version="2.72.0" PrivateAssets="All"/>
<PackageVersion Include="Hangfire" Version="1.8.20"/>
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.1"/>
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.12"/>
@ -106,7 +106,7 @@
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="$(ResilienceVersion)"/>
<PackageVersion Include="Microsoft.Identity.Client" Version="4.72.1"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageVersion Include="Moq" Version="4.20.72"/>
<PackageVersion Include="MongoDB.Driver" Version="3.4.0"/>
<PackageVersion Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0"/>
@ -133,7 +133,7 @@
<PackageVersion Include="Polly" Version="8.6.2"/>
<PackageVersion Include="Polly.Extensions" Version="8.6.2"/>
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0"/>
<PackageVersion Include="PolySharp" Version="1.15.0"/>
<PackageVersion Include="PolySharp" Version="1.15.0" PrivateAssets="All"/>
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0"/>
<PackageVersion Include="Proto.Actor" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster" Version="1.7.0"/>
@ -152,6 +152,10 @@
<PackageVersion Include="Refit" Version="8.0.0"/>
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0"/>
<PackageVersion Include="Scrutor" Version="6.1.0"/>
<PackageVersion Include="Serilog" Version="4.3.0"/>
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.2"/>
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0"/>
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0"/>
<PackageVersion Include="ShortGuid" Version="2.0.1"/>
<PackageVersion Include="StackExchange.Redis" Version="2.8.37"/>
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
@ -171,7 +175,7 @@
<PackageVersion Include="xunit" Version="2.9.3"/>
<PackageVersion Include="xunit.abstractions" Version="2.0.3"/>
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" PrivateAssets="All"/>
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0"/>
</ItemGroup>
</Project>

View file

@ -64,6 +64,9 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}"
ProjectSection(SolutionItems) = preProject
test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}"
ProjectSection(SolutionItems) = preProject
@ -247,6 +250,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.JavaScript
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.JavaScript.Libraries", "src\modules\Elsa.Expressions.JavaScript.Libraries\Elsa.Expressions.JavaScript.Libraries.csproj", "{08B69CC4-B5F0-44E8-FA7A-6BF6F00CA40A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics", "{1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging", "src\modules\Elsa.Logging\Elsa.Logging.csproj", "{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Console", "src\modules\Elsa.Logging.Console\Elsa.Logging.Console.csproj", "{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Serilog", "src\modules\Elsa.Logging.Serilog\Elsa.Logging.Serilog.csproj", "{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core", "src\modules\Elsa.Logging.Core\Elsa.Logging.Core.csproj", "{48A85A19-B654-4570-B332-653BC0B6A846}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.IntegrationTests", "test\integration\Elsa.Logging.Core.IntegrationTests\Elsa.Logging.Core.IntegrationTests.csproj", "{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Logging.Core.UnitTests", "test\unit\Elsa.Logging.Core.UnitTests\Elsa.Logging.Core.UnitTests.csproj", "{4229B9B3-60D3-4CFE-B147-B3865212C6C8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.Liquid", "src\modules\Elsa.Expressions.Liquid\Elsa.Expressions.Liquid.csproj", "{6AF53651-99F0-1DE0-D37B-4FF6B0348DBB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.Python", "src\modules\Elsa.Expressions.Python\Elsa.Expressions.Python.csproj", "{9D8FB664-88B4-10BE-58A2-D9A1644AD2E4}"
@ -461,6 +478,42 @@ Global
{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU
{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.Build.0 = Release|Any CPU
{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.Build.0 = Release|Any CPU
{C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.Build.0 = Release|Any CPU
{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68A0BC44-8A3E-4C45-8AFF-0662B81D2739}.Release|Any CPU.Build.0 = Release|Any CPU
{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188}.Release|Any CPU.Build.0 = Release|Any CPU
{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6}.Release|Any CPU.Build.0 = Release|Any CPU
{48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48A85A19-B654-4570-B332-653BC0B6A846}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48A85A19-B654-4570-B332-653BC0B6A846}.Release|Any CPU.Build.0 = Release|Any CPU
{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293}.Release|Any CPU.Build.0 = Release|Any CPU
{4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4229B9B3-60D3-4CFE-B147-B3865212C6C8}.Release|Any CPU.Build.0 = Release|Any CPU
{6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F14B066-DF7B-2409-59D8-CCCA90A4974C}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -565,6 +618,8 @@ Global
{690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
{060FD0BA-BD78-48E1-A8A7-4906A5AD5E39} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
{169A82A5-2DB3-40EA-801E-14C08D743DF7} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}
{01B96BB9-35E8-4364-ACB8-6D12A14D8DBA} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{47FBCB04-0C2D-453C-BE2F-7052CAC22524} = {EB3A7401-0DE3-476F-9E6F-057F1F4590FB}
{B32DB9B2-AD6C-48A5-8682-4373CB045185} = {C80C8231-D35C-4ACC-9ED6-9F3DB221535E}
{454652D5-E1BB-4D4B-9B21-9CEFC900C4FB} = {B32DB9B2-AD6C-48A5-8682-4373CB045185}
{31089E79-694B-4F45-97AF-86D34A7B231E} = {B32DB9B2-AD6C-48A5-8682-4373CB045185}
@ -578,6 +633,21 @@ Global
{66E2E2CF-967F-4564-89E8-F46FA973C99B} = {986E5482-0482-448C-B9E4-EC67A9474B85}
{0A04B1FD-06C0-4271-A910-A08C263DBC44} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
{9B80A705-2E31-4012-964A-83963DCDB384} = {0354F050-3992-4DD4-B0EE-5FBA04AC72B6}
{CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E}
{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E}
{7FD1FD1E-5778-4065-AAA5-1F878129EF77} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77}
{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77}
{C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77}
{1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{68A0BC44-8A3E-4C45-8AFF-0662B81D2739} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}
{2CDF3E1C-267D-4198-B1C7-7E1F548FC120} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}
{2CE3BD1E-0966-47DF-B870-6A4EB7EA0188} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}
{3E6DFD22-5F71-4A4E-A792-B010ADDCFBA6} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}
{48A85A19-B654-4570-B332-653BC0B6A846} = {1FB2FE77-5D7F-48D5-8FFE-530D21AFBA7D}
{A5C87AAF-E607-4DA7-B2E1-08FEAA41B293} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{4229B9B3-60D3-4CFE-B147-B3865212C6C8} = {18453B51-25EB-4317-A4B3-B10518252E92}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{6F14B066-DF7B-2409-59D8-CCCA90A4974C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{C55015F0-E9DF-4BF9-8131-D7539E938220} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}

View file

@ -45,7 +45,6 @@
- "5500:5500"
volumes:
- ./data/oracle-data:/opt/oracle/oradata
- ./setup/oracle-setup:/opt/oracle/scripts/setup
mongodb:
image: mongo:latest
@ -158,7 +157,6 @@
volumes:
sqlserver_data:
postgres-data:
oracle-data-free1:
mysql_data2:
cockroachdb-data:
mongodb_data:

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\..\modules\Elsa.Caching\Elsa.Caching.csproj" />

View file

@ -1,14 +1,6 @@
using Elsa.Common.Models;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.State;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
@ -45,6 +37,7 @@ public static class RunActivityExtensions
/// <returns>The result of running the activity.</returns>
public static async Task<RunWorkflowResult> RunActivityAsync(this IServiceProvider services, IActivity activity, RunWorkflowOptions options, CancellationToken cancellationToken = default)
{
await services.PopulateRegistriesAsync();
var workflowRunner = services.GetRequiredService<IWorkflowRunner>();
var result = await workflowRunner.RunAsync(activity, options, cancellationToken);
return result;

View file

@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Common.Converters;
public class BooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.String:
var value = reader.GetString();
if (bool.TryParse(value, out var b))
return b;
break;
}
throw new JsonException($"Cannot convert {reader.TokenType} to bool");
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}

View file

@ -0,0 +1,34 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Common.Converters;
public class NullableBooleanConverter : JsonConverter<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.String:
var value = reader.GetString();
if (bool.TryParse(value, out var b))
return b;
break;
case JsonTokenType.Null:
return null;
}
throw new JsonException($"Cannot convert {reader.TokenType} to bool?");
}
public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteBooleanValue(value.Value);
else
writer.WriteNullValue();
}
}

View file

@ -216,6 +216,7 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
async Task<HttpResponseMessage> SendRequestAsyncCore(CancellationToken ct = default)
{
var request = PrepareRequest(context);
return await httpClient.SendAsync(request, ct);
}
}

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Resilience.Entities;
using Elsa.Resilience.Extensions;
using Elsa.Resilience.Models;
@ -12,55 +13,56 @@ using Polly.Telemetry;
namespace Elsa.Resilience;
public class ResilientActivityInvoker(
IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator,
IRetryAttemptRecorder retryAttemptRecorder,
IIdentityGenerator identityGenerator,
IResilienceStrategyConfigEvaluator resilienceStrategyConfigEvaluator,
IRetryAttemptRecorder retryAttemptRecorder,
IIdentityGenerator identityGenerator,
ResilienceStrategySerializer resilienceStrategySerializer) : IResilientActivityInvoker
{
private const string ResilienceStrategyIdPropKey = "resilienceStrategy";
private const string RetryAttemptsCountKey = "RetryAttemptsCount";
public async Task<T> InvokeAsync<T>(IResilientActivity activity, ActivityExecutionContext context, Func<Task<T>> action, CancellationToken cancellationToken = default)
{
// Get the resilience strategy.
var strategyConfig = GetStrategyConfig(activity);
var resilienceStrategy = await resilienceStrategyConfigEvaluator.EvaluateAsync(strategyConfig, context.ExpressionExecutionContext, cancellationToken);
// If no resilience strategy is configured, execute the action as-is.
if (resilienceStrategy == null)
return await action();
// Record the applied strategy as part of the activity execution context for diagnostics.
var resilienceStrategyModel = JsonSerializer.SerializeToNode(resilienceStrategy, resilienceStrategySerializer.SerializerOptions)!;
context.SetResilienceStrategy(resilienceStrategyModel);
// Create a resilience pipeline builder.
var builder = CreateResiliencePipelineBuilder<T>();
var retries = new List<RetryAttempt>();
context.TransientProperties[RetryAttempt.RetriesKey] = retries;
// Create a resilience context.
var resilienceContext = ResilienceContextPool.Shared.Get(cancellationToken);
resilienceContext.Properties.Set(new(nameof(ActivityExecutionContext)), context);
try
{
// Configure the resilience pipeline.
await resilienceStrategy.ConfigurePipeline(builder, resilienceContext);
var pipeline = builder.Build();
// Execute the action within the resilience pipeline.
var result = await pipeline.ExecuteAsync<T>(async _ => await action(), resilienceContext);
// Record the retry attempts.
await RecordRetryAttempts(activity, context, retries, cancellationToken);
return result;
}
finally
{
ResilienceContextPool.Shared.Return(resilienceContext);
}
}
private async Task RecordRetryAttempts(IResilientActivity activity, ActivityExecutionContext context, ICollection<RetryAttempt> attempts, CancellationToken cancellationToken = default)
@ -70,9 +72,11 @@ public class ResilientActivityInvoker(
var records = Map(context, activity, attempts);
var recordContext = new RecordRetryAttemptsContext(context, records, cancellationToken);
await retryAttemptRecorder.RecordAsync(recordContext);
// Propagate a flag that retries have occurred. This information can then be used to show the retry attempts in the workflow designer.
context.SetRetriesAttemptedFlag();
context.SetExtensionsMetadata(RetryAttemptsCountKey, attempts.Count);
}
}
@ -89,7 +93,7 @@ public class ResilientActivityInvoker(
? null
: value.ConvertTo<ResilienceStrategyConfig>();
}
private ICollection<RetryAttemptRecord> Map(ActivityExecutionContext activityExecutionContext, IResilientActivity resilientActivity, ICollection<RetryAttempt> attempts)
{
return attempts.Select(x => Map(activityExecutionContext, resilientActivity, x)).ToList();

View file

@ -1,3 +1,4 @@
using Elsa.Abstractions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
@ -12,13 +13,27 @@ internal class GetEndpoint(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer)
: EndpointBase<GetRequest>(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
IApiSerializer apiSerializer)
: ElsaEndpoint<GetRequest>
{
/// <inheritdoc />
public override void Configure()
{
base.Configure();
Routes("/workflow-definitions/{definitionId}/execute");
ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.GET);
}
/// <inheritdoc />
public override async Task HandleAsync(GetRequest request, CancellationToken cancellationToken)
{
await WorkflowExecutionHelper.ExecuteWorkflowAsync(
request,
workflowDefinitionService,
workflowRuntime,
workflowStarter,
apiSerializer,
HttpContext,
cancellationToken);
}
}

View file

@ -1,5 +1,4 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Common.Models;
using Elsa.Expressions.Helpers;

View file

@ -1,3 +1,5 @@
using System.Text.Json;
using Elsa.Abstractions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
@ -13,12 +15,59 @@ internal class PostEndpoint(
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer)
: EndpointBase<PostRequest>(workflowDefinitionService, workflowRuntime, workflowStarter, apiSerializer)
: ElsaEndpointWithoutRequest<Response>
{
/// <inheritdoc />
public override void Configure()
{
base.Configure();
Routes("/workflow-definitions/{definitionId}/execute");
ConfigurePermissions("exec:workflow-definitions");
Verbs(FastEndpoints.Http.POST);
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
PostRequest? request = null;
if (HttpContext.Request.ContentLength > 0 && (HttpContext.Request.ContentType?.Contains("application/json") ?? true))
{
try
{
request = await JsonSerializer.DeserializeAsync<PostRequest>(HttpContext.Request.Body,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}, cancellationToken: cancellationToken);
}
catch
{
AddError("Invalid request body.");
}
}
request ??= new();
var definitionId = Route<string>("definitionId");
if (string.IsNullOrWhiteSpace(definitionId))
AddError("Missing workflow definition ID.");
else
request.DefinitionId = definitionId;
if (ValidationFailed)
{
await Send.ErrorsAsync(cancellation: cancellationToken);
return;
}
await WorkflowExecutionHelper.ExecuteWorkflowAsync(
request,
workflowDefinitionService,
workflowRuntime,
workflowStarter,
apiSerializer,
HttpContext,
cancellationToken);
}
}

View file

@ -0,0 +1,91 @@
using System.Net.Mime;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.State;
using FastEndpoints;
using Microsoft.AspNetCore.Http;
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
public static class WorkflowExecutionHelper
{
public static async Task ExecuteWorkflowAsync(
IExecutionRequest request,
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowRuntime workflowRuntime,
IWorkflowStarter workflowStarter,
IApiSerializer apiSerializer,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var definitionId = request.DefinitionId;
var versionOptions = request.VersionOptions ?? VersionOptions.Published;
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionId, versionOptions, cancellationToken);
if (workflowGraph == null)
{
await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
return;
}
var startRequest = new StartWorkflowRequest
{
Workflow = workflowGraph.Workflow,
CorrelationId = request.CorrelationId,
Name = request.Name,
Input = request.GetInputAsDictionary(),
Variables = request.GetVariablesAsDictionary(),
TriggerActivityId = request.TriggerActivityId,
ActivityHandle = request.ActivityHandle
};
var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken);
if(!httpContext.Response.HasStarted)
httpContext.Response.Headers.Append("x-elsa-workflow-cannot-start", startResponse.CannotStart.ToString());
if (startResponse.CannotStart)
{
httpContext.Response.StatusCode = StatusCodes.Status200OK;
await httpContext.Response.SendOkAsync(cancellationToken);
return;
}
var instanceId = startResponse.WorkflowInstanceId!;
if(!httpContext.Response.HasStarted)
httpContext.Response.Headers.Append("x-elsa-workflow-instance-id", instanceId);
var workflowClient = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken);
if (startResponse.SubStatus == WorkflowSubStatus.Faulted)
{
var workflowState = await workflowClient.ExportStateAsync(cancellationToken);
await HandleFaultAsync(workflowState, apiSerializer, httpContext, cancellationToken);
}
else
{
if (!httpContext.Response.HasStarted)
{
httpContext.Response.Headers.Append("x-elsa-response", "true");
if (httpContext.Response.StatusCode == StatusCodes.Status200OK)
{
var workflowState = await workflowClient.ExportStateAsync(cancellationToken);
var response = apiSerializer.Serialize(new Response(workflowState));
httpContext.Response.ContentType = MediaTypeNames.Application.Json;
await httpContext.Response.WriteAsync(response, cancellationToken);
}
}
}
}
private static async Task HandleFaultAsync(WorkflowState workflowState, IApiSerializer apiSerializer, HttpContext httpContext, CancellationToken cancellationToken)
{
var faultedResponse = apiSerializer.Serialize(new Response(workflowState));
httpContext.Response.ContentType = MediaTypeNames.Application.Json;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync(faultedResponse, cancellationToken);
}
}

View file

@ -78,6 +78,12 @@ public class InputAttribute : Attribute
/// </summary>
public bool AutoEvaluate { get; set; } = true;
/// <summary>
/// Specifies the type of a custom evaluator to use for evaluating the input property value.
/// The evaluator type determines how the value for the property is resolved at runtime.
/// </summary>
public Type? EvaluatorType { get; set; }
/// <summary>
/// A value indicating whether this input can be serialized as part of the workflow instance,
/// </summary>

View file

@ -0,0 +1,12 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
public record ActivityInputEvaluatorContext(
ActivityExecutionContext ActivityExecutionContext,
ExpressionExecutionContext ExpressionExecutionContext,
InputDescriptor InputDescriptor,
Input Input,
IExpressionEvaluator ExpressionEvaluator);

View file

@ -636,7 +636,7 @@ public partial class WorkflowExecutionContext : IExecutionContext
{
// Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables.
// This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing.
// We may ultimately restore direct output access, but in a different way.
// We may ultimately restore direct output access, but differently.
return ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null);
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Workflows;
public interface IActivityInputEvaluator
{
Task<object?> EvaluateAsync(ActivityInputEvaluatorContext context);
}

View file

@ -91,9 +91,16 @@ public static partial class ActivityExecutionContextExtensions
}
else
{
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expressionEvaluator = context.GetRequiredService<IExpressionEvaluator>();
var expressionExecutionContext = context.ExpressionExecutionContext;
value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue;
var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator);
if (wrappedInput?.Expression != null)
{
var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType);
var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator);
value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext);
}
}
var memoryReference = wrappedInput?.MemoryBlockReference();

View file

@ -24,6 +24,8 @@ namespace Elsa.Extensions;
[PublicAPI]
public static partial class ActivityExecutionContextExtensions
{
private const string ExtensionsMetadataKey = "Extensions";
/// <summary>
/// Attempts to get a value from the input provided via <see cref="WorkflowExecutionContext"/>. If a value was found, an attempt is made to convert it into the specified type <code>T</code>.
/// </summary>
@ -436,6 +438,26 @@ public static partial class ActivityExecutionContextExtensions
}
}
/// <summary>
/// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity.
/// </summary>
public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value)
{
var extensionsDictionary = context.GetExtensionsMetadata() ?? new Dictionary<string, object?>();
extensionsDictionary[key] = value;
context.Metadata[ExtensionsMetadataKey] = extensionsDictionary;
}
/// <summary>
/// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity.
/// </summary>
public static Dictionary<string, object?>? GetExtensionsMetadata(this ActivityExecutionContext context)
{
return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary<string, object?> : null;
}
internal static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue<bool>("HasEvaluatedProperties", out var value) && value;
internal static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true;
}

View file

@ -22,6 +22,7 @@ using Elsa.Workflows.Serialization.Helpers;
using Elsa.Workflows.Serialization.Serializers;
using Elsa.Workflows.Services;
using Elsa.Workflows.UIHints.CheckList;
using Elsa.Workflows.UIHints.Dictionary;
using Elsa.Workflows.UIHints.Dropdown;
using Elsa.Workflows.UIHints.JsonEditor;
using Elsa.Workflows.UIHints.RadioList;
@ -188,6 +189,7 @@ public class WorkflowsFeature : FeatureBase
.AddScoped<IActivityStateFilterManager, DefaultActivityStateFilterManager>()
.AddScoped<IWorkflowInstanceVariableReader, DefaultWorkflowInstanceVariableReader>()
.AddScoped<IWorkflowInstanceVariableWriter, DefaultWorkflowInstanceVariableWriter>()
.AddScoped<DefaultActivityInputEvaluator>()
// Incident Strategies.
.AddTransient<IIncidentStrategy, FaultStrategy>()
@ -229,17 +231,17 @@ public class WorkflowsFeature : FeatureBase
// Instantiation strategies.
.AddScoped<IWorkflowActivationStrategy, AllowAlwaysStrategy>()
// UI hints.
// UI.
.AddScoped<IUIHintHandler, DropDownUIHintHandler>()
.AddScoped<IUIHintHandler, CheckListUIHintHandler>()
.AddScoped<IUIHintHandler, RadioListUIHintHandler>()
.AddScoped<IUIHintHandler, JsonEditorUIHintHandler>()
// UI property handlers.
.AddScoped<IPropertyUIHandler, StaticCheckListOptionsProvider>()
.AddScoped<IPropertyUIHandler, StaticRadioListOptionsProvider>()
.AddScoped<IPropertyUIHandler, StaticDropDownOptionsProvider>()
.AddScoped<IPropertyUIHandler, JsonCodeOptionsProvider>()
.AddScoped<DictionaryValueEvaluator>()
.AddSingleton<IActivityDescriptorModifier, DictionaryUIHintInputModifier>()
// Logger state generators.
.AddSingleton(WorkflowLoggerStateGenerator)

View file

@ -21,20 +21,21 @@ public class InputDescriptor : PropertyDescriptor
bool isWrapped,
string uiHint,
string displayName,
string? description = default,
string? category = default,
string? description = null,
string? category = null,
float order = 0,
object? defaultValue = default,
object? defaultValue = null,
string? defaultSyntax = "Literal",
bool isReadOnly = false,
bool isBrowsable = true,
bool isSerializable = true,
bool isSynthetic = false,
bool autoEvaluate = true,
Type? storageDriverType = default,
PropertyInfo? propertyInfo = default,
IDictionary<string, object>? uiSpecifications = default
)
Type? evaluatorType = null,
Type? storageDriverType = null,
PropertyInfo? propertyInfo = null,
IDictionary<string, object>? uiSpecifications = null
)
{
Name = name;
Type = type;
@ -50,6 +51,7 @@ public class InputDescriptor : PropertyDescriptor
DefaultSyntax = defaultSyntax;
IsReadOnly = isReadOnly;
AutoEvaluate = autoEvaluate;
EvaluatorType = evaluatorType;
StorageDriverType = storageDriverType;
IsSynthetic = isSynthetic;
IsBrowsable = isBrowsable;
@ -66,7 +68,7 @@ public class InputDescriptor : PropertyDescriptor
/// <summary>
/// A string value that hints at what UI control might be used to render in a UI tool.
/// </summary>
public string UIHint { get; set; } = default!;
public string UIHint { get; set; } = null!;
/// <summary>
/// The category to which this input belongs. Can be used by UI to e.g. render different inputs in different tabs.
@ -105,6 +107,12 @@ public class InputDescriptor : PropertyDescriptor
/// </summary>
public bool AutoEvaluate { get; set; } = true;
/// <summary>
/// Specifies the type of a custom evaluator to use for evaluating the input property value.
/// The evaluator type determines how the value for the property is resolved at runtime.
/// </summary>
public Type? EvaluatorType { get; set; }
/// <summary>
/// A dictionary of UI specifications to be used by the UI.
/// </summary>

View file

@ -141,7 +141,7 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve
var uiSpecification = await propertyUIHandlerResolver.GetUIPropertiesAsync(propertyInfo, null, cancellationToken);
return new InputDescriptor(
return new(
inputAttribute?.Name ?? propertyInfo.Name,
wrappedPropertyType,
propertyInfo.GetValue,
@ -159,7 +159,8 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve
inputAttribute?.IsSerializable ?? true,
false,
autoEvaluate,
default,
inputAttribute?.EvaluatorType,
null,
propertyInfo,
uiSpecification
);

View file

@ -0,0 +1,14 @@
using Elsa.Extensions;
namespace Elsa.Workflows;
public class DefaultActivityInputEvaluator : IActivityInputEvaluator
{
public async Task<object?> EvaluateAsync(ActivityInputEvaluatorContext context)
{
var wrappedInput = context.Input;
var evaluator = context.ExpressionEvaluator;
var expressionExecutionContext = context.ExpressionExecutionContext;
return await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext);
}
}

View file

@ -0,0 +1,14 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows.UIHints.Dictionary;
public class DictionaryUIHintInputModifier : IActivityDescriptorModifier
{
public void Modify(ActivityDescriptor descriptor)
{
var dictionaryInputs = descriptor.Inputs.Where(x => x.UIHint == InputUIHints.Dictionary).ToList();
foreach (var dictionaryInput in dictionaryInputs)
dictionaryInput.EvaluatorType = typeof(DictionaryValueEvaluator);
}
}

View file

@ -0,0 +1,59 @@
using System.Text.Json;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.UIHints.Dictionary;
/// <summary>
/// A class that evaluates activity inputs configured to be dictionaries. It resolves expressions and modifies the input dictionary accordingly.
/// </summary>
public class DictionaryValueEvaluator(ILogger<DictionaryValueEvaluator> logger) : IActivityInputEvaluator
{
public async Task<object?> EvaluateAsync(ActivityInputEvaluatorContext context)
{
var wrappedInput = context.Input;
var evaluator = context.ExpressionEvaluator;
var expressionExecutionContext = context.ExpressionExecutionContext;
var inputDescriptor = context.InputDescriptor;
var defaultValue = inputDescriptor.DefaultValue;
var value = wrappedInput.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue;
if (value is not IDictionary<string, object> dictionary || inputDescriptor.UIHint != InputUIHints.Dictionary)
return value;
var tempDictionary = new Dictionary<string, object?>(dictionary.Count);
foreach (var dict in dictionary)
{
if (dict.Value is not JsonElement json)
{
// Not a JSON object, so just use the value as-is.
tempDictionary[dict.Key] = dict.Value;
continue;
}
// JSON object, so extract the type and value properties.
var hasType = json.TryGetProperty("type", out var typeProperty);
var hasValue = json.TryGetProperty("value", out var valueProperty);
if (!hasType || !hasValue)
{
// Skip this entry or handle as needed (e.g., log, throw, etc.)
logger.LogWarning("Dictionary entry is missing type or value property: {Json}", JsonSerializer.Serialize(json));
continue;
}
// Evaluate the expression.
var expression = new Expression(typeProperty.ToString(), valueProperty.ToString());
var val = await evaluator.EvaluateAsync<object>(expression, expressionExecutionContext);
// Add the evaluated value to the dictionary.
tempDictionary[dict.Key] = val;
}
// Replace the original dictionary with the evaluated one.
value = tempDictionary;
return value;
}
}

View file

@ -9,6 +9,7 @@ public static class InputUIHints
public const string Checkbox = "checkbox";
public const string CheckList = "checklist";
public const string CodeEditor = "code-editor";
public const string Dictionary = "dictionary";
public const string DateTimePicker = "datetime-picker";
public const string DropDown = "dropdown";
public const string DynamicOutcomes = "dynamic-outcomes";

View file

@ -5,6 +5,7 @@ namespace Elsa.Workflows.Runtime;
/// <summary>
/// Represents a service that looks up bookmark-bound workflows.
/// </summary>
[Obsolete("Will be removed in a future version.")]
public interface IBookmarkBoundWorkflowService
{
/// <summary>

View file

@ -6,6 +6,7 @@ namespace Elsa.Workflows.Runtime;
/// <summary>
/// Resumes workflows using a given stimulus or bookmark filter.
/// </summary>
[Obsolete("Use IWorkflowResumer instead.")]
public interface IBookmarkResumer
{
/// <summary>

View file

@ -0,0 +1,39 @@
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.Runtime.Options;
namespace Elsa.Workflows.Runtime;
/// <summary>
/// Resumes workflows using a given stimulus or bookmark filter.
/// </summary>
public interface IWorkflowResumer
{
/// <summary>
/// Resumes the workflows associated with the bookmarks matching the given stimulus.
/// </summary>
Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync<TActivity>(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity;
/// <summary>
/// Resumes the workflow associated with the bookmark specified by the given bookmark ID.
/// </summary>
Task<RunWorkflowInstanceResponse?> ResumeAsync(string bookmarkId, IDictionary<string, object> input, CancellationToken cancellationToken = default);
/// <summary>
/// Resumes the workflows associated with the bookmarks matching the given stimulus. If a workflow instance ID is specified, only resumes workflows associated with that instance.
/// </summary>
Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync<TActivity>(object stimulus, string? workflowInstanceId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity;
/// <summary>
/// Resumes the workflow associated with the bookmark specified by the given bookmark ID.
/// </summary>
Task<RunWorkflowInstanceResponse?> ResumeAsync<TActivity>(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity;
/// Resumes the workflows associated with the bookmarks matching the given request.
Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Resumes the workflows matching the given bookmark filter.
/// </summary>
Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default);
}

View file

@ -276,6 +276,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
.AddScoped<IBookmarksPersister, BookmarksPersister>()
.AddScoped<IBookmarkResumer, BookmarkResumer>()
.AddScoped<IBookmarkQueue, StoreBookmarkQueue>()
.AddScoped<IWorkflowResumer, WorkflowResumer>()
.AddScoped<ITriggerInvoker, TriggerInvoker>()
.AddScoped<IWorkflowCanceler, WorkflowCanceler>()
.AddScoped<IWorkflowCancellationService, WorkflowCancellationService>()

View file

@ -1,3 +1,5 @@
using System.Collections;
using System.Text;
using Elsa.Workflows.Runtime.Entities;
namespace Elsa.Workflows.Runtime.Filters;
@ -7,6 +9,9 @@ namespace Elsa.Workflows.Runtime.Filters;
/// </summary>
public class BookmarkFilter
{
// Cache the properties of BookmarkFilter for performance.
private static readonly System.Reflection.PropertyInfo[] CachedProperties = typeof(BookmarkFilter).GetProperties();
/// <summary>
/// Gets or sets the ID of the bookmark.
/// </summary>
@ -86,4 +91,40 @@ public class BookmarkFilter
{
Names = activityTypeNames.ToList()
};
public string GetHashableString()
{
// Return a hashable string representation of the filter, excluding null values.
var sb = new StringBuilder();
foreach (var prop in CachedProperties)
{
var value = prop.GetValue(this);
if (value == null)
continue;
string valueString;
// Handle collections (excluding string)
if (value is IEnumerable enumerable and not string)
{
var items = new List<string>();
foreach (var item in enumerable)
{
if (item != null)
items.Add(item.ToString()!);
}
items.Sort(StringComparer.Ordinal);
valueString = string.Join(",", items);
}
else
{
var toStringResult = value.ToString();
if (toStringResult == null)
continue;
valueString = toStringResult;
}
sb.Append($"{prop.Name}:{valueString};");
}
return sb.ToString();
}
}

View file

@ -4,14 +4,20 @@ namespace Elsa.Workflows.Runtime;
public class ResumeBookmarkRequest
{
public string WorkflowInstanceId { get; set; } = default!;
public string WorkflowInstanceId { get; set; } = null!;
/// The ID of the bookmark that triggered the workflow instance, if any.
public string BookmarkId { get; set; } = default!;
public string BookmarkId { get; set; } = null!;
/// The handle of the activity to schedule, if any.
[Obsolete("Use ActivityInstanceId instead")]
public ActivityHandle? ActivityHandle { get; set; }
/// <summary>
/// The ID of the activity instance to resume, if any.
/// </summary>
public string? ActivityInstanceId { get; set; }
/// Any additional properties to associate with the workflow instance.
public IDictionary<string, object>? Properties { get; set; }

View file

@ -74,7 +74,7 @@ public class BackgroundActivityInvoker(
[inputKey] = outputValues,
[journalDataKey] = activityExecutionContext.JournalData,
[bookmarksKey] = activityExecutionContext.Bookmarks.ToList(),
[propsKey] = activityExecutionContext.Properties
[propsKey] = activityExecutionContext.Properties.ToDictionary() // ChangeTrackingDictionary is not persistable, so we need to create a copy of the dictionary.
};
if (outcomes != null) bookmarkProps[outcomesKey] = outcomes;

View file

@ -4,6 +4,7 @@ using Elsa.Workflows.Runtime.Options;
namespace Elsa.Workflows.Runtime;
/// <inheritdoc />
[Obsolete("Will be removed in a future version.")]
public class BookmarkBoundWorkflowService(IWorkflowMatcher workflowMatcher) : IBookmarkBoundWorkflowService
{
/// <inheritdoc />

View file

@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Runtime;
public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer bookmarkResumer, ILogger<BookmarkQueueProcessor> logger) : IBookmarkQueueProcessor
public class BookmarkQueueProcessor(IBookmarkQueueStore store, IWorkflowResumer workflowResumer, ILogger<BookmarkQueueProcessor> logger) : IBookmarkQueueProcessor
{
public async Task ProcessAsync(CancellationToken cancellationToken = default)
{
@ -41,16 +41,16 @@ public class BookmarkQueueProcessor(IBookmarkQueueStore store, IBookmarkResumer
logger.LogDebug("Processing bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName);
var result = await bookmarkResumer.ResumeAsync(filter, options, cancellationToken);
var responses = (await workflowResumer.ResumeAsync(filter, options, cancellationToken)).ToList();
if (result.Matched)
if (responses.Count > 0)
{
logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName);
logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash} for activity type {ActivityType}", responses.Count, item.StimulusHash, item.ActivityTypeName);
await store.DeleteAsync(item.Id, cancellationToken);
}
else
{
logger.LogDebug("No matching bookmark found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName);
logger.LogDebug("No matching bookmarks found for bookmark queue item {BookmarkQueueItemId} for workflow instance {WorkflowInstanceId} for activity type {ActivityType} with stimulus {StimulusHash}", item.Id, item.WorkflowInstanceId, item.ActivityTypeName, item.StimulusHash);
}
}
}

View file

@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Runtime;
/// <inheritdoc />
[Obsolete("Use WorkflowResumer instead.")]
public class BookmarkResumer(IWorkflowRuntime workflowRuntime, IBookmarkStore bookmarkStore, IStimulusHasher stimulusHasher, ILogger<BookmarkResumer> logger) : IBookmarkResumer
{
/// <inheritdoc />

View file

@ -1,6 +1,5 @@
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Results;
using Microsoft.Extensions.Logging;
using Open.Linq.AsyncExtensions;
@ -11,9 +10,8 @@ namespace Elsa.Workflows.Runtime;
public class StimulusSender(
IStimulusHasher stimulusHasher,
ITriggerBoundWorkflowService triggerBoundWorkflowService,
IBookmarkBoundWorkflowService bookmarkBoundWorkflowService,
IWorkflowResumer workflowResumer,
IBookmarkQueue bookmarkQueue,
IWorkflowRuntime workflowRuntime,
ITriggerInvoker triggerInvoker,
ILogger<StimulusSender> logger) : IStimulusSender
{
@ -65,15 +63,15 @@ public class StimulusSender(
Properties = properties,
ParentWorkflowInstanceId = parentId
};
var response = await triggerInvoker.InvokeAsync(triggerRequest, cancellationToken);
if (response.CannotStart)
{
logger.LogWarning("Workflow activation strategy disallowed starting workflow {WorkflowDefinitionHandle} with correlation ID {CorrelationId}", workflow.DefinitionHandle, correlationId);
continue;
}
responses.Add(response.ToRunWorkflowInstanceResponse());
}
}
@ -83,60 +81,48 @@ public class StimulusSender(
private async Task<ICollection<RunWorkflowInstanceResponse>> ResumeExistingWorkflowsAsync(string stimulusHash, StimulusMetadata? metadata, CancellationToken cancellationToken)
{
var bookmarkOptions = metadata != null
? new FindBookmarkOptions
{
CorrelationId = metadata.CorrelationId,
WorkflowInstanceId = metadata.WorkflowInstanceId,
ActivityInstanceId = metadata.ActivityInstanceId,
}
: null;
var bookmarkBoundWorkflows = await bookmarkBoundWorkflowService.FindManyAsync(stimulusHash, bookmarkOptions, cancellationToken).ToList();
var input = metadata?.Input;
var properties = metadata?.Properties;
var activityHandle = metadata?.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(metadata.ActivityInstanceId) : null;
var responses = new List<RunWorkflowInstanceResponse>();
if (bookmarkBoundWorkflows.Count > 0)
var bookmarkFilter = new BookmarkFilter
{
foreach (var bookmarkBoundWorkflow in bookmarkBoundWorkflows)
{
var workflowInstanceId = bookmarkBoundWorkflow.WorkflowInstanceId;
var workflowClient = await workflowRuntime.CreateClientAsync(workflowInstanceId, cancellationToken);
Hash = stimulusHash,
CorrelationId = metadata?.CorrelationId,
WorkflowInstanceId = metadata?.WorkflowInstanceId,
ActivityInstanceId = metadata?.ActivityInstanceId,
BookmarkId = metadata?.BookmarkId
};
var responses = (await workflowResumer.ResumeAsync(bookmarkFilter, new()
{
Input = input,
Properties = properties
}, cancellationToken)).ToList();
foreach (var storedBookmark in bookmarkBoundWorkflow.Bookmarks)
{
var request = new RunWorkflowInstanceRequest
{
Input = input,
Properties = properties,
ActivityHandle = activityHandle,
BookmarkId = storedBookmark.Id,
};
var response = await workflowClient.RunInstanceAsync(request, cancellationToken);
responses.Add(response);
}
if (responses.Count > 0)
{
logger.LogDebug("Successfully resumed {WorkflowCount} workflow instances using stimulus {StimulusHash}", responses.Count, stimulusHash);
return responses;
}
// If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future.
var workflowInstanceId = metadata?.WorkflowInstanceId;
var bookmarkQueueItem = new NewBookmarkQueueItem
{
WorkflowInstanceId = workflowInstanceId,
BookmarkId = metadata?.BookmarkId,
CorrelationId = metadata?.CorrelationId,
StimulusHash = stimulusHash,
Options = new()
{
Input = input,
Properties = properties
}
}
else
{
// If no bookmarks were matched, enqueue the request in case a matching bookmark is created in the near future.
var workflowInstanceId = metadata?.WorkflowInstanceId;
var bookmarkQueueItem = new NewBookmarkQueueItem
{
WorkflowInstanceId = workflowInstanceId,
BookmarkId = metadata?.BookmarkId,
CorrelationId = metadata?.CorrelationId,
StimulusHash = stimulusHash,
Options = new()
{
Input = input,
Properties = properties
}
};
await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken);
}
};
logger.LogDebug("Bookmark queue item enqueued with stimulus: {StimulusHash}", bookmarkQueueItem.StimulusHash);
await bookmarkQueue.EnqueueAsync(bookmarkQueueItem, cancellationToken);
return responses;
}

View file

@ -1,13 +1,11 @@
using Elsa.Common;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Filters;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Runtime;
public class StoreBookmarkQueue(
IBookmarkQueueStore store,
IBookmarkResumer resumer,
IBookmarkQueueSignaler bookmarkQueueSignaler,
ISystemClock systemClock,
IIdentityGenerator identityGenerator,
@ -15,26 +13,6 @@ public class StoreBookmarkQueue(
{
public async Task EnqueueAsync(NewBookmarkQueueItem item, CancellationToken cancellationToken = default)
{
var filter = new BookmarkFilter
{
BookmarkId = item.BookmarkId,
CorrelationId = item.CorrelationId,
Hash = item.StimulusHash,
WorkflowInstanceId = item.WorkflowInstanceId,
Name = item.ActivityTypeName
};
var result = await resumer.ResumeAsync(filter, item.Options, cancellationToken);
if (result.Matched)
{
logger.LogDebug("Successfully resumed workflow instance {WorkflowInstance} using bookmark {BookmarkId} for activity type {ActivityType}", item.WorkflowInstanceId, item.BookmarkId, item.ActivityTypeName);
return;
}
// There was no matching bookmark yet, or the associated workflow instance hasn't been stored in the DB yet. Store the queue item for the system to pick up whenever the bookmark or workflow instance becomes present.
logger.LogDebug("No bookmark with ID {BookmarkId} found for workflow {WorkflowInstance} for activity type {ActivityType}. Adding the request to the bookmark queue", item.BookmarkId, item.WorkflowInstanceId, item.ActivityTypeName);
var entity = new BookmarkQueueItem
{
Id = identityGenerator.GenerateId(),
@ -48,6 +26,8 @@ public class StoreBookmarkQueue(
CreatedAt = systemClock.UtcNow,
};
logger.LogDebug("Enqueuing bookmark queue item {BookmarkQueueItemId} with bookmark {BookmarkId} and stimulus {StimulusHash}", entity.Id, entity.BookmarkId, entity.StimulusHash);
await store.AddAsync(entity, cancellationToken);
// Trigger the bookmark queue processor.

View file

@ -0,0 +1,135 @@
using Elsa.Common.DistributedHosting;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Runtime.Exceptions;
using Elsa.Workflows.Runtime.Filters;
using Elsa.Workflows.Runtime.Messages;
using Elsa.Workflows.Runtime.Options;
using Medallion.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Workflows.Runtime;
/// <inheritdoc />
public class WorkflowResumer(
IWorkflowRuntime workflowRuntime,
IBookmarkStore bookmarkStore,
IStimulusHasher stimulusHasher,
IDistributedLockProvider distributedLockProvider,
IOptions<DistributedLockingOptions> distributedLockingOptions,
ILogger<WorkflowResumer> logger) : IWorkflowResumer
{
/// <inheritdoc />
public Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync<TActivity>(object stimulus, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity
{
return ResumeAsync<TActivity>(stimulus, null, options, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync<TActivity>(object stimulus, string? workflowInstanceId = null, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity
{
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<TActivity>();
var stimulusHash = stimulusHasher.Hash(activityTypeName, stimulus);
var bookmarkFilter = new BookmarkFilter
{
Name = activityTypeName,
WorkflowInstanceId = workflowInstanceId,
Hash = stimulusHash,
};
return await ResumeAsync(bookmarkFilter, options, cancellationToken);
}
/// <inheritdoc />
public async Task<RunWorkflowInstanceResponse?> ResumeAsync(string bookmarkId, IDictionary<string, object> input, CancellationToken cancellationToken = default)
{
var bookmarkFilter = new BookmarkFilter
{
BookmarkId = bookmarkId
};
var options = new ResumeBookmarkOptions
{
Input = input
};
var responses = await ResumeAsync(bookmarkFilter, options, cancellationToken);
return responses.FirstOrDefault();
}
/// <inheritdoc />
public async Task<RunWorkflowInstanceResponse?> ResumeAsync<TActivity>(string bookmarkId, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default) where TActivity : IActivity
{
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<TActivity>();
var bookmarkFilter = new BookmarkFilter
{
Name = activityTypeName,
BookmarkId = bookmarkId
};
var response = await ResumeAsync(bookmarkFilter, options, cancellationToken);
return response.FirstOrDefault();
}
public async Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync(ResumeBookmarkRequest request, CancellationToken cancellationToken = default)
{
var filter = new BookmarkFilter
{
BookmarkId = request.BookmarkId,
ActivityInstanceId = request.ActivityInstanceId ?? request.ActivityHandle?.ActivityInstanceId,
};
var resumeOptions = new ResumeBookmarkOptions()
{
Input = request.Input,
Properties = request.Properties,
};
return await ResumeAsync(filter, resumeOptions, cancellationToken);
}
/// <inheritdoc />
public async Task<IEnumerable<RunWorkflowInstanceResponse>> ResumeAsync(BookmarkFilter filter, ResumeBookmarkOptions? options = null, CancellationToken cancellationToken = default)
{
var hashableFilterString = filter.GetHashableString();
var lockKey = $"workflow-resumer:{hashableFilterString}";
try
{
await using var filterLock = await distributedLockProvider.AcquireLockAsync(lockKey, distributedLockingOptions.Value.LockAcquisitionTimeout, cancellationToken);
var bookmarks = (await bookmarkStore.FindManyAsync(filter, cancellationToken)).ToList();
if (bookmarks.Count == 0)
{
logger.LogDebug("No bookmarks found in store for filter {@Filter}", filter);
return [];
}
var responses = new List<RunWorkflowInstanceResponse>();
foreach (var bookmark in bookmarks)
{
var workflowClient = await workflowRuntime.CreateClientAsync(bookmark.WorkflowInstanceId, cancellationToken);
var runRequest = new RunWorkflowInstanceRequest
{
Input = options?.Input,
Properties = options?.Properties,
BookmarkId = bookmark.Id
};
try
{
var response = await workflowClient.RunInstanceAsync(runRequest, cancellationToken);
logger.LogDebug("Resumed workflow instance {WorkflowInstanceId} with bookmark {BookmarkId}", bookmark.WorkflowInstanceId, bookmark.Id);
responses.Add(response);
}
catch (WorkflowInstanceNotFoundException)
{
// The workflow instance does not (yet) exist in the DB.
logger.LogDebug("No workflow instance with ID {WorkflowInstanceId} found for bookmark {BookmarkId} at this time.", bookmark.WorkflowInstanceId, bookmark.Id);
}
}
return responses;
}
catch (TimeoutException e)
{
// Rethrow but with a more specific message.
throw new TimeoutException($"Could not acquire distributed lock with key '{lockKey}' within the configured timeout of {distributedLockingOptions.Value.LockAcquisitionTimeout}.", e);
}
}
}

View file

@ -1,25 +1,29 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))"/>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitHubActionsTestLogger" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitHubActionsTestLogger"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Moq"/>
<PackageReference Include="NSubstitute"/>
<PackageReference Include="coverlet.collector" PrivateAssets="all"/>
<PackageReference Include="xunit"/>
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
</Project>

View file

@ -93,6 +93,9 @@
<None Update="Scenarios\InputOutput\Workflows\provider.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\RestApis\Workflows\hello-world.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -30,6 +30,14 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
return RestService.For<TClient>(client, CreateRefitSettings(Services));
}
public HttpClient CreateHttpClient()
{
var client = CreateClient();
client.BaseAddress = new(client.BaseAddress!, "/elsa/api/");
client.Timeout = TimeSpan.FromMinutes(1);
return client;
}
public HttpClient CreateHttpWorkflowClient()
{
var client = CreateClient();

View file

@ -0,0 +1,44 @@
using System.Net;
using Elsa.Testing.Shared.Extensions;
using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute;
public class GetTests(App app) : AppComponentTest(app)
{
private const string DefinitionId = "3790068018ac4f02";
private const string Url = "workflow-definitions/{0}/execute";
[Fact]
public async Task Get_WithCorrelationId_ShouldReturnOk()
{
var client = WorkflowServer.CreateHttpClient();
var url = string.Format(Url, DefinitionId) + "?correlationId=" + Guid.NewGuid();
using var response = await client.GetAsync(url);
var model = await response.ReadAsJsonAsync<Response>(WorkflowServer.Services);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
}
[Fact]
public async Task Get_WithoutCorrelationId_ShouldReturnOk()
{
var client = WorkflowServer.CreateHttpClient();
var url = string.Format(Url, DefinitionId);
using var response = await client.GetAsync(url);
var model = await response.ReadAsJsonAsync<Response>(WorkflowServer.Services);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
}
[Fact]
public async Task Get_MissingDefinitionId_ShouldReturnNotFoundError()
{
var client = WorkflowServer.CreateHttpClient();
var url = "/workflow-definitions//execute";
using var response = await client.GetAsync(url);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,63 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Elsa.Testing.Shared.Extensions;
using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Execute;
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Execute;
public class PostTests(App app) : AppComponentTest(app)
{
private const string DefinitionId = "3790068018ac4f02";
private const string Url = "workflow-definitions/{0}/execute";
[Fact]
public async Task Post_WithValidJsonBody_ShouldReturnOk()
{
var client = WorkflowServer.CreateHttpClient();
var requestBody = JsonSerializer.Serialize(new PostRequest
{
CorrelationId = Guid.NewGuid().ToString()
});
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
using var response = await client.PostAsync(string.Format(Url, DefinitionId), content);
var model = await response.ReadAsJsonAsync<Response>(WorkflowServer.Services);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
}
[Fact]
public async Task Post_WithoutBodyAndWithoutContentType_ShouldReturnOk()
{
var client = WorkflowServer.CreateHttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId));
// No content, no content-type
using var response = await client.SendAsync(request);
var model = await response.ReadAsJsonAsync<Response>(WorkflowServer.Services);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
}
[Fact]
public async Task Post_WithoutBodyButWithContentType_ShouldReturnOk()
{
var client = WorkflowServer.CreateHttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, string.Format(Url, DefinitionId));
request.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var model = await response.ReadAsJsonAsync<Response>(WorkflowServer.Services);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus);
}
[Fact]
public async Task Post_MissingDefinitionId_ShouldReturnNotFoundError()
{
var client = WorkflowServer.CreateHttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/workflow-definitions//execute");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,42 @@
{
"id": "2790068018ac4f01",
"definitionId": "3790068018ac4f02",
"name": "Hello World",
"isLatest": true,
"isPublished": true,
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "969b0703a9379c3b",
"nodeId": "Workflow1:969b0703a9379c3b",
"activities": [
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "Hello World!"
}
},
"id": "b039045bb7443e57",
"nodeId": "Workflow1:969b0703a9379c3b:b039045bb7443e57",
"name": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"metadata": {
"designer": {
"position": {
"x": -231.796875,
"y": 269
},
"size": {
"width": 139.296875,
"height": 50
}
}
}
}
],
"connections": []
}
}