diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings index 3698200b1..ecdd59c86 100644 --- a/Squidex.sln.DotSettings +++ b/Squidex.sln.DotSettings @@ -44,4 +44,7 @@ All rights reserved. Licensed under the MIT license. ========================================================================== + True + True + True \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs index 0e5727cbc..398f01c91 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs @@ -24,13 +24,13 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions { private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; private readonly ConcurrentDictionary<(string AppId, string ApiKey, string IndexName), Index> clients = new ConcurrentDictionary<(string AppId, string ApiKey, string IndexName), Index>(); - private readonly JsonSerializer serializer; + private readonly RuleEventFormatter formatter; - public AlgoliaActionHandler(JsonSerializer serializer) + public AlgoliaActionHandler(RuleEventFormatter formatter) { - Guard.NotNull(serializer, nameof(serializer)); + Guard.NotNull(formatter, nameof(formatter)); - this.serializer = serializer; + this.formatter = formatter; } protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, AlgoliaAction action) @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions { ruleData["ContentId"] = contentEvent.ContentId.ToString(); ruleData["Operation"] = "Upsert"; - ruleData["IndexName"] = action.IndexName.Replace(SchemaNamePlaceholder, contentEvent.SchemaId.Name); + ruleData["IndexName"] = formatter.FormatString(action.IndexName, @event); var timestamp = @event.Headers.Timestamp().ToString(); @@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions new JProperty("createdBy", created.Actor.ToString()), new JProperty("lastModified", timestamp), new JProperty("lastModifiedBy", created.Actor.ToString()), - new JProperty("data", JObject.FromObject(created.Data, serializer))); + new JProperty("data", formatter.ToRouteData(created.Data))); break; } @@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions ruleData["Content"] = new JObject( new JProperty("lastModified", timestamp), new JProperty("lastModifiedBy", updated.Actor.ToString()), - new JProperty("data", JObject.FromObject(updated.Data, serializer))); + new JProperty("data", formatter.ToRouteData(updated.Data))); break; } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs index 0400ef435..7b85b1b0e 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs @@ -24,14 +24,13 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class WebhookActionHandler : RuleActionHandler { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); + private readonly RuleEventFormatter formatter; - private readonly JsonSerializer serializer; - - public WebhookActionHandler(JsonSerializer serializer) + public WebhookActionHandler(RuleEventFormatter formatter) { - Guard.NotNull(serializer, nameof(serializer)); + Guard.NotNull(formatter, nameof(formatter)); - this.serializer = serializer; + this.formatter = formatter; } protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, WebhookAction action) @@ -55,7 +54,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions { return new JObject( new JProperty("type", eventName), - new JProperty("payload", JObject.FromObject(@event.Payload, serializer)), + new JProperty("payload", formatter.ToRouteData(@event.Payload)), new JProperty("timestamp", @event.Headers.Timestamp().ToString())); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs new file mode 100644 index 000000000..cf7f653a5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -0,0 +1,137 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// =========================================-================================= + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public class RuleEventFormatter + { + private const string Undefined = "UNDEFINED"; + private const string AppIdPlaceholder = "$APP_ID"; + private const string AppNamePlaceholder = "$APP_NAME"; + private const string SchemaIdPlaceholder = "$SCHEMA_ID"; + private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; + private const string TimestampDatePlaceholder = "$TIMESTAMP_DATE"; + private const string TimestampDateTimePlaceholder = "$TIMESTAMP_DATETIME"; + private static readonly Regex ContentDataPlaceholder = new Regex(@"\$CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); + private readonly JsonSerializer serializer; + + public RuleEventFormatter(JsonSerializer serializer) + { + Guard.NotNull(serializer, nameof(serializer)); + + this.serializer = serializer; + } + + public virtual JToken ToRouteData(object value) + { + return JToken.FromObject(value, serializer); + } + + public virtual string FormatString(string text, Envelope @event) + { + var sb = new StringBuilder(text); + + if (@event.Headers.Contains(CommonHeaders.Timestamp)) + { + var timestamp = @event.Headers.Timestamp().ToDateTimeUtc(); + + sb.Replace(TimestampDateTimePlaceholder, timestamp.ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture)); + sb.Replace(TimestampDatePlaceholder, timestamp.ToString("yyy-MM-dd", CultureInfo.InvariantCulture)); + } + + if (@event.Payload.AppId != null) + { + sb.Replace(AppIdPlaceholder, @event.Payload.AppId.Id.ToString()); + sb.Replace(AppNamePlaceholder, @event.Payload.AppId.Name); + } + + if (@event.Payload is SchemaEvent schemaEvent && schemaEvent.SchemaId != null) + { + sb.Replace(SchemaIdPlaceholder, schemaEvent.SchemaId.Id.ToString()); + sb.Replace(SchemaNamePlaceholder, schemaEvent.SchemaId.Name); + } + + var result = sb.ToString(); + + if (@event.Payload is ContentCreated contentCreated && contentCreated.Data != null) + { + result = ReplaceData(contentCreated.Data, result); + } + + if (@event.Payload is ContentUpdated contentUpdated && contentUpdated.Data != null) + { + result = ReplaceData(contentUpdated.Data, result); + } + + return result; + } + + private static string ReplaceData(NamedContentData data, string text) + { + return ContentDataPlaceholder.Replace(text, match => + { + var captures = match.Groups[2].Captures; + + var path = new string[captures.Count]; + + for (var i = 0; i < path.Length; i++) + { + path[i] = captures[i].Value; + } + + if (!data.TryGetValue(path[0], out var field)) + { + return Undefined; + } + + if (!field.TryGetValue(path[1], out var value)) + { + return Undefined; + } + + for (var j = 2; j < path.Length; j++) + { + if (value is JObject obj && obj.TryGetValue(path[j], out value)) + { + continue; + } + if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) + { + value = arr[idx]; + } + else + { + return Undefined; + } + } + + if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + { + return Undefined; + } + + if (value is JValue jValue && jValue != null) + { + return jValue.Value.ToString(); + } + + return value?.ToString(Formatting.Indented) ?? Undefined; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs index a9a71bdc1..8d42986bf 100644 --- a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs +++ b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs @@ -12,8 +12,6 @@ namespace Squidex.Domain.Apps.Events { public abstract class SquidexEvent : IEvent { - public string Username { get; set; } - public RefToken Actor { get; set; } } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs new file mode 100644 index 000000000..9b58ca316 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs @@ -0,0 +1,246 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.HandleRules +{ + public class RuleEventFormatterTests + { + private readonly JsonSerializer serializer = JsonSerializer.CreateDefault(); + private readonly RuleEventFormatter sut; + + public RuleEventFormatterTests() + { + sut = new RuleEventFormatter(serializer); + } + + [Fact] + public void Should_serialize_object_to_json() + { + var result = sut.ToRouteData(new { Value = 1 }); + + Assert.True(result is JObject); + } + + [Fact] + public void Should_replace_app_information_from_event() + { + var appId = Guid.NewGuid(); + + var @event = new ContentCreated + { + AppId = new NamedId(appId, "my-app") + }; + + var result = sut.FormatString("Name $APP_NAME has id $APP_ID", Envelope.Create(@event).To()); + + Assert.Equal($"Name my-app has id {appId}", result); + } + + [Fact] + public void Should_replace_schema_information_from_event() + { + var schemaId = Guid.NewGuid(); + + var @event = new ContentCreated + { + SchemaId = new NamedId(schemaId, "my-schema") + }; + + var result = sut.FormatString("Name $SCHEMA_NAME has id $SCHEMA_ID", Envelope.Create(@event).To()); + + Assert.Equal($"Name my-schema has id {schemaId}", result); + } + + [Fact] + public void Should_replace_timestamp_information_from_event() + { + var now = DateTime.UtcNow; + + var envelope = Envelope.Create(new ContentCreated()).To().SetTimestamp(Instant.FromDateTimeUtc(now)); + + var result = sut.FormatString("Date: $TIMESTAMP_DATE, Full: $TIMESTAMP_DATETIME", envelope); + + Assert.Equal($"Date: {now:yyyy-MM-dd}, Full: {now:yyyy-MM-dd-hh-mm-ss}", result); + } + + [Fact] + public void Should_return_undefined_when_field_not_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = sut.FormatString("$CONTENT_DATA.country.iv", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_undefined_when_partition_not_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.de", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_undefined_when_array_item_not_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", new JArray())) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.de.10", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_undefined_when_property_not_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", new JObject( + new JProperty("name", "Berlin")))) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.de.Name", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_plain_value_when_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", "Berlin")) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv", Envelope.Create(@event).To()); + + Assert.Equal("Berlin", result); + } + + [Fact] + public void Should_return_undefined_when_null() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", JValue.CreateNull())) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_undefined_when_undefined() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", JValue.CreateUndefined())) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv", Envelope.Create(@event).To()); + + Assert.Equal("UNDEFINED", result); + } + + [Fact] + public void Should_return_string_when_object() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", new JObject( + new JProperty("name", "Berlin")))) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv", Envelope.Create(@event).To()); + + Assert.Equal(JObject.FromObject(new { name = "Berlin" }).ToString(Formatting.Indented), result); + } + + [Fact] + public void Should_return_plain_value_from_array_when_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", new JArray( + "Berlin"))) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv.0", Envelope.Create(@event).To()); + + Assert.Equal("Berlin", result); + } + + [Fact] + public void Should_return_plain_value_from_object_when_found() + { + var @event = new ContentCreated + { + Data = + new NamedContentData() + .AddField("city", new ContentFieldData() + .AddValue("iv", new JObject( + new JProperty("name", "Berlin")))) + }; + + var result = sut.FormatString("$CONTENT_DATA.city.iv.name", Envelope.Create(@event).To()); + + Assert.Equal("Berlin", result); + } + } +}