From 2bc51425468545d800b2ece272965c5745e63a3b Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Sun, 28 Jan 2024 19:45:35 +0100 Subject: [PATCH 1/4] Introducing EventNotification to wrap multiple events in one notification job --- .../Services/UserCreatedWebhookFactory.cs | 10 +- .../Webhooks/DefaultMongoWebhookConverter.cs | 15 +- .../Webhooks/IWebhookConverter.cs | 6 +- .../MongoDbWebhookDeliveryResultLogger.cs | 22 +- .../Webhooks/MongoDbWebhookStorageBuilder.cs | 9 +- .../Webhooks/DefaultWebhookFactory_1.cs | 17 +- .../Webhooks/EventNotification.cs | 60 +++ .../Webhooks/ITenantWebhookNotifier.cs | 6 +- .../Webhooks/IWebhookDeliveryResultLogger.cs | 10 +- .../Webhooks/IWebhookFactory.cs | 5 +- .../Webhooks/IWebhookNotifier.cs | 6 +- .../NullWebhookDeliveryResultLogger.cs | 5 +- .../Webhooks/TenantWebhookNotifier.cs | 16 +- .../TenantWebhookNotifierExtensions.cs | 14 + .../Webhooks/WebhookNotificationResult.cs | 14 +- .../Webhooks/WebhookNotifier.cs | 10 +- .../Webhooks/WebhookNotifierBase.cs | 54 +-- .../Webhooks/WebhookNotifierExtensions.cs | 8 + .../Webhooks/DeliveryResultLoggerTestSuite.cs | 7 +- .../Webhooks/WebhookNotificationTests.cs | 369 ++---------------- .../Webhooks/WebhookNotifierTests.cs | 364 +++++++++++++++++ 21 files changed, 575 insertions(+), 452 deletions(-) create mode 100644 src/Deveel.Webhooks/Webhooks/EventNotification.cs create mode 100644 src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs create mode 100644 src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs create mode 100644 test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs diff --git a/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs b/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs index 776533b..8f849c0 100644 --- a/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs +++ b/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs @@ -8,17 +8,19 @@ public UserCreatedWebhookFactory(IUserResolver userResolver) { this.userResolver = userResolver; } - public async Task CreateAsync(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken = default) { - var userCreated = (UserCreatedEvent?)eventInfo.Data; + public async Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default) { + var @event = notification.SingleEvent; + + var userCreated = (UserCreatedEvent?)@event.Data; var user = await userResolver.ResolveUserAsync(userCreated!.UserId, cancellationToken); if (user == null) throw new InvalidOperationException(); return new IdentityWebhook { - EventId = eventInfo.Id, + EventId = @event.Id, EventType = "user_created", - TimeStamp = eventInfo.TimeStamp, + TimeStamp = @event.TimeStamp, User = user }; } diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs index 19ed693..6328c89 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs @@ -32,9 +32,6 @@ public class DefaultMongoWebhookConverter : IMongoWebhookConverter and the webhook object into /// a object. /// - /// - /// The event information that is being notified. - /// /// /// The webhook that was notified to the subscribers. /// @@ -42,7 +39,7 @@ public class DefaultMongoWebhookConverter : IMongoWebhookConverter that represents the /// webhook that can be stored into the database. /// - public MongoWebhook ConvertWebhook(EventInfo eventInfo, TWebhook webhook) { + public MongoWebhook ConvertWebhook(EventNotification notification, TWebhook webhook) { if (webhook is IWebhook obj) { return new MongoWebhook { WebhookId = obj.Id, @@ -52,10 +49,14 @@ public MongoWebhook ConvertWebhook(EventInfo eventInfo, TWebhook webhook) { }; } + // TODO: we should support multiple events in a single notification + + var firstEvent = notification.Events.First(); + return new MongoWebhook { - EventType = eventInfo.EventType, - TimeStamp = eventInfo.TimeStamp, - WebhookId = eventInfo.Id, + EventType = notification.EventType, + TimeStamp = firstEvent.TimeStamp, + WebhookId = notification.NotificationId, Data = BsonValueUtil.ConvertData(webhook) }; } diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs index cf3d7e7..b3577d5 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs @@ -26,10 +26,6 @@ public interface IMongoWebhookConverter /// Converts the given webhook to an object that can be stored /// in a MongoDB database. /// - /// - /// The information about the event that triggered the - /// notification of the webhook. - /// /// /// The instance of the webhook to be converted. /// @@ -37,6 +33,6 @@ public interface IMongoWebhookConverter /// Returns an instance of /// that can be stored in a MongoDB database. /// - MongoWebhook ConvertWebhook(EventInfo eventInfo, TWebhook webhook); + MongoWebhook ConvertWebhook(EventNotification notification, TWebhook webhook); } } diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs index 307d270..6364dde 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs @@ -12,14 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Collections; -using System.Reflection; - using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using MongoDB.Bson; - namespace Deveel.Webhooks { /// /// An implementation of that @@ -87,14 +82,14 @@ public MongoDbWebhookDeliveryResultLogger( /// /// Returns an object that can be stored in the MongoDB database collection. /// - protected virtual TResult ConvertResult(EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult result) { + protected virtual TResult ConvertResult(EventNotification notification, EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult result) { var obj = new TResult(); obj.TenantId = subscription.TenantId; obj.OperationId = result.OperationId; obj.EventInfo = CreateEvent(eventInfo); obj.Receiver = CreateReceiver(subscription); - obj.Webhook = ConvertWebhook(eventInfo, result.Webhook); + obj.Webhook = ConvertWebhook(notification, result.Webhook); obj.DeliveryAttempts = result.Attempts?.Select(ConvertDeliveryAttempt).ToList() ?? new List(); @@ -162,9 +157,6 @@ protected virtual MongoWebhookDeliveryAttempt ConvertDeliveryAttempt(WebhookDeli /// Converts the given webhook to an object that can be stored in the /// MongoDB database collection. /// - /// - /// The information about the event that triggered the delivery of the webhook. - /// /// /// The instance of the webhook to convert. /// @@ -175,15 +167,15 @@ protected virtual MongoWebhookDeliveryAttempt ConvertDeliveryAttempt(WebhookDeli /// Thrown when the given type of webhook is not supported by this instance and /// no converter was provided. /// - protected virtual MongoWebhook ConvertWebhook(EventInfo eventInfo, TWebhook webhook) { + protected virtual MongoWebhook ConvertWebhook(EventNotification notification, TWebhook webhook) { if (webhookConverter != null) - return webhookConverter.ConvertWebhook(eventInfo, webhook); + return webhookConverter.ConvertWebhook(notification, webhook); throw new NotSupportedException("The given type of webhook is not supported by this instance of the logger"); } /// - public async Task LogResultAsync(EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken) { + public async Task LogResultAsync(EventNotification notification, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(result, nameof(result)); ArgumentNullException.ThrowIfNull(subscription, nameof(subscription)); @@ -195,11 +187,11 @@ public async Task LogResultAsync(EventInfo eventInfo, IWebhookSubscription subsc typeof(TWebhook), subscription.TenantId); try { - var resultObj = ConvertResult(eventInfo, subscription, result); + var results = notification.Select(e => ConvertResult(notification, e, subscription, result)); var repository = await RepositoryProvider.GetRepositoryAsync(subscription.TenantId, cancellationToken); - await repository.AddAsync(resultObj, cancellationToken); + await repository.AddRangeAsync(results, cancellationToken); } catch (Exception ex) { Logger.LogError(ex, "Could not log the result of the delivery of the Webhook of type '{WebhookType}' for tenant '{TenantId}' because of an error", typeof(TWebhook), subscription.TenantId); diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookStorageBuilder.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookStorageBuilder.cs index d653cff..f0a2f9e 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookStorageBuilder.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookStorageBuilder.cs @@ -202,7 +202,7 @@ public MongoDbWebhookStorageBuilder UseWebhookConverter /// Returns the current instance of the builder for chaining. /// - public MongoDbWebhookStorageBuilder UseWebhookConverter(Func converter) + public MongoDbWebhookStorageBuilder UseWebhookConverter(Func converter) where TWebhook : class { Services.AddSingleton>(new MongoWebhookConverterWrapper(converter)); @@ -211,13 +211,14 @@ public MongoDbWebhookStorageBuilder UseWebhookConverter } private class MongoWebhookConverterWrapper : IMongoWebhookConverter where TWebhook : class { - private readonly Func converter; + private readonly Func converter; - public MongoWebhookConverterWrapper(Func converter) { + public MongoWebhookConverterWrapper(Func converter) { this.converter = converter; } - public MongoWebhook ConvertWebhook(EventInfo eventInfo, TWebhook webhook) => converter.Invoke(eventInfo, webhook); + public MongoWebhook ConvertWebhook(EventNotification notification, TWebhook webhook) + => converter.Invoke(notification, webhook); } } } diff --git a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs index ceb46cb..16bef2c 100644 --- a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs +++ b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs @@ -65,7 +65,7 @@ namespace Deveel.Webhooks { /// /// The subscription that is listening to the event /// - /// + /// /// The event that is being delivered to the subscription /// /// @@ -74,14 +74,19 @@ namespace Deveel.Webhooks { /// /// Returns a task that resolves to the created webhook /// - public Task CreateAsync(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken) { + public Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { + if (!notification.HasSingleEvent) + throw new WebhookException("Multiple events per notification not supported yet."); + + var @event = notification.SingleEvent; + var webhook = new TWebhook { - Id = eventInfo.Id, - EventType = eventInfo.EventType, + Id = @event.Id, + EventType = @event.EventType, SubscriptionId = subscription.SubscriptionId, Name = subscription.Name, - TimeStamp = eventInfo.TimeStamp, - Data = eventInfo.Data, + TimeStamp = @event.TimeStamp, + Data = @event.Data, }; return Task.FromResult(webhook); diff --git a/src/Deveel.Webhooks/Webhooks/EventNotification.cs b/src/Deveel.Webhooks/Webhooks/EventNotification.cs new file mode 100644 index 0000000..e3ff0f6 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/EventNotification.cs @@ -0,0 +1,60 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Deveel.Webhooks { + public sealed class EventNotification : IEnumerable { + public EventNotification(string eventType, IEnumerable events) { + ArgumentNullException.ThrowIfNull(events, nameof(events)); + +#if NET7_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(eventType, nameof(eventType)); +#else + if (String.IsNullOrEmpty(eventType)) + throw new ArgumentException("The event type cannot be null or empty", nameof(eventType)); +#endif + if (!events.Any()) + throw new ArgumentException("The list of events cannot be empty", nameof(events)); + + ValidateAllEventsOfType(eventType, events); + + Events = events.ToList().AsReadOnly(); + EventType = eventType; + NotificationId = Guid.NewGuid().ToString(); + } + + public EventNotification(EventInfo eventInfo) + : this(eventInfo.EventType, new[] {eventInfo}) { + } + + public IReadOnlyList Events { get; } + + public bool HasSingleEvent => Events.Count == 1; + + public EventInfo SingleEvent { + get { + if (!HasSingleEvent) + throw new InvalidOperationException("The notification has more than one event"); + + return Events[0]; + } + } + + public string EventType { get; } + + public string NotificationId { get; set; } + + public IDictionary Properties { get; set; } = new Dictionary(); + + public IEnumerator GetEnumerator() => Events.GetEnumerator(); + + [ExcludeFromCodeCoverage] + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)Events).GetEnumerator(); + + private static void ValidateAllEventsOfType(string eventType, IEnumerable events) { + foreach (var @event in events) { + if (!String.Equals(@event.EventType, eventType, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"The event {@event.EventType} is not of the type {eventType}"); + } + } + } +} diff --git a/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs index 877f28a..1ae1de4 100644 --- a/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs @@ -36,12 +36,14 @@ public interface ITenantWebhookNotifier where TWebhook : class { /// /// The scope of the tenant holding the subscriptions /// to the given event. - /// The ifnormation of the event that occurred. + /// + /// The aggregate of the events to be notified to the subscribers. + /// /// /// /// Returns an object that describes the aggregated final result of /// the notification process executed. /// - Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken); + Task> NotifyAsync(string tenantId, EventNotification notification, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookDeliveryResultLogger.cs b/src/Deveel.Webhooks/Webhooks/IWebhookDeliveryResultLogger.cs index 7285915..eb7838f 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookDeliveryResultLogger.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookDeliveryResultLogger.cs @@ -12,10 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Threading; -using System.Threading.Tasks; - namespace Deveel.Webhooks { /// /// Defines a service that is able to log the result of @@ -28,8 +24,8 @@ public interface IWebhookDeliveryResultLogger where TWebhook : class { /// /// Logs the result of a delivery of a webhook /// - /// - /// The information about the event that triggered the notification. + /// + /// /// /// /// The subscription that was used to deliver the webhook @@ -43,6 +39,6 @@ public interface IWebhookDeliveryResultLogger where TWebhook : class { /// /// Returns a task that when completed will log the result of the delivery /// - Task LogResultAsync(EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken = default); + Task LogResultAsync(EventNotification notification, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs index 10858f8..c060128 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs @@ -31,9 +31,6 @@ public interface IWebhookFactory where TWebhook : class { /// /// The subscription that is requesting the webhook. /// - /// - /// The event information that is triggering the delivery of the webhook. - /// /// /// A token that can be used to cancel the operation. /// @@ -41,6 +38,6 @@ public interface IWebhookFactory where TWebhook : class { /// Returns an instance of the webhook that will be delivered to /// the receiver that is subscribed to the event. /// - Task CreateAsync(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken = default); + Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs index 6a7ecd9..4552564 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs @@ -36,7 +36,9 @@ public interface IWebhookNotifier where TWebhook : class { /// Notifies to the subscribers the occurrence of the /// given event. /// - /// The ifnormation of the event that occurred. + /// + /// The aggregate of the events to be notified to the subscribers. + /// /// /// A token that can be used to cancel the notification process. /// @@ -44,6 +46,6 @@ public interface IWebhookNotifier where TWebhook : class { /// Returns an object that describes the aggregated final result of /// the notification process executed. /// - Task> NotifyAsync(EventInfo eventInfo, CancellationToken cancellationToken = default); + Task> NotifyAsync(EventNotification notification, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks/Webhooks/NullWebhookDeliveryResultLogger.cs b/src/Deveel.Webhooks/Webhooks/NullWebhookDeliveryResultLogger.cs index 23287b3..bb9f5f4 100644 --- a/src/Deveel.Webhooks/Webhooks/NullWebhookDeliveryResultLogger.cs +++ b/src/Deveel.Webhooks/Webhooks/NullWebhookDeliveryResultLogger.cs @@ -12,9 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Threading; -using System.Threading.Tasks; - namespace Deveel.Webhooks { /// /// A default implementation of that @@ -34,7 +31,7 @@ private NullWebhookDeliveryResultLogger() { public static readonly NullWebhookDeliveryResultLogger Instance = new NullWebhookDeliveryResultLogger(); /// - public Task LogResultAsync(EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken) { + public Task LogResultAsync(EventNotification notification, IWebhookSubscription subscription, WebhookDeliveryResult result, CancellationToken cancellationToken) { return Task.CompletedTask; } } diff --git a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs index ae492e7..8ea96b7 100644 --- a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs @@ -74,8 +74,8 @@ public TenantWebhookNotifier( /// /// The identifier of the tenant for which the event was raised. /// - /// - /// The information about the event that was raised. + /// + /// The aggreate of the events to notify. /// /// /// A cancellation token that can be used to cancel the operation. @@ -83,12 +83,12 @@ public TenantWebhookNotifier( /// /// Returns a list of subscriptions that should be notified for the given event. /// - protected virtual async Task> ResolveSubscriptionsAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken) { + protected virtual async Task> ResolveSubscriptionsAsync(string tenantId, EventNotification notification, CancellationToken cancellationToken) { if (subscriptionResolver == null) return new List(); try { - return await subscriptionResolver.ResolveSubscriptionsAsync(tenantId, eventInfo.EventType, true, cancellationToken); + return await subscriptionResolver.ResolveSubscriptionsAsync(tenantId, notification.EventType, true, cancellationToken); } catch(WebhookException) { throw; } catch (Exception ex) { @@ -97,18 +97,18 @@ protected virtual async Task> ResolveSubscriptionsAs } /// - public virtual async Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken) { + public virtual async Task> NotifyAsync(string tenantId, EventNotification notification, CancellationToken cancellationToken) { IEnumerable subscriptions; try { - subscriptions = await ResolveSubscriptionsAsync(tenantId, eventInfo, cancellationToken); + subscriptions = await ResolveSubscriptionsAsync(tenantId, notification, cancellationToken); } catch (WebhookException ex) { Logger.LogError(ex, "Error while resolving the subscriptions to event {EventType} for tenant '{TenantId}'", - eventInfo.EventType, tenantId); + notification.EventType, tenantId); throw; } - return await NotifySubscriptionsAsync(eventInfo, subscriptions, cancellationToken); + return await NotifySubscriptionsAsync(notification, subscriptions, cancellationToken); } } } diff --git a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs new file mode 100644 index 0000000..0e6d537 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + public static class TenantWebhookNotifierExtensions { + public static Task> NotifyAsync(this ITenantWebhookNotifier notifier, string tenantId, EventInfo eventInfo, CancellationToken cancellationToken = default) + where TWebhook : class { + return notifier.NotifyAsync(tenantId, new EventNotification(eventInfo), cancellationToken); + } + } +} diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotificationResult.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotificationResult.cs index 90e1d78..77346e0 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotificationResult.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotificationResult.cs @@ -30,21 +30,23 @@ public sealed class WebhookNotificationResult : IEnumerable /// Constructs a notification result for the given event. /// - /// - /// The information of the event that was notified through webhooks. + /// + /// The aggregate of the events to be notified to the subscribers. /// /// - /// Thrown when the given is null. + /// Thrown when the given is null. /// - public WebhookNotificationResult(EventInfo eventInfo) { + public WebhookNotificationResult(EventNotification notification) { + ArgumentNullException.ThrowIfNull(notification, nameof(notification)); + deliveryResults = new ConcurrentDictionary>>(); - EventInfo = eventInfo; + Notification = notification; } /// /// Gets the information of the event that was notified. /// - public EventInfo EventInfo { get; } + public EventNotification Notification { get; } /// /// Adds a delivery result for the given subscription. diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs index 0ba0766..7307bf9 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs @@ -63,12 +63,12 @@ public WebhookNotifier( } /// - protected virtual async Task> ResolveSubscriptionsAsync(EventInfo eventInfo, CancellationToken cancellationToken) { + protected virtual async Task> ResolveSubscriptionsAsync(EventNotification notification, CancellationToken cancellationToken) { if (subscriptionResolver == null) return new List(); try { - return await subscriptionResolver.ResolveSubscriptionsAsync(eventInfo.EventType, true, cancellationToken); + return await subscriptionResolver.ResolveSubscriptionsAsync(notification.EventType, true, cancellationToken); } catch (WebhookException) { throw; } catch (Exception ex) { @@ -78,10 +78,10 @@ protected virtual async Task> ResolveSubscript } /// - public async Task> NotifyAsync(EventInfo eventInfo, CancellationToken cancellationToken) { - var subscriptions = await ResolveSubscriptionsAsync(eventInfo, cancellationToken); + public async Task> NotifyAsync(EventNotification notification, CancellationToken cancellationToken) { + var subscriptions = await ResolveSubscriptionsAsync(notification, cancellationToken); - return await NotifySubscriptionsAsync(eventInfo, subscriptions, cancellationToken); + return await NotifySubscriptionsAsync(notification, subscriptions, cancellationToken); } } diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs index 963a7c5..608d9fa 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs @@ -200,23 +200,23 @@ protected virtual void OnDeliveryResult(IWebhookSubscription subscription, TWebh /// /// Returns a task that completes when the operation is done. /// - protected virtual async Task LogDeliveryResultAsync(EventInfo eventInfo, IWebhookSubscription subscription, WebhookDeliveryResult deliveryResult, CancellationToken cancellationToken) { + protected virtual async Task LogDeliveryResultAsync(EventNotification notification, IWebhookSubscription subscription, WebhookDeliveryResult deliveryResult, CancellationToken cancellationToken) { try { if (deliveryResultLogger != null) - await deliveryResultLogger.LogResultAsync(eventInfo, subscription, deliveryResult, cancellationToken); + await deliveryResultLogger.LogResultAsync(notification, subscription, deliveryResult, cancellationToken); } catch (Exception ex) { // If an error occurs here, we report it, but we don't throw it... - Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId!, eventInfo.EventType); + Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId!, notification.EventType); } } - private void TraceDeliveryResult(EventInfo eventInfo, WebhookDeliveryResult deliveryResult) { + private void TraceDeliveryResult(EventNotification notification, WebhookDeliveryResult deliveryResult) { if (!deliveryResult.HasAttempted) { - Logger.WarnDeliveryNotAttempted(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), eventInfo.EventType); + Logger.WarnDeliveryNotAttempted(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), notification.EventType); } else if (deliveryResult.Successful) { - Logger.TraceDeliveryDoneAfterAttempts(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), eventInfo.EventType, deliveryResult.Attempts.Count); + Logger.TraceDeliveryDoneAfterAttempts(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), notification.EventType, deliveryResult.Attempts.Count); } else { - Logger.WarnDeliveryFailed(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), eventInfo.EventType, deliveryResult.Attempts.Count); + Logger.WarnDeliveryFailed(deliveryResult.Destination.Url.GetLeftPart(UriPartial.Path), notification.EventType, deliveryResult.Attempts.Count); } if (deliveryResult.HasAttempted) { @@ -230,16 +230,16 @@ private void TraceDeliveryResult(EventInfo eventInfo, WebhookDeliveryResult result, EventInfo eventInfo, IWebhookSubscription subscription, CancellationToken cancellationToken) { + private async Task NotifySubscription(WebhookNotificationResult result, EventNotification notification, IWebhookSubscription subscription, CancellationToken cancellationToken) { if (String.IsNullOrWhiteSpace(subscription.SubscriptionId)) throw new WebhookException("The subscription identifier is missing"); - Logger.TraceEvaluatingSubscription(subscription.SubscriptionId, eventInfo.EventType); + Logger.TraceEvaluatingSubscription(subscription.SubscriptionId, notification.EventType); - var webhook = await CreateWebhook(subscription, eventInfo, cancellationToken); + var webhook = await CreateWebhook(subscription, notification, cancellationToken); if (webhook == null) { - Logger.WarnWebhookNotCreated(subscription.SubscriptionId, eventInfo.EventType); + Logger.WarnWebhookNotCreated(subscription.SubscriptionId, notification.EventType); return; } @@ -247,26 +247,26 @@ private async Task NotifySubscription(WebhookNotificationResult result var filter = BuildSubscriptionFilter(subscription); if (await MatchesAsync(filter, webhook, cancellationToken)) { - Logger.TraceSubscriptionMatched(subscription.SubscriptionId, eventInfo.EventType); + Logger.TraceSubscriptionMatched(subscription.SubscriptionId, notification.EventType); var deliveryResult = await SendAsync(subscription, webhook, cancellationToken); result.AddDelivery(subscription.SubscriptionId, deliveryResult); - await LogDeliveryResultAsync(eventInfo, subscription, deliveryResult, cancellationToken); + await LogDeliveryResultAsync(notification, subscription, deliveryResult, cancellationToken); - TraceDeliveryResult(eventInfo, deliveryResult); + TraceDeliveryResult(notification, deliveryResult); try { await OnDeliveryResultAsync(subscription, webhook, deliveryResult, cancellationToken); } catch (Exception ex) { - Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, eventInfo.EventType); + Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); } } else { - Logger.TraceSubscriptionNotMatched(subscription.SubscriptionId, eventInfo.EventType); + Logger.TraceSubscriptionNotMatched(subscription.SubscriptionId, notification.EventType); } } catch (Exception ex) { - Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, eventInfo.EventType); + Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); await OnDeliveryErrorAsync(subscription, webhook, ex, cancellationToken); @@ -278,8 +278,8 @@ private async Task NotifySubscription(WebhookNotificationResult result /// Performs the notification of the given event to the subscriptions /// resolved that are listening for it. /// - /// - /// The information about the event that is being notified. + /// + /// The aggregate of the events to be notified to the subscribers. /// /// /// The subscriptions that are listening for the event. @@ -290,8 +290,8 @@ private async Task NotifySubscription(WebhookNotificationResult result /// /// Returns a task that completes when the operation is done. /// - protected virtual async Task> NotifySubscriptionsAsync(EventInfo eventInfo, IEnumerable subscriptions, CancellationToken cancellationToken) { - var result = new WebhookNotificationResult(eventInfo); + protected virtual async Task> NotifySubscriptionsAsync(EventNotification notification, IEnumerable subscriptions, CancellationToken cancellationToken) { + var result = new WebhookNotificationResult(notification); // TODO: Make the parallel thread count configurable var options = new ParallelOptions { @@ -300,7 +300,7 @@ protected virtual async Task> NotifySubscrip }; await Parallel.ForEachAsync(subscriptions, options, async (subscription, token) => { - await NotifySubscription(result, eventInfo, subscription, cancellationToken); + await NotifySubscription(result, notification, subscription, cancellationToken); }); @@ -378,8 +378,8 @@ protected virtual Task> SendAsync(IWebhookSubscr /// /// The subscription that is being notified. /// - /// - /// The information about the event that is being notified. + /// + /// The aggregate of the events to be notified to the subscribers. /// /// /// A cancellation token that can be used to cancel the operation. @@ -389,11 +389,11 @@ protected virtual Task> SendAsync(IWebhookSubscr /// or null if it was not possible to constuct the data. /// /// - protected virtual async Task CreateWebhook(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken) { + protected virtual async Task CreateWebhook(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { try { - return await webhookFactory.CreateAsync(subscription, eventInfo, cancellationToken); + return await webhookFactory.CreateAsync(subscription, notification, cancellationToken); } catch (Exception ex) { - Logger.LogWebhookCreationError(ex, subscription.SubscriptionId!, eventInfo.EventType); + Logger.LogWebhookCreationError(ex, subscription.SubscriptionId!, notification.EventType); throw new WebhookException("An error occurred while creating a new webhook", ex); } diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs new file mode 100644 index 0000000..99f5864 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs @@ -0,0 +1,8 @@ +namespace Deveel.Webhooks { + public static class WebhookNotifierExtensions { + public static Task> NotifyAsync(this IWebhookNotifier notifier, EventInfo eventInfo, CancellationToken cancellationToken = default) + where TWebhook : class { + return notifier.NotifyAsync(new EventNotification(eventInfo), cancellationToken); + } + } +} diff --git a/test/Deveel.Webhooks.DeliveryResultLogging.Tests/Webhooks/DeliveryResultLoggerTestSuite.cs b/test/Deveel.Webhooks.DeliveryResultLogging.Tests/Webhooks/DeliveryResultLoggerTestSuite.cs index 637beeb..1780df5 100644 --- a/test/Deveel.Webhooks.DeliveryResultLogging.Tests/Webhooks/DeliveryResultLoggerTestSuite.cs +++ b/test/Deveel.Webhooks.DeliveryResultLogging.Tests/Webhooks/DeliveryResultLoggerTestSuite.cs @@ -70,6 +70,8 @@ protected virtual void ConfigureService(IServiceCollection services) { public async Task LogSuccessfulDelivery() { var webhook = WebhookFaker.Generate(); var eventInfo = new EventInfo("test", "executed", "1.0.0", new { name = "logTest" }); + var notification = new EventNotification(eventInfo); + var subscription = SubscriptionFaker.Generate(); var destination = subscription.AsDestination(); @@ -83,7 +85,7 @@ public async Task LogSuccessfulDelivery() { Assert.Equal(200, result.Attempts[0].ResponseCode); Assert.Equal("OK", result.Attempts[0].ResponseMessage); - await ResultLogger.LogResultAsync(eventInfo, subscription, result); + await ResultLogger.LogResultAsync(notification, subscription, result); var logged = await FindResultByOperationIdAsync(result.OperationId); @@ -101,6 +103,7 @@ public async Task LogSuccessfulDelivery() { public async Task LogFailedDelivery() { var webhook = WebhookFaker.Generate(); var eventInfo = new EventInfo("test", "executed", "1.0.0", new { name = "logTest" }); + var notification = new EventNotification(eventInfo); var subscription = SubscriptionFaker.Generate(); var destination = subscription.AsDestination(); @@ -126,7 +129,7 @@ public async Task LogFailedDelivery() { Assert.Equal(200, result.Attempts[2].ResponseCode); Assert.Equal("OK", result.Attempts[2].ResponseMessage); - await ResultLogger.LogResultAsync(eventInfo, subscription, result); + await ResultLogger.LogResultAsync(notification, subscription, result); var logged = await FindResultByOperationIdAsync(result.OperationId); diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs index 522cbd8..4958881 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs @@ -1,364 +1,45 @@ -// Copyright 2022-2024 Antonello Provenzano -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; - -using Microsoft.Extensions.DependencyInjection; - -using Xunit; -using Xunit.Abstractions; +using Xunit; namespace Deveel.Webhooks { - public class WebhookNotificationTests : WebhookServiceTestBase { - private const int TimeOutSeconds = 2; - private bool testTimeout = false; - - private TestSubscriptionResolver subscriptionResolver; - private IWebhookNotifier notifier; - - private Webhook? lastWebhook; - private HttpResponseMessage? testResponse; - - public WebhookNotificationTests(ITestOutputHelper outputHelper) : base(outputHelper) { - notifier = Services.GetRequiredService>(); - subscriptionResolver = Services.GetRequiredService(); - } - - protected override void ConfigureServices(IServiceCollection services) { - services.AddWebhookNotifier(config => config - .UseLinqFilter() - .UseSubscriptionResolver(ServiceLifetime.Singleton) - .UseSender(options => { - options.Retry.MaxRetries = 2; - options.Retry.Timeout = TimeSpan.FromSeconds(TimeOutSeconds); - })); - - base.ConfigureServices(services); - } - - protected override async Task OnRequestAsync(HttpRequestMessage httpRequest) { - try { - if (testTimeout) { - await Task.Delay(TimeSpan.FromSeconds(TimeOutSeconds + 2)); - return new HttpResponseMessage(HttpStatusCode.RequestTimeout); - } - - lastWebhook = await httpRequest.Content!.ReadFromJsonAsync(); - - if (testResponse != null) - return testResponse; - - return new HttpResponseMessage(HttpStatusCode.Accepted); - } catch (Exception) { - return new HttpResponseMessage(HttpStatusCode.InternalServerError); - } - } - - private string CreateSubscription(string name, string eventType, params WebhookFilter[] filters) { - return CreateSubscription(new TestWebhookSubscription { - EventTypes = new[] { eventType }, - DestinationUrl = "https://callback.example.com/webhook", - Name = name, - RetryCount = 3, - Filters = filters, - Status = WebhookSubscriptionStatus.Active, - CreatedAt = DateTimeOffset.UtcNow - }, true); - } - - private string CreateSubscription(TestWebhookSubscription subscription, bool enabled = true) { - var id = Guid.NewGuid().ToString(); - - subscription.SubscriptionId = id; - - subscriptionResolver.AddSubscription(subscription); - - return id; - } - - [Fact] - public async Task DeliverWebhookFromEvent() { - var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - Assert.True(result.HasSuccessful); - Assert.False(result.HasFailed); - Assert.NotEmpty(result.Successful); - Assert.Empty(result.Failed); - - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.True(webhookResult.Successful); - Assert.True(webhookResult.HasAttempted); - Assert.Single(webhookResult.Attempts); - Assert.NotNull(webhookResult.LastAttempt); - Assert.True(webhookResult.LastAttempt.HasResponse); - - Assert.NotNull(lastWebhook); - Assert.Equal("data.created", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); - - var testData = Assert.IsType(lastWebhook.Data); - - Assert.Equal("test", testData.GetProperty("type").GetString()); - } - - [Fact] - public async Task DeliverWebhookFromEvent_NoTransformations() { - var subscriptionId = CreateSubscription("Data Modified", "data.modified"); - var notification = new EventInfo("test", "data.modified", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.True(result.HasSuccessful); - Assert.False(result.HasFailed); - Assert.NotEmpty(result.Successful); - Assert.Empty(result.Failed); - - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.True(webhookResult.HasAttempted); - Assert.True(webhookResult.Successful); - Assert.Single(webhookResult.Attempts); - Assert.NotNull(webhookResult.LastAttempt); - Assert.True(webhookResult.LastAttempt.HasResponse); - - Assert.NotNull(lastWebhook); - Assert.Equal("data.modified", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); - - var eventData = Assert.IsType(lastWebhook.Data); - - Assert.Equal("test", eventData.GetProperty("type").GetString()); - Assert.True(eventData.TryGetProperty("creationTime", out var creationTime)); - } - - - [Fact] - public async Task DeliverWebhookWithMultipleFiltersFromEvent() { - var subscriptionId = CreateSubscription("Data Created", "data.created", - new WebhookFilter("hook.data.type == \"test\"", "linq"), - new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { - creator = new { - user_name = "antonello" - }, - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - - Assert.NotNull(result[subscriptionId]); - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.True(webhookResult.Successful); - Assert.Single(webhookResult.Attempts); - - Assert.NotNull(lastWebhook); - Assert.Equal("data.created", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); - } - - + public static class WebhookNotificationTests { [Fact] - public async Task DeliverWebhookWithoutFilter() { - var subscriptionId = CreateSubscription("Data Created", "data.created"); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - - Assert.NotNull(result[subscriptionId]); - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.True(webhookResult.Successful); - Assert.Single(webhookResult.Attempts); - - Assert.NotNull(lastWebhook); - Assert.Equal("data.created", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); - } - - [Fact] - public async Task DeliverWenhookWithFilterTypeNotRegistered() { - var subscriptionId = CreateSubscription("Data Created", "data.created", - new WebhookFilter("hook.data.type == \"test\"", "query"), - new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "query")); - - var notification = new EventInfo("test", "data.created", data: new { - creator = new { - user_name = "antonello" - }, - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); + public static void NewNotification_OneEvent() { + var eventInfo = new EventInfo("subj1", "test.event"); + var notification = new EventNotification(eventInfo); - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - Assert.NotNull(result); - Assert.Empty(result); + Assert.Equal("test.event", notification.EventType); + Assert.Single(notification.Events); + Assert.Equal(eventInfo, notification.Events[0]); + Assert.Single(notification); } [Fact] - public async Task DeliverSignedWebhookFromEvent() { - var subscriptionId = CreateSubscription(new TestWebhookSubscription { - EventTypes = new[] { "data.created" }, - DestinationUrl = "https://callback.example.com", - Filters = new[] { new WebhookFilter("hook.data.type == \"test\"", "linq") }, - Name = "Data Created", - Secret = "abc12345", - RetryCount = 3, - Status = WebhookSubscriptionStatus.Active - }); + public static void NewNotification_MultipleEvents() { + var eventInfo1 = new EventInfo("subj1", "test.event"); + var eventInfo2 = new EventInfo("subj2", "test.event"); + var notification = new EventNotification("test.event", new[] {eventInfo1, eventInfo2}); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - - Assert.NotNull(result[subscriptionId]); - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.True(webhookResult.Successful); - Assert.Single(webhookResult.Attempts); - - Assert.NotNull(lastWebhook); - Assert.Equal("data.created", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); + Assert.Equal("test.event", notification.EventType); + Assert.Equal(2, notification.Events.Count); + Assert.Equal(eventInfo1, notification.Events[0]); + Assert.Equal(eventInfo2, notification.Events[1]); } [Fact] - public async Task FailToDeliver() { - var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - testResponse = new HttpResponseMessage(HttpStatusCode.InternalServerError); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - Assert.NotEmpty(result.Failed); - Assert.True(result.HasFailed); - - Assert.NotNull(result[subscriptionId]); - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.False(webhookResult.Successful); - Assert.Equal(3, webhookResult.Attempts.Count); - Assert.Equal((int)HttpStatusCode.InternalServerError, webhookResult.Attempts[0].ResponseCode); + public static void NewNotification_InvalidEventType() { + var eventInfo1 = new EventInfo("subj1", "test.event"); + var eventInfo2 = new EventInfo("subj2", "test.event"); + Assert.Throws(() => new EventNotification("test.event2", new[] {eventInfo1, eventInfo2})); } [Fact] - public async Task TimeOutWhileDelivering() { - var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - testTimeout = true; - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.NotEmpty(result); - Assert.Single(result); - - Assert.NotNull(result[subscriptionId]); - Assert.Single(result[subscriptionId]!); - - var webhookResult = result[subscriptionId]![0]; - - Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); - Assert.False(webhookResult.Successful); - Assert.Equal(3, webhookResult.Attempts.Count); - Assert.Equal((int)HttpStatusCode.RequestTimeout, webhookResult.Attempts.ElementAt(0).ResponseCode); + public static void NewNotification_EmptyEvents() { + Assert.Throws(() => new EventNotification("test.event", Array.Empty())); } - - [Fact] - public async Task NoSubscriptionMatches() { - CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test-data2\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.Empty(result); + public static void NewNotification_EmptyEventType() { + Assert.Throws(() => new EventNotification("", new[] {new EventInfo("sub1", "test.event")})); } } } diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs new file mode 100644 index 0000000..abfc2cf --- /dev/null +++ b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs @@ -0,0 +1,364 @@ +// Copyright 2022-2024 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; +using Xunit.Abstractions; + +namespace Deveel.Webhooks { + public class WebhookNotifierTests : WebhookServiceTestBase { + private const int TimeOutSeconds = 2; + private bool testTimeout = false; + + private TestSubscriptionResolver subscriptionResolver; + private IWebhookNotifier notifier; + + private Webhook? lastWebhook; + private HttpResponseMessage? testResponse; + + public WebhookNotifierTests(ITestOutputHelper outputHelper) : base(outputHelper) { + notifier = Services.GetRequiredService>(); + subscriptionResolver = Services.GetRequiredService(); + } + + protected override void ConfigureServices(IServiceCollection services) { + services.AddWebhookNotifier(config => config + .UseLinqFilter() + .UseSubscriptionResolver(ServiceLifetime.Singleton) + .UseSender(options => { + options.Retry.MaxRetries = 2; + options.Retry.Timeout = TimeSpan.FromSeconds(TimeOutSeconds); + })); + + base.ConfigureServices(services); + } + + protected override async Task OnRequestAsync(HttpRequestMessage httpRequest) { + try { + if (testTimeout) { + await Task.Delay(TimeSpan.FromSeconds(TimeOutSeconds + 2)); + return new HttpResponseMessage(HttpStatusCode.RequestTimeout); + } + + lastWebhook = await httpRequest.Content!.ReadFromJsonAsync(); + + if (testResponse != null) + return testResponse; + + return new HttpResponseMessage(HttpStatusCode.Accepted); + } catch (Exception) { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } + } + + private string CreateSubscription(string name, string eventType, params WebhookFilter[] filters) { + return CreateSubscription(new TestWebhookSubscription { + EventTypes = new[] { eventType }, + DestinationUrl = "https://callback.example.com/webhook", + Name = name, + RetryCount = 3, + Filters = filters, + Status = WebhookSubscriptionStatus.Active, + CreatedAt = DateTimeOffset.UtcNow + }, true); + } + + private string CreateSubscription(TestWebhookSubscription subscription, bool enabled = true) { + var id = Guid.NewGuid().ToString(); + + subscription.SubscriptionId = id; + + subscriptionResolver.AddSubscription(subscription); + + return id; + } + + [Fact] + public async Task DeliverWebhookFromEvent() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + Assert.True(result.HasSuccessful); + Assert.False(result.HasFailed); + Assert.NotEmpty(result.Successful); + Assert.Empty(result.Failed); + + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.True(webhookResult.HasAttempted); + Assert.Single(webhookResult.Attempts); + Assert.NotNull(webhookResult.LastAttempt); + Assert.True(webhookResult.LastAttempt.HasResponse); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.created", lastWebhook.EventType); + Assert.Equal(notification.Id, lastWebhook.Id); + Assert.Equal(notification.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); + + var testData = Assert.IsType(lastWebhook.Data); + + Assert.Equal("test", testData.GetProperty("type").GetString()); + } + + [Fact] + public async Task DeliverWebhookFromEvent_NoTransformations() { + var subscriptionId = CreateSubscription("Data Modified", "data.modified"); + var notification = new EventInfo("test", "data.modified", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.True(result.HasSuccessful); + Assert.False(result.HasFailed); + Assert.NotEmpty(result.Successful); + Assert.Empty(result.Failed); + + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.HasAttempted); + Assert.True(webhookResult.Successful); + Assert.Single(webhookResult.Attempts); + Assert.NotNull(webhookResult.LastAttempt); + Assert.True(webhookResult.LastAttempt.HasResponse); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.modified", lastWebhook.EventType); + Assert.Equal(notification.Id, lastWebhook.Id); + Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); + + var eventData = Assert.IsType(lastWebhook.Data); + + Assert.Equal("test", eventData.GetProperty("type").GetString()); + Assert.True(eventData.TryGetProperty("creationTime", out var creationTime)); + } + + + [Fact] + public async Task DeliverWebhookWithMultipleFiltersFromEvent() { + var subscriptionId = CreateSubscription("Data Created", "data.created", + new WebhookFilter("hook.data.type == \"test\"", "linq"), + new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creator = new { + user_name = "antonello" + }, + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + + Assert.NotNull(result[subscriptionId]); + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.Single(webhookResult.Attempts); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.created", lastWebhook.EventType); + Assert.Equal(notification.Id, lastWebhook.Id); + Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); + } + + + [Fact] + public async Task DeliverWebhookWithoutFilter() { + var subscriptionId = CreateSubscription("Data Created", "data.created"); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + + Assert.NotNull(result[subscriptionId]); + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.Single(webhookResult.Attempts); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.created", lastWebhook.EventType); + Assert.Equal(notification.Id, lastWebhook.Id); + Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); + } + + [Fact] + public async Task DeliverWenhookWithFilterTypeNotRegistered() { + var subscriptionId = CreateSubscription("Data Created", "data.created", + new WebhookFilter("hook.data.type == \"test\"", "query"), + new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "query")); + + var notification = new EventInfo("test", "data.created", data: new { + creator = new { + user_name = "antonello" + }, + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task DeliverSignedWebhookFromEvent() { + var subscriptionId = CreateSubscription(new TestWebhookSubscription { + EventTypes = new[] { "data.created" }, + DestinationUrl = "https://callback.example.com", + Filters = new[] { new WebhookFilter("hook.data.type == \"test\"", "linq") }, + Name = "Data Created", + Secret = "abc12345", + RetryCount = 3, + Status = WebhookSubscriptionStatus.Active + }); + + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + + Assert.NotNull(result[subscriptionId]); + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.Single(webhookResult.Attempts); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.created", lastWebhook.EventType); + Assert.Equal(notification.Id, lastWebhook.Id); + Assert.Equal(notification.TimeStamp.ToUnixTimeMilliseconds(), lastWebhook.TimeStamp.ToUnixTimeMilliseconds()); + } + + [Fact] + public async Task FailToDeliver() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + testResponse = new HttpResponseMessage(HttpStatusCode.InternalServerError); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + Assert.NotEmpty(result.Failed); + Assert.True(result.HasFailed); + + Assert.NotNull(result[subscriptionId]); + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.False(webhookResult.Successful); + Assert.Equal(3, webhookResult.Attempts.Count); + Assert.Equal((int)HttpStatusCode.InternalServerError, webhookResult.Attempts[0].ResponseCode); + } + + [Fact] + public async Task TimeOutWhileDelivering() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + testTimeout = true; + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + + Assert.NotNull(result[subscriptionId]); + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.False(webhookResult.Successful); + Assert.Equal(3, webhookResult.Attempts.Count); + Assert.Equal((int)HttpStatusCode.RequestTimeout, webhookResult.Attempts.ElementAt(0).ResponseCode); + } + + + + [Fact] + public async Task NoSubscriptionMatches() { + CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test-data2\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.Empty(result); + } + } +} From 11b1cd2cfcd08370981d1bad6f160c7f27d0486f Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Mon, 29 Jan 2024 22:56:38 +0100 Subject: [PATCH 2/4] Further support and tests for the notification of webhooks created from multiple events --- .../Webhooks/DefaultMongoWebhookConverter.cs | 3 + .../Webhooks/IWebhookConverter.cs | 3 + .../MongoDbWebhookDeliveryResultLogger.cs | 6 ++ .../Webhooks/WebhookSubscriptionResolver.cs | 2 +- .../Webhooks/DefaultWebhookFactory_1.cs | 50 ++++++--- .../Webhooks/EventNotification.cs | 100 +++++++++++++++++- .../Webhooks/IWebhookFactory.cs | 5 +- .../TenantWebhookNotifierExtensions.cs | 44 +++++++- .../Webhooks/WebhookNotifierBase.cs | 4 +- .../Webhooks/WebhookNotifierExtensions.cs | 40 ++++++- .../Webhooks/EventInfoTests.cs | 6 -- ...tionTests.cs => EventNotificationTests.cs} | 3 +- .../Webhooks/WebhookNotifierTests.cs | 59 ++++++++++- 13 files changed, 285 insertions(+), 40 deletions(-) rename test/Deveel.Webhooks.XUnit/Webhooks/{WebhookNotificationTests.cs => EventNotificationTests.cs} (94%) diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs index 6328c89..f7437e8 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/DefaultMongoWebhookConverter.cs @@ -32,6 +32,9 @@ public class DefaultMongoWebhookConverter : IMongoWebhookConverter and the webhook object into /// a object. /// + /// + /// The event notification that was sent to the subscribers. + /// /// /// The webhook that was notified to the subscribers. /// diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs index b3577d5..f1238ed 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs @@ -26,6 +26,9 @@ public interface IMongoWebhookConverter /// Converts the given webhook to an object that can be stored /// in a MongoDB database. /// + /// + /// The event notification that was sent to the subscribers. + /// /// /// The instance of the webhook to be converted. /// diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs index 6364dde..5b57dca 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookDeliveryResultLogger.cs @@ -70,6 +70,9 @@ public MongoDbWebhookDeliveryResultLogger( /// Converts the given result to an object that can be stored in the /// MongoDB database collection. /// + /// + /// The aggregate of the events that are being delivered to the receiver. + /// /// /// The information about the event that triggered the delivery of the webhook. /// @@ -157,6 +160,9 @@ protected virtual MongoWebhookDeliveryAttempt ConvertDeliveryAttempt(WebhookDeli /// Converts the given webhook to an object that can be stored in the /// MongoDB database collection. /// + /// + /// The aggregate of the events that are being delivered to the receiver. + /// /// /// The instance of the webhook to convert. /// diff --git a/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs index cf35839..adae585 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs @@ -76,7 +76,7 @@ public async Task> ResolveSubscriptionsAsync(string var list = await GetCachedAsync(eventType, cancellationToken); if (list == null) { - logger.LogTrace("No webhook subscriptions to event {EventType} of tenant {TenantId} were found in cache", eventType); + logger.LogTrace("No webhook subscriptions to event {EventType} were found in cache", eventType); var result = await repository.GetByEventTypeAsync(eventType, activeOnly, cancellationToken); list = result.Cast().ToList(); diff --git a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs index 16bef2c..516307d 100644 --- a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs +++ b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs @@ -12,9 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Threading; -using System.Threading.Tasks; - namespace Deveel.Webhooks { /// /// A default implementation of the @@ -29,7 +26,7 @@ namespace Deveel.Webhooks { /// /// /// It is possible to create a custom webhook type by implementing - /// this factory and overriding the + /// this factory and overriding the /// /// /// By default the class is configured with attributes from @@ -47,14 +44,38 @@ namespace Deveel.Webhooks { /// /// The subscription that is listening to the event /// - /// - /// The event that is being delivered to the subscription + /// + /// The aggregate of the events that are being delivered to the subscription. /// + /// + /// The default implementation of this method returns an array of + /// objects, each one created by the , + /// when the notification contains multiple events, or the single object + /// if the notification contains a single event. + /// /// /// Returns a data object that is carried by the webhook /// through the property. /// - protected virtual object? CreateData(IWebhookSubscription subscription, EventInfo eventInfo) { + protected virtual object? CreateData(IWebhookSubscription subscription, EventNotification notification) { + if (notification.HasSingleEvent) + return CreateEventData(subscription, notification.SingleEvent); + + return notification.Events.Select(x => CreateEventData(subscription, x)).ToArray(); + } + + /// + /// When overridden, creates the data object that is carried by + /// a webhook to the receiver. + /// + /// + /// + /// + /// The default implementation of this method returns the + /// object of the given event. + /// + /// + protected virtual object? CreateEventData(IWebhookSubscription subscription, EventInfo eventInfo) { return eventInfo.Data; } @@ -74,19 +95,14 @@ namespace Deveel.Webhooks { /// /// Returns a task that resolves to the created webhook /// - public Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { - if (!notification.HasSingleEvent) - throw new WebhookException("Multiple events per notification not supported yet."); - - var @event = notification.SingleEvent; - + public virtual Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { var webhook = new TWebhook { - Id = @event.Id, - EventType = @event.EventType, + Id = notification.NotificationId, + EventType = notification.EventType, SubscriptionId = subscription.SubscriptionId, Name = subscription.Name, - TimeStamp = @event.TimeStamp, - Data = @event.Data, + TimeStamp = notification.TimeStamp, + Data = CreateData(subscription, notification), }; return Task.FromResult(webhook); diff --git a/src/Deveel.Webhooks/Webhooks/EventNotification.cs b/src/Deveel.Webhooks/Webhooks/EventNotification.cs index e3ff0f6..7e6e1c8 100644 --- a/src/Deveel.Webhooks/Webhooks/EventNotification.cs +++ b/src/Deveel.Webhooks/Webhooks/EventNotification.cs @@ -1,8 +1,39 @@ -using System.Collections; +// Copyright 2022-2024 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections; using System.Diagnostics.CodeAnalysis; namespace Deveel.Webhooks { + /// + /// Represents a notification of one or more events of the same type, + /// to be delivered to a receiver. + /// public sealed class EventNotification : IEnumerable { + /// + /// Constructs the notification with the given type and events. + /// + /// + /// The type of the events that are being notified. + /// + /// + /// The list of events that are being notified. + /// + /// + /// Thrown when the given event type is null or empty, or when the list + /// is empty, or when the list contains events that are not of the given type. + /// public EventNotification(string eventType, IEnumerable events) { ArgumentNullException.ThrowIfNull(events, nameof(events)); @@ -20,16 +51,42 @@ public EventNotification(string eventType, IEnumerable events) { Events = events.ToList().AsReadOnly(); EventType = eventType; NotificationId = Guid.NewGuid().ToString(); + TimeStamp = DateTimeOffset.UtcNow; } + /// + /// Constructs the notification of a single event. + /// + /// + /// The event that is being notified. + /// public EventNotification(EventInfo eventInfo) : this(eventInfo.EventType, new[] {eventInfo}) { + NotificationId = eventInfo.Id; + TimeStamp = eventInfo.TimeStamp; } + /// + /// Gets the list of events that are being notified. + /// public IReadOnlyList Events { get; } + /// + /// Gets a value indicating if the notification has a single event. + /// public bool HasSingleEvent => Events.Count == 1; + /// + /// Gets the single event of the notification. + /// + /// + /// It is recommended to use this property only after checking that + /// the notification has a single event, using the . + /// + /// + /// Thrown when the notification has more than one event. + /// + /// public EventInfo SingleEvent { get { if (!HasSingleEvent) @@ -39,12 +96,27 @@ public EventInfo SingleEvent { } } + /// + /// Gets the type of the events that are being notified. + /// public string EventType { get; } + /// + /// Gets or sets the unique identifier of the notification. + /// public string NotificationId { get; set; } + /// + /// Gets or sets the timestamp of the notification. + /// + public DateTimeOffset TimeStamp { get; set; } + + /// + /// Gets or sets the properties of the notification. + /// public IDictionary Properties { get; set; } = new Dictionary(); + /// public IEnumerator GetEnumerator() => Events.GetEnumerator(); [ExcludeFromCodeCoverage] @@ -56,5 +128,31 @@ private static void ValidateAllEventsOfType(string eventType, IEnumerable + /// Implicitly converts an event into a notification. + /// + /// + /// The event to be notified. + /// + public static implicit operator EventNotification(EventInfo eventInfo) { + return new EventNotification(eventInfo); + } + + /// + /// Implicitly converts a list of events into a notification. + /// + /// + /// The list of events to be notified. + /// + /// + /// Thrown if the list of events is empty. + /// + public static implicit operator EventNotification(EventInfo[] events) { + if (events.Length == 0) + throw new ArgumentException("The list of events cannot be empty", nameof(events)); + + return new EventNotification(events[0].EventType, events); + } } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs index c060128..10e8077 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs @@ -19,7 +19,7 @@ namespace Deveel.Webhooks { /// /// Defines a factory that can create - /// instances given the subscription and the event information. + /// instances given the subscription and the events notification. /// /// /// The type of the webhook instance to create. @@ -31,6 +31,9 @@ public interface IWebhookFactory where TWebhook : class { /// /// The subscription that is requesting the webhook. /// + /// + /// The notification that is being delivered to the receiver. + /// /// /// A token that can be used to cancel the operation. /// diff --git a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs index 0e6d537..13f13a8 100644 --- a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs +++ b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifierExtensions.cs @@ -1,11 +1,45 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +// Copyright 2022-2024 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. namespace Deveel.Webhooks { + /// + /// Extends the with + /// methods to notify a tenant of a single event. + /// public static class TenantWebhookNotifierExtensions { + /// + /// Notifies the tenant of the given event. + /// + /// + /// The type of the webhook to be notified. + /// + /// + /// The instance of the notifier to use to send the notification. + /// + /// + /// The unique identifier of the tenant to notify. + /// + /// + /// The event that is being notified. + /// + /// + /// A cancellation token that can be used to cancel the notification. + /// + /// + /// Returns the result of the notification process, that contains + /// the notification of a single event. + /// public static Task> NotifyAsync(this ITenantWebhookNotifier notifier, string tenantId, EventInfo eventInfo, CancellationToken cancellationToken = default) where TWebhook : class { return notifier.NotifyAsync(tenantId, new EventNotification(eventInfo), cancellationToken); diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs index 608d9fa..e65b436 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs @@ -185,8 +185,8 @@ protected virtual void OnDeliveryResult(IWebhookSubscription subscription, TWebh /// /// Logs the given delivery result. /// - /// - /// The information about the event that triggered the notification. + /// + /// The aggregate of the events that are being delivered to the receiver. /// /// /// The subscription that was used to send the webhook. diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs index 99f5864..ae31d86 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierExtensions.cs @@ -1,5 +1,43 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2024 Antonello Provenzano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Extensions for to notify + /// a single event to the subscribers. + /// public static class WebhookNotifierExtensions { + /// + /// Notifies the given event to the subscribers. + /// + /// + /// The type of the webhook to be notified. + /// + /// + /// The instance of the notifier service used to deliver + /// the notification. + /// + /// + /// The event to be notified to the subscribers. + /// + /// + /// A token that can be used to cancel the notification. + /// + /// + /// Returns the result of the notification that contains + /// a single event. + /// public static Task> NotifyAsync(this IWebhookNotifier notifier, EventInfo eventInfo, CancellationToken cancellationToken = default) where TWebhook : class { return notifier.NotifyAsync(new EventNotification(eventInfo), cancellationToken); diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/EventInfoTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/EventInfoTests.cs index 52b4c49..f9d2b7c 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/EventInfoTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/EventInfoTests.cs @@ -12,12 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - using Xunit; namespace Deveel.Webhooks { diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs similarity index 94% rename from test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs rename to test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs index 4958881..626087e 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs @@ -1,13 +1,14 @@ using Xunit; namespace Deveel.Webhooks { - public static class WebhookNotificationTests { + public static class EventNotificationTests { [Fact] public static void NewNotification_OneEvent() { var eventInfo = new EventInfo("subj1", "test.event"); var notification = new EventNotification(eventInfo); Assert.Equal("test.event", notification.EventType); + Assert.True(notification.HasSingleEvent); Assert.Single(notification.Events); Assert.Equal(eventInfo, notification.Events[0]); Assert.Single(notification); diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs index abfc2cf..479b433 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs @@ -90,14 +90,14 @@ private string CreateSubscription(TestWebhookSubscription subscription, bool ena } [Fact] - public async Task DeliverWebhookFromEvent() { + public async Task DeliverWebhookFromSingleEvent() { var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); - var notification = new EventInfo("test", "data.created", data: new { + var eventInfo = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(notification, CancellationToken.None); + var result = await notifier.NotifyAsync(eventInfo, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -120,14 +120,63 @@ public async Task DeliverWebhookFromEvent() { Assert.NotNull(lastWebhook); Assert.Equal("data.created", lastWebhook.EventType); - Assert.Equal(notification.Id, lastWebhook.Id); - Assert.Equal(notification.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); + Assert.Equal(eventInfo.Id, lastWebhook.Id); + Assert.Equal(eventInfo.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); var testData = Assert.IsType(lastWebhook.Data); Assert.Equal("test", testData.GetProperty("type").GetString()); } + [Fact] + public async Task DeliverWebhookFromMultipleEvents() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data[0].type.equals(\"test\")", "linq")); + EventNotification notification = new[] { + new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }), + new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow.AddSeconds(3), + type = "test2" + }) + }; + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + Assert.True(result.HasSuccessful); + Assert.False(result.HasFailed); + Assert.NotEmpty(result.Successful); + Assert.Empty(result.Failed); + + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.True(webhookResult.HasAttempted); + Assert.Single(webhookResult.Attempts); + Assert.NotNull(webhookResult.LastAttempt); + Assert.True(webhookResult.LastAttempt.HasResponse); + + Assert.NotNull(lastWebhook); + Assert.Equal("data.created", lastWebhook.EventType); + Assert.Equal(notification.NotificationId, lastWebhook.Id); + // TODO: how to determine the timestamp of the notification that has multiple events? + // Assert.Equal(notification.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); + + var testData = Assert.IsType(lastWebhook.Data); + + Assert.Equal(JsonValueKind.Array, testData.ValueKind); + Assert.Equal(2, testData.GetArrayLength()); + Assert.Equal("test", testData[0].GetProperty("type").GetString()); + } + + [Fact] public async Task DeliverWebhookFromEvent_NoTransformations() { var subscriptionId = CreateSubscription("Data Modified", "data.modified"); From 7686600647e88af834a4b1d6d00efb9fe5f2093e Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Tue, 13 Feb 2024 23:26:23 +0100 Subject: [PATCH 3/4] Changing the strategy for building webhooks from notifications (default factory) --- .../Services/UserCreatedWebhookFactory.cs | 16 +++-- .../Webhooks/DefaultWebhookFactory.cs | 4 ++ .../Webhooks/DefaultWebhookFactory_1.cs | 66 ++++++++++++++----- .../Webhooks/EventNotification.cs | 25 ------- .../Webhooks/IWebhookFactory.cs | 2 +- .../Webhooks/WebhookCreateStrategy.cs | 19 ++++++ .../Webhooks/WebhookFactoryOptions.cs | 14 ++++ .../Webhooks/WebhookNotifierBase.cs | 49 +++++++------- .../Webhooks/WebhookNotifierBuilder.cs | 3 + .../Webhooks/EventNotificationTests.cs | 1 - 10 files changed, 126 insertions(+), 73 deletions(-) create mode 100644 src/Deveel.Webhooks/Webhooks/WebhookCreateStrategy.cs create mode 100644 src/Deveel.Webhooks/Webhooks/WebhookFactoryOptions.cs diff --git a/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs b/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs index 8f849c0..451ae7e 100644 --- a/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs +++ b/samples/WebhookNotifierApp/Services/UserCreatedWebhookFactory.cs @@ -8,8 +8,8 @@ public UserCreatedWebhookFactory(IUserResolver userResolver) { this.userResolver = userResolver; } - public async Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default) { - var @event = notification.SingleEvent; + public async Task> CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default) { + var @event = notification.Events[0]; var userCreated = (UserCreatedEvent?)@event.Data; var user = await userResolver.ResolveUserAsync(userCreated!.UserId, cancellationToken); @@ -17,11 +17,13 @@ public async Task CreateAsync(IWebhookSubscription subscription if (user == null) throw new InvalidOperationException(); - return new IdentityWebhook { - EventId = @event.Id, - EventType = "user_created", - TimeStamp = @event.TimeStamp, - User = user + return new [] { + new IdentityWebhook { + EventId = @event.Id, + EventType = @event.EventType, + TimeStamp = @event.TimeStamp, + User = user + } }; } } diff --git a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory.cs b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory.cs index be3f1a3..0453d50 100644 --- a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Microsoft.Extensions.Options; + namespace Deveel.Webhooks { /// /// A default implementation of the @@ -19,5 +21,7 @@ namespace Deveel.Webhooks { /// provided by the subscription and the event. /// public sealed class DefaultWebhookFactory : DefaultWebhookFactory { + public DefaultWebhookFactory(IOptions> options) : base(options) { + } } } diff --git a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs index 516307d..e8646aa 100644 --- a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs +++ b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Microsoft.Extensions.Options; + namespace Deveel.Webhooks { /// /// A default implementation of the @@ -26,7 +28,7 @@ namespace Deveel.Webhooks { /// /// /// It is possible to create a custom webhook type by implementing - /// this factory and overriding the + /// this factory and overriding the /// /// /// By default the class is configured with attributes from @@ -37,6 +39,17 @@ namespace Deveel.Webhooks { /// /// public class DefaultWebhookFactory : IWebhookFactory where TWebhook : Webhook, new() { + /// + /// Constructs the factory with the options to use when creating + /// webhooks from a notification for a subscription. + /// + /// + public DefaultWebhookFactory(IOptions> options) { + Options = options.Value; + } + + protected WebhookFactoryOptions Options { get; } + /// /// When overridden, creates the data object that is carried /// by the webhook to the receiver. @@ -57,11 +70,18 @@ namespace Deveel.Webhooks { /// Returns a data object that is carried by the webhook /// through the property. /// - protected virtual object? CreateData(IWebhookSubscription subscription, EventNotification notification) { - if (notification.HasSingleEvent) - return CreateEventData(subscription, notification.SingleEvent); + protected virtual object? CreateNotificationData(IWebhookSubscription subscription, EventNotification notification) { + if (Options.CreateStrategy == WebhookCreateStrategy.OnePerNotification) { + if (notification.Events.Count == 1) + return CreateEventData(subscription, notification.Events[0]); + + return notification.Select(e => CreateEventData(subscription, e)).ToArray(); + } - return notification.Events.Select(x => CreateEventData(subscription, x)).ToArray(); + if (notification.Events.Count == 1) + return CreateEventData(subscription, notification.Events[0]); + + throw new WebhookException("The strategy 'OnePerEvent' requires a single event in the notification"); } /// @@ -95,17 +115,31 @@ namespace Deveel.Webhooks { /// /// Returns a task that resolves to the created webhook /// - public virtual Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { - var webhook = new TWebhook { - Id = notification.NotificationId, - EventType = notification.EventType, - SubscriptionId = subscription.SubscriptionId, - Name = subscription.Name, - TimeStamp = notification.TimeStamp, - Data = CreateData(subscription, notification), - }; + public virtual Task> CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { + IList result; + + if (Options.CreateStrategy == WebhookCreateStrategy.OnePerEvent) { + result = notification.Events.Select(e => new TWebhook { + Id = e.Id, + EventType = e.EventType, + SubscriptionId = subscription.SubscriptionId, + Name = subscription.Name, + TimeStamp = e.TimeStamp, + Data = CreateEventData(subscription, e), + }).ToList(); + } else { + result = new List { new TWebhook { + Id = notification.NotificationId, + EventType = notification.EventType, + SubscriptionId = subscription.SubscriptionId, + Name = subscription.Name, + TimeStamp = notification.TimeStamp, + Data = CreateNotificationData(subscription, notification), + } + }; + } - return Task.FromResult(webhook); + return Task.FromResult(result); } } -} +} \ No newline at end of file diff --git a/src/Deveel.Webhooks/Webhooks/EventNotification.cs b/src/Deveel.Webhooks/Webhooks/EventNotification.cs index 7e6e1c8..66f6787 100644 --- a/src/Deveel.Webhooks/Webhooks/EventNotification.cs +++ b/src/Deveel.Webhooks/Webhooks/EventNotification.cs @@ -71,31 +71,6 @@ public EventNotification(EventInfo eventInfo) /// public IReadOnlyList Events { get; } - /// - /// Gets a value indicating if the notification has a single event. - /// - public bool HasSingleEvent => Events.Count == 1; - - /// - /// Gets the single event of the notification. - /// - /// - /// It is recommended to use this property only after checking that - /// the notification has a single event, using the . - /// - /// - /// Thrown when the notification has more than one event. - /// - /// - public EventInfo SingleEvent { - get { - if (!HasSingleEvent) - throw new InvalidOperationException("The notification has more than one event"); - - return Events[0]; - } - } - /// /// Gets the type of the events that are being notified. /// diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs index 10e8077..b284afc 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs @@ -41,6 +41,6 @@ public interface IWebhookFactory where TWebhook : class { /// Returns an instance of the webhook that will be delivered to /// the receiver that is subscribed to the event. /// - Task CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default); + Task> CreateAsync(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken = default); } } diff --git a/src/Deveel.Webhooks/Webhooks/WebhookCreateStrategy.cs b/src/Deveel.Webhooks/Webhooks/WebhookCreateStrategy.cs new file mode 100644 index 0000000..fb5ee42 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/WebhookCreateStrategy.cs @@ -0,0 +1,19 @@ +namespace Deveel.Webhooks { + /// + /// The strategy to use when creating a webhook instance + /// from a notification. + /// + public enum WebhookCreateStrategy { + /// + /// The factory will create a single webhook instance + /// for each event in the notification. + /// + OnePerEvent = 1, + + /// + /// The factory will create a single webhook instance + /// for all the events in the notification. + /// + OnePerNotification = 2 + } +} diff --git a/src/Deveel.Webhooks/Webhooks/WebhookFactoryOptions.cs b/src/Deveel.Webhooks/Webhooks/WebhookFactoryOptions.cs new file mode 100644 index 0000000..a1ad45e --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/WebhookFactoryOptions.cs @@ -0,0 +1,14 @@ +namespace Deveel.Webhooks { + /// + /// Configures the behavior of the + /// when creating webhooks from a notification for a subscription. + /// + /// + public class WebhookFactoryOptions { + /// + /// Gets or sets the strategy to use when creating webhooks + /// from a notification for a subscription. + /// + public WebhookCreateStrategy CreateStrategy { get; set; } = WebhookCreateStrategy.OnePerNotification; + } +} diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs index e65b436..044d02a 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs @@ -236,41 +236,44 @@ private async Task NotifySubscription(WebhookNotificationResult result Logger.TraceEvaluatingSubscription(subscription.SubscriptionId, notification.EventType); - var webhook = await CreateWebhook(subscription, notification, cancellationToken); + var webhooks = await CreateWebhook(subscription, notification, cancellationToken); - if (webhook == null) { - Logger.WarnWebhookNotCreated(subscription.SubscriptionId, notification.EventType); + if (webhooks == null || webhooks.Count == 0) { + Logger.WarnWebhookNotCreated(subscription.SubscriptionId, notification.EventType); return; } - try { - var filter = BuildSubscriptionFilter(subscription); + var filter = BuildSubscriptionFilter(subscription); + + foreach (var webhook in webhooks) { + try { - if (await MatchesAsync(filter, webhook, cancellationToken)) { - Logger.TraceSubscriptionMatched(subscription.SubscriptionId, notification.EventType); + if (await MatchesAsync(filter, webhook, cancellationToken)) { + Logger.TraceSubscriptionMatched(subscription.SubscriptionId, notification.EventType); - var deliveryResult = await SendAsync(subscription, webhook, cancellationToken); + var deliveryResult = await SendAsync(subscription, webhook, cancellationToken); - result.AddDelivery(subscription.SubscriptionId, deliveryResult); + result.AddDelivery(subscription.SubscriptionId, deliveryResult); - await LogDeliveryResultAsync(notification, subscription, deliveryResult, cancellationToken); + await LogDeliveryResultAsync(notification, subscription, deliveryResult, cancellationToken); - TraceDeliveryResult(notification, deliveryResult); + TraceDeliveryResult(notification, deliveryResult); - try { - await OnDeliveryResultAsync(subscription, webhook, deliveryResult, cancellationToken); - } catch (Exception ex) { - Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); + try { + await OnDeliveryResultAsync(subscription, webhook, deliveryResult, cancellationToken); + } catch (Exception ex) { + Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); + } + } else { + Logger.TraceSubscriptionNotMatched(subscription.SubscriptionId, notification.EventType); } - } else { - Logger.TraceSubscriptionNotMatched(subscription.SubscriptionId, notification.EventType); - } - } catch (Exception ex) { - Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); + } catch (Exception ex) { + Logger.LogUnknownEventDeliveryError(ex, subscription.SubscriptionId, notification.EventType); - await OnDeliveryErrorAsync(subscription, webhook, ex, cancellationToken); + await OnDeliveryErrorAsync(subscription, webhook, ex, cancellationToken); - // result.AddDelivery(new WebhookDeliveryResult(destination, webhook)); + // result.AddDelivery(new WebhookDeliveryResult(destination, webhook)); + } } } @@ -389,7 +392,7 @@ protected virtual Task> SendAsync(IWebhookSubscr /// or null if it was not possible to constuct the data. /// /// - protected virtual async Task CreateWebhook(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { + protected virtual async Task> CreateWebhook(IWebhookSubscription subscription, EventNotification notification, CancellationToken cancellationToken) { try { return await webhookFactory.CreateAsync(subscription, notification, cancellationToken); } catch (Exception ex) { diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs index b1da7eb..02611e6 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace Deveel.Webhooks { /// @@ -59,6 +60,8 @@ private void RegisterDefaultServices() { Services.TryAddSingleton(factoryType); } + Services.AddSingleton(Options.Create(new WebhookFactoryOptions())); + // TODO: register the default filter evaluator } diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs index 626087e..3b25a84 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/EventNotificationTests.cs @@ -8,7 +8,6 @@ public static void NewNotification_OneEvent() { var notification = new EventNotification(eventInfo); Assert.Equal("test.event", notification.EventType); - Assert.True(notification.HasSingleEvent); Assert.Single(notification.Events); Assert.Equal(eventInfo, notification.Events[0]); Assert.Single(notification); From d7722201668d463f191cd1a14b7b5f236169ad44 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Thu, 15 Feb 2024 22:24:16 +0100 Subject: [PATCH 4/4] Strategy for creating webhooks from events --- .../Webhooks/LinqWebhookFilterEvaluator.cs | 36 +++- .../Webhooks/DefaultWebhookFactory_1.cs | 4 +- .../Webhooks/WebhookNotifierBuilder.cs | 35 +++- .../Webhooks/NotifyOneWebhookPerEventTests.cs | 158 ++++++++++++++++++ .../Webhooks/WebhookNotifierTests.cs | 2 +- 5 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 test/Deveel.Webhooks.XUnit/Webhooks/NotifyOneWebhookPerEventTests.cs diff --git a/src/Deveel.Webhooks.DynamicLinq/Webhooks/LinqWebhookFilterEvaluator.cs b/src/Deveel.Webhooks.DynamicLinq/Webhooks/LinqWebhookFilterEvaluator.cs index cf60fb1..deff85f 100644 --- a/src/Deveel.Webhooks.DynamicLinq/Webhooks/LinqWebhookFilterEvaluator.cs +++ b/src/Deveel.Webhooks.DynamicLinq/Webhooks/LinqWebhookFilterEvaluator.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Collections.Concurrent; using System.Linq.Dynamic.Core; using System.Linq.Expressions; @@ -23,7 +24,7 @@ namespace Deveel.Webhooks { /// /// public sealed class LinqWebhookFilterEvaluator : IWebhookFilterEvaluator where TWebhook : class { - private readonly IDictionary> filterCache; + private readonly IDictionary> filterCache; private readonly WebhookSenderOptions senderOptions; /// @@ -35,7 +36,7 @@ public sealed class LinqWebhookFilterEvaluator : IWebhookFilterEvaluat /// the filter evaluator. /// public LinqWebhookFilterEvaluator(IOptions> senderOptions) { - filterCache = new Dictionary>(); + filterCache = new ConcurrentDictionary>(); this.senderOptions = senderOptions.Value; } @@ -51,7 +52,8 @@ static LinqWebhookFilterEvaluator() { string IWebhookFilterEvaluator.Format => "linq"; private Func Compile(Type objType, string filter) { - if (!filterCache.TryGetValue(filter, out var compiled)) { + var key = new FilterKey(objType.FullName!, filter); + if (!filterCache.TryGetValue(key, out var compiled)) { var config = ParsingConfig.Default; var parameters = new[] { @@ -59,7 +61,7 @@ private Func Compile(Type objType, string filter) { }; var parsed = DynamicExpressionParser.ParseLambda(config, parameters, typeof(bool), filter).Compile(); compiled = hook => (bool)(parsed.DynamicInvoke(hook)!); - filterCache[filter] = compiled; + filterCache[key] = compiled; } return compiled; @@ -84,10 +86,8 @@ private Func Compile(Type objType, IList filters) { /// public async Task MatchesAsync(WebhookSubscriptionFilter filter, TWebhook webhook, CancellationToken cancellationToken) { - if (filter is null) - throw new ArgumentNullException(nameof(filter)); - if (webhook is null) - throw new ArgumentNullException(nameof(webhook)); + ArgumentNullException.ThrowIfNull(filter, nameof(filter)); + ArgumentNullException.ThrowIfNull(webhook, nameof(webhook)); if (filter.FilterFormat != "linq") throw new ArgumentException($"Filter format '{filter.FilterFormat}' not supported by the LINQ evaluator"); @@ -111,7 +111,27 @@ public async Task MatchesAsync(WebhookSubscriptionFilter filter, TWebhook } catch(Exception ex) { throw new WebhookException("Unable to evaluate the filter", ex); } + } + + readonly struct FilterKey { + public FilterKey(string typeName, string filter) : this() { + TypeName = typeName; + Filter = filter; + } + + public string TypeName { get; } + public string Filter { get; } + + public override bool Equals(object? obj) { + return obj is FilterKey key && + TypeName == key.TypeName && + Filter == key.Filter; + } + + public override int GetHashCode() { + return HashCode.Combine(TypeName, Filter); + } } } } diff --git a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs index e8646aa..391b1fe 100644 --- a/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs +++ b/src/Deveel.Webhooks/Webhooks/DefaultWebhookFactory_1.cs @@ -44,8 +44,8 @@ namespace Deveel.Webhooks { /// webhooks from a notification for a subscription. /// /// - public DefaultWebhookFactory(IOptions> options) { - Options = options.Value; + public DefaultWebhookFactory(IOptions>? options = null) { + Options = options?.Value ?? new WebhookFactoryOptions(); } protected WebhookFactoryOptions Options { get; } diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs index 02611e6..6009582 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs @@ -60,8 +60,6 @@ private void RegisterDefaultServices() { Services.TryAddSingleton(factoryType); } - Services.AddSingleton(Options.Create(new WebhookFactoryOptions())); - // TODO: register the default filter evaluator } @@ -102,7 +100,7 @@ public WebhookNotifierBuilder UseSender(Action /// Returns an instance of the builder to allow chaining. /// - public WebhookNotifierBuilder UseDefaultSender() + public WebhookNotifierBuilder UseSender() => UseSender((WebhookSenderBuilder builder) => { }); /// @@ -195,6 +193,37 @@ public WebhookNotifierBuilder UseWebhookFactory(ServiceLifet return this; } + public WebhookNotifierBuilder UseWebhookFactory() { + if (!typeof(TWebhook).IsClass || typeof(TWebhook).IsAbstract) + throw new InvalidOperationException("The webhook type must be a concrete class"); + + // TODO: check if the TWebhook has a parameterless constructor + if (!(typeof(TWebhook).GetConstructor(Type.EmptyTypes)?.IsPublic ?? false)) + throw new InvalidOperationException("The webhook type must have a public parameterless constructor"); + + Services.RemoveAll>(); + + var factoryType = typeof(DefaultWebhookFactory<>).MakeGenericType(typeof(TWebhook)); + Services.AddSingleton(typeof(IWebhookFactory), factoryType); + Services.AddSingleton(factoryType); + + if (typeof(TWebhook).IsAssignableFrom(typeof(Webhook))) { + Services.AddSingleton(typeof(DefaultWebhookFactory)); + } + + return this; + } + + public WebhookNotifierBuilder UseWebhookFactory(Action> configure) { + UseWebhookFactory(); + if (configure != null) { + Services.AddOptions>() + .Configure(configure); + } + + return this; + } + /// /// Adds a service that evaluates webhooks against a set /// of filters, to determine whether they should be sent. diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/NotifyOneWebhookPerEventTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/NotifyOneWebhookPerEventTests.cs new file mode 100644 index 0000000..841b942 --- /dev/null +++ b/test/Deveel.Webhooks.XUnit/Webhooks/NotifyOneWebhookPerEventTests.cs @@ -0,0 +1,158 @@ +using System.Net.Http.Json; +using System.Net; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit.Abstractions; +using System.Text.Json; +using Xunit; + +namespace Deveel.Webhooks { + public class NotifyOneWebhookPerEventTests : WebhookServiceTestBase { + private TestSubscriptionResolver subscriptionResolver; + private IWebhookNotifier notifier; + + private IList lastWebhooks; + private HttpResponseMessage? testResponse; + + public NotifyOneWebhookPerEventTests(ITestOutputHelper outputHelper) : base(outputHelper) { + notifier = Services.GetRequiredService>(); + subscriptionResolver = Services.GetRequiredService(); + } + + protected override void ConfigureServices(IServiceCollection services) { + services.AddWebhookNotifier(notifier => notifier + .UseWebhookFactory(options => options.CreateStrategy = WebhookCreateStrategy.OnePerEvent) + .UseLinqFilter() + .UseSubscriptionResolver(ServiceLifetime.Singleton) + .UseSender()); + + base.ConfigureServices(services); + } + + protected override async Task OnRequestAsync(HttpRequestMessage httpRequest) { + try { + if (lastWebhooks == null) + lastWebhooks = new List(); + + var webhook = await httpRequest.Content!.ReadFromJsonAsync(); + if (webhook != null) + lastWebhooks.Add(webhook); + + return new HttpResponseMessage(HttpStatusCode.Accepted); + } catch (Exception) { + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + } + } + + private string CreateSubscription(string name, string eventType, params WebhookFilter[] filters) { + return CreateSubscription(new TestWebhookSubscription { + EventTypes = new[] { eventType }, + DestinationUrl = "https://callback.example.com/webhook", + Name = name, + RetryCount = 3, + Filters = filters, + Status = WebhookSubscriptionStatus.Active, + CreatedAt = DateTimeOffset.UtcNow + }, true); + } + + private string CreateSubscription(TestWebhookSubscription subscription, bool enabled = true) { + var id = Guid.NewGuid().ToString(); + + subscription.SubscriptionId = id; + + subscriptionResolver.AddSubscription(subscription); + + return id; + } + + [Fact] + public async Task DeliverWebhookFromSingleEvent() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type == \"test\"", "linq")); + var eventInfo = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(eventInfo, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + Assert.True(result.HasSuccessful); + Assert.False(result.HasFailed); + Assert.NotEmpty(result.Successful); + Assert.Empty(result.Failed); + + Assert.Single(result[subscriptionId]!); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.True(webhookResult.HasAttempted); + Assert.Single(webhookResult.Attempts); + Assert.NotNull(webhookResult.LastAttempt); + Assert.True(webhookResult.LastAttempt.HasResponse); + + Assert.NotNull(lastWebhooks); + Assert.Single(lastWebhooks); + Assert.Equal("data.created", lastWebhooks[0].EventType); + Assert.Equal(eventInfo.Id, lastWebhooks[0].Id); + Assert.Equal(eventInfo.TimeStamp.ToUnixTimeSeconds(), lastWebhooks[0].TimeStamp.ToUnixTimeSeconds()); + + var testData = Assert.IsType(lastWebhooks[0].Data); + + Assert.Equal("test", testData.GetProperty("type").GetString()); + } + + [Fact] + public async Task DeliverWebhookFromMultipleEvents() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.type.startsWith(\"test\")", "linq")); + EventNotification notification = new[] { + new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }), + new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow.AddSeconds(3), + type = "test2" + }) + }; + + var result = await notifier.NotifyAsync(notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + Assert.True(result.HasSuccessful); + Assert.False(result.HasFailed); + Assert.NotEmpty(result.Successful); + Assert.Empty(result.Failed); + + Assert.Equal(2, result[subscriptionId]!.Count); + + var webhookResult = result[subscriptionId]![0]; + + Assert.Equal(subscriptionId, webhookResult.Webhook.SubscriptionId); + Assert.True(webhookResult.Successful); + Assert.True(webhookResult.HasAttempted); + Assert.Single(webhookResult.Attempts); + Assert.NotNull(webhookResult.LastAttempt); + Assert.True(webhookResult.LastAttempt.HasResponse); + + Assert.NotNull(lastWebhooks); + Assert.Equal(2, lastWebhooks.Count); + Assert.Equal("data.created", lastWebhooks[0].EventType); + Assert.Equal(notification.Events[0].Id, lastWebhooks[0].Id); + // TODO: how to determine the timestamp of the notification that has multiple events? + // Assert.Equal(notification.TimeStamp.ToUnixTimeSeconds(), lastWebhook.TimeStamp.ToUnixTimeSeconds()); + + var testData = Assert.IsType(lastWebhooks[0].Data); + + Assert.Equal("test", testData.GetProperty("type").GetString()); + } + + } +} diff --git a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs index 479b433..e285f9f 100644 --- a/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs +++ b/test/Deveel.Webhooks.XUnit/Webhooks/WebhookNotifierTests.cs @@ -38,7 +38,7 @@ public WebhookNotifierTests(ITestOutputHelper outputHelper) : base(outputHelper) } protected override void ConfigureServices(IServiceCollection services) { - services.AddWebhookNotifier(config => config + services.AddWebhookNotifier(notifier => notifier .UseLinqFilter() .UseSubscriptionResolver(ServiceLifetime.Singleton) .UseSender(options => {