diff --git a/Directory.Packages.props b/Directory.Packages.props index 986818c65..ca4477da2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,10 +30,10 @@ - + - + diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 206866f3d..9d537dd43 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -37,9 +37,6 @@ Always - - Always - diff --git a/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa deleted file mode 100644 index 39439a8da..000000000 --- a/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa +++ /dev/null @@ -1,16 +0,0 @@ -use Elsa.Activities.Console; - -workflow FlowchartTest { - flowchart { - var counter = 0; - - entry Start; - - Start: WriteLine("Starting flowchart"); - CheckCondition: WriteLine("Checking condition"); - EndNode: WriteLine("Done!"); - - Start -> CheckCondition; - CheckCondition.Done -> EndNode; - } -} diff --git a/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa deleted file mode 100644 index 05998558e..000000000 --- a/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa +++ /dev/null @@ -1,29 +0,0 @@ -use Elsa.Activities.Console; - -workflow HelloWorldDsl( - DisplayName: "Hello World DSL", - Description: "Demonstrates a simple ElsaScript workflow with metadata.", - DefinitionId: "hello-world-dsl", - DefinitionVersionId: "hello-world-dsl-v1", - Version: 1, - UsableAsActivity: true -) -{ - use expressions js; - - WriteLine("Hello World from Elsa DSL!"); -} - -workflow HelloWorldAsActivity( - DisplayName: "Hello World as Activity", - Description: "Can be embedded as an activity in other workflows.", - DefinitionId: "hello-world-activity", - DefinitionVersionId: "hello-world-activity-v1", - Version: 1, - UsableAsActivity: true -) -{ - use expressions js; - - WriteLine(`Inner hello at ${Date.now()}`); -} diff --git a/src/modules/Elsa.Persistence.EFCore.MySql/Elsa.Persistence.EFCore.MySql.csproj b/src/modules/Elsa.Persistence.EFCore.MySql/Elsa.Persistence.EFCore.MySql.csproj index a96f6299b..3ed3c1a69 100644 --- a/src/modules/Elsa.Persistence.EFCore.MySql/Elsa.Persistence.EFCore.MySql.csproj +++ b/src/modules/Elsa.Persistence.EFCore.MySql/Elsa.Persistence.EFCore.MySql.csproj @@ -1,6 +1,7 @@  - + + net8.0;net9.0 Provides MySQL EF Core migrations for various modules. @@ -8,12 +9,12 @@ - - + + - + \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobExtensions.cs new file mode 100644 index 000000000..c0641b7ad --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobExtensions.cs @@ -0,0 +1,8 @@ +using FluentStorage.Blobs; + +namespace Elsa.Extensions; + +public static class BlobExtensions +{ + public static string GetExtension(this Blob blob) => Path.GetExtension(blob.Name).TrimStart('.'); +} \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobWorkflowFormatHandlerExtensions.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobWorkflowFormatHandlerExtensions.cs new file mode 100644 index 000000000..e2d794dfa --- /dev/null +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Extensions/BlobWorkflowFormatHandlerExtensions.cs @@ -0,0 +1,8 @@ +using Elsa.WorkflowProviders.BlobStorage.Contracts; + +namespace Elsa.Extensions; + +public static class BlobWorkflowFormatHandlerExtensions +{ + public static bool SupportsExtension(this IBlobWorkflowFormatHandler handler, string extension) => handler.SupportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs index bfc28b143..75de5fe9b 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Providers/BlobStorageWorkflowsProvider.cs @@ -1,4 +1,5 @@ -using Elsa.WorkflowProviders.BlobStorage.Contracts; +using Elsa.Extensions; +using Elsa.WorkflowProviders.BlobStorage.Contracts; using Elsa.Workflows.Runtime; using FluentStorage.Blobs; using JetBrains.Annotations; @@ -71,11 +72,13 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider { var blobStorage = _blobStorageProvider.GetBlobStorage(); var content = await blobStorage.ReadTextAsync(blob.FullPath, cancellationToken: cancellationToken); - var contentType = blob.Properties.TryGetValue("ContentType", out var ct) ? ct?.ToString() : null; foreach (var handler in _handlers) { + if (!handler.SupportsExtension(blob.GetExtension())) + continue; + if (!handler.CanHandle(blob, contentType)) continue; diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 28e017ca0..a07f8268e 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -81,32 +81,53 @@ public class ActivityJsonConverter( activityDescriptor = null; activityTypeVersion = 0; - // First try and find the activity by its workflow definition version id. This is a special case when working with the WorkflowDefinitionActivity. - if (activityRoot.TryGetProperty("workflowDefinitionVersionId", out var workflowDefinitionVersionIdElement)) - { - var workflowDefinitionVersionId = workflowDefinitionVersionIdElement.GetString(); - activityDescriptor = activityRegistry.Find(x => - x.CustomProperties.TryGetValue("WorkflowDefinitionVersionId", out var value) && (string?)value == workflowDefinitionVersionId); - activityTypeVersion = activityDescriptor?.Version ?? 0; - } + // First, we check whether the activity type name is a 'well-known' activity; not a workflow-as-activity // If the activity type version is specified, use that to find the activity descriptor. - if (activityDescriptor == null && activityRoot.TryGetProperty("version", out var activityVersionElement)) + if (activityRoot.TryGetProperty("version", out var activityVersionElement)) { activityTypeVersion = activityVersionElement.GetInt32(); activityDescriptor = activityRegistry.Find(activityTypeName, activityTypeVersion); } - // If the activity type version is not specified, use the latest version of the activity descriptor. + // If a version is not specified, or activity with specified version is not found: use the latest version of the activity descriptor. if (activityDescriptor == null) { activityDescriptor = activityRegistry.Find(activityTypeName); activityTypeVersion = activityDescriptor?.Version ?? 0; } + // This is a special case when working with the WorkflowDefinitionActivity: workflowDefinitionVersionId should be used to find the workflow-as-activity. + if (activityRoot.TryGetProperty("workflowDefinitionVersionId", out var workflowDefinitionVersionIdElement)) + { + var activityDescriptorOverride = FindActivityDescriptorByCustomProperty("WorkflowDefinitionVersionId", workflowDefinitionVersionIdElement); + if (activityDescriptorOverride is null) + return activityTypeName; + + activityDescriptor = activityDescriptorOverride; + activityTypeVersion = activityDescriptor.Version; + } + // This is also a special case when working with the WorkflowDefinitionActivity: if no 'well-known' activity could be found, it might be a workflow-as-activity with a workflowDefinitionId + else if (activityDescriptor is null + && activityRoot.TryGetProperty("workflowDefinitionId", out var workflowDefinitionIdElement) + && workflowDefinitionIdElement.ValueKind == JsonValueKind.String) + { + activityDescriptor = FindActivityDescriptorByCustomProperty("WorkflowDefinitionId", workflowDefinitionIdElement); + activityTypeVersion = activityDescriptor?.Version ?? 0; + } + return activityTypeName; } + private ActivityDescriptor? FindActivityDescriptorByCustomProperty(string customPropertyName, JsonElement valueElement) + { + if (valueElement.ValueKind != JsonValueKind.String) + return null; + + var searchValue = valueElement.GetString(); + return activityRegistry.Find(x => x.CustomProperties.TryGetValue(customPropertyName, out var value) && (string?)value == searchValue); + } + private JsonSerializerOptions GetClonedOptions(JsonSerializerOptions options) { var clonedOptions = new JsonSerializerOptions(options); @@ -115,7 +136,7 @@ public class ActivityJsonConverter( clonedOptions.Converters.Add(new ExpressionJsonConverterFactory(expressionDescriptorRegistry)); return clonedOptions; } - + private JsonSerializerOptions GetClonedWriterOptions(JsonSerializerOptions options) { var clonedOptions = GetClonedOptions(options); diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs new file mode 100644 index 000000000..d8969c243 --- /dev/null +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs @@ -0,0 +1,350 @@ +using System.Text.Json; +using Elsa.Common.Serialization; +using Elsa.Expressions.Services; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Models; +using Elsa.Workflows.Serialization.Configurators; +using Elsa.Workflows.Serialization.Converters; +using Elsa.Workflows.Serialization.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Workflows.Core.UnitTests.Serialization.Converters; + +public sealed class ActivityJsonConverterTests +{ + [Fact] + public void When_DeserializeKnownActivity_And_TypeNameSpecified_Then_FindsAndInstantiatesActivity() + { + // Arrange + var activityRegistry = CreateActivityRegistry(WriteLineActivityTypeName, WriteLineActivity); + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, WriteLineActivityJson_WithoutVersion); + + // Assert + Assert.Same(WriteLineActivity, result); + } + + [Fact] + public void When_DeserializeKnownActivity_And_TypeNameSpecified_And_VersionSpecified_Then_FindsAndInstantiatesActivity() + { + // Arrange + var activityRegistry = CreateActivityRegistry(WriteLineActivityTypeName, WriteLineActivity, version: 1); + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, WriteLineActivityJson_WithVersion); + + // Assert + Assert.Same(WriteLineActivity, result); + } + + [Fact] + public void When_DeserializeUnknownActivity_Then_ReturnsNotFoundActivity() + { + // Arrange + var activityRegistry = Substitute.For(); + activityRegistry + .Find(NotFoundActivityTypeName) + .Returns(new ActivityDescriptor()); + + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, UnknownActivityJson); + + // Assert + Assert.IsType(result); + var notFoundActivity = (NotFoundActivity)result; + Assert.Equal(UnknownActivityTypeName, notFoundActivity.MissingTypeName); + Assert.Equal(0, notFoundActivity.MissingTypeVersion); + + var expectedJsonDoc = JsonDocument.Parse(UnknownActivityJson); + var actualJsonDoc = JsonDocument.Parse(notFoundActivity.OriginalActivityJson); + Assert.Equal(expectedJsonDoc.RootElement.ToString(), actualJsonDoc.RootElement.ToString()); + Assert.True(notFoundActivity.Metadata.ContainsKey("displayText")); + Assert.True(notFoundActivity.Metadata.ContainsKey("description")); + } + + [Fact] + public void When_DeserializeWorkflowAsActivity_And_WorkflowDefinitionIdSpecified_Then_FindsAndInstantiatesActivity() + { + // Arrange + var activityRegistry = CreateActivityRegistry_FindByCustomProperty( + WorkflowDefinitionIdCustomPropertyName, + WorkflowAsActivityDefinitionId + ); + + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, WorkflowAsActivityJson_WithDefinitionId); + + // Assert + Assert.Same(WorkflowAsActivity, result); + } + + [Fact] + public void When_DeserializeWorkflowAsActivity_And_WorkflowDefinitionVersionIdSpecified_Then_FindsAndInstantiatesActivity() + { + // Arrange + var activityRegistry = CreateActivityRegistry_FindByCustomProperty( + WorkflowDefinitionVersionIdCustomPropertyName, + WorkflowAsActivityDefinitionVersionId + ); + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, WorkflowAsActivityJson_WithVersionId); + + // Assert + Assert.Same(WorkflowAsActivity, result); + } + + [Fact] + public void When_DeserializeWorkflowAsActivity_And_TypeNameSpecified_Then_FindsAndInstantiatesActivity() + { + // Arrange + var activityRegistry = CreateActivityRegistry(WorkflowAsActivityTypeName, WorkflowAsActivity); + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, WorkflowAsActivityJson_WithTypeNameOnly); + + // Assert + Assert.Same(WorkflowAsActivity, result); + } + + static IActivity? Execute(ActivityJsonConverter sut, string json) => + JsonSerializer.Deserialize(json, GetSerializerOptions(sut)); + + static IActivityRegistry CreateActivityRegistry(string typeName, IActivity activity, int? version = null) + { + var descriptor = new ActivityDescriptor { Constructor = _ => activity }; + var activityRegistry = Substitute.For(); + + if (version.HasValue) + activityRegistry.Find(typeName, version.Value).Returns(descriptor); + else + activityRegistry.Find(typeName).Returns(descriptor); + + return activityRegistry; + } + + static IActivityRegistry CreateActivityRegistry_FindByCustomProperty(string customPropertyName, string customPropertyValue) + { + var descriptor = new ActivityDescriptor + { + Constructor = _ => WorkflowAsActivity, + CustomProperties = { [customPropertyName] = customPropertyValue } + }; + + var activityRegistry = Substitute.For(); + + // Mock type name lookups to return null, ensuring the custom property lookup path is tested + activityRegistry.Find(Arg.Any()).Returns((ActivityDescriptor?)null); + activityRegistry.Find(Arg.Any(), Arg.Any()).Returns((ActivityDescriptor?)null); + + activityRegistry + .Find(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()(descriptor) ? descriptor : null); + + return activityRegistry; + } + + static JsonSerializerOptions GetSerializerOptions(ActivityJsonConverter sut) => new() + { + Converters = + { + sut, + new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()), + new PolymorphicObjectConverterFactory() + }, + TypeInfoResolver = new ModifiableJsonTypeInfoResolver(new CustomConstructorConfigurator().GetModifiers()) + }; + + private const string UnknownActivityTypeName = "Some.Unknown.Activity"; + private const string WorkflowAsActivityTypeName = "Test-SubWorkflow"; + private const string WorkflowDefinitionIdCustomPropertyName = "WorkflowDefinitionId"; + private const string WorkflowDefinitionVersionIdCustomPropertyName = "WorkflowDefinitionVersionId"; + private const string WorkflowAsActivityDefinitionId = "bd94124913202141"; + private const string WorkflowAsActivityDefinitionVersionId = "aaa1112fff2443"; + private static readonly WriteLine WriteLineActivity = new("Hello world!"); + private static readonly string WriteLineActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + private static readonly string NotFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + + private const string WriteLineActivityJson_WithVersion = +""" + { + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Hello world!" + } + }, + "id": "b54b024aa7469287", + "nodeId": "Workflow1:51139b0cc83d3b07:b54b024aa7469287", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} + } + """; + + private const string UnknownActivityJson = +""" +{ + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Hello world!" + } + }, + "id": "b54b024aa7469287", + "nodeId": "Workflow1:51139b0cc83d3b07:b54b024aa7469287", + "name": "Unknown1", + "type": "Some.Unknown.Activity", + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} +} +"""; + + private const string WriteLineActivityJson_WithoutVersion = +""" +{ + "text": { + "typeName": "String", + "expression": { + "type": "Literal", + "value": "Hello world!" + } + }, + "id": "b54b024aa7469287", + "nodeId": "Workflow1:51139b0cc83d3b07:b54b024aa7469287", + "name": "WriteLine1", + "type": "Elsa.WriteLine", + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": {} +} +"""; + + private const string WorkflowAsActivityJson_WithVersionId = +""" +{ + "workflowDefinitionVersionId": "aaa1112fff2443", + "id": "5fc551ed366fe9fd", + "nodeId": "Workflow2:cad9d2186550a8d7:5fc551ed366fe9fd", + "name": "Test-SubWorkflow1", + "type": "Test-SubWorkflow", + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "logPersistenceMode": { + "default": "Inherit", + "inputs": {}, + "outputs": {} + }, + "logPersistenceConfig": { + "default": null, + "internalState": null, + "inputs": {}, + "outputs": {} + } + }, + "metadata": {} +} +"""; + + private const string WorkflowAsActivityJson_WithDefinitionId = +""" +{ + "workflowDefinitionId": "bd94124913202141", + "id": "5fc551ed366fe9fd", + "nodeId": "Workflow2:cad9d2186550a8d7:5fc551ed366fe9fd", + "name": "Test-SubWorkflow1", + "type": "Test-SubWorkflow", + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "logPersistenceMode": { + "default": "Inherit", + "inputs": {}, + "outputs": {} + }, + "logPersistenceConfig": { + "default": null, + "internalState": null, + "inputs": {}, + "outputs": {} + } + }, + "metadata": {} +} +"""; + + private const string WorkflowAsActivityJson_WithTypeNameOnly = +""" +{ + "id": "5fc551ed366fe9fd", + "nodeId": "Workflow2:cad9d2186550a8d7:5fc551ed366fe9fd", + "name": "Test-SubWorkflow1", + "type": "Test-SubWorkflow", + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false, + "logPersistenceMode": { + "default": "Inherit", + "inputs": {}, + "outputs": {} + }, + "logPersistenceConfig": { + "default": null, + "internalState": null, + "inputs": {}, + "outputs": {} + } + }, + "metadata": {} +} +"""; + + private static readonly IActivity WorkflowAsActivity = new WorkflowDefinitionActivity + { + WorkflowDefinitionId = WorkflowAsActivityDefinitionId, + WorkflowDefinitionVersionId = WorkflowAsActivityDefinitionVersionId + }; + + private static ActivityJsonConverter CreateSut(IActivityRegistry activityRegistry) + { + var expressionDescriptorRegistry = new ExpressionDescriptorRegistry([]); + return new( + activityRegistry, + expressionDescriptorRegistry, + new( + activityRegistry, + new(expressionDescriptorRegistry), + CreateLogger() + ), + Substitute.For() + ); + } + + private static ILogger CreateLogger() => LoggerFactory.Create(_ => { }).CreateLogger(); +} \ No newline at end of file