WriteHttpFileResponse Activity (#4445)

* Incremental work on WriteFileHttpResponse activity

* BREAKING Schema change - Increase bookmark flexibility

* Implement zipped file response
This commit is contained in:
Sipke Schoorstra 2023-09-16 11:06:56 +02:00 committed by GitHub
parent 12f9e0844b
commit bb2fa55d0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 797 additions and 120 deletions

View file

@ -3,6 +3,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=abcdefghijklmnopqrstuvwxyz/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Cronos/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Deconstructor/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=downloadables/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=granularly/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Initializable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=materializers/@EntryIndexedValue">True</s:Boolean>

View file

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

View file

@ -23,7 +23,8 @@ public record Bookmark(
string ActivityNodeId,
string ActivityInstanceId,
bool AutoBurn = true,
string? CallbackMethodName = default
string? CallbackMethodName = default,
IDictionary<string, string>? Metadata = default
)
{
/// <inheritdoc />

View file

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

View file

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

View file

@ -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<StoredBookmark> Map(IEnumerable<StoredBookmarkRecord> 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<Dictionary<string, string>>(source.SerializedMetadata) : default,
CreatedAt = source.CreatedAt
};
}

View file

@ -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
{
/// <inheritdoc />
@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("SerializedMetadata")
.HasColumnType("longtext");
b.Property<string>("SerializedPayload")
.HasColumnType("longtext");

View file

@ -70,6 +70,8 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
CorrelationId = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
SerializedMetadata = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SerializedPayload = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},

View file

@ -124,6 +124,9 @@ namespace Elsa.EntityFrameworkCore.MySql.Migrations.Runtime
.IsRequired()
.HasColumnType("varchar(255)");
b.Property<string>("SerializedMetadata")
.HasColumnType("longtext");
b.Property<string>("SerializedPayload")
.HasColumnType("longtext");

View file

@ -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
{
/// <inheritdoc />
@ -130,6 +130,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
.IsRequired()
.HasColumnType("text");
b.Property<string>("SerializedMetadata")
.HasColumnType("text");
b.Property<string>("SerializedPayload")
.HasColumnType("text");

View file

@ -51,6 +51,7 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
ActivityInstanceId = table.Column<string>(type: "text", nullable: true),
CorrelationId = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
SerializedMetadata = table.Column<string>(type: "text", nullable: true),
SerializedPayload = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>

View file

@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.PostgreSql.Migrations.Runtime
.IsRequired()
.HasColumnType("text");
b.Property<string>("SerializedMetadata")
.HasColumnType("text");
b.Property<string>("SerializedPayload")
.HasColumnType("text");

View file

@ -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
{
/// <inheritdoc />
@ -130,6 +130,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SerializedMetadata")
.HasColumnType("nvarchar(max)");
b.Property<string>("SerializedPayload")
.HasColumnType("nvarchar(max)");

View file

@ -51,6 +51,7 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
ActivityInstanceId = table.Column<string>(type: "nvarchar(450)", nullable: true),
CorrelationId = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
SerializedMetadata = table.Column<string>(type: "nvarchar(max)", nullable: true),
SerializedPayload = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>

View file

@ -127,6 +127,9 @@ namespace Elsa.EntityFrameworkCore.SqlServer.Migrations.Runtime
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SerializedMetadata")
.HasColumnType("nvarchar(max)");
b.Property<string>("SerializedPayload")
.HasColumnType("nvarchar(max)");

View file

@ -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
{
/// <inheritdoc />
@ -125,6 +125,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SerializedMetadata")
.HasColumnType("TEXT");
b.Property<string>("SerializedPayload")
.HasColumnType("TEXT");

View file

@ -45,6 +45,7 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
ActivityInstanceId = table.Column<string>(type: "TEXT", nullable: true),
CorrelationId = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<string>(type: "TEXT", nullable: false),
SerializedMetadata = table.Column<string>(type: "TEXT", nullable: true),
SerializedPayload = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>

View file

@ -122,6 +122,9 @@ namespace Elsa.EntityFrameworkCore.Sqlite.Migrations.Runtime
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SerializedMetadata")
.HasColumnType("TEXT");
b.Property<string>("SerializedPayload")
.HasColumnType("TEXT");

View file

@ -31,10 +31,11 @@ public class EFCoreBookmarkStore : IBookmarkStore
/// <inheritdoc />
public async ValueTask<long> 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<string>("SerializedPayload").CurrentValue;
entity.Payload = !string.IsNullOrEmpty(json) ? _serializer.Deserialize(json) : null;
var payloadJson = dbContext.Entry(entity).Property<string>("SerializedPayload").CurrentValue;
var metadataJson = dbContext.Entry(entity).Property<string>("SerializedMetadata").CurrentValue;
entity.Payload = !string.IsNullOrEmpty(payloadJson) ? _serializer.Deserialize(payloadJson) : null;
entity.Metadata = !string.IsNullOrEmpty(metadataJson) ? _serializer.Deserialize<Dictionary<string, string>>(metadataJson) : null;
return default;
}
}

View file

@ -78,7 +78,9 @@ public class Configurations :
public void Configure(EntityTypeBuilder<StoredBookmark> builder)
{
builder.Ignore(x => x.Payload);
builder.Ignore(x => x.Metadata);
builder.Property<string>("SerializedPayload");
builder.Property<string>("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)}");

View file

@ -0,0 +1,49 @@
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
namespace Elsa.Http.Abstractions;
/// <summary>
/// Provides a base class for <see cref="IDownloadableProvider"/> implementations.
/// </summary>
public abstract class DownloadableProviderBase : IDownloadableProvider
{
/// <inheritdoc />
public virtual float Priority => 0;
/// <inheritdoc />
public abstract bool GetSupportsContent(object content);
/// <summary>
/// Returns a list of downloadables from the specified content.
/// </summary>
protected virtual async ValueTask<IEnumerable<Downloadable>> GetDownloadablesAsync(DownloadableContext context)
{
var downloadable = await GetDownloadableAsync(context);
return new[]{ downloadable };
}
/// <summary>
/// Returns a downloadable from the specified content.
/// </summary>
protected virtual ValueTask<Downloadable> GetDownloadableAsync(DownloadableContext context)
{
var downloadable = GetDownloadable(context);
return new (downloadable);
}
/// <summary>
/// Returns a downloadable from the specified content.
/// </summary>
/// <exception cref="NotImplementedException">This method is not implemented. The derived class must implement at least one of the GetDownloadable methods.</exception>
protected virtual Downloadable GetDownloadable(DownloadableContext context)
{
throw new NotImplementedException();
}
async ValueTask<IEnumerable<Downloadable>> IDownloadableProvider.GetDownloadablesAsync(DownloadableContext context)
{
return await GetDownloadablesAsync(context);
}
}

View file

@ -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<HttpRequest>
{
// 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;
}

View file

@ -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;
/// <summary>
/// Sends a file to the HTTP response.
/// </summary>
[Activity("Elsa", "HTTP", "Send one ore more files (zipped) to the HTTP response.", DisplayName = "HTTP File Response")]
public class WriteFileHttpResponse : Activity
{
/// <summary>
/// The MIME type of the file to serve.
/// </summary>
[Input(Description = "The content type of the file to serve. Leave empty to let the system determine the content type.")]
public Input<string?> ContentType { get; set; } = default!;
/// <summary>
/// The name of the file to serve.
/// </summary>
[Input(Description = "The name of the file to serve. Leave empty to let the system determine the file name.")]
public Input<string?> FileName { get; set; } = default!;
/// <summary>
/// The file content to serve. Supports byte array, streams, string, Uri and an array of the aforementioned types.
/// </summary>
[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<object> Content { get; set; } = default!;
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
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<Downloadable> 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<Downloadable> 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);
}
/// <summary>
/// Leverages the <see cref="IDownloadableManager"/> to get a list of <see cref="Downloadable"/> instances from the <paramref name="content"/>.
/// </summary>
private async Task<IEnumerable<Downloadable>> GetDownloadablesAsync(ActivityExecutionContext context, object content)
{
var manager = context.GetRequiredService<IDownloadableManager>();
return await manager.GetDownloadablesAsync(content, context.CancellationToken);
}
private string GetContentType(ActivityExecutionContext context, string filename)
{
var provider = context.GetService<IContentTypeProvider>() ?? new FileExtensionContentTypeProvider();
return provider.TryGetContentType(filename, out var contentType) ? contentType : "application/octet-stream";
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// We're not in an HTTP context, so let's fail.
throw new FaultException("Cannot execute in a non-HTTP context");
}
await WriteResponseAsync(context, httpContext.Response);
}
}

View file

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

View file

@ -0,0 +1,17 @@
namespace Elsa.Http;
/// <summary>
/// Provides metadata for HTTP bookmarks.
/// </summary>
public static class BookmarkMetadata
{
/// <summary>
/// The metadata key for cross-HTTP boundary activity execution.
/// </summary>
public const string HttpCrossBoundaryMetadataKey = "X-HttpCrossBoundary";
/// <summary>
/// Provides metadata for cross-HTTP boundary activity execution.
/// </summary>
public static readonly IDictionary<string, string> HttpCrossBoundary = new Dictionary<string, string> { [HttpCrossBoundaryMetadataKey] = "true" };
}

View file

@ -0,0 +1,60 @@
using System.Text.Json.Serialization;
using Elsa.Workflows.Core.Attributes;
namespace Elsa.Http.Bookmarks;
/// <summary>
/// A bookmark used by the <see cref="HttpEndpoint"/> activity.
/// </summary>
public record HttpEndpointBookmarkPayload
{
private readonly string _path = default!;
private readonly string _method = default!;
/// <summary>
/// Initializes a new instance of the <see cref="HttpEndpointBookmarkPayload"/> class.
/// </summary>
[JsonConstructor]
public HttpEndpointBookmarkPayload()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="HttpEndpointBookmarkPayload"/> class.
/// </summary>
public HttpEndpointBookmarkPayload(string path, string method, bool? authorize = default, string? policy = default)
{
Path = path;
Method = method;
Authorize = authorize;
Policy = policy;
}
/// <summary>
/// Gets or sets the path of the HTTP endpoint.
/// </summary>
public string Path
{
get => _path;
init => _path = value.ToLowerInvariant();
}
/// <summary>
/// Gets or sets the HTTP method of the endpoint.
/// </summary>
public string Method
{
get => _method;
init => _method = value.ToLowerInvariant();
}
/// <summary>
/// Gets or sets the policy to use for authorization.
/// </summary>
[ExcludeFromHash] public string? Policy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the endpoint requires authorization.
/// </summary>
[ExcludeFromHash] public bool? Authorize { get; set; }
}

View file

@ -0,0 +1,11 @@
using Elsa.Http.Contracts;
namespace Elsa.Http.Contexts;
/// <summary>
/// Provides context for downloadable providers.
/// </summary>
/// <param name="Manager">The manager.</param>
/// <param name="Content">The content to get downloadables from.</param>
/// <param name="CancellationToken">The cancellation token.</param>
public record DownloadableContext(IDownloadableManager Manager, object Content, CancellationToken CancellationToken);

View file

@ -0,0 +1,14 @@
using Elsa.Http.Models;
namespace Elsa.Http.Contracts;
/// <summary>
/// Provides downloadables from the specified content, if supported.
/// </summary>
public interface IDownloadableManager
{
/// <summary>
/// Returns a list of downloadables from the specified content.
/// </summary>
ValueTask<IEnumerable<Downloadable>> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,26 @@
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.Contracts;
/// <summary>
/// Provides downloadables from the specified content, if supported.
/// </summary>
public interface IDownloadableProvider
{
/// <summary>
/// The priority of this provider. Providers with lower priority are tried first.
/// </summary>
float Priority { get; }
/// <summary>
/// Returns true if this provider supports the specified content.
/// </summary>
/// <param name="content">The content to check.</param>
bool GetSupportsContent(object content);
/// <summary>
/// Returns a list of downloadables from the specified content.
/// </summary>
ValueTask<IEnumerable<Downloadable>> GetDownloadablesAsync(DownloadableContext context);
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Http.Contracts;
/// <summary>
/// A general-purpose downloader of files from a given URL.
/// </summary>
public interface IFileDownloader
{
/// <summary>
/// Downloads a file from the specified URL.
/// </summary>
Task<HttpResponseMessage> DownloadAsync(Uri url, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,24 @@
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.DownloadableProviders;
/// <summary>
/// Handles content that represents a downloadable binary file.
/// </summary>
public class BinaryDownloadableProvider : DownloadableProviderBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is byte[];
/// <inheritdoc />
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);
}
}

View file

@ -0,0 +1,20 @@
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.DownloadableProviders;
/// <summary>
/// Handles content that represents a downloadable.
/// </summary>
public class DownloadableDownloadableProvider : DownloadableProviderBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is Downloadable;
/// <inheritdoc />
protected override Downloadable GetDownloadable(DownloadableContext context)
{
return (Downloadable) context.Content;
}
}

View file

@ -0,0 +1,32 @@
using System.Collections;
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.DownloadableProviders;
/// <summary>
/// Handles content that represents a list of downloadable objects.
/// </summary>
public class MultiDownloadableProvider : DownloadableProviderBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is IEnumerable enumerable and not string;
/// <inheritdoc />
protected override async ValueTask<IEnumerable<Downloadable>> GetDownloadablesAsync(DownloadableContext context)
{
var collectedDownloadables = new List<Downloadable>();
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;
}
}

View file

@ -0,0 +1,23 @@
using Elsa.Http.Abstractions;
using Elsa.Http.Contexts;
using Elsa.Http.Models;
namespace Elsa.Http.DownloadableProviders;
/// <summary>
/// Handles content that represents a downloadable stream.
/// </summary>
public class StreamDownloadableProvider : DownloadableProviderBase
{
/// <inheritdoc />
public override bool GetSupportsContent(object content) => content is Stream;
/// <inheritdoc />
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);
}
}

View file

@ -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;
/// <summary>
/// Handles content that represents a downloadable URL.
/// </summary>
public class UrlDownloadableProvider : DownloadableProviderBase
{
private readonly IFileDownloader _fileDownloader;
/// <inheritdoc />
public UrlDownloadableProvider(IFileDownloader fileDownloader)
{
_fileDownloader = fileDownloader;
}
/// <inheritdoc />
public override bool GetSupportsContent(object content) => (content is string url && url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) || content is Uri;
/// <inheritdoc />
protected override async ValueTask<Downloadable> 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="?(?<filename>[^";]*)"?""");
return match.Success ? match.Groups["filename"].Value : null;
}
}

View file

@ -27,8 +27,11 @@
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
<ProjectReference Include="..\Elsa.JavaScript\Elsa.JavaScript.csproj" />
</ItemGroup>
</Project>

View file

@ -1,4 +1,5 @@
using Elsa.Http;
using Elsa.Http.Bookmarks;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
using Elsa.Workflows.Core.Helpers;

View file

@ -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<IActivityPortResolver, SendHttpRequestActivityPortResolver>()
// Add HTTP endpoint handlers.
// HTTP endpoint handlers.
.AddSingleton<AuthenticationBasedHttpEndpointAuthorizationHandler>()
.AddSingleton<AllowAnonymousHttpEndpointAuthorizationHandler>()
.AddSingleton<DefaultHttpEndpointWorkflowFaultHandler>()
.AddSingleton(HttpEndpointWorkflowFaultHandler)
.AddSingleton(HttpEndpointAuthorizationHandler)
// File related services.
.AddSingleton<IDownloadableManager, DefaultDownloadableManager>()
.AddSingleton<IDownloadableProvider, BinaryDownloadableProvider>()
.AddSingleton<IDownloadableProvider, DownloadableDownloadableProvider>()
.AddSingleton<IDownloadableProvider, MultiDownloadableProvider>()
.AddSingleton<IDownloadableProvider, StreamDownloadableProvider>()
.AddSingleton<IDownloadableProvider, UrlDownloadableProvider>()
// Add mediator handlers.
.AddNotificationHandlersFrom<HttpFeature>()
@ -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<IFileDownloader, HttpClientFileDownloader>();
// Add selectors.
foreach (var httpCorrelationIdSelectorType in HttpCorrelationIdSelectorTypes)

View file

@ -1,4 +1,5 @@
using Elsa.Extensions;
using Elsa.Http.Bookmarks;
using Elsa.Http.Models;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Core.Helpers;

View file

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

View file

@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace Elsa.Http.Models;
/// <summary>
/// Represents a downloadable object.
/// </summary>
public class Downloadable
{
/// <summary>
/// Initializes a new instance of the <see cref="Downloadable"/> class.
/// </summary>
[JsonConstructor]
public Downloadable()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Downloadable"/> class.
/// </summary>
/// <param name="stream">The stream to download.</param>
/// <param name="filename">The filename to use when downloading the stream.</param>
/// <param name="contentType">The content type to use when downloading the stream.</param>
public Downloadable(Stream stream, string? filename = default, string? contentType = default)
{
Stream = stream;
Filename = filename;
ContentType = contentType;
}
/// <summary>
/// The stream to download.
/// </summary>
public Stream Stream { get; set; } = default!;
/// <summary>
/// The filename to use when downloading the stream.
/// </summary>
public string? Filename { get; set; }
/// <summary>
/// The content type to use when downloading the stream.
/// </summary>
public string? ContentType { get; set; }
}

View file

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

View file

@ -18,7 +18,7 @@ internal class WriteHttpResponseContentTypeOptionsProvider : IActivityPropertyOp
{
var options = new Dictionary<string, object>
{
["AvailableContentTypes"] = new[] { "" }.Concat(_options.AvailableContentTypes)
["items"] = new[] { "" }.Concat(_options.AvailableContentTypes)
};
return new(options);

View file

@ -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;
/// <summary>
/// Configures the JavaScript engine with workflow input getters.
/// Configures the JavaScript engine with additional .NET types that can be instantiated.
/// </summary>
[PublicAPI]
public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>
public class HttpJavaScriptHandler : INotificationHandler<EvaluatingJavaScript>, ITypeDefinitionProvider
{
private readonly ITypeDescriber _typeDescriber;
/// <summary>
/// Initializes a new instance of the <see cref="HttpJavaScriptHandler"/> class.
/// </summary>
public HttpJavaScriptHandler(ITypeDescriber typeDescriber)
{
_typeDescriber = typeDescriber;
}
/// <inheritdoc />
public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken)
{
var engine = notification.Engine;
engine.RegisterType<HttpRequestHeaders>();
engine.RegisterType<Downloadable>();
return Task.CompletedTask;
}
/// <inheritdoc />
public ValueTask<IEnumerable<TypeDefinition>> GetTypeDefinitionsAsync(TypeDefinitionContext context)
{
var definitions = GetTypeDefinitions(context);
return new(definitions);
}
private IEnumerable<TypeDefinition> GetTypeDefinitions(TypeDefinitionContext context)
{
yield return _typeDescriber.DescribeType(typeof(HttpRequestHeaders));
yield return _typeDescriber.DescribeType(typeof(Downloadable));
}
}

View file

@ -0,0 +1,33 @@
using Elsa.Http.Contexts;
using Elsa.Http.Contracts;
using Elsa.Http.Models;
namespace Elsa.Http.Services;
/// <inheritdoc />
public class DefaultDownloadableManager : IDownloadableManager
{
private readonly IEnumerable<IDownloadableProvider> _providers;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultDownloadableManager"/> class.
/// </summary>
public DefaultDownloadableManager(IEnumerable<IDownloadableProvider> providers)
{
_providers = providers.OrderBy(x => x.Priority).ToList();
}
/// <inheritdoc />
public async ValueTask<IEnumerable<Downloadable>> GetDownloadablesAsync(object content, CancellationToken cancellationToken = default)
{
var provider = _providers.FirstOrDefault(x => x.GetSupportsContent(content));
if (provider == null)
return Enumerable.Empty<Downloadable>();
var context = new DownloadableContext(this, content, cancellationToken);
var downloadables = await provider.GetDownloadablesAsync(context);
return downloadables;
}
}

View file

@ -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;
}
/// <inheritdoc />
public async Task<IEnumerable<WorkflowState>> ProcessBookmarks(
IEnumerable<WorkflowExecutionResult> 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<HttpEndpoint>();
var writeHttpResponseTypeName = ActivityTypeNameHelper.GenerateTypeName<WriteHttpResponse>();
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;

View file

@ -0,0 +1,25 @@
using Elsa.Http.Contracts;
namespace Elsa.Http.Services;
/// <summary>
/// A general-purpose downloader of files from a given URL that uses <see cref="HttpClient"/>.
/// </summary>
public class HttpClientFileDownloader : IFileDownloader
{
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="HttpClientFileDownloader"/> class.
/// </summary>
public HttpClientFileDownloader(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <inheritdoc />
public async Task<HttpResponseMessage> DownloadAsync(Uri url, CancellationToken cancellationToken = default)
{
return await _httpClient.GetAsync(url, cancellationToken);
}
}

View file

@ -19,35 +19,37 @@ internal class BookmarkMapper
{
_bookmarkPayloadSerializer = bookmarkPayloadSerializer;
}
public IEnumerable<ProtoBookmark> Map(IEnumerable<Bookmark> source) =>
source.Select(x =>
public IEnumerable<ProtoBookmark> Map(IEnumerable<Bookmark> 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<string, string>() }
});
public IEnumerable<Bookmark> Map(IEnumerable<ProtoBookmark> 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)));
}

View file

@ -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<string, string> Metadata = 8;
optional bool AutoBurn = 9;
optional string CallbackMethodName = 10;
string CreatedAt = 11; // ISO 8601
}

View file

@ -34,7 +34,7 @@ public class CallAnswered : Activity<CallAnsweredPayload>
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));
}
}

View file

@ -34,7 +34,7 @@ public class CallHangup : Activity<CallHangupPayload>
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));
}
}

View file

@ -48,7 +48,7 @@ public class WebhookEvent : Activity<Payload>
var eventType = EventType;
var payload = new WebhookEventBookmarkPayload(eventType);
context.CreateBookmark(new BookmarkOptions(payload, Resume, Type, false));
context.CreateBookmark(new CreateBookmarkArgs(payload, Resume, Type, false));
}
}

View file

@ -276,7 +276,7 @@ public class ActivityExecutionContext : IExecutionContext
public void CreateBookmarks(IEnumerable<object> 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));
}
/// <summary>
@ -295,8 +295,12 @@ public class ActivityExecutionContext : IExecutionContext
/// Creates a bookmark so that this activity can be resumed at a later time.
/// </summary>
/// <param name="callback">An optional callback that is invoked when the bookmark is resumed.</param>
/// <param name="metadata">Custom properties to associate with the bookmark.</param>
/// <returns>The created bookmark.</returns>
public Bookmark CreateBookmark(ExecuteActivityDelegate callback) => CreateBookmark(new BookmarkOptions(default, callback));
public Bookmark CreateBookmark(ExecuteActivityDelegate callback, IDictionary<string, string>? metadata = default)
{
return CreateBookmark(new CreateBookmarkArgs(default, callback, Metadata: metadata));
}
/// <summary>
/// Creates a bookmark so that this activity can be resumed at a later time.
@ -304,21 +308,29 @@ public class ActivityExecutionContext : IExecutionContext
/// <param name="payload">The payload to associate with the bookmark.</param>
/// <param name="callback">An optional callback that is invoked when the bookmark is resumed.</param>
/// <param name="includeActivityInstanceId">Whether or not the activity instance ID should be included in the bookmark payload.</param>
/// <param name="customProperties">Custom properties to associate with the bookmark.</param>
/// <returns>The created bookmark.</returns>
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<string, string>? customProperties = default)
{
return CreateBookmark(new CreateBookmarkArgs(payload, callback, IncludeActivityInstanceId: includeActivityInstanceId, Metadata: customProperties));
}
/// <summary>
/// Creates a bookmark so that this activity can be resumed at a later time.
/// </summary>
/// <param name="payload">The payload to associate with the bookmark.</param>
/// <param name="metadata">Custom properties to associate with the bookmark.</param>
/// <returns>The created bookmark.</returns>
public Bookmark CreateBookmark(object payload) => CreateBookmark(new BookmarkOptions(payload));
public Bookmark CreateBookmark(object payload, IDictionary<string, string>? metadata = default)
{
return CreateBookmark(new CreateBookmarkArgs(payload, Metadata: metadata));
}
/// <summary>
/// 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.
/// </summary>
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;

View file

@ -15,6 +15,7 @@ namespace Elsa.Workflows.Core.Models;
/// <param name="CreatedAt">The date and time the bookmark was created.</param>
/// <param name="AutoBurn">Whether or not the bookmark should be automatically burned.</param>
/// <param name="CallbackMethodName">The name of the method on the activity class to invoke when the bookmark is resumed.</param>
/// <param name="Metadata">Custom properties associated with the bookmark.</param>
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<string, string>? Metadata = default)
{
/// <inheritdoc />
[JsonConstructor]
public Bookmark() : this("", "", "", null, "", "", "", default)
public Bookmark() : this("", "", "", null, "", "", "", default, default)
{
}
}

View file

@ -10,4 +10,10 @@ namespace Elsa.Workflows.Core.Models;
/// <param name="BookmarkName">An optional name to associate with the bookmark.</param>
/// <param name="AutoBurn">Whether or not the bookmark should be automatically burned when triggered.</param>
/// <param name="IncludeActivityInstanceId">Whether or not the activity instance ID should be included in the bookmark payload.</param>
public record BookmarkOptions(object? Payload = default, ExecuteActivityDelegate? Callback = default, string? BookmarkName = default, bool AutoBurn = true, bool IncludeActivityInstanceId = true);
public record CreateBookmarkArgs(
object? Payload = default,
ExecuteActivityDelegate? Callback = default,
string? BookmarkName = default,
bool AutoBurn = true,
bool IncludeActivityInstanceId = true,
IDictionary<string, string>? Metadata = default);

View file

@ -65,7 +65,7 @@ public class DispatchWorkflow : Activity<object>
// 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),

View file

@ -75,7 +75,7 @@ public class Event : Trigger<object?>
if (!context.IsTriggerOfWorkflow())
{
var options = new BookmarkOptions
var options = new CreateBookmarkArgs
{
Payload = new EventBookmarkPayload(eventName),
IncludeActivityInstanceId = false

View file

@ -18,7 +18,8 @@ public class StoredBookmark
DateTimeOffset createdAt,
string? activityInstanceId = default,
string? correlationId = default,
object? payload = default)
object? payload = default,
IDictionary<string, string>? metadata = default)
{
BookmarkId = bookmarkId;
ActivityTypeName = activityTypeName;
@ -28,6 +29,7 @@ public class StoredBookmark
ActivityInstanceId = activityInstanceId;
CorrelationId = correlationId;
Payload = payload;
Metadata = metadata;
}
/// <summary>
@ -73,6 +75,11 @@ public class StoredBookmark
/// </summary>
public object? Payload { get; set; }
/// <summary>
/// Custom properties associated with the bookmark.
/// </summary>
public IDictionary<string, string>? Metadata { get; set; }
/// <summary>
/// The date and time the bookmark was created.
/// </summary>

View file

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

View file

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