From bb2fa55d0e42449ab0061a3d8985d0ac15b0d479 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 16 Sep 2023 11:06:56 +0200 Subject: [PATCH] WriteHttpFileResponse Activity (#4445) * Incremental work on WriteFileHttpResponse activity * BREAKING Schema change - Increase bookmark flexibility * Implement zipped file response --- Elsa.sln.DotSettings | 1 + .../Elsa.WorkflowServer.Web/Program.cs | 2 +- .../WorkflowInstances/Models/Bookmark.cs | 3 +- .../Elsa.Dapper.Migrations/Runtime/Initial.cs | 2 + .../Runtime/Records/StoredBookmarkRecord.cs | 1 + .../Runtime/Stores/DapperBookmarkStore.cs | 8 +- ....cs => 20230915185743_Initial.Designer.cs} | 5 +- ...3_Initial.cs => 20230915185743_Initial.cs} | 2 + .../RuntimeElsaDbContextModelSnapshot.cs | 3 + ....cs => 20230915185758_Initial.Designer.cs} | 5 +- ...3_Initial.cs => 20230915185758_Initial.cs} | 1 + .../RuntimeElsaDbContextModelSnapshot.cs | 3 + ....cs => 20230915185751_Initial.Designer.cs} | 5 +- ...7_Initial.cs => 20230915185751_Initial.cs} | 1 + .../RuntimeElsaDbContextModelSnapshot.cs | 3 + ....cs => 20230915185754_Initial.Designer.cs} | 5 +- ...0_Initial.cs => 20230915185754_Initial.cs} | 1 + .../RuntimeElsaDbContextModelSnapshot.cs | 3 + .../Modules/Runtime/BookmarkStore.cs | 11 +- .../Modules/Runtime/Configurations.cs | 2 + .../Abstractions/DownloadableProviderBase.cs | 49 ++++++ .../Elsa.Http/Activities/HttpEndpoint.cs | 3 +- .../Activities/WriteFileHttpResponse.cs | 165 ++++++++++++++++++ .../Elsa.Http/Activities/WriteHttpResponse.cs | 6 +- src/modules/Elsa.Http/BookmarkMetadata.cs | 17 ++ .../Bookmarks/HttpEndpointBookmarkPayload.cs | 60 +++++++ .../Elsa.Http/Contexts/DownloadableContext.cs | 11 ++ .../Contracts/IDownloadableManager.cs | 14 ++ .../Contracts/IDownloadableProvider.cs | 26 +++ .../Elsa.Http/Contracts/IFileDownloader.cs | 12 ++ .../BinaryDownloadableProvider.cs | 24 +++ .../DownloadableDownloadableProvider.cs | 20 +++ .../MultiDownloadableProvider.cs | 32 ++++ .../StreamDownloadableProvider.cs | 23 +++ .../UrlDownloadableProvider.cs | 49 ++++++ src/modules/Elsa.Http/Elsa.Http.csproj | 3 + .../Extensions/RouteTableExtensions.cs | 1 + src/modules/Elsa.Http/Features/HttpFeature.cs | 14 +- .../ValidateWorkflowRequestHandler.cs | 1 + .../Middleware/WorkflowsMiddleware.cs | 1 + src/modules/Elsa.Http/Models/Downloadable.cs | 45 +++++ .../Models/HttpEndpointBookmarkPayload.cs | 42 ----- ...eHttpResponseContentTypeOptionsProvider.cs | 2 +- .../JavaScript/HttpJavaScriptHandler.cs | 30 +++- .../Services/DefaultDownloadableManager.cs | 33 ++++ .../Services/HttpBookmarkProcessor.cs | 18 +- .../Services/HttpClientFileDownloader.cs | 25 +++ .../Elsa.ProtoActor/Mappers/BookmarkMapper.cs | 54 +++--- .../Proto/WorkflowInstance.Messages.proto | 7 +- .../Elsa.Telnyx/Activities/CallAnswered.cs | 2 +- .../Elsa.Telnyx/Activities/CallHangup.cs | 2 +- .../Elsa.Telnyx/Activities/WebhookEvent.cs | 2 +- .../Contexts/ActivityExecutionContext.cs | 25 ++- .../Elsa.Workflows.Core/Models/Bookmark.cs | 7 +- ...okmarkOptions.cs => CreateBookmarkArgs.cs} | 8 +- .../Activities/DispatchWorkflow.cs | 2 +- .../Activities/Event.cs | 2 +- .../Entities/StoredBookmark.cs | 9 +- .../BackgroundActivityInvokerMiddleware.cs | 2 +- update-migrations.sh | 2 +- 60 files changed, 797 insertions(+), 120 deletions(-) rename src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/{20230911202213_Initial.Designer.cs => 20230915185743_Initial.Designer.cs} (98%) rename src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/{20230911202213_Initial.cs => 20230915185743_Initial.cs} (99%) rename src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/{20230911202223_Initial.Designer.cs => 20230915185758_Initial.Designer.cs} (98%) rename src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/{20230911202223_Initial.cs => 20230915185758_Initial.cs} (99%) rename src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/{20230911202217_Initial.Designer.cs => 20230915185751_Initial.Designer.cs} (98%) rename src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/{20230911202217_Initial.cs => 20230915185751_Initial.cs} (99%) rename src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/{20230911202220_Initial.Designer.cs => 20230915185754_Initial.Designer.cs} (98%) rename src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/{20230911202220_Initial.cs => 20230915185754_Initial.cs} (99%) create mode 100644 src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs create mode 100644 src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs create mode 100644 src/modules/Elsa.Http/BookmarkMetadata.cs create mode 100644 src/modules/Elsa.Http/Bookmarks/HttpEndpointBookmarkPayload.cs create mode 100644 src/modules/Elsa.Http/Contexts/DownloadableContext.cs create mode 100644 src/modules/Elsa.Http/Contracts/IDownloadableManager.cs create mode 100644 src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/Contracts/IFileDownloader.cs create mode 100644 src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs create mode 100644 src/modules/Elsa.Http/Models/Downloadable.cs delete mode 100644 src/modules/Elsa.Http/Models/HttpEndpointBookmarkPayload.cs create mode 100644 src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs create mode 100644 src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs rename src/modules/Elsa.Workflows.Core/Models/{BookmarkOptions.cs => CreateBookmarkArgs.cs} (70%) diff --git a/Elsa.sln.DotSettings b/Elsa.sln.DotSettings index 176b45769..0c8033163 100644 --- a/Elsa.sln.DotSettings +++ b/Elsa.sln.DotSettings @@ -3,6 +3,7 @@ True True True + True True True True diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index 5d20b3b54..d7f8181d3 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -19,7 +19,7 @@ using Proto.Persistence.SqlServer; const bool useMongoDb = false; const bool useSqlServer = false; const bool useDapper = false; -const bool useProtoActor = false; +const bool useProtoActor = true; const bool useHangfire = false; var builder = WebApplication.CreateBuilder(args); diff --git a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/Bookmark.cs b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/Bookmark.cs index 085193157..f4a48d1bf 100644 --- a/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/Bookmark.cs +++ b/src/clients/Elsa.Api.Client/Resources/WorkflowInstances/Models/Bookmark.cs @@ -23,7 +23,8 @@ public record Bookmark( string ActivityNodeId, string ActivityInstanceId, bool AutoBurn = true, - string? CallbackMethodName = default + string? CallbackMethodName = default, + IDictionary? Metadata = default ) { /// diff --git a/src/modules/Elsa.Dapper.Migrations/Runtime/Initial.cs b/src/modules/Elsa.Dapper.Migrations/Runtime/Initial.cs index d57e3ed8a..768f1bc0c 100644 --- a/src/modules/Elsa.Dapper.Migrations/Runtime/Initial.cs +++ b/src/modules/Elsa.Dapper.Migrations/Runtime/Initial.cs @@ -31,6 +31,7 @@ public class Initial : Migration .WithColumn("ActivityInstanceId").AsString().NotNullable() .WithColumn("Hash").AsString().Nullable() .WithColumn("SerializedPayload").AsString().Nullable() + .WithColumn("SerializedMetadata").AsString().Nullable() .WithColumn("CreatedAt").AsDateTimeOffset().NotNullable(); IfDatabase("Sqlite") @@ -43,6 +44,7 @@ public class Initial : Migration .WithColumn("ActivityInstanceId").AsString().NotNullable() .WithColumn("Hash").AsString().Nullable() .WithColumn("SerializedPayload").AsString().Nullable() + .WithColumn("SerializedMetadata").AsString().Nullable() .WithColumn("CreatedAt").AsDateTime2().NotNullable(); IfDatabase("SqlServer", "Oracle", "MySql", "Postgres") diff --git a/src/modules/Elsa.Dapper/Modules/Runtime/Records/StoredBookmarkRecord.cs b/src/modules/Elsa.Dapper/Modules/Runtime/Records/StoredBookmarkRecord.cs index f366840f9..caa1a9c9b 100644 --- a/src/modules/Elsa.Dapper/Modules/Runtime/Records/StoredBookmarkRecord.cs +++ b/src/modules/Elsa.Dapper/Modules/Runtime/Records/StoredBookmarkRecord.cs @@ -9,5 +9,6 @@ internal class StoredBookmarkRecord public string? CorrelationId { get; set; } public string? ActivityInstanceId { get; set; } public string? SerializedPayload { get; set; } + public string? SerializedMetadata { get; set; } public DateTimeOffset CreatedAt { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs index e19c325cd..164bfd5b9 100644 --- a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs +++ b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperBookmarkStore.cs @@ -48,7 +48,7 @@ public class DapperBookmarkStore : IBookmarkStore { return await _store.DeleteAsync(q => ApplyFilter(q, filter), cancellationToken); } - + private void ApplyFilter(ParameterizedQuery query, BookmarkFilter filter) { query @@ -62,7 +62,7 @@ public class DapperBookmarkStore : IBookmarkStore .Is(nameof(StoredBookmarkRecord.ActivityInstanceId), filter.ActivityInstanceId) ; } - + private IEnumerable Map(IEnumerable source) => source.Select(Map); private StoredBookmarkRecord Map(StoredBookmark source) @@ -76,10 +76,11 @@ public class DapperBookmarkStore : IBookmarkStore ActivityTypeName = source.ActivityTypeName, Hash = source.Hash, SerializedPayload = source.Payload != null ? _payloadSerializer.Serialize(source.Payload) : default, + SerializedMetadata = source.Metadata != null ? _payloadSerializer.Serialize(source.Metadata) : default, CreatedAt = source.CreatedAt }; } - + private StoredBookmark Map(StoredBookmarkRecord source) { return new StoredBookmark @@ -91,6 +92,7 @@ public class DapperBookmarkStore : IBookmarkStore ActivityTypeName = source.ActivityTypeName, Hash = source.Hash, Payload = source.SerializedPayload != null ? _payloadSerializer.Deserialize(source.SerializedPayload) : default, + Metadata = source.SerializedMetadata != null ? _payloadSerializer.Deserialize>(source.SerializedMetadata) : default, CreatedAt = source.CreatedAt }; } diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.Designer.cs similarity index 98% rename from src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.Designer.cs index 5e7dc03e4..e0688e9b0 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime { [DbContext(typeof(RuntimeElsaDbContext))] - [Migration("20230911202213_Initial")] + [Migration("20230915185743_Initial")] partial class Initial { /// @@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime .IsRequired() .HasColumnType("varchar(255)"); + b.Property("SerializedMetadata") + .HasColumnType("longtext"); + b.Property("SerializedPayload") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.cs rename to src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.cs index 4ea5076a7..68020f524 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230911202213_Initial.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/20230915185743_Initial.cs @@ -70,6 +70,8 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime CorrelationId = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), CreatedAt = table.Column(type: "datetime(6)", nullable: false), + SerializedMetadata = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), SerializedPayload = table.Column(type: "longtext", nullable: true) .Annotation("MySql:CharSet", "utf8mb4") }, diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index 0eb4a2e62..1a2441f74 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -124,6 +124,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime .IsRequired() .HasColumnType("varchar(255)"); + b.Property("SerializedMetadata") + .HasColumnType("longtext"); + b.Property("SerializedPayload") .HasColumnType("longtext"); diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.Designer.cs similarity index 98% rename from src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.Designer.cs index d74a13b62..1b5b43127 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime { [DbContext(typeof(RuntimeElsaDbContext))] - [Migration("20230911202223_Initial")] + [Migration("20230915185758_Initial")] partial class Initial { /// @@ -130,6 +130,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime .IsRequired() .HasColumnType("text"); + b.Property("SerializedMetadata") + .HasColumnType("text"); + b.Property("SerializedPayload") .HasColumnType("text"); diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.cs rename to src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.cs index fd85141ec..e962de2be 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230911202223_Initial.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/20230915185758_Initial.cs @@ -51,6 +51,7 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime ActivityInstanceId = table.Column(type: "text", nullable: true), CorrelationId = table.Column(type: "text", nullable: true), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + SerializedMetadata = table.Column(type: "text", nullable: true), SerializedPayload = table.Column(type: "text", nullable: true) }, constraints: table => diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index 300f2b9d0..967e84dcc 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime .IsRequired() .HasColumnType("text"); + b.Property("SerializedMetadata") + .HasColumnType("text"); + b.Property("SerializedPayload") .HasColumnType("text"); diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.Designer.cs similarity index 98% rename from src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.Designer.cs index df161b077..1028f836b 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime { [DbContext(typeof(RuntimeElsaDbContext))] - [Migration("20230911202217_Initial")] + [Migration("20230915185751_Initial")] partial class Initial { /// @@ -130,6 +130,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime .IsRequired() .HasColumnType("nvarchar(450)"); + b.Property("SerializedMetadata") + .HasColumnType("nvarchar(max)"); + b.Property("SerializedPayload") .HasColumnType("nvarchar(max)"); diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.cs rename to src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.cs index 255977b24..0cba84f36 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230911202217_Initial.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/20230915185751_Initial.cs @@ -51,6 +51,7 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime ActivityInstanceId = table.Column(type: "nvarchar(450)", nullable: true), CorrelationId = table.Column(type: "nvarchar(max)", nullable: true), CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + SerializedMetadata = table.Column(type: "nvarchar(max)", nullable: true), SerializedPayload = table.Column(type: "nvarchar(max)", nullable: true) }, constraints: table => diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index 5506bcd1b..2904fb96c 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime .IsRequired() .HasColumnType("nvarchar(450)"); + b.Property("SerializedMetadata") + .HasColumnType("nvarchar(max)"); + b.Property("SerializedPayload") .HasColumnType("nvarchar(max)"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.Designer.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.Designer.cs similarity index 98% rename from src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.Designer.cs rename to src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.Designer.cs index 24c3efb09..378bcb984 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.Designer.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime { [DbContext(typeof(RuntimeElsaDbContext))] - [Migration("20230911202220_Initial")] + [Migration("20230915185754_Initial")] partial class Initial { /// @@ -125,6 +125,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime .IsRequired() .HasColumnType("TEXT"); + b.Property("SerializedMetadata") + .HasColumnType("TEXT"); + b.Property("SerializedPayload") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.cs similarity index 99% rename from src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.cs rename to src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.cs index 3388f4c8a..520f3ba13 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230911202220_Initial.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/20230915185754_Initial.cs @@ -45,6 +45,7 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime ActivityInstanceId = table.Column(type: "TEXT", nullable: true), CorrelationId = table.Column(type: "TEXT", nullable: true), CreatedAt = table.Column(type: "TEXT", nullable: false), + SerializedMetadata = table.Column(type: "TEXT", nullable: true), SerializedPayload = table.Column(type: "TEXT", nullable: true) }, constraints: table => diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs index fae87ddf7..90684a2a6 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Migrations/Runtime/RuntimeElsaDbContextModelSnapshot.cs @@ -122,6 +122,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime .IsRequired() .HasColumnType("TEXT"); + b.Property("SerializedMetadata") + .HasColumnType("TEXT"); + b.Property("SerializedPayload") .HasColumnType("TEXT"); diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/BookmarkStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/BookmarkStore.cs index 9fd05ada0..abe18b015 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/BookmarkStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/BookmarkStore.cs @@ -31,10 +31,11 @@ public class EFCoreBookmarkStore : IBookmarkStore /// public async ValueTask DeleteAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) => await _store.DeleteWhereAsync(filter.Apply, cancellationToken); - + private ValueTask OnSaveAsync(RuntimeElsaDbContext dbContext, StoredBookmark entity, CancellationToken cancellationToken) { dbContext.Entry(entity).Property("SerializedPayload").CurrentValue = entity.Payload != null ? _serializer.Serialize(entity.Payload) : default; + dbContext.Entry(entity).Property("SerializedMetadata").CurrentValue = entity.Metadata != null ? _serializer.Serialize(entity.Metadata) : default; return default; } @@ -43,9 +44,11 @@ public class EFCoreBookmarkStore : IBookmarkStore if (entity is null) return default; - var json = dbContext.Entry(entity).Property("SerializedPayload").CurrentValue; - entity.Payload = !string.IsNullOrEmpty(json) ? _serializer.Deserialize(json) : null; - + var payloadJson = dbContext.Entry(entity).Property("SerializedPayload").CurrentValue; + var metadataJson = dbContext.Entry(entity).Property("SerializedMetadata").CurrentValue; + entity.Payload = !string.IsNullOrEmpty(payloadJson) ? _serializer.Deserialize(payloadJson) : null; + entity.Metadata = !string.IsNullOrEmpty(metadataJson) ? _serializer.Deserialize>(metadataJson) : null; + return default; } } \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Configurations.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Configurations.cs index 2b35ce80d..7360307e1 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Configurations.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/Configurations.cs @@ -78,7 +78,9 @@ public class Configurations : public void Configure(EntityTypeBuilder builder) { builder.Ignore(x => x.Payload); + builder.Ignore(x => x.Metadata); builder.Property("SerializedPayload"); + builder.Property("SerializedMetadata"); builder.HasKey(x => x.BookmarkId); builder.HasIndex(x => x.ActivityTypeName, $"IX_{nameof(StoredBookmark)}_{nameof(StoredBookmark.ActivityTypeName)}"); builder.HasIndex(x => x.Hash, $"IX_{nameof(StoredBookmark)}_{nameof(StoredBookmark.Hash)}"); diff --git a/src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs b/src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs new file mode 100644 index 000000000..7f1f9a696 --- /dev/null +++ b/src/modules/Elsa.Http/Abstractions/DownloadableProviderBase.cs @@ -0,0 +1,49 @@ +using Elsa.Http.Contexts; +using Elsa.Http.Contracts; +using Elsa.Http.Models; + +namespace Elsa.Http.Abstractions; + +/// +/// Provides a base class for implementations. +/// +public abstract class DownloadableProviderBase : IDownloadableProvider +{ + /// + public virtual float Priority => 0; + + /// + public abstract bool GetSupportsContent(object content); + + /// + /// Returns a list of downloadables from the specified content. + /// + protected virtual async ValueTask> GetDownloadablesAsync(DownloadableContext context) + { + var downloadable = await GetDownloadableAsync(context); + return new[]{ downloadable }; + } + + /// + /// Returns a downloadable from the specified content. + /// + protected virtual ValueTask GetDownloadableAsync(DownloadableContext context) + { + var downloadable = GetDownloadable(context); + return new (downloadable); + } + + /// + /// Returns a downloadable from the specified content. + /// + /// This method is not implemented. The derived class must implement at least one of the GetDownloadable methods. + protected virtual Downloadable GetDownloadable(DownloadableContext context) + { + throw new NotImplementedException(); + } + + async ValueTask> IDownloadableProvider.GetDownloadablesAsync(DownloadableContext context) + { + return await GetDownloadablesAsync(context); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 1d3681407..7e1372260 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Elsa.Expressions.Models; using Elsa.Extensions; +using Elsa.Http.Bookmarks; using Elsa.Http.Contracts; using Elsa.Http.Models; using Elsa.Workflows.Core; @@ -103,7 +104,7 @@ public class HttpEndpoint : Trigger { // 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); + context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary); return; } diff --git a/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs new file mode 100644 index 000000000..3c2837b94 --- /dev/null +++ b/src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs @@ -0,0 +1,165 @@ +using System.IO.Compression; +using System.Net; +using Elsa.Extensions; +using Elsa.Http.Contracts; +using Elsa.Http.Models; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Exceptions; +using Elsa.Workflows.Core.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; + +namespace Elsa.Http; + +/// +/// Sends a file to the HTTP response. +/// +[Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")] +public class WriteFileHttpResponse : Activity +{ + /// + /// The MIME type of the file to serve. + /// + [Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content type.")] + public Input ContentType { get; set; } = default!; + + /// + /// The name of the file to serve. + /// + [Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")] + public Input FileName { get; set; } = default!; + + /// + /// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types. + /// + [Input(Description = "The file content to serve. Supports various types, such as byte array, stream, string, Uri, Downloadable and a (mixed) array of the aforementioned types.")] + public Input Content { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var httpContextAccessor = context.GetRequiredService(); + var httpContext = httpContextAccessor.HttpContext; + + 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, BookmarkMetadata.HttpCrossBoundary); + return; + } + + await WriteResponseAsync(context, httpContext.Response); + } + + private async Task WriteResponseAsync(ActivityExecutionContext context, HttpResponse response) + { + // Set status code. + var statusCode = HttpStatusCode.OK; + response.StatusCode = (int)statusCode; + + // Get content and content type. + var content = context.Get(Content); + + if (content == null) + return; + + // Write content. + var downloadables = await GetDownloadablesAsync(context, content); + await SendDownloadablesAsync(context, response, downloadables); + + // Complete activity. + await context.CompleteActivityAsync(); + } + + private async Task SendDownloadablesAsync(ActivityExecutionContext context, HttpResponse response, IEnumerable downloadables) + { + var downloadableList = downloadables.ToList(); + + switch (downloadableList.Count) + { + case 0: + return; + case 1: + { + var downloadable = downloadableList[0]; + await SendSingleFileAsync(context, response, downloadable); + return; + } + default: + await SendMultipleFilesAsync(context, response, downloadableList); + break; + } + } + + private async Task SendSingleFileAsync(ActivityExecutionContext context, HttpResponse response, Downloadable downloadable) + { + var contentType = ContentType.GetOrDefault(context); + var filename = FileName.GetOrDefault(context); + filename = !string.IsNullOrWhiteSpace(filename) ? filename : !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : "file.bin"; + contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : !string.IsNullOrWhiteSpace(downloadable.ContentType) ? downloadable.ContentType : GetContentType(context, filename); + response.ContentType = contentType; + response.Headers.Add("Content-Disposition", $"attachment; filename=\"{filename}\""); + await downloadable.Stream.CopyToAsync(response.Body); + } + + private async Task SendMultipleFilesAsync(ActivityExecutionContext context, HttpResponse response, ICollection downloadables) + { + var memoryStream = new MemoryStream(); + var currentFileIndex = 0; + + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + foreach (var downloadable in downloadables) + { + var entryName = !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : $"file-{currentFileIndex}.bin"; + var entry = archive.CreateEntry(entryName); + var fileStream = downloadable.Stream; + await using var entryStream = entry.Open(); + await fileStream.CopyToAsync(entryStream); + } + } + + var contentType = ContentType.GetOrDefault(context); + var filename = FileName.GetOrDefault(context); + + contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Zip; + filename = !string.IsNullOrWhiteSpace(filename) ? filename : "download.zip"; + + memoryStream.Position = 0; + response.ContentType = contentType; + response.Headers.Add("Content-Disposition", $"attachment; filename=\"{filename}\""); + await memoryStream.CopyToAsync(response.Body); + } + + /// + /// Leverages the to get a list of instances from the . + /// + private async Task> GetDownloadablesAsync(ActivityExecutionContext context, object content) + { + var manager = context.GetRequiredService(); + return await manager.GetDownloadablesAsync(content, context.CancellationToken); + } + + private string GetContentType(ActivityExecutionContext context, string filename) + { + var provider = context.GetService() ?? new FileExtensionContentTypeProvider(); + return provider.TryGetContentType(filename, out var contentType) ? contentType : "application/octet-stream"; + } + + private async ValueTask OnResumeAsync(ActivityExecutionContext context) + { + var httpContextAccessor = context.GetRequiredService(); + var httpContext = httpContextAccessor.HttpContext; + + if (httpContext == null) + { + // We're not in an HTTP context, so let's fail. + throw new FaultException("Cannot execute in a non-HTTP context"); + } + + await WriteResponseAsync(context, httpContext.Response); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs index ebb9026fa..246107371 100644 --- a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs +++ b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs @@ -1,11 +1,13 @@ using System.Net; using System.Runtime.CompilerServices; using Elsa.Extensions; +using Elsa.Http.Bookmarks; using Elsa.Http.ContentWriters; using Elsa.Http.Models; using Elsa.Http.Providers; using Elsa.Workflows.Core; using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Exceptions; using Elsa.Workflows.Core.Models; using Microsoft.AspNetCore.Http; @@ -61,7 +63,7 @@ public class WriteHttpResponse : Activity // 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); + context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary); return; } @@ -76,7 +78,7 @@ public class WriteHttpResponse : Activity if (httpContext == null) { // We're not in an HTTP context, so let's fail. - throw new Exception("Cannot execute in a non-HTTP context"); + throw new FaultException("Cannot execute in a non-HTTP context"); } await WriteResponseAsync(context, httpContext.Response); diff --git a/src/modules/Elsa.Http/BookmarkMetadata.cs b/src/modules/Elsa.Http/BookmarkMetadata.cs new file mode 100644 index 000000000..0a6987d6a --- /dev/null +++ b/src/modules/Elsa.Http/BookmarkMetadata.cs @@ -0,0 +1,17 @@ +namespace Elsa.Http; + +/// +/// Provides metadata for HTTP bookmarks. +/// +public static class BookmarkMetadata +{ + /// + /// The metadata key for cross-HTTP boundary activity execution. + /// + public const string HttpCrossBoundaryMetadataKey = "X-HttpCrossBoundary"; + + /// + /// Provides metadata for cross-HTTP boundary activity execution. + /// + public static readonly IDictionary HttpCrossBoundary = new Dictionary { [HttpCrossBoundaryMetadataKey] = "true" }; +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Bookmarks/HttpEndpointBookmarkPayload.cs b/src/modules/Elsa.Http/Bookmarks/HttpEndpointBookmarkPayload.cs new file mode 100644 index 000000000..f43518ab8 --- /dev/null +++ b/src/modules/Elsa.Http/Bookmarks/HttpEndpointBookmarkPayload.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using Elsa.Workflows.Core.Attributes; + +namespace Elsa.Http.Bookmarks; + +/// +/// A bookmark used by the activity. +/// +public record HttpEndpointBookmarkPayload +{ + private readonly string _path = default!; + private readonly string _method = default!; + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public HttpEndpointBookmarkPayload() + { + } + + /// + /// Initializes a new instance of the class. + /// + public HttpEndpointBookmarkPayload(string path, string method, bool? authorize = default, string? policy = default) + { + Path = path; + Method = method; + Authorize = authorize; + Policy = policy; + } + + /// + /// Gets or sets the path of the HTTP endpoint. + /// + public string Path + { + get => _path; + init => _path = value.ToLowerInvariant(); + } + + /// + /// Gets or sets the HTTP method of the endpoint. + /// + public string Method + { + get => _method; + init => _method = value.ToLowerInvariant(); + } + + /// + /// Gets or sets the policy to use for authorization. + /// + [ExcludeFromHash] public string? Policy { get; set; } + + /// + /// Gets or sets a value indicating whether the endpoint requires authorization. + /// + [ExcludeFromHash] public bool? Authorize { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs new file mode 100644 index 000000000..5678f24ce --- /dev/null +++ b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs @@ -0,0 +1,11 @@ +using Elsa.Http.Contracts; + +namespace Elsa.Http.Contexts; + +/// +/// Provides context for downloadable providers. +/// +/// The manager. +/// The content to get downloadables from. +/// The cancellation token. +public record DownloadableContext(IDownloadableManager Manager, object Content, CancellationToken CancellationToken); \ No newline at end of file diff --git a/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs b/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs new file mode 100644 index 000000000..3674add4f --- /dev/null +++ b/src/modules/Elsa.Http/Contracts/IDownloadableManager.cs @@ -0,0 +1,14 @@ +using Elsa.Http.Models; + +namespace Elsa.Http.Contracts; + +/// +/// Provides downloadables from the specified content, if supported. +/// +public interface IDownloadableManager +{ + /// + /// Returns a list of downloadables from the specified content. + /// + ValueTask> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs b/src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs new file mode 100644 index 000000000..829e003df --- /dev/null +++ b/src/modules/Elsa.Http/Contracts/IDownloadableProvider.cs @@ -0,0 +1,26 @@ +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.Contracts; + +/// +/// Provides downloadables from the specified content, if supported. +/// +public interface IDownloadableProvider +{ + /// + /// The priority of this provider. Providers with lower priority are tried first. + /// + float Priority { get; } + + /// + /// Returns true if this provider supports the specified content. + /// + /// The content to check. + bool GetSupportsContent(object content); + + /// + /// Returns a list of downloadables from the specified content. + /// + ValueTask> GetDownloadablesAsync(DownloadableContext context); +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Contracts/IFileDownloader.cs b/src/modules/Elsa.Http/Contracts/IFileDownloader.cs new file mode 100644 index 000000000..e97983f81 --- /dev/null +++ b/src/modules/Elsa.Http/Contracts/IFileDownloader.cs @@ -0,0 +1,12 @@ +namespace Elsa.Http.Contracts; + +/// +/// A general-purpose downloader of files from a given URL. +/// +public interface IFileDownloader +{ + /// + /// Downloads a file from the specified URL. + /// + Task DownloadAsync(Uri url, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs new file mode 100644 index 000000000..2fe2bfd2d --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableProviders/BinaryDownloadableProvider.cs @@ -0,0 +1,24 @@ +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableProviders; + +/// +/// Handles content that represents a downloadable binary file. +/// +public class BinaryDownloadableProvider : DownloadableProviderBase +{ + /// + public override bool GetSupportsContent(object content) => content is byte[]; + + /// + protected override Downloadable GetDownloadable(DownloadableContext context) + { + var bytes = (byte[]) context.Content; + var stream = new MemoryStream(bytes); + var fileName = "file.bin"; + var contentType = "application/octet-stream"; + return new Downloadable(stream, fileName, contentType); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs new file mode 100644 index 000000000..c031a130d --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableProviders/DownloadableDownloadableProvider.cs @@ -0,0 +1,20 @@ +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableProviders; + +/// +/// Handles content that represents a downloadable. +/// +public class DownloadableDownloadableProvider : DownloadableProviderBase +{ + /// + public override bool GetSupportsContent(object content) => content is Downloadable; + + /// + protected override Downloadable GetDownloadable(DownloadableContext context) + { + return (Downloadable) context.Content; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs new file mode 100644 index 000000000..dc1215ade --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableProviders/MultiDownloadableProvider.cs @@ -0,0 +1,32 @@ +using System.Collections; +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableProviders; + +/// +/// Handles content that represents a list of downloadable objects. +/// +public class MultiDownloadableProvider : DownloadableProviderBase +{ + /// + public override bool GetSupportsContent(object content) => content is IEnumerable enumerable and not string; + + /// + protected override async ValueTask> GetDownloadablesAsync(DownloadableContext context) + { + var collectedDownloadables = new List(); + var content = context.Content; + var enumerable = (IEnumerable) content; + var manager = context.Manager; + + foreach (var item in enumerable) + { + var downloadables = await manager.GetDownloadablesAsync(item, context.CancellationToken); + collectedDownloadables.AddRange(downloadables); + } + + return collectedDownloadables; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs new file mode 100644 index 000000000..4e82dcd54 --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableProviders/StreamDownloadableProvider.cs @@ -0,0 +1,23 @@ +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableProviders; + +/// +/// Handles content that represents a downloadable stream. +/// +public class StreamDownloadableProvider : DownloadableProviderBase +{ + /// + public override bool GetSupportsContent(object content) => content is Stream; + + /// + protected override Downloadable GetDownloadable(DownloadableContext context) + { + var stream = (Stream) context.Content; + var fileName = "file.bin"; + var contentType = "application/octet-stream"; + return new Downloadable(stream, fileName, contentType); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs b/src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs new file mode 100644 index 000000000..008736973 --- /dev/null +++ b/src/modules/Elsa.Http/DownloadableProviders/UrlDownloadableProvider.cs @@ -0,0 +1,49 @@ +using System.Text.RegularExpressions; +using Elsa.Http.Abstractions; +using Elsa.Http.Contexts; +using Elsa.Http.Contracts; +using Elsa.Http.Models; + +namespace Elsa.Http.DownloadableProviders; + +/// +/// Handles content that represents a downloadable URL. +/// +public class UrlDownloadableProvider : DownloadableProviderBase +{ + private readonly IFileDownloader _fileDownloader; + + /// + public UrlDownloadableProvider(IFileDownloader fileDownloader) + { + _fileDownloader = fileDownloader; + } + + /// + public override bool GetSupportsContent(object content) => (content is string url && url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) || content is Uri; + + /// + protected override async ValueTask GetDownloadableAsync(DownloadableContext context) + { + var url = context.Content is string s ? new Uri(s) : (Uri)context.Content; + var cancellationToken = context.CancellationToken; + var response = await _fileDownloader.DownloadAsync(url, cancellationToken); + var fileName = GetFilename(response) ?? url.Segments.Last(); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; + + return new Downloadable(stream, fileName, contentType); + } + + private static string? GetFilename(HttpResponseMessage response) + { + if (!response.Content.Headers.TryGetValues("Content-Disposition", out var values)) + return null; + + var contentDisposition = string.Join("", values); + var match = Regex.Match(contentDisposition, """filename="?(?[^";]*)"?"""); + + return match.Success ? match.Groups["filename"].Value : null; + } + +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index d0cbfa09e..fb2101f13 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -27,8 +27,11 @@ + + + diff --git a/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs b/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs index 96371587f..729b050a2 100644 --- a/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs @@ -1,4 +1,5 @@ using Elsa.Http; +using Elsa.Http.Bookmarks; using Elsa.Http.Contracts; using Elsa.Http.Models; using Elsa.Workflows.Core.Helpers; diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 758be5462..c07bfa9a6 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -5,6 +5,7 @@ using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.Http.ContentWriters; using Elsa.Http.Contracts; +using Elsa.Http.DownloadableProviders; using Elsa.Http.Handlers; using Elsa.Http.HostedServices; using Elsa.Http.Models; @@ -147,12 +148,20 @@ public class HttpFeature : FeatureBase // Port resolvers. .AddSingleton() - // Add HTTP endpoint handlers. + // HTTP endpoint handlers. .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(HttpEndpointWorkflowFaultHandler) .AddSingleton(HttpEndpointAuthorizationHandler) + + // File related services. + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() // Add mediator handlers. .AddNotificationHandlersFrom() @@ -160,6 +169,9 @@ public class HttpFeature : FeatureBase // AuthenticationBasedHttpEndpointAuthorizationHandler requires Authorization services. // We could consider creating a separate module for installing authorization services. .AddAuthorization(); + + // HTTP clients. + Services.AddHttpClient(); // Add selectors. foreach (var httpCorrelationIdSelectorType in HttpCorrelationIdSelectorTypes) diff --git a/src/modules/Elsa.Http/Handlers/ValidateWorkflowRequestHandler.cs b/src/modules/Elsa.Http/Handlers/ValidateWorkflowRequestHandler.cs index bd29eadf5..f80502693 100644 --- a/src/modules/Elsa.Http/Handlers/ValidateWorkflowRequestHandler.cs +++ b/src/modules/Elsa.Http/Handlers/ValidateWorkflowRequestHandler.cs @@ -1,4 +1,5 @@ using Elsa.Extensions; +using Elsa.Http.Bookmarks; using Elsa.Http.Models; using Elsa.Mediator.Contracts; using Elsa.Workflows.Core.Helpers; diff --git a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs index 3a56b2823..af9cd6016 100644 --- a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs @@ -11,6 +11,7 @@ using System.Net; using System.Net.Mime; using System.Text.Json; using Elsa.Extensions; +using Elsa.Http.Bookmarks; using Elsa.Workflows.Core; using Elsa.Workflows.Core.State; using Elsa.Workflows.Runtime.Filters; diff --git a/src/modules/Elsa.Http/Models/Downloadable.cs b/src/modules/Elsa.Http/Models/Downloadable.cs new file mode 100644 index 000000000..fe53677e6 --- /dev/null +++ b/src/modules/Elsa.Http/Models/Downloadable.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Elsa.Http.Models; + +/// +/// Represents a downloadable object. +/// +public class Downloadable +{ + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public Downloadable() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The stream to download. + /// The filename to use when downloading the stream. + /// The content type to use when downloading the stream. + public Downloadable(Stream stream, string? filename = default, string? contentType = default) + { + Stream = stream; + Filename = filename; + ContentType = contentType; + } + + /// + /// The stream to download. + /// + public Stream Stream { get; set; } = default!; + + /// + /// The filename to use when downloading the stream. + /// + public string? Filename { get; set; } + + /// + /// The content type to use when downloading the stream. + /// + public string? ContentType { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpEndpointBookmarkPayload.cs b/src/modules/Elsa.Http/Models/HttpEndpointBookmarkPayload.cs deleted file mode 100644 index 2d77513f2..000000000 --- a/src/modules/Elsa.Http/Models/HttpEndpointBookmarkPayload.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Elsa.Workflows.Core.Attributes; -using System.Text.Json.Serialization; - -namespace Elsa.Http.Models; - -public record HttpEndpointBookmarkPayload -{ - private readonly string _path = default!; - private readonly string _method = default!; - - [JsonConstructor] - public HttpEndpointBookmarkPayload() - { - } - - public HttpEndpointBookmarkPayload(string path, string method, - bool? authorize = default, string? policy = default) - { - Path = path; - Method = method; - Authorize = authorize; - Policy = policy; - } - - public string Path - { - get => _path; - init => _path = value.ToLowerInvariant(); - } - - public string Method - { - get => _method; - init => _method = value.ToLowerInvariant(); - } - - [ExcludeFromHash] - public string? Policy { get; set; } - - [ExcludeFromHash] - public bool? Authorize { get; set; } -} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Providers/WriteHttpResponseContentTypeOptionsProvider.cs b/src/modules/Elsa.Http/Providers/WriteHttpResponseContentTypeOptionsProvider.cs index b8393b541..bd28025f1 100644 --- a/src/modules/Elsa.Http/Providers/WriteHttpResponseContentTypeOptionsProvider.cs +++ b/src/modules/Elsa.Http/Providers/WriteHttpResponseContentTypeOptionsProvider.cs @@ -18,7 +18,7 @@ internal class WriteHttpResponseContentTypeOptionsProvider : IActivityPropertyOp { var options = new Dictionary { - ["AvailableContentTypes"] = new[] { "" }.Concat(_options.AvailableContentTypes) + ["items"] = new[] { "" }.Concat(_options.AvailableContentTypes) }; return new(options); diff --git a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs index 9d8732ad6..46125aebd 100644 --- a/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs +++ b/src/modules/Elsa.Http/Scripting/JavaScript/HttpJavaScriptHandler.cs @@ -1,22 +1,48 @@ using Elsa.Extensions; using Elsa.Http.Models; using Elsa.JavaScript.Notifications; +using Elsa.JavaScript.TypeDefinitions.Contracts; +using Elsa.JavaScript.TypeDefinitions.Models; using Elsa.Mediator.Contracts; using JetBrains.Annotations; namespace Elsa.Http.Scripting.JavaScript; /// -/// Configures the JavaScript engine with workflow input getters. +/// Configures the JavaScript engine with additional .NET types that can be instantiated. /// [PublicAPI] -public class HttpJavaScriptHandler : INotificationHandler +public class HttpJavaScriptHandler : INotificationHandler, ITypeDefinitionProvider { + private readonly ITypeDescriber _typeDescriber; + + /// + /// Initializes a new instance of the class. + /// + public HttpJavaScriptHandler(ITypeDescriber typeDescriber) + { + _typeDescriber = typeDescriber; + } + /// public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) { var engine = notification.Engine; engine.RegisterType(); + engine.RegisterType(); return Task.CompletedTask; } + + /// + public ValueTask> GetTypeDefinitionsAsync(TypeDefinitionContext context) + { + var definitions = GetTypeDefinitions(context); + return new(definitions); + } + + private IEnumerable GetTypeDefinitions(TypeDefinitionContext context) + { + yield return _typeDescriber.DescribeType(typeof(HttpRequestHeaders)); + yield return _typeDescriber.DescribeType(typeof(Downloadable)); + } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs new file mode 100644 index 000000000..b646b5c96 --- /dev/null +++ b/src/modules/Elsa.Http/Services/DefaultDownloadableManager.cs @@ -0,0 +1,33 @@ +using Elsa.Http.Contexts; +using Elsa.Http.Contracts; +using Elsa.Http.Models; + +namespace Elsa.Http.Services; + +/// +public class DefaultDownloadableManager : IDownloadableManager +{ + private readonly IEnumerable _providers; + + /// + /// Initializes a new instance of the class. + /// + public DefaultDownloadableManager(IEnumerable providers) + { + _providers = providers.OrderBy(x => x.Priority).ToList(); + } + + /// + public async ValueTask> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default) + { + var provider = _providers.FirstOrDefault(x => x.GetSupportsContent(content)); + + if (provider == null) + return Enumerable.Empty(); + + var context = new DownloadableContext(this, content, cancellationToken); + var downloadables = await provider.GetDownloadablesAsync(context); + + return downloadables; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Services/HttpBookmarkProcessor.cs b/src/modules/Elsa.Http/Services/HttpBookmarkProcessor.cs index 4409f48f5..ef211e06b 100644 --- a/src/modules/Elsa.Http/Services/HttpBookmarkProcessor.cs +++ b/src/modules/Elsa.Http/Services/HttpBookmarkProcessor.cs @@ -1,4 +1,5 @@ using Elsa.Common.Models; +using Elsa.Http.Bookmarks; using Elsa.Http.Contracts; using Elsa.Http.Models; using Elsa.Workflows.Core.Helpers; @@ -39,7 +40,7 @@ public class HttpBookmarkProcessor : IHttpBookmarkProcessor _workflowInstanceManager = workflowInstanceManager; _workflowStateMapper = workflowStateMapper; } - + /// public async Task> ProcessBookmarks( IEnumerable executionResults, @@ -53,18 +54,13 @@ public class HttpBookmarkProcessor : IHttpBookmarkProcessor throw new Exception("Invalid use of this method, because there is no HTTP context"); // We must assume that the workflow executed in a different process (when e.g. using Proto.Actor) - // and check if we received any `HttpEndpoint` or `WriteHttpResponse` activity bookmarks. + // and check if we received any HTTP-related 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(); - var writeHttpResponseTypeName = ActivityTypeNameHelper.GenerateTypeName(); var query = from executionResult in executionResults from bookmark in executionResult.Bookmarks - where bookmark.Name == writeHttpResponseTypeName - || (bookmark.Name == httpEndpointTypeName - // If a "regular" HttpEndpointBookmarkPayload bookmark was created, we do not want to resume that - it's a bookmark that was created to block the workflow until the desired HTTP request comes in. - && bookmark.Payload is not HttpEndpointBookmarkPayload) + where bookmark.Metadata?.ContainsKey(BookmarkMetadata.HttpCrossBoundaryMetadataKey) == true select (InstanceId: executionResult.WorkflowInstanceId, bookmark.Id); var workflowExecutionResults = new Stack<(string InstanceId, string BookmarkId)>(query); @@ -97,11 +93,11 @@ public class HttpBookmarkProcessor : IHttpBookmarkProcessor var workflow = await _workflowDefinitionService.MaterializeWorkflowAsync( workflowDefinition, cancellationToken); - + var workflowHost = await _workflowHostFactory.CreateAsync(workflow, workflowState, cancellationToken); var options = new ResumeWorkflowHostOptions(correlationId, result.BookmarkId, Input: input); await workflowHost.ResumeWorkflowAsync(options, cancellationToken); - + // Import the updated workflow state into the runtime. workflowState = workflowHost.WorkflowState; await _workflowRuntime.ImportWorkflowStateAsync(workflowState, cancellationToken); @@ -109,7 +105,7 @@ public class HttpBookmarkProcessor : IHttpBookmarkProcessor } // Save the updated workflow states. - foreach (var workflowInstance in workflowStates.Select(workflowState => _workflowStateMapper.Map(workflowState)!)) + foreach (var workflowInstance in workflowStates.Select(workflowState => _workflowStateMapper.Map(workflowState)!)) await _workflowInstanceManager.SaveAsync(workflowInstance, cancellationToken); return workflowStates; diff --git a/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs b/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs new file mode 100644 index 000000000..1a751b642 --- /dev/null +++ b/src/modules/Elsa.Http/Services/HttpClientFileDownloader.cs @@ -0,0 +1,25 @@ +using Elsa.Http.Contracts; + +namespace Elsa.Http.Services; + +/// +/// A general-purpose downloader of files from a given URL that uses . +/// +public class HttpClientFileDownloader : IFileDownloader +{ + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + public HttpClientFileDownloader(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + public async Task DownloadAsync(Uri url, CancellationToken cancellationToken = default) + { + return await _httpClient.GetAsync(url, cancellationToken); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor/Mappers/BookmarkMapper.cs b/src/modules/Elsa.ProtoActor/Mappers/BookmarkMapper.cs index a5b918c5b..b97d99d0a 100644 --- a/src/modules/Elsa.ProtoActor/Mappers/BookmarkMapper.cs +++ b/src/modules/Elsa.ProtoActor/Mappers/BookmarkMapper.cs @@ -19,35 +19,37 @@ internal class BookmarkMapper { _bookmarkPayloadSerializer = bookmarkPayloadSerializer; } - - - public IEnumerable Map(IEnumerable source) => - source.Select(x => + + + public IEnumerable Map(IEnumerable source) => + source.Select(bookmark => new ProtoBookmark { - Id = x.Id, - Name = x.Name, - Hash = x.Hash, - Payload = x.Payload != null ? _bookmarkPayloadSerializer.Serialize(x.Payload) : string.Empty, - ActivityId = x.ActivityId, - ActivityNodeId = x.ActivityNodeId, - ActivityInstanceId = x.ActivityInstanceId, - AutoBurn = x.AutoBurn, - CallbackMethodName = x.CallbackMethodName.EmptyIfNull(), - CreatedAt = x.CreatedAt.ToString("O") + Id = bookmark.Id, + Name = bookmark.Name, + Hash = bookmark.Hash, + Payload = bookmark.Payload != null ? _bookmarkPayloadSerializer.Serialize(bookmark.Payload) : string.Empty, + ActivityId = bookmark.ActivityId, + ActivityNodeId = bookmark.ActivityNodeId, + ActivityInstanceId = bookmark.ActivityInstanceId, + AutoBurn = bookmark.AutoBurn, + CallbackMethodName = bookmark.CallbackMethodName.EmptyIfNull(), + CreatedAt = bookmark.CreatedAt.ToString("O"), + Metadata = { bookmark.Metadata ?? new Dictionary() } }); - + public IEnumerable Map(IEnumerable source) => - source.Select(x => + source.Select(bookmark => new Bookmark( - x.Id, - x.Name, - x.Hash, - x.Payload.NullIfEmpty(), - x.ActivityId, - x.ActivityNodeId, - x.ActivityInstanceId, - DateTimeOffset.Parse(x.CreatedAt), - x.AutoBurn, - x.CallbackMethodName.NullIfEmpty())); + bookmark.Id, + bookmark.Name, + bookmark.Hash, + bookmark.Payload.NullIfEmpty(), + bookmark.ActivityId, + bookmark.ActivityNodeId, + bookmark.ActivityInstanceId, + DateTimeOffset.Parse(bookmark.CreatedAt), + bookmark.AutoBurn, + bookmark.CallbackMethodName.NullIfEmpty(), + bookmark.Metadata.ToDictionary(x => x.Key, x => x.Value))); } \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.Messages.proto b/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.Messages.proto index a9c8201c6..d2c2f49b6 100644 --- a/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.Messages.proto +++ b/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.Messages.proto @@ -93,7 +93,8 @@ message Bookmark { string ActivityId = 5; string ActivityNodeId = 6; string ActivityInstanceId = 7; - optional bool AutoBurn = 8; - optional string CallbackMethodName = 9; - string CreatedAt = 10; // ISO 8601 + map Metadata = 8; + optional bool AutoBurn = 9; + optional string CallbackMethodName = 10; + string CreatedAt = 11; // ISO 8601 } \ No newline at end of file diff --git a/src/modules/Elsa.Telnyx/Activities/CallAnswered.cs b/src/modules/Elsa.Telnyx/Activities/CallAnswered.cs index 4d3d6c869..e0267c8cc 100644 --- a/src/modules/Elsa.Telnyx/Activities/CallAnswered.cs +++ b/src/modules/Elsa.Telnyx/Activities/CallAnswered.cs @@ -34,7 +34,7 @@ public class CallAnswered : Activity foreach (var callControlId in callControlIds) { var payload = new CallAnsweredBookmarkPayload(callControlId); - context.CreateBookmark(new BookmarkOptions(payload, Resume, Type, IncludeActivityInstanceId: false)); + context.CreateBookmark(new CreateBookmarkArgs(payload, Resume, Type, IncludeActivityInstanceId: false)); } } diff --git a/src/modules/Elsa.Telnyx/Activities/CallHangup.cs b/src/modules/Elsa.Telnyx/Activities/CallHangup.cs index babc59575..fd644b226 100644 --- a/src/modules/Elsa.Telnyx/Activities/CallHangup.cs +++ b/src/modules/Elsa.Telnyx/Activities/CallHangup.cs @@ -34,7 +34,7 @@ public class CallHangup : Activity foreach (var callControlId in callControlIds) { var payload = new CallHangupBookmarkPayload(callControlId); - context.CreateBookmark(new BookmarkOptions(payload, Resume, Type, IncludeActivityInstanceId: false)); + context.CreateBookmark(new CreateBookmarkArgs(payload, Resume, Type, IncludeActivityInstanceId: false)); } } diff --git a/src/modules/Elsa.Telnyx/Activities/WebhookEvent.cs b/src/modules/Elsa.Telnyx/Activities/WebhookEvent.cs index d987f15cd..e0a93cbca 100644 --- a/src/modules/Elsa.Telnyx/Activities/WebhookEvent.cs +++ b/src/modules/Elsa.Telnyx/Activities/WebhookEvent.cs @@ -48,7 +48,7 @@ public class WebhookEvent : Activity var eventType = EventType; var payload = new WebhookEventBookmarkPayload(eventType); - context.CreateBookmark(new BookmarkOptions(payload, Resume, Type, false)); + context.CreateBookmark(new CreateBookmarkArgs(payload, Resume, Type, false)); } } diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 07014a818..a02080cc4 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -276,7 +276,7 @@ public class ActivityExecutionContext : IExecutionContext public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegate? callback = default, bool includeActivityInstanceId = true) { foreach (var payload in payloads) - CreateBookmark(new BookmarkOptions(payload, callback, IncludeActivityInstanceId: includeActivityInstanceId)); + CreateBookmark(new CreateBookmarkArgs(payload, callback, IncludeActivityInstanceId: includeActivityInstanceId)); } /// @@ -295,8 +295,12 @@ public class ActivityExecutionContext : IExecutionContext /// Creates a bookmark so that this activity can be resumed at a later time. /// /// An optional callback that is invoked when the bookmark is resumed. + /// Custom properties to associate with the bookmark. /// The created bookmark. - public Bookmark CreateBookmark(ExecuteActivityDelegate callback) => CreateBookmark(new BookmarkOptions(default, callback)); + public Bookmark CreateBookmark(ExecuteActivityDelegate callback, IDictionary? metadata = default) + { + return CreateBookmark(new CreateBookmarkArgs(default, callback, Metadata: metadata)); + } /// /// Creates a bookmark so that this activity can be resumed at a later time. @@ -304,21 +308,29 @@ public class ActivityExecutionContext : IExecutionContext /// The payload to associate with the bookmark. /// An optional callback that is invoked when the bookmark is resumed. /// Whether or not the activity instance ID should be included in the bookmark payload. + /// Custom properties to associate with the bookmark. /// The created bookmark. - public Bookmark CreateBookmark(object payload, ExecuteActivityDelegate callback, bool includeActivityInstanceId = true) => CreateBookmark(new BookmarkOptions(payload, callback, IncludeActivityInstanceId: includeActivityInstanceId)); + public Bookmark CreateBookmark(object payload, ExecuteActivityDelegate callback, bool includeActivityInstanceId = true, IDictionary? customProperties = default) + { + return CreateBookmark(new CreateBookmarkArgs(payload, callback, IncludeActivityInstanceId: includeActivityInstanceId, Metadata: customProperties)); + } /// /// Creates a bookmark so that this activity can be resumed at a later time. /// /// The payload to associate with the bookmark. + /// Custom properties to associate with the bookmark. /// The created bookmark. - public Bookmark CreateBookmark(object payload) => CreateBookmark(new BookmarkOptions(payload)); + public Bookmark CreateBookmark(object payload, IDictionary? metadata = default) + { + return CreateBookmark(new CreateBookmarkArgs(payload, Metadata: metadata)); + } /// /// Creates a bookmark so that this activity can be resumed at a later time. /// Creating a bookmark will automatically suspend the workflow after all pending activities have executed. /// - public Bookmark CreateBookmark(BookmarkOptions? options = default) + public Bookmark CreateBookmark(CreateBookmarkArgs? options = default) { var payload = options?.Payload; var callback = options?.Callback; @@ -338,7 +350,8 @@ public class ActivityExecutionContext : IExecutionContext Id, _systemClock.UtcNow, options?.AutoBurn ?? true, - callback?.Method.Name); + callback?.Method.Name, + options?.Metadata); AddBookmark(bookmark); return bookmark; diff --git a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs index 596161e34..68716d5e7 100644 --- a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs +++ b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs @@ -15,6 +15,7 @@ namespace Elsa.Workflows.Core.Models; /// The date and time the bookmark was created. /// Whether or not the bookmark should be automatically burned. /// The name of the method on the activity class to invoke when the bookmark is resumed. +/// Custom properties associated with the bookmark. public record Bookmark( string Id, string Name, @@ -25,12 +26,12 @@ public record Bookmark( string ActivityInstanceId, DateTimeOffset CreatedAt, bool AutoBurn = true, - string? CallbackMethodName = default -) + string? CallbackMethodName = default, + IDictionary? Metadata = default) { /// [JsonConstructor] - public Bookmark() : this("", "", "", null, "", "", "", default) + public Bookmark() : this("", "", "", null, "", "", "", default, default) { } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/BookmarkOptions.cs b/src/modules/Elsa.Workflows.Core/Models/CreateBookmarkArgs.cs similarity index 70% rename from src/modules/Elsa.Workflows.Core/Models/BookmarkOptions.cs rename to src/modules/Elsa.Workflows.Core/Models/CreateBookmarkArgs.cs index 40f73f437..06dcc3a8b 100644 --- a/src/modules/Elsa.Workflows.Core/Models/BookmarkOptions.cs +++ b/src/modules/Elsa.Workflows.Core/Models/CreateBookmarkArgs.cs @@ -10,4 +10,10 @@ namespace Elsa.Workflows.Core.Models; /// An optional name to associate with the bookmark. /// Whether or not the bookmark should be automatically burned when triggered. /// Whether or not the activity instance ID should be included in the bookmark payload. -public record BookmarkOptions(object? Payload = default, ExecuteActivityDelegate? Callback = default, string? BookmarkName = default, bool AutoBurn = true, bool IncludeActivityInstanceId = true); \ No newline at end of file +public record CreateBookmarkArgs( + object? Payload = default, + ExecuteActivityDelegate? Callback = default, + string? BookmarkName = default, + bool AutoBurn = true, + bool IncludeActivityInstanceId = true, + IDictionary? Metadata = default); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs index 7a0e47feb..bb5edffc9 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs @@ -65,7 +65,7 @@ public class DispatchWorkflow : Activity // If we need to wait for the child workflow to complete, create a bookmark. if (waitForCompletion) { - var bookmarkOptions = new BookmarkOptions + var bookmarkOptions = new CreateBookmarkArgs { Callback = OnChildWorkflowCompletedAsync, Payload = new DispatchWorkflowBookmark(instanceId), diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs index 56225a0c2..26a91778a 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/Event.cs @@ -75,7 +75,7 @@ public class Event : Trigger if (!context.IsTriggerOfWorkflow()) { - var options = new BookmarkOptions + var options = new CreateBookmarkArgs { Payload = new EventBookmarkPayload(eventName), IncludeActivityInstanceId = false diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/StoredBookmark.cs b/src/modules/Elsa.Workflows.Runtime/Entities/StoredBookmark.cs index 45e31bd71..8483ba0c9 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/StoredBookmark.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/StoredBookmark.cs @@ -18,7 +18,8 @@ public class StoredBookmark DateTimeOffset createdAt, string? activityInstanceId = default, string? correlationId = default, - object? payload = default) + object? payload = default, + IDictionary? metadata = default) { BookmarkId = bookmarkId; ActivityTypeName = activityTypeName; @@ -28,6 +29,7 @@ public class StoredBookmark ActivityInstanceId = activityInstanceId; CorrelationId = correlationId; Payload = payload; + Metadata = metadata; } /// @@ -73,6 +75,11 @@ public class StoredBookmark /// public object? Payload { get; set; } + /// + /// Custom properties associated with the bookmark. + /// + public IDictionary? Metadata { get; set; } + /// /// The date and time the bookmark was created. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index 30bf3f4d9..69bcd5fe6 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -64,7 +64,7 @@ public class BackgroundActivityInvokerMiddleware : DefaultActivityInvokerMiddlew var workflowInstanceId = context.WorkflowExecutionContext.Id; var activityNodeId = context.NodeId; var bookmarkPayload = new BackgroundActivityBookmark(); - var bookmarkOptions = new BookmarkOptions { BookmarkName = BackgroundActivityBookmarkName, Payload = bookmarkPayload }; + var bookmarkOptions = new CreateBookmarkArgs { BookmarkName = BackgroundActivityBookmarkName, Payload = bookmarkPayload }; var bookmark = context.CreateBookmark(bookmarkOptions); scheduledBackgroundActivities.Add(new ScheduledBackgroundActivity(workflowInstanceId, activityNodeId, bookmark.Id)); } diff --git a/update-migrations.sh b/update-migrations.sh index 6a6774d2d..c72555f06 100644 --- a/update-migrations.sh +++ b/update-migrations.sh @@ -1,7 +1,7 @@ #!/usr/bin/env zsh # Define the modules to update -mods=("Management") +mods=("Runtime") # Define the list of providers providers=("MySql" "SqlServer" "Sqlite" "PostgreSql")