From 94852f12d8807fe309fea18e498cfa7e3d4c453a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 20:44:24 +0200 Subject: [PATCH] More options for webhooks. --- .../Actions/Webhook/WebhookAction.cs | 26 +++- .../Actions/Webhook/WebhookActionHandler.cs | 104 ++++++++++++-- .../HandleRules/RuleActionProperty.cs | 2 + .../HandleRules/RuleActionPropertyEditor.cs | 1 + .../HandleRules/RuleRegistry.cs | 127 ++++++++++++++---- .../Rules/Models/RuleElementPropertyDto.cs | 5 + .../HandleRules/RuleElementRegistryTests.cs | 32 +++++ .../actions/generic-action.component.html | 8 +- .../app/shared/services/rules.service.spec.ts | 8 +- frontend/app/shared/services/rules.service.ts | 6 +- 10 files changed, 268 insertions(+), 51 deletions(-) diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs index 954c74a2d..2ddf43463 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs @@ -28,13 +28,33 @@ namespace Squidex.Extensions.Actions.Webhook [Formattable] public Uri Url { get; set; } - [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the signature.")] - [DataType(DataType.Text)] - public string SharedSecret { get; set; } + [LocalizedRequired] + [Display(Name = "Url", Description = "The type of the request.")] + public WebhookMethod Method { get; set; } [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [DataType(DataType.MultilineText)] [Formattable] public string Payload { get; set; } + + [Display(Name = "Payload Type", Description = "The mime type of the payload.")] + [DataType(DataType.Text)] + public string PayloadType { get; set; } + + [Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")] + [DataType(DataType.MultilineText)] + [Formattable] + public string Headers { get; set; } + + [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")] + [DataType(DataType.Text)] + public string SharedSecret { get; set; } + } + + public enum WebhookMethod + { + POST, + PUT, + GET } } diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs index 25fc872a8..57789717b 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; @@ -29,15 +30,21 @@ namespace Squidex.Extensions.Actions.Webhook protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action) { - string requestBody; + var requestBody = string.Empty; + var requestSignature = string.Empty; - if (!string.IsNullOrEmpty(action.Payload)) + if (action.Method != WebhookMethod.GET) { - requestBody = await FormatAsync(action.Payload, @event); - } - else - { - requestBody = ToEnvelopeJson(@event); + if (!string.IsNullOrEmpty(action.Payload)) + { + requestBody = await FormatAsync(action.Payload, @event); + } + else + { + requestBody = ToEnvelopeJson(@event); + } + + requestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(); } var requestUrl = await FormatAsync(action.Url, @event); @@ -45,27 +52,90 @@ namespace Squidex.Extensions.Actions.Webhook var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleJob = new WebhookJob { + Method = action.Method, RequestUrl = await FormatAsync(action.Url.ToString(), @event), - RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), - RequestBody = requestBody + RequestSignature = requestSignature, + RequestBody = requestBody, + RequestBodyType = action.PayloadType, + Headers = await ParseHeadersAsync(action.Headers, @event) }; return (ruleDescription, ruleJob); } + private async Task> ParseHeadersAsync(string headers, EnrichedEvent @event) + { + if (string.IsNullOrWhiteSpace(headers)) + { + return null; + } + + var headersDictionary = new Dictionary(); + + var lines = headers.Split('\n'); + + foreach (var line in lines) + { + var indexEqual = line.IndexOf('='); + + if (indexEqual > 0 && indexEqual < line.Length - 1) + { + var key = line.Substring(0, indexEqual); + var val = line.Substring(indexEqual + 1); + + val = await FormatAsync(val, @event); + + headersDictionary[key] = val; + } + } + + return headersDictionary; + } + protected override async Task ExecuteJobAsync(WebhookJob job, CancellationToken ct = default) { using (var httpClient = httpClientFactory.CreateClient()) { - using (var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + var method = HttpMethod.Post; + + switch (job.Method) { - Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") - }) + case WebhookMethod.PUT: + method = HttpMethod.Put; + break; + case WebhookMethod.GET: + method = HttpMethod.Get; + break; + } + + var request = new HttpRequestMessage(method, job.RequestUrl); + + if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET) + { + var mediaType = job.RequestBodyType.Or("application/json"); + + request.Content = new StringContent(job.RequestBody, Encoding.UTF8, mediaType); + } + + using (request) { - request.Headers.Add("X-Signature", job.RequestSignature); - request.Headers.Add("X-Application", "Squidex Webhook"); request.Headers.Add("User-Agent", "Squidex Webhook"); + if (job.Headers != null) + { + foreach (var (key, value) in job.Headers) + { + request.Headers.TryAddWithoutValidation(key, value); + } + } + + if (!string.IsNullOrWhiteSpace(job.RequestSignature)) + { + request.Headers.Add("X-Signature", job.RequestSignature); + } + + request.Headers.Add("X-Application", "Squidex Webhook"); + return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); } } @@ -74,10 +144,16 @@ namespace Squidex.Extensions.Actions.Webhook public sealed class WebhookJob { + public WebhookMethod Method { get; set; } + public string RequestUrl { get; set; } public string RequestSignature { get; set; } public string RequestBody { get; set; } + + public string RequestBodyType { get; set; } + + public Dictionary Headers { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs index 956314daa..61793f8bc 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules public string? Description { get; set; } + public string[]? Options { get; set; } + public bool IsFormattable { get; set; } public bool IsRequired { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs index 469e01a35..0371def5f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs @@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules public enum RuleActionPropertyEditor { Checkbox, + Dropdown, Email, Number, Password, diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs index 6a0f6fab6..b1b9c0fab 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs @@ -73,7 +73,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules { if (property.CanRead && property.CanWrite) { - var actionProperty = new RuleActionProperty { Name = property.Name.ToCamelCase(), Display = property.Name }; + var actionProperty = new RuleActionProperty + { + Name = property.Name.ToCamelCase() + }; var display = property.GetCustomAttribute(); @@ -81,17 +84,29 @@ namespace Squidex.Domain.Apps.Core.HandleRules { actionProperty.Display = display.Name; } + else + { + actionProperty.Display = property.Name; + } if (!string.IsNullOrWhiteSpace(display?.Description)) { actionProperty.Description = display.Description; } - var type = property.PropertyType; + var type = GetType(property); - if ((GetDataAttribute(property) != null || (type.IsValueType && !IsNullable(type))) && type != typeof(bool) && type != typeof(bool?)) + if (!IsNullable(property.PropertyType)) { - actionProperty.IsRequired = true; + if (GetDataAttribute(property) != null) + { + actionProperty.IsRequired = true; + } + + if (type.IsValueType && !IsBoolean(type) && !type.IsEnum) + { + actionProperty.IsRequired = true; + } } if (property.GetCustomAttribute() != null) @@ -99,35 +114,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules actionProperty.IsFormattable = true; } - var dataType = GetDataAttribute(property)?.DataType; - - if (type == typeof(bool) || type == typeof(bool?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Checkbox; - } - else if (type == typeof(int) || type == typeof(int?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Number; - } - else if (dataType == DataType.Url) + if (type.IsEnum) { - actionProperty.Editor = RuleActionPropertyEditor.Url; - } - else if (dataType == DataType.Password) - { - actionProperty.Editor = RuleActionPropertyEditor.Password; - } - else if (dataType == DataType.EmailAddress) - { - actionProperty.Editor = RuleActionPropertyEditor.Email; - } - else if (dataType == DataType.MultilineText) - { - actionProperty.Editor = RuleActionPropertyEditor.TextArea; + var values = Enum.GetNames(type); + + actionProperty.Options = values; + actionProperty.Editor = RuleActionPropertyEditor.Dropdown; } else { - actionProperty.Editor = RuleActionPropertyEditor.Text; + var dataType = GetDataAttribute(property)?.DataType; + + if (IsBoolean(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Checkbox; + } + else if (IsNumericType(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Number; + } + else if (dataType == DataType.Url) + { + actionProperty.Editor = RuleActionPropertyEditor.Url; + } + else if (dataType == DataType.Password) + { + actionProperty.Editor = RuleActionPropertyEditor.Password; + } + else if (dataType == DataType.EmailAddress) + { + actionProperty.Editor = RuleActionPropertyEditor.Email; + } + else if (dataType == DataType.MultilineText) + { + actionProperty.Editor = RuleActionPropertyEditor.TextArea; + } + else + { + actionProperty.Editor = RuleActionPropertyEditor.Text; + } } definition.Properties.Add(actionProperty); @@ -151,6 +176,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); } + private static bool IsBoolean(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + return true; + default: + return false; + } + } + + private static bool IsNumericType(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + return true; + default: + return false; + } + } + + private static Type GetType(PropertyInfo property) + { + var type = property.PropertyType; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + type = type.GetGenericArguments()[0]; + } + + return type; + } + private static string GetActionName(Type type) { return type.TypeName(false, ActionSuffix, ActionSuffixV2); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs index f6180f369..529540b1a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs @@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [LocalizedRequired] public string Display { get; set; } + /// + /// The options, if the editor is a dropdown. + /// + public string[]? Options { get; set; } + /// /// The optional description. /// diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs index 3cfca4238..c446ab897 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs @@ -41,6 +41,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules public string Custom { get; set; } } + public enum ActionEnum + { + Yes, + No + } + [RuleAction( Title = "Action", IconImage = "", @@ -68,6 +74,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [DataType(DataType.Password)] public string Password { get; set; } + [DataType(DataType.Text)] + public ActionEnum Enum { get; set; } + + [DataType(DataType.Text)] + public ActionEnum? EnumOptional { get; set; } + [DataType(DataType.Text)] public bool Boolean { get; set; } @@ -141,6 +153,26 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules IsRequired = false }); + expected.Properties.Add(new RuleActionProperty + { + Name = "enum", + Display = "Enum", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = true, + Options = new[] { "Yes", "No" } + }); + + expected.Properties.Add(new RuleActionProperty + { + Name = "enumOptional", + Display = "EnumOptional", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = false, + Options = new[] { "Yes", "No" } + }); + expected.Properties.Add(new RuleActionProperty { Name = "boolean", diff --git a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html index bf34b98c8..5000d2f75 100644 --- a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html +++ b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html @@ -13,12 +13,18 @@
- +
+ + + diff --git a/frontend/app/shared/services/rules.service.spec.ts b/frontend/app/shared/services/rules.service.spec.ts index ff60594e5..3966c6359 100644 --- a/frontend/app/shared/services/rules.service.spec.ts +++ b/frontend/app/shared/services/rules.service.spec.ts @@ -64,7 +64,11 @@ describe('RulesService', () => { display: 'Display2', description: 'Description2', isRequired: false, - isFormattable: true + isFormattable: true, + options: [ + 'Yes', + 'No' + ] }] }, action1: { @@ -82,7 +86,7 @@ describe('RulesService', () => { const action2 = new RuleElementDto('title2', 'display2', 'description2', '#222', '', null, 'link2', [ new RuleElementPropertyDto('property1', 'Editor1', 'Display1', 'Description1', false, true), - new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false) + new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false, ['Yes', 'No']) ]); expect(actions!).toEqual({ diff --git a/frontend/app/shared/services/rules.service.ts b/frontend/app/shared/services/rules.service.ts index 56358e7fc..db8f46bc3 100644 --- a/frontend/app/shared/services/rules.service.ts +++ b/frontend/app/shared/services/rules.service.ts @@ -97,7 +97,8 @@ export class RuleElementPropertyDto { public readonly display: string, public readonly description: string, public readonly isFormattable: boolean, - public readonly isRequired: boolean + public readonly isRequired: boolean, + public readonly options?: ReadonlyArray ) { } } @@ -228,7 +229,8 @@ export class RulesService { property.display, property.description, property.isFormattable, - property.isRequired + property.isRequired, + property.options )); actions[key] = new RuleElementDto(