Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public interface IWorkflowDefinitionsApi
/// <param name="versionOptions">The version options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Get("/workflow-definitions?versionOptions={versionOptions}")]
Task<PagedListResponse<WorkflowDefinitionSummary>> ListAsync([Query]ListWorkflowDefinitionsRequest request, [Query]VersionOptions? versionOptions = default, CancellationToken cancellationToken = default);
Task<PagedListResponse<WorkflowDefinitionSummary>> ListAsync([Query]ListWorkflowDefinitionsRequest request, [Query]VersionOptions? versionOptions = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a workflow definition by definition ID.
Expand All @@ -30,7 +30,7 @@ public interface IWorkflowDefinitionsApi
/// <param name="versionOptions">The version options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Get("/workflow-definitions/by-definition-id/{definitionId}?versionOptions={versionOptions}")]
Task<WorkflowDefinition?> GetByDefinitionIdAsync(string definitionId, VersionOptions? versionOptions = default, CancellationToken cancellationToken = default);
Task<WorkflowDefinition?> GetByDefinitionIdAsync(string definitionId, VersionOptions? versionOptions = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a workflow definition by ID.
Expand Down Expand Up @@ -73,14 +73,22 @@ public interface IWorkflowDefinitionsApi
[Get("/workflow-definitions/query/count")]
Task<CountWorkflowDefinitionsResponse> CountAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Gets all workflow definitions that consume (reference) the specified workflow definition, recursively.
/// </summary>
/// <param name="definitionId">The definition ID of the workflow definition to get consumers for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Get("/workflow-definitions/{definitionId}/consumers")]
Task<GetConsumingWorkflowDefinitionsResponse> GetConsumersAsync(string definitionId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a value indicating whether a workflow definition name is unique.
/// </summary>
/// <param name="name">The name to check.</param>
/// <param name="definitionId">The ID of the workflow definition to exclude from the check.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Get("/workflow-definitions/validation/is-name-unique?name={name}")]
Task<GetIsNameUniqueResponse> GetIsNameUniqueAsync(string name, string? definitionId = default, CancellationToken cancellationToken = default);
Task<GetIsNameUniqueResponse> GetIsNameUniqueAsync(string name, string? definitionId = null, CancellationToken cancellationToken = default);

/// <summary>
/// Saves a workflow definition.
Expand Down Expand Up @@ -163,14 +171,15 @@ public interface IWorkflowDefinitionsApi
/// </summary>
/// <param name="definitionId">The ID of the workflow definition to export.</param>
/// <param name="versionOptions">The version options.</param>
/// <param name="includeConsumingWorkflows">Whether to include all workflows that consume (reference) the specified workflow.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Get("/workflow-definitions/{definitionId}/export?versionOptions={versionOptions}")]
Task<IApiResponse<Stream>> ExportAsync(string definitionId, VersionOptions? versionOptions = default, CancellationToken cancellationToken = default);
[Get("/workflow-definitions/{definitionId}/export?versionOptions={versionOptions}&includeConsumingWorkflows={includeConsumingWorkflows}")]
Task<IApiResponse<Stream>> ExportAsync(string definitionId, VersionOptions? versionOptions = null, bool includeConsumingWorkflows = false, CancellationToken cancellationToken = default);

/// <summary>
/// Exports a set of workflow definitions.
/// </summary>
/// <param name="request">The request containing the IDs of the workflow definitions to export.</param>
/// <param name="request">The request containing the IDs of the workflow definitions to export and options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
[Post("/bulk-actions/export/workflow-definitions")]
Task<IApiResponse<Stream>> BulkExportAsync(BulkExportWorkflowDefinitionsRequest request, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
/// A request to bulk export workflow definitions.
/// </summary>
/// <param name="Ids">The version IDs of the workflow definitions to export.</param>
public record BulkExportWorkflowDefinitionsRequest(string[] Ids);
/// <param name="IncludeConsumingWorkflows">Whether to include all workflows that consume (reference) the specified workflows.</param>
public record BulkExportWorkflowDefinitionsRequest(string[] Ids, bool IncludeConsumingWorkflows = false);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Responses;

/// <summary>
/// A response containing the IDs of workflow definitions that consume a specified workflow definition.
/// </summary>
/// <param name="ConsumingWorkflowDefinitionIds">The IDs of consuming workflow definitions.</param>
public record GetConsumingWorkflowDefinitionsResponse(ICollection<string> ConsumingWorkflowDefinitionIds);

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Elsa.Abstractions;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
using JetBrains.Annotations;

namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers;

/// <summary>
/// Returns all workflow definitions that consume the specified workflow definition (recursively).
/// </summary>
[PublicAPI]
internal class Consumers(IWorkflowDefinitionStore store, IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder) : ElsaEndpoint<Request, Response>
{
/// <inheritdoc />
public override void Configure()
{
Get("/workflow-definitions/{definitionId}/consumers");
ConfigurePermissions("read:workflow-definitions");
}

/// <inheritdoc />
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var filter = new WorkflowDefinitionFilter
{
DefinitionId = request.DefinitionId,
VersionOptions = VersionOptions.Latest
};

var definition = await store.FindAsync(filter, cancellationToken);

if (definition == null)
{
await Send.NotFoundAsync(cancellationToken);
return;
}

var graph = await workflowReferenceGraphBuilder.BuildGraphAsync(request.DefinitionId, cancellationToken);
var consumerIds = graph.ConsumerDefinitionIds.ToList();
await Send.OkAsync(new Response(consumerIds), cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Consumers;

internal record Request
{
/// <summary>
/// The workflow definition ID.
/// </summary>
public string DefinitionId { get; set; } = null!;
}

internal record Response(ICollection<string> ConsumingWorkflowDefinitionIds);
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ internal class Export : ElsaEndpoint<Request>
{
private readonly IApiSerializer _serializer;
private readonly IWorkflowDefinitionStore _store;
private readonly IWorkflowReferenceGraphBuilder _workflowReferenceGraphBuilder;
private readonly WorkflowDefinitionMapper _workflowDefinitionMapper;

/// <inheritdoc />
public Export(
IWorkflowDefinitionStore store,
IWorkflowDefinitionService workflowDefinitionService,
IApiSerializer serializer,
WorkflowDefinitionMapper workflowDefinitionMapper,
VariableDefinitionMapper variableDefinitionMapper)
IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder)
{
_store = store;
_serializer = serializer;
_workflowDefinitionMapper = workflowDefinitionMapper;
_workflowReferenceGraphBuilder = workflowReferenceGraphBuilder;
}

/// <inheritdoc />
Expand All @@ -47,49 +48,35 @@ public override void Configure()
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
if (request.DefinitionId != null)
await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, cancellationToken);
await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, request.IncludeConsumingWorkflows, cancellationToken);
else if (request.Ids != null)
await DownloadMultipleWorkflowsAsync(request.Ids, cancellationToken);
await DownloadMultipleWorkflowsAsync(request.Ids, request.IncludeConsumingWorkflows, cancellationToken);
else await Send.NoContentAsync(cancellationToken);
}

private async Task DownloadMultipleWorkflowsAsync(ICollection<string> ids, CancellationToken cancellationToken)
private async Task DownloadMultipleWorkflowsAsync(ICollection<string> ids, bool includeConsumingWorkflows, CancellationToken cancellationToken)
{
List<WorkflowDefinition> definitions = (await _store.FindManyAsync(new()
var definitions = (await _store.FindManyAsync(new()
{
Ids = ids
}, cancellationToken)).ToList();

if (includeConsumingWorkflows)
definitions = await IncludeConsumersAsync(definitions, cancellationToken);

if (!definitions.Any())
{
await Send.NoContentAsync(cancellationToken);
return;
}

var zipStream = new MemoryStream();
using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
{
// Create a JSON file for each workflow definition:
foreach (var definition in definitions)
{
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
var fileName = GetFileName(model);
var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
await using var entryStream = entry.Open();
await entryStream.WriteAsync(binaryJson, cancellationToken);
}
}

// Send the zip file to the client:
zipStream.Position = 0;
await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken);
await WriteZipResponseAsync(definitions, cancellationToken);
}

private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, CancellationToken cancellationToken)
private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, bool includeConsumingWorkflows, CancellationToken cancellationToken)
{
var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions);
WorkflowDefinition? definition = (await _store.FindManyAsync(new()
var definition = (await _store.FindManyAsync(new()
{
DefinitionId = definitionId,
VersionOptions = parsedVersionOptions
Expand All @@ -101,18 +88,93 @@ private async Task DownloadSingleWorkflowAsync(string definitionId, string? vers
return;
}

if (includeConsumingWorkflows)
{
var definitions = await IncludeConsumersAsync([definition], cancellationToken);
await WriteZipResponseAsync(definitions, cancellationToken);
return;
}

var model = await CreateWorkflowModelAsync(definition, cancellationToken);
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
var fileName = GetFileName(model);

await Send.BytesAsync(binaryJson, fileName, cancellation: cancellationToken);
}

/// <summary>
/// Recursively discovers all consuming workflow definitions and includes them.
/// Consumers are always resolved at <see cref="VersionOptions.Latest"/>, regardless of the version used for the initial definitions.
/// </summary>
private async Task<List<WorkflowDefinition>> IncludeConsumersAsync(List<WorkflowDefinition> definitions, CancellationToken cancellationToken)
{
var initialDefinitionIds = definitions.Select(d => d.DefinitionId).ToList();
var graph = await _workflowReferenceGraphBuilder.BuildGraphAsync(initialDefinitionIds, cancellationToken);

// Find any consumer definitions not already in our list.
var newDefinitionIds = graph.ConsumerDefinitionIds.Except(initialDefinitionIds).ToList();

if (newDefinitionIds.Count > 0)
{
var consumerDefinitions = await _store.FindManyAsync(new WorkflowDefinitionFilter
{
DefinitionIds = newDefinitionIds.ToArray(),
VersionOptions = VersionOptions.Latest
}, cancellationToken);

definitions = definitions.Concat(consumerDefinitions).ToList();
}

return definitions;
}

private async Task WriteZipResponseAsync(List<WorkflowDefinition> definitions, CancellationToken cancellationToken)
{
var zipStream = new MemoryStream();
var sortedDefinitions = definitions.OrderBy(d => d.DefinitionId).ToList();

#if NET10_0_OR_GREATER
await using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
{
// Create a JSON file for each workflow definition:
foreach (var definition in sortedDefinitions)
{
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
var fileName = GetFileName(model);
var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
entry.LastWriteTime = DateTimeOffset.UnixEpoch;
await using var entryStream = await entry.OpenAsync(cancellationToken);
await entryStream.WriteAsync(binaryJson, cancellationToken);
}
}
#else
using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
{
// Create a JSON file for each workflow definition:
foreach (var definition in sortedDefinitions)
{
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
var fileName = GetFileName(model);
var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
entry.LastWriteTime = DateTimeOffset.UnixEpoch;
await using var entryStream = entry.Open();
await entryStream.WriteAsync(binaryJson, cancellationToken);
}
}
#endif

// Send the zip file to the client:
zipStream.Position = 0;
await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken);
}

private string GetFileName(WorkflowDefinitionModel definition)
{
var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name);
var workflowName = hasWorkflowName ? definition.Name!.Trim() : definition.DefinitionId;
var fileName = $"workflow-definition-{workflowName.Underscore().Dasherize().ToLowerInvariant()}.json";
var fileName = $"workflow-definition-{workflowName.Underscore().Dasherize().ToLowerInvariant()}-{definition.DefinitionId}.json";
return fileName;
}

Expand All @@ -134,8 +196,7 @@ private async Task<byte[]> SerializeWorkflowDefinitionAsync(WorkflowDefinitionMo
writer.WriteEndObject();

await writer.FlushAsync(cancellationToken);
var binaryJson = output.ToArray();
return binaryJson;
return output.ToArray();
}

private async Task<WorkflowDefinitionModel> CreateWorkflowModelAsync(WorkflowDefinition definition, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ internal class Request
/// <summary>
/// The workflow definition ID.
/// </summary>
public string? DefinitionId { get; set; } = default!;
public string? DefinitionId { get; set; } = null!;

/// <summary>
/// The version options.
Expand All @@ -19,5 +19,10 @@ internal class Request
/// <summary>
/// A list of workflow definition version IDs.
/// </summary>
public ICollection<string>? Ids { get; set; } = default!;
public ICollection<string>? Ids { get; set; } = null!;

/// <summary>
/// When true, automatically includes all consuming workflow definitions in the export.
/// </summary>
public bool IncludeConsumingWorkflows { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Elsa.Workflows.Management.Models;

namespace Elsa.Workflows.Management;

/// <summary>
/// Builds a complete graph of workflow references by recursively discovering all workflows
/// that consume (directly or indirectly) a given workflow definition.
/// </summary>
public interface IWorkflowReferenceGraphBuilder
{
/// <summary>
/// Builds a complete reference graph starting from the specified workflow definition.
/// </summary>
/// <param name="definitionId">The ID of the workflow definition to start from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="WorkflowReferenceGraph"/> containing all reference relationships.</returns>
Task<WorkflowReferenceGraph> BuildGraphAsync(string definitionId, CancellationToken cancellationToken = default);

/// <summary>
/// Builds a complete reference graph starting from multiple workflow definitions.
/// </summary>
/// <param name="definitionIds">The IDs of the workflow definitions to start from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A merged <see cref="WorkflowReferenceGraph"/> containing all reference relationships from all starting points.</returns>
Task<WorkflowReferenceGraph> BuildGraphAsync(IEnumerable<string> definitionIds, CancellationToken cancellationToken = default);
}

Loading