diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs index 352585a67..d0fc22edc 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs @@ -58,7 +58,7 @@ public class PolymorphicObjectConverter : JsonConverter if (isNewtonsoftObject) { - var parsedModel = JsonElement.ParseValue(ref reader)!; + var parsedModel = JsonElement.ParseValue(ref reader); var newtonsoftJson = parsedModel.GetProperty(IslandPropertyName).GetString(); return !string.IsNullOrWhiteSpace(newtonsoftJson) ? JObject.Parse(newtonsoftJson) : new JObject(); } @@ -68,7 +68,7 @@ public class PolymorphicObjectConverter : JsonConverter if (isNewtonsoftArray) { - var parsedModel = JsonElement.ParseValue(ref reader)!; + var parsedModel = JsonElement.ParseValue(ref reader); var newtonsoftJson = parsedModel.GetProperty(IslandPropertyName).GetString(); return !string.IsNullOrWhiteSpace(newtonsoftJson) ? JArray.Parse(newtonsoftJson) : new JArray(); } @@ -78,7 +78,7 @@ public class PolymorphicObjectConverter : JsonConverter if (isJsonObject) { - var parsedModel = JsonElement.ParseValue(ref reader)!; + var parsedModel = JsonElement.ParseValue(ref reader); var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString(); return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonObject.Parse(systemTextJson) : new JsonObject(); } @@ -87,7 +87,7 @@ public class PolymorphicObjectConverter : JsonConverter if (isJsonArray) { - var parsedModel = JsonElement.ParseValue(ref reader)!; + var parsedModel = JsonElement.ParseValue(ref reader); var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString(); return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonArray.Parse(systemTextJson) : new JsonArray(); } @@ -327,7 +327,7 @@ public class PolymorphicObjectConverter : JsonConverter } case JsonTokenType.StartObject: var dict = new ExpandoObject() as IDictionary; - var referenceResolver = (options.ReferenceHandler as CrossScopedReferenceHandler)?.GetResolver(); + var referenceResolver = (CustomPreserveReferenceResolver)(options.ReferenceHandler as CrossScopedReferenceHandler)?.GetResolver()!; while (reader.Read()) { switch (reader.TokenType) @@ -344,13 +344,15 @@ public class PolymorphicObjectConverter : JsonConverter if (key == RefPropertyName) { var referenceId = reader.GetString(); - var reference = referenceResolver!.ResolveReference(referenceId!); + var reference = referenceResolver.ResolveReference(referenceId!); dict.Add(key, reference); } else if (key == IdPropertyName) { var referenceId = reader.GetString()!; - referenceResolver!.AddReference(referenceId, dict); + + // Attempt to add the reference; if not found, we can ignore it and assume that the user is using the $id property for something else, such as in JSON $schema. + referenceResolver.TryAddReference(referenceId, dict); } else { diff --git a/src/modules/Elsa.Workflows.Core/Serialization/ReferenceHandlers/CustomPreserveReferenceResolver.cs b/src/modules/Elsa.Workflows.Core/Serialization/ReferenceHandlers/CustomPreserveReferenceResolver.cs index b396085ff..0b0076594 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/ReferenceHandlers/CustomPreserveReferenceResolver.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/ReferenceHandlers/CustomPreserveReferenceResolver.cs @@ -19,6 +19,14 @@ public class CustomPreserveReferenceResolver : ReferenceResolver if (!_referenceIdToObjectMap.TryAdd(referenceId, value)) throw new JsonException(); } + + /// + /// Tries to add a reference. + /// + public bool TryAddReference(string referenceId, object value) + { + return _referenceIdToObjectMap.TryAdd(referenceId, value); + } /// public override string GetReference(object value, out bool alreadyExists) diff --git a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj index 758c4b543..c7cb91283 100644 --- a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj +++ b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj @@ -150,6 +150,9 @@ Always + + Always + diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Tests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Tests.cs new file mode 100644 index 000000000..2363838d3 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Tests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Elsa.Expressions.Contracts; +using Elsa.Testing.Shared; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Contracts; +using Elsa.Workflows.Core.Serialization.Converters; +using Elsa.Workflows.Core.Serialization.ReferenceHandlers; +using Elsa.Workflows.Core.State; +using Elsa.Workflows.Management.Contracts; +using Elsa.Workflows.Management.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.IntegrationTests.Scenarios.JsonObjectSerialization; + +/// +/// Tests for serializing and deserializing JSON objects containing reserved keywords such as $id. +/// +public class Tests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + private readonly IWorkflowStateSerializer _workflowStateSerializer; + + public Tests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + _workflowStateSerializer = _services.GetRequiredService(); + } + + [Fact(DisplayName = "User-objects containing $id don't break deserialization into ExpandoObject.")] + public async Task Test1() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + + // Import workflow. + var workflowFileName = "Scenarios/JsonObjectSerialization/Workflows/instance-serialization.json"; + var workflowDefinition = await _services.ImportWorkflowDefinitionAsync(workflowFileName); + + // Execute. + var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId); + + // Serialize workflow state. + var serializedWorkflowState = await _workflowStateSerializer.SerializeAsync(workflowState); + + // Attempt to deserialize workflow state. + await _workflowStateSerializer.DeserializeAsync(serializedWorkflowState); + + // If we reach this point, the test has passed. Otherwise, an exception would have been thrown. + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Workflows/instance-serialization.json b/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Workflows/instance-serialization.json new file mode 100644 index 000000000..ee7820723 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/JsonObjectSerialization/Workflows/instance-serialization.json @@ -0,0 +1,140 @@ +{ + "id": "b78e443339b5bf1e", + "definitionId": "861e51cdbe07931", + "name": "Instance Serialization", + "description": "This workflow stores a user-JSON object containing $id as a field, which should not affect the deserialization process form JSON back into ExpandoObject.", + "createdAt": "2023-11-23T15:18:12.805077+00:00", + "version": 13, + "toolVersion": "3.0.0.0", + "variables": [ + { + "id": "d4f37df0c6781767", + "name": "Variable1", + "typeName": "Object", + "isArray": false, + "storageDriverTypeName": "Elsa.Workflows.Core.Services.WorkflowStorageDriver, Elsa.Workflows.Core" + }, + { + "id": "4e256dab27d33eac", + "name": "Variable2", + "typeName": "Object", + "isArray": false, + "storageDriverTypeName": "Elsa.Workflows.Core.Services.WorkflowStorageDriver, Elsa.Workflows.Core" + } + ], + "inputs": [], + "outputs": [], + "outcomes": [], + "customProperties": {}, + "isReadonly": false, + "isLatest": true, + "isPublished": false, + "options": { + "autoUpdateConsumingWorkflows": false + }, + "root": { + "type": "Elsa.Flowchart", + "version": 1, + "id": "35cdc2a3529d9b55", + "nodeId": "Workflow1:35cdc2a3529d9b55", + "metadata": {}, + "customProperties": { + "source": "FlowchartJsonConverter.cs:47", + "notFoundConnections": [], + "canStartWorkflow": false, + "runAsynchronously": false + }, + "activities": [ + { + "variable": { + "id": "d4f37df0c6781767", + "name": "Variable1", + "typeName": "Object", + "storageDriverTypeName": "Elsa.Workflows.Core.Services.WorkflowStorageDriver, Elsa.Workflows.Core" + }, + "value": { + "typeName": "Object", + "expression": { + "type": "JavaScript", + "value": "return {\n \u0022inputSchema\u0022: {\n \u0022$schema\u0022: \u0022http://json-schema.org/draft-07/schema#\u0022,\n \u0022title\u0022: \u0022Inform front-office afd 2.0 input schema\u0022,\n \u0022description\u0022: \u0022Schema of the AFD 2.0 message that is sent to the front-office\u0022,\n \u0022$id\u0022: \u0022#/definitions/inform-front-office-input-afd\u0022,\n \u0022type\u0022: \u0022object\u0022\n }\n}" + }, + "memoryReference": { + "id": "d4f32ee0ad659ba5:input-0" + } + }, + "id": "d4f32ee0ad659ba5", + "nodeId": "Workflow1:35cdc2a3529d9b55:d4f32ee0ad659ba5", + "name": "SetVariable1", + "type": "Elsa.SetVariable", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -456.5988464355469, + "y": -104.93463134765625 + }, + "size": { + "width": 153.828125, + "height": 50 + } + } + } + }, + { + "variable": { + "id": "4e256dab27d33eac", + "name": "Variable2", + "typeName": "Object", + "storageDriverTypeName": "Elsa.Workflows.Core.Services.WorkflowStorageDriver, Elsa.Workflows.Core" + }, + "value": { + "typeName": "Object", + "expression": { + "type": "JavaScript", + "value": "return getVariable1()" + }, + "memoryReference": { + "id": "31c60a181a1a66d8:input-0" + } + }, + "id": "31c60a181a1a66d8", + "nodeId": "Workflow1:35cdc2a3529d9b55:31c60a181a1a66d8", + "name": "SetVariable2", + "type": "Elsa.SetVariable", + "version": 1, + "customProperties": { + "canStartWorkflow": false, + "runAsynchronously": false + }, + "metadata": { + "designer": { + "position": { + "x": -210.69132232666016, + "y": -104.93463134765625 + }, + "size": { + "width": 153.828125, + "height": 50 + } + } + } + } + ], + "connections": [ + { + "source": { + "activity": "d4f32ee0ad659ba5", + "port": "Done" + }, + "target": { + "activity": "31c60a181a1a66d8", + "port": "In" + } + } + ] + } +} \ No newline at end of file