Browse Source

More options for webhooks.

pull/588/head
Sebastian 5 years ago
parent
commit
94852f12d8
  1. 26
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs
  2. 104
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  3. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs
  4. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs
  5. 127
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs
  6. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs
  7. 32
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs
  8. 8
      frontend/app/features/rules/pages/rules/actions/generic-action.component.html
  9. 8
      frontend/app/shared/services/rules.service.spec.ts
  10. 6
      frontend/app/shared/services/rules.service.ts

26
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs

@ -28,13 +28,33 @@ namespace Squidex.Extensions.Actions.Webhook
[Formattable] [Formattable]
public Uri Url { get; set; } public Uri Url { get; set; }
[Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the signature.")] [LocalizedRequired]
[DataType(DataType.Text)] [Display(Name = "Url", Description = "The type of the request.")]
public string SharedSecret { get; set; } public WebhookMethod Method { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[DataType(DataType.MultilineText)] [DataType(DataType.MultilineText)]
[Formattable] [Formattable]
public string Payload { get; set; } 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
} }
} }

104
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading; 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) 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); if (!string.IsNullOrEmpty(action.Payload))
} {
else requestBody = await FormatAsync(action.Payload, @event);
{ }
requestBody = ToEnvelopeJson(@event); else
{
requestBody = ToEnvelopeJson(@event);
}
requestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64();
} }
var requestUrl = await FormatAsync(action.Url, @event); var requestUrl = await FormatAsync(action.Url, @event);
@ -45,27 +52,90 @@ namespace Squidex.Extensions.Actions.Webhook
var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob var ruleJob = new WebhookJob
{ {
Method = action.Method,
RequestUrl = await FormatAsync(action.Url.ToString(), @event), RequestUrl = await FormatAsync(action.Url.ToString(), @event),
RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), RequestSignature = requestSignature,
RequestBody = requestBody RequestBody = requestBody,
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event)
}; };
return (ruleDescription, ruleJob); return (ruleDescription, ruleJob);
} }
private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
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<Result> ExecuteJobAsync(WebhookJob job, CancellationToken ct = default) protected override async Task<Result> ExecuteJobAsync(WebhookJob job, CancellationToken ct = default)
{ {
using (var httpClient = httpClientFactory.CreateClient()) 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"); 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); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);
} }
} }
@ -74,10 +144,16 @@ namespace Squidex.Extensions.Actions.Webhook
public sealed class WebhookJob public sealed class WebhookJob
{ {
public WebhookMethod Method { get; set; }
public string RequestUrl { get; set; } public string RequestUrl { get; set; }
public string RequestSignature { get; set; } public string RequestSignature { get; set; }
public string RequestBody { get; set; } public string RequestBody { get; set; }
public string RequestBodyType { get; set; }
public Dictionary<string, string> Headers { get; set; }
} }
} }

2
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? Description { get; set; }
public string[]? Options { get; set; }
public bool IsFormattable { get; set; } public bool IsFormattable { get; set; }
public bool IsRequired { get; set; } public bool IsRequired { get; set; }

1
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs

@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
public enum RuleActionPropertyEditor public enum RuleActionPropertyEditor
{ {
Checkbox, Checkbox,
Dropdown,
Email, Email,
Number, Number,
Password, Password,

127
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) 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<DisplayAttribute>(); var display = property.GetCustomAttribute<DisplayAttribute>();
@ -81,17 +84,29 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{ {
actionProperty.Display = display.Name; actionProperty.Display = display.Name;
} }
else
{
actionProperty.Display = property.Name;
}
if (!string.IsNullOrWhiteSpace(display?.Description)) if (!string.IsNullOrWhiteSpace(display?.Description))
{ {
actionProperty.Description = display.Description; actionProperty.Description = display.Description;
} }
var type = property.PropertyType; var type = GetType(property);
if ((GetDataAttribute<RequiredAttribute>(property) != null || (type.IsValueType && !IsNullable(type))) && type != typeof(bool) && type != typeof(bool?)) if (!IsNullable(property.PropertyType))
{ {
actionProperty.IsRequired = true; if (GetDataAttribute<RequiredAttribute>(property) != null)
{
actionProperty.IsRequired = true;
}
if (type.IsValueType && !IsBoolean(type) && !type.IsEnum)
{
actionProperty.IsRequired = true;
}
} }
if (property.GetCustomAttribute<FormattableAttribute>() != null) if (property.GetCustomAttribute<FormattableAttribute>() != null)
@ -99,35 +114,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules
actionProperty.IsFormattable = true; actionProperty.IsFormattable = true;
} }
var dataType = GetDataAttribute<DataTypeAttribute>(property)?.DataType; if (type.IsEnum)
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)
{ {
actionProperty.Editor = RuleActionPropertyEditor.Url; var values = Enum.GetNames(type);
}
else if (dataType == DataType.Password) actionProperty.Options = values;
{ actionProperty.Editor = RuleActionPropertyEditor.Dropdown;
actionProperty.Editor = RuleActionPropertyEditor.Password;
}
else if (dataType == DataType.EmailAddress)
{
actionProperty.Editor = RuleActionPropertyEditor.Email;
}
else if (dataType == DataType.MultilineText)
{
actionProperty.Editor = RuleActionPropertyEditor.TextArea;
} }
else else
{ {
actionProperty.Editor = RuleActionPropertyEditor.Text; var dataType = GetDataAttribute<DataTypeAttribute>(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); definition.Properties.Add(actionProperty);
@ -151,6 +176,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); 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) private static string GetActionName(Type type)
{ {
return type.TypeName(false, ActionSuffix, ActionSuffixV2); return type.TypeName(false, ActionSuffix, ActionSuffixV2);

5
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs

@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
[LocalizedRequired] [LocalizedRequired]
public string Display { get; set; } public string Display { get; set; }
/// <summary>
/// The options, if the editor is a dropdown.
/// </summary>
public string[]? Options { get; set; }
/// <summary> /// <summary>
/// The optional description. /// The optional description.
/// </summary> /// </summary>

32
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 string Custom { get; set; }
} }
public enum ActionEnum
{
Yes,
No
}
[RuleAction( [RuleAction(
Title = "Action", Title = "Action",
IconImage = "<svg></svg>", IconImage = "<svg></svg>",
@ -68,6 +74,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[DataType(DataType.Password)] [DataType(DataType.Password)]
public string Password { get; set; } public string Password { get; set; }
[DataType(DataType.Text)]
public ActionEnum Enum { get; set; }
[DataType(DataType.Text)]
public ActionEnum? EnumOptional { get; set; }
[DataType(DataType.Text)] [DataType(DataType.Text)]
public bool Boolean { get; set; } public bool Boolean { get; set; }
@ -141,6 +153,26 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
IsRequired = false 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 expected.Properties.Add(new RuleActionProperty
{ {
Name = "boolean", Name = "boolean",

8
frontend/app/features/rules/pages/rules/actions/generic-action.component.html

@ -13,12 +13,18 @@
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Checkbox'"> <ng-container *ngSwitchCase="'Checkbox'">
<div class="custom-control custom-checkbox"> <div class="custom-control custom-checkbox">
<input class="custom-control-input" type="checkbox" id="{{property.name}}" [formControlName]="property.name"> <input class="custom-control-input" type="checkbox" id="{{property.name}}">
<label class="custom-control-label" for="{{property.name}}"> <label class="custom-control-label" for="{{property.name}}">
{{property.display}} {{property.display}}
</label> </label>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Dropdown'">
<select class="custom-select" [formControlName]="property.name">
<option *ngIf="!property.isRequired"></option>
<option *ngFor="let option of property.options">{{option}}</option>
</select>
</ng-container>
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<input type="{{property.editor | lowercase}}" class="form-control" id="{{property.name}}" [formControlName]="property.name"> <input type="{{property.editor | lowercase}}" class="form-control" id="{{property.name}}" [formControlName]="property.name">
</ng-container> </ng-container>

8
frontend/app/shared/services/rules.service.spec.ts

@ -64,7 +64,11 @@ describe('RulesService', () => {
display: 'Display2', display: 'Display2',
description: 'Description2', description: 'Description2',
isRequired: false, isRequired: false,
isFormattable: true isFormattable: true,
options: [
'Yes',
'No'
]
}] }]
}, },
action1: { action1: {
@ -82,7 +86,7 @@ describe('RulesService', () => {
const action2 = new RuleElementDto('title2', 'display2', 'description2', '#222', '<svg path="2" />', null, 'link2', [ const action2 = new RuleElementDto('title2', 'display2', 'description2', '#222', '<svg path="2" />', null, 'link2', [
new RuleElementPropertyDto('property1', 'Editor1', 'Display1', 'Description1', false, true), 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({ expect(actions!).toEqual({

6
frontend/app/shared/services/rules.service.ts

@ -97,7 +97,8 @@ export class RuleElementPropertyDto {
public readonly display: string, public readonly display: string,
public readonly description: string, public readonly description: string,
public readonly isFormattable: boolean, public readonly isFormattable: boolean,
public readonly isRequired: boolean public readonly isRequired: boolean,
public readonly options?: ReadonlyArray<string>
) { ) {
} }
} }
@ -228,7 +229,8 @@ export class RulesService {
property.display, property.display,
property.description, property.description,
property.isFormattable, property.isFormattable,
property.isRequired property.isRequired,
property.options
)); ));
actions[key] = new RuleElementDto( actions[key] = new RuleElementDto(

Loading…
Cancel
Save