mirror of https://github.com/Squidex/squidex.git
33 changed files with 1251 additions and 0 deletions
@ -0,0 +1,26 @@ |
|||
// ==========================================================================
|
|||
// WebhookAction.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules.Actions |
|||
{ |
|||
[TypeName(nameof(WebhookAction))] |
|||
public sealed class WebhookAction : RuleAction |
|||
{ |
|||
public Uri Url { get; set; } |
|||
|
|||
public string SharedSecret { get; set; } |
|||
|
|||
public override T Accept<T>(IRuleActionVisitor<T> visitor) |
|||
{ |
|||
return visitor.Visit(this); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// IActionVisitor.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public interface IRuleActionVisitor<T> |
|||
{ |
|||
T Visit(WebhookAction action); |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// IRuleTriggerVisitor.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules.Triggers; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public interface IRuleTriggerVisitor<T> |
|||
{ |
|||
T Visit(ContentChangedTrigger trigger); |
|||
} |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
// ==========================================================================
|
|||
// Rule.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public sealed class Rule |
|||
{ |
|||
private RuleTrigger trigger; |
|||
private RuleAction action; |
|||
private bool isEnabled = true; |
|||
|
|||
public RuleTrigger Trigger |
|||
{ |
|||
get { return trigger; } |
|||
} |
|||
|
|||
public RuleAction Action |
|||
{ |
|||
get { return action; } |
|||
} |
|||
|
|||
public bool IsEnabled |
|||
{ |
|||
get { return isEnabled; } |
|||
} |
|||
|
|||
public Rule(RuleTrigger trigger, RuleAction action) |
|||
{ |
|||
Guard.NotNull(trigger, nameof(trigger)); |
|||
Guard.NotNull(action, nameof(action)); |
|||
|
|||
this.trigger = trigger; |
|||
this.action = action; |
|||
} |
|||
|
|||
public void Enable() |
|||
{ |
|||
this.isEnabled = true; |
|||
} |
|||
|
|||
public void Disable() |
|||
{ |
|||
this.isEnabled = false; |
|||
} |
|||
|
|||
public void Update(RuleTrigger newTrigger) |
|||
{ |
|||
Guard.NotNull(newTrigger, nameof(newTrigger)); |
|||
|
|||
if (newTrigger.GetType() != trigger.GetType()) |
|||
{ |
|||
throw new ArgumentException("New trigger has another type.", nameof(newTrigger)); |
|||
} |
|||
|
|||
trigger = newTrigger; |
|||
} |
|||
|
|||
public void Update(RuleAction newAction) |
|||
{ |
|||
Guard.NotNull(newAction, nameof(newAction)); |
|||
|
|||
if (newAction.GetType() != trigger.GetType()) |
|||
{ |
|||
throw new ArgumentException("New action has another type.", nameof(newAction)); |
|||
} |
|||
|
|||
action = newAction; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// RuleAction.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public abstract class RuleAction |
|||
{ |
|||
public abstract T Accept<T>(IRuleActionVisitor<T> visitor); |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
// ==========================================================================
|
|||
// RuleJob.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using NodaTime; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public sealed class RuleJob |
|||
{ |
|||
public Guid Id { get; set; } |
|||
|
|||
public Guid AppId { get; set; } |
|||
|
|||
public string EventName { get; set; } |
|||
|
|||
public string ActionName { get; set; } |
|||
|
|||
public string Description { get; set; } |
|||
|
|||
public Instant Created { get; set; } |
|||
|
|||
public Instant Expires { get; set; } |
|||
|
|||
public RuleJobData ActionData { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// RuleJobData.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using Newtonsoft.Json.Linq; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public sealed class RuleJobData : Dictionary<string, JToken> |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
// ==========================================================================
|
|||
// RuleTrigger.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules |
|||
{ |
|||
public abstract class RuleTrigger |
|||
{ |
|||
public abstract T Accept<T>(IRuleTriggerVisitor<T> visitor); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// ContentChangedTrigger.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules.Triggers |
|||
{ |
|||
[TypeName(nameof(ContentChangedTrigger))] |
|||
public sealed class ContentChangedTrigger : RuleTrigger |
|||
{ |
|||
public List<ContentChangedTriggerSchema> Schemas { get; set; } |
|||
|
|||
public override T Accept<T>(IRuleTriggerVisitor<T> visitor) |
|||
{ |
|||
return visitor.Visit(this); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
// ==========================================================================
|
|||
// ContentChangedTriggerSchema.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules.Triggers |
|||
{ |
|||
public sealed class ContentChangedTriggerSchema |
|||
{ |
|||
public Guid SchemaId { get; set; } |
|||
|
|||
public bool SendCreate { get; set; } |
|||
|
|||
public bool SendUpdate { get; set; } |
|||
|
|||
public bool SendDelete { get; set; } |
|||
|
|||
public bool SendPublish { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
// ==========================================================================
|
|||
// WebhookActionHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Newtonsoft.Json; |
|||
using Newtonsoft.Json.Linq; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
using Squidex.Infrastructure.Http; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.ActionHandlers |
|||
{ |
|||
public sealed class WebhookActionHandler : RuleActionHandler<WebhookAction> |
|||
{ |
|||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); |
|||
|
|||
private readonly JsonSerializer serializer; |
|||
|
|||
public WebhookActionHandler(JsonSerializer serializer) |
|||
{ |
|||
Guard.NotNull(serializer, nameof(serializer)); |
|||
|
|||
this.serializer = serializer; |
|||
} |
|||
|
|||
protected override (string Description, RuleJobData Data) CreateJob(Envelope<AppEvent> @event, string eventName, WebhookAction action) |
|||
{ |
|||
var body = CreatePayload(@event, eventName); |
|||
|
|||
var signature = $"{body.ToString(Formatting.Indented)}{action.SharedSecret}".Sha256Base64(); |
|||
|
|||
var ruleDescription = $"Send event to webhook {action.Url}"; |
|||
var ruleData = new RuleJobData |
|||
{ |
|||
["RequestUrl"] = action.Url, |
|||
["RequestBody"] = body, |
|||
["RequestSignature"] = signature |
|||
}; |
|||
|
|||
return (ruleDescription, ruleData); |
|||
} |
|||
|
|||
private JObject CreatePayload(Envelope<AppEvent> @event, string eventName) |
|||
{ |
|||
return new JObject( |
|||
new JProperty("type", eventName), |
|||
new JProperty("payload", JObject.FromObject(@event.Payload, serializer)), |
|||
new JProperty("timestamp", @event.Headers.Timestamp().ToString())); |
|||
} |
|||
|
|||
public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) |
|||
{ |
|||
var requestBody = job["RequestBody"].ToString(Formatting.Indented); |
|||
var request = BuildRequest(job, requestBody); |
|||
|
|||
HttpResponseMessage response = null; |
|||
|
|||
try |
|||
{ |
|||
using (var client = new HttpClient { Timeout = Timeout }) |
|||
{ |
|||
response = await client.SendAsync(request); |
|||
|
|||
var responseString = await response.Content.ReadAsStringAsync(); |
|||
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString, TimeSpan.Zero, false); |
|||
|
|||
return (requestDump, null); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
if (request != null) |
|||
{ |
|||
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString(), TimeSpan.Zero, false); |
|||
|
|||
return (requestDump, ex); |
|||
} |
|||
else |
|||
{ |
|||
var requestDump = ex.ToString(); |
|||
|
|||
return (requestDump, ex); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static HttpRequestMessage BuildRequest(Dictionary<string, JToken> job, string requestBody) |
|||
{ |
|||
var requestUrl = job["RequestUrl"].ToString(); |
|||
var requestSignature = job["RequestSignature"].ToString(); |
|||
|
|||
var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) |
|||
{ |
|||
Content = new StringContent(requestBody, Encoding.UTF8, "application/json") |
|||
}; |
|||
|
|||
request.Headers.Add("X-Signature", requestSignature); |
|||
request.Headers.Add("User-Agent", "Squidex Webhook"); |
|||
|
|||
return request; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
// ==========================================================================
|
|||
// IRuleActionHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public interface IRuleActionHandler |
|||
{ |
|||
Type ActionType { get; } |
|||
|
|||
(string Description, RuleJobData Data) CreateJob(Envelope<AppEvent> @event, string eventName, RuleAction action); |
|||
|
|||
Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData data); |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
// ==========================================================================
|
|||
// IRuleTriggerHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public interface IRuleTriggerHandler |
|||
{ |
|||
Type TriggerType { get; } |
|||
|
|||
bool Triggers(Envelope<AppEvent> @event, RuleTrigger trigger); |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
// ==========================================================================
|
|||
// RuleActionHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public abstract class RuleActionHandler<T> : IRuleActionHandler where T : RuleAction |
|||
{ |
|||
Type IRuleActionHandler.ActionType |
|||
{ |
|||
get { return typeof(T); } |
|||
} |
|||
|
|||
(string Description, RuleJobData Data) IRuleActionHandler.CreateJob(Envelope<AppEvent> @event, string eventName, RuleAction action) |
|||
{ |
|||
return CreateJob(@event, eventName, (T)action); |
|||
} |
|||
|
|||
protected abstract (string Description, RuleJobData Data) CreateJob(Envelope<AppEvent> @event, string eventName, T action); |
|||
|
|||
public abstract Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// RuleResult.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public enum RuleResult |
|||
{ |
|||
Pending, |
|||
Success, |
|||
Failed, |
|||
Timeout |
|||
} |
|||
} |
|||
@ -0,0 +1,159 @@ |
|||
// ==========================================================================
|
|||
// RuleService.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics; |
|||
using System.Linq; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Newtonsoft.Json.Linq; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public sealed class RuleService |
|||
{ |
|||
private const string ContentPrefix = "Content"; |
|||
private static readonly Duration ExpirationTime = Duration.FromDays(2); |
|||
private readonly Dictionary<Type, IRuleActionHandler> ruleActionHandlers; |
|||
private readonly Dictionary<Type, IRuleTriggerHandler> ruleTriggerHandlers; |
|||
private readonly TypeNameRegistry typeNameRegistry; |
|||
private readonly IClock clock; |
|||
|
|||
public RuleService( |
|||
IEnumerable<IRuleTriggerHandler> ruleTriggerHandlers, |
|||
IEnumerable<IRuleActionHandler> ruleActionHandlers, |
|||
IClock clock, |
|||
TypeNameRegistry typeNameRegistry) |
|||
{ |
|||
Guard.NotNull(ruleTriggerHandlers, nameof(ruleTriggerHandlers)); |
|||
Guard.NotNull(ruleActionHandlers, nameof(ruleActionHandlers)); |
|||
Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); |
|||
Guard.NotNull(clock, nameof(clock)); |
|||
|
|||
this.typeNameRegistry = typeNameRegistry; |
|||
|
|||
this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType); |
|||
this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType); |
|||
|
|||
this.clock = clock; |
|||
} |
|||
|
|||
public RuleJob CreateJob(Rule rule, Envelope<IEvent> @event) |
|||
{ |
|||
Guard.NotNull(rule, nameof(rule)); |
|||
Guard.NotNull(@event, nameof(@event)); |
|||
|
|||
if (!(@event.Payload is AppEvent appEvent)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var actionType = rule.Action.GetType(); |
|||
|
|||
if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var appEventEnvelope = @event.To<AppEvent>(); |
|||
|
|||
if (!triggerHandler.Triggers(appEventEnvelope, rule.Trigger)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var eventName = CreateEventName(appEvent); |
|||
|
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
var actionName = typeNameRegistry.GetName(actionType); |
|||
var actionData = actionHandler.CreateJob(appEventEnvelope, eventName, rule.Action); |
|||
|
|||
var job = new RuleJob |
|||
{ |
|||
Id = Guid.NewGuid(), |
|||
ActionName = actionName, |
|||
ActionData = actionData.Data, |
|||
AppId = appEvent.AppId.Id, |
|||
Created = now, |
|||
EventName = eventName, |
|||
Expires = now.Plus(ExpirationTime), |
|||
Description = actionData.Description |
|||
}; |
|||
|
|||
return job; |
|||
} |
|||
|
|||
public async Task<(string Dump, RuleResult Result, TimeSpan Elapsed)> InvokeAsync(string actionName, Dictionary<string, JToken> job) |
|||
{ |
|||
try |
|||
{ |
|||
var actionType = typeNameRegistry.GetType(actionName); |
|||
var actionWatch = Stopwatch.StartNew(); |
|||
|
|||
var result = await ruleActionHandlers[actionType].ExecuteJobAsync(job); |
|||
|
|||
actionWatch.Stop(); |
|||
|
|||
var dumpBuilder = new StringBuilder(result.Dump); |
|||
|
|||
dumpBuilder.AppendLine(); |
|||
dumpBuilder.AppendFormat("Elapesed {0}.", actionWatch.Elapsed); |
|||
dumpBuilder.AppendLine(); |
|||
|
|||
if (result.Exception is TimeoutException || result.Exception is OperationCanceledException) |
|||
{ |
|||
dumpBuilder.AppendLine(); |
|||
dumpBuilder.AppendLine("Action timed out."); |
|||
|
|||
return (dumpBuilder.ToString(), RuleResult.Timeout, actionWatch.Elapsed); |
|||
} |
|||
else if (result.Exception != null) |
|||
{ |
|||
return (dumpBuilder.ToString(), RuleResult.Failed, actionWatch.Elapsed); |
|||
} |
|||
else |
|||
{ |
|||
return (dumpBuilder.ToString(), RuleResult.Success, actionWatch.Elapsed); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
return (ex.ToString(), RuleResult.Failed, TimeSpan.Zero); |
|||
} |
|||
} |
|||
|
|||
private string CreateEventName(AppEvent appEvent) |
|||
{ |
|||
var eventName = typeNameRegistry.GetName(appEvent.GetType()); |
|||
|
|||
if (appEvent is SchemaEvent schemaEvent) |
|||
{ |
|||
if (eventName.StartsWith(ContentPrefix, StringComparison.Ordinal)) |
|||
{ |
|||
eventName = eventName.Substring(ContentPrefix.Length); |
|||
} |
|||
|
|||
return $"{schemaEvent.SchemaId.Name.ToPascalCase()}{eventName}"; |
|||
} |
|||
|
|||
return eventName; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
// ==========================================================================
|
|||
// RuleTriggerHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public abstract class RuleTriggerHandler<T> : IRuleTriggerHandler where T : RuleTrigger |
|||
{ |
|||
public Type TriggerType |
|||
{ |
|||
get { return typeof(T); } |
|||
} |
|||
|
|||
bool IRuleTriggerHandler.Triggers(Envelope<AppEvent> @event, RuleTrigger trigger) |
|||
{ |
|||
return Triggers(@event, (T)trigger); |
|||
} |
|||
|
|||
protected abstract bool Triggers(Envelope<AppEvent> @event, T trigger); |
|||
} |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
// ==========================================================================
|
|||
// ContentChangedTriggerHandler.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Core.Rules.Triggers; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Domain.Apps.Events.Contents; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.Triggers |
|||
{ |
|||
public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTrigger> |
|||
{ |
|||
protected override bool Triggers(Envelope<AppEvent> @event, ContentChangedTrigger trigger) |
|||
{ |
|||
if (trigger.Schemas != null && @event.Payload is SchemaEvent schemaEvent) |
|||
{ |
|||
foreach (var schema in trigger.Schemas) |
|||
{ |
|||
if (MatchsSchema(schema, schemaEvent) && MatchsType(schema, schemaEvent)) |
|||
{ |
|||
return true; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private static bool MatchsSchema(ContentChangedTriggerSchema webhookSchema, SchemaEvent @event) |
|||
{ |
|||
return @event.SchemaId.Id == webhookSchema.SchemaId; |
|||
} |
|||
|
|||
private static bool MatchsType(ContentChangedTriggerSchema webhookSchema, SchemaEvent @event) |
|||
{ |
|||
return |
|||
(webhookSchema.SendCreate && @event is ContentCreated) || |
|||
(webhookSchema.SendUpdate && @event is ContentUpdated) || |
|||
(webhookSchema.SendDelete && @event is ContentDeleted) || |
|||
(webhookSchema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Status == Status.Published); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// RuleCreated.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
[EventType(nameof(RuleCreated))] |
|||
public sealed class RuleCreated : RuleEvent |
|||
{ |
|||
public RuleTrigger Trigger { get; set; } |
|||
|
|||
public RuleAction Action { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// RuleDeleted.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
[EventType(nameof(RuleDeleted))] |
|||
public sealed class RuleDeleted : RuleEvent |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// RuleDisabled.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
[EventType(nameof(RuleDisabled))] |
|||
public sealed class RuleDisabled : RuleEvent |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// RuleEnabled.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
[EventType(nameof(RuleEnabled))] |
|||
public sealed class RuleEnabled : RuleEvent |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// RuleEvent.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
public abstract class RuleEvent : AppEvent |
|||
{ |
|||
public Guid RuleId { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// RuleUpdated.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules |
|||
{ |
|||
[EventType(nameof(RuleUpdated))] |
|||
public sealed class RuleUpdated : RuleEvent |
|||
{ |
|||
public RuleTrigger Trigger { get; set; } |
|||
|
|||
public RuleAction Action { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
// ==========================================================================
|
|||
// RuleEventDispatcher.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Linq; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
using Squidex.Domain.Apps.Core.Rules.Triggers; |
|||
using Squidex.Domain.Apps.Events.Webhooks; |
|||
using Squidex.Infrastructure.Reflection; |
|||
|
|||
namespace Squidex.Domain.Apps.Events.Rules.Utils |
|||
{ |
|||
public static class RuleEventDispatcher |
|||
{ |
|||
public static Rule Create(RuleCreated @event) |
|||
{ |
|||
return new Rule(@event.Trigger, @event.Action); |
|||
} |
|||
|
|||
public static Rule Create(WebhookCreated @event) |
|||
{ |
|||
return new Rule(CreateTrigger(@event), CreateAction(@event)); |
|||
} |
|||
|
|||
public static void Apply(this Rule rule, WebhookUpdated @event) |
|||
{ |
|||
rule.Update(CreateTrigger(@event)); |
|||
|
|||
if (rule.Action is WebhookAction webhookAction) |
|||
{ |
|||
webhookAction.Url = @event.Url; |
|||
} |
|||
} |
|||
|
|||
public static void Apply(this Rule rule, RuleUpdated @event) |
|||
{ |
|||
if (@event.Trigger != null) |
|||
{ |
|||
rule.Update(@event.Trigger); |
|||
} |
|||
|
|||
if (@event.Action != null) |
|||
{ |
|||
rule.Update(@event.Action); |
|||
} |
|||
} |
|||
|
|||
public static void Apply(this Rule rule, RuleEnabled @event) |
|||
{ |
|||
rule.Enable(); |
|||
} |
|||
|
|||
public static void Apply(this Rule rule, RuleDisabled @event) |
|||
{ |
|||
rule.Disable(); |
|||
} |
|||
|
|||
private static WebhookAction CreateAction(WebhookCreated @event) |
|||
{ |
|||
var action = new WebhookAction { Url = @event.Url, SharedSecret = @event.SharedSecret }; |
|||
|
|||
return action; |
|||
} |
|||
|
|||
private static ContentChangedTrigger CreateTrigger(WebhookEditEvent @event) |
|||
{ |
|||
var trigger = new ContentChangedTrigger |
|||
{ |
|||
Schemas = @event.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchema())).ToList() |
|||
}; |
|||
|
|||
return trigger; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// IRuleEntity.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules |
|||
{ |
|||
public interface IRuleEntity : IAppRefEntity, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion |
|||
{ |
|||
Rule Rule { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
// ==========================================================================
|
|||
// IRuleEventEntity.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules |
|||
{ |
|||
public interface IRuleEventEntity : IEntity |
|||
{ |
|||
RuleJob Job { get; } |
|||
|
|||
Instant? NextAttempt { get; } |
|||
|
|||
RuleJobResult JobResult { get; } |
|||
|
|||
RuleResult Result { get; } |
|||
|
|||
int NumCalls { get; } |
|||
|
|||
string LastDump { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
// ==========================================================================
|
|||
// IRuleEventRepository.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules.Repositories |
|||
{ |
|||
public interface IRuleEventRepository |
|||
{ |
|||
Task EnqueueAsync(RuleJob job, Instant nextAttempt); |
|||
|
|||
Task EnqueueAsync(Guid id, Instant nextAttempt); |
|||
|
|||
Task MarkSendingAsync(Guid jobId); |
|||
|
|||
Task TraceSentAsync(Guid jobId, string dump, RuleResult result, TimeSpan elapsed, Instant? nextCall); |
|||
|
|||
Task QueryPendingAsync(Func<IRuleEventEntity, Task> callback, CancellationToken cancellationToken = default(CancellationToken)); |
|||
|
|||
Task<int> CountByAppAsync(Guid appId); |
|||
|
|||
Task<IReadOnlyList<IRuleEventEntity>> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); |
|||
|
|||
Task<IRuleEventEntity> FindAsync(Guid id); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// IRuleRepository.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules.Repositories |
|||
{ |
|||
public interface IRuleRepository |
|||
{ |
|||
Task<IReadOnlyList<IRuleEntity>> QueryByAppAsync(Guid appId); |
|||
|
|||
Task<IReadOnlyList<IRuleEntity>> QueryCachedByAppAsync(Guid appId); |
|||
} |
|||
} |
|||
@ -0,0 +1,148 @@ |
|||
// ==========================================================================
|
|||
// RuleDequeuer.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using System.Threading.Tasks.Dataflow; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Read.Rules.Repositories; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.Timers; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules |
|||
{ |
|||
public sealed class RuleDequeuer : DisposableObjectBase, IExternalSystem |
|||
{ |
|||
private readonly ActionBlock<IRuleEventEntity> requestBlock; |
|||
private readonly TransformBlock<IRuleEventEntity, IRuleEventEntity> blockBlock; |
|||
private readonly IRuleEventRepository ruleEventRepository; |
|||
private readonly RuleService ruleService; |
|||
private readonly CompletionTimer timer; |
|||
private readonly ISemanticLog log; |
|||
|
|||
public RuleDequeuer(RuleService ruleService, IRuleEventRepository ruleEventRepository, ISemanticLog log) |
|||
{ |
|||
Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); |
|||
Guard.NotNull(ruleService, nameof(ruleService)); |
|||
Guard.NotNull(log, nameof(log)); |
|||
|
|||
this.ruleEventRepository = ruleEventRepository; |
|||
this.ruleService = ruleService; |
|||
|
|||
this.log = log; |
|||
|
|||
requestBlock = |
|||
new ActionBlock<IRuleEventEntity>(MakeRequestAsync, |
|||
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); |
|||
|
|||
blockBlock = |
|||
new TransformBlock<IRuleEventEntity, IRuleEventEntity>(x => BlockAsync(x), |
|||
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 1, BoundedCapacity = 1 }); |
|||
|
|||
blockBlock.LinkTo(requestBlock, new DataflowLinkOptions { PropagateCompletion = true }); |
|||
|
|||
timer = new CompletionTimer(5000, QueryAsync); |
|||
} |
|||
|
|||
protected override void DisposeObject(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
timer.StopAsync().Wait(); |
|||
|
|||
blockBlock.Complete(); |
|||
requestBlock.Completion.Wait(); |
|||
} |
|||
} |
|||
|
|||
public void Connect() |
|||
{ |
|||
} |
|||
|
|||
public void Next() |
|||
{ |
|||
timer.SkipCurrentDelay(); |
|||
} |
|||
|
|||
private async Task QueryAsync(CancellationToken cancellationToken) |
|||
{ |
|||
try |
|||
{ |
|||
await ruleEventRepository.QueryPendingAsync(blockBlock.SendAsync, cancellationToken); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, w => w |
|||
.WriteProperty("action", "QueueWebhookEvents") |
|||
.WriteProperty("status", "Failed")); |
|||
} |
|||
} |
|||
|
|||
private async Task<IRuleEventEntity> BlockAsync(IRuleEventEntity @event) |
|||
{ |
|||
try |
|||
{ |
|||
await ruleEventRepository.MarkSendingAsync(@event.Id); |
|||
|
|||
return @event; |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, w => w |
|||
.WriteProperty("action", "BlockWebhookEvent") |
|||
.WriteProperty("status", "Failed")); |
|||
|
|||
throw; |
|||
} |
|||
} |
|||
|
|||
private async Task MakeRequestAsync(IRuleEventEntity @event) |
|||
{ |
|||
try |
|||
{ |
|||
var job = @event.Job; |
|||
|
|||
var response = await ruleService.InvokeAsync(job.ActionName, job.Details); |
|||
|
|||
Instant? nextCall = null; |
|||
|
|||
if (response.Result != RuleResult.Success) |
|||
{ |
|||
switch (@event.NumCalls) |
|||
{ |
|||
case 0: |
|||
nextCall = job.Created.Plus(Duration.FromMinutes(5)); |
|||
break; |
|||
case 1: |
|||
nextCall = job.Created.Plus(Duration.FromHours(1)); |
|||
break; |
|||
case 2: |
|||
nextCall = job.Created.Plus(Duration.FromHours(6)); |
|||
break; |
|||
case 3: |
|||
nextCall = job.Created.Plus(Duration.FromHours(12)); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
await ruleEventRepository.TraceSentAsync(@event.Id, response.Dump, response.Result, response.Elapsed, nextCall); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, w => w |
|||
.WriteProperty("action", "SendWebhookEvent") |
|||
.WriteProperty("status", "Failed")); |
|||
|
|||
throw; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
// ==========================================================================
|
|||
// RuleEnqueuer.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Domain.Apps.Read.Rules.Repositories; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.CQRS.Events; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules |
|||
{ |
|||
public sealed class RuleEnqueuer : IEventConsumer |
|||
{ |
|||
private readonly IRuleEventRepository ruleEventRepository; |
|||
private readonly IRuleRepository ruleRepository; |
|||
private readonly RuleService ruleService; |
|||
|
|||
public string Name |
|||
{ |
|||
get { return GetType().Name; } |
|||
} |
|||
|
|||
public string EventsFilter |
|||
{ |
|||
get { return "^content-"; } |
|||
} |
|||
|
|||
public RuleEnqueuer( |
|||
IRuleEventRepository ruleEventRepository, |
|||
IRuleRepository ruleRepository, |
|||
RuleService ruleService) |
|||
{ |
|||
Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); |
|||
Guard.NotNull(ruleRepository, nameof(ruleRepository)); |
|||
Guard.NotNull(ruleService, nameof(ruleService)); |
|||
|
|||
this.ruleEventRepository = ruleEventRepository; |
|||
this.ruleRepository = ruleRepository; |
|||
this.ruleService = ruleService; |
|||
} |
|||
|
|||
public Task ClearAsync() |
|||
{ |
|||
return TaskHelper.Done; |
|||
} |
|||
|
|||
public async Task On(Envelope<IEvent> @event) |
|||
{ |
|||
if (@event.Payload is AppEvent appEvent) |
|||
{ |
|||
var rules = await ruleRepository.QueryCachedByAppAsync(appEvent.AppId.Id); |
|||
|
|||
foreach (var ruleEntity in rules) |
|||
{ |
|||
var job = ruleService.CreateJob(ruleEntity.Rule, @event); |
|||
|
|||
if (job == null) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
await ruleEventRepository.EnqueueAsync(job, job.Created); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// RuleJobResult.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Read.Rules |
|||
{ |
|||
public enum RuleJobResult |
|||
{ |
|||
Pending, |
|||
Success, |
|||
Retry, |
|||
Failed |
|||
} |
|||
} |
|||
Loading…
Reference in new issue