diff --git a/samples/WebhookNotifierApp/Controllers/IdentityEventController.cs b/samples/WebhookNotifierApp/Controllers/IdentityEventController.cs index a7f2a70..d725fea 100644 --- a/samples/WebhookNotifierApp/Controllers/IdentityEventController.cs +++ b/samples/WebhookNotifierApp/Controllers/IdentityEventController.cs @@ -14,10 +14,10 @@ public IdentityEventController(IWebhookNotifier notifier) { this.notifier = notifier; } - [HttpPost] - public async Task PostAsync(UserCreatedEvent userCreated) { + [HttpPost("{tenantId}")] + public async Task PostAsync([FromRoute] string tenantId, [FromBody] UserCreatedEvent userCreated) { var eventInfo = new EventInfo("user", "created", userCreated); - var result = await notifier.NotifyAsync(userCreated.TenantId, eventInfo, HttpContext.RequestAborted); + var result = await notifier.NotifyAsync(eventInfo, HttpContext.RequestAborted); // TODO: output the result of the notification diff --git a/samples/WebhookNotifierApp/Program.cs b/samples/WebhookNotifierApp/Program.cs index 01ab48f..cefd589 100644 --- a/samples/WebhookNotifierApp/Program.cs +++ b/samples/WebhookNotifierApp/Program.cs @@ -28,17 +28,6 @@ public static void Main(string[] args) { }); }); - builder.Services.AddMultiTenant() - .WithClaimStrategy("tenant") - .WithRouteStrategy("tenant") - .WithInMemoryStore(options => { - options.Tenants.Add(new TenantInfo { - Id = "339191991", - Identifier = "tenant1", - Name = "Tenant 1", - ConnectionString = builder.Configuration.GetConnectionString("WebhookSubscriptions") - }); - }); builder.Services.AddWebhooks() .AddNotifier(notifier => notifier @@ -46,12 +35,10 @@ public static void Main(string[] args) { .UseMongoSubscriptionResolver()); builder.Services.AddWebhookSubscriptions() - .UseMongoDb(mongo => mongo.UseMultiTenant()); + .UseMongoDb("WebhookSubscriptions"); var app = builder.Build(); - app.UseMultiTenant(); - // Configure the HTTP request pipeline. app.UseHttpsRedirection(); diff --git a/samples/WebhookNotifierApp/README.md b/samples/WebhookNotifierApp/README.md index 0601886..384f051 100644 --- a/samples/WebhookNotifierApp/README.md +++ b/samples/WebhookNotifierApp/README.md @@ -22,17 +22,22 @@ builder.Services.AddWebhooks() .UseMongoSubscriptionResolver()); builder.Services.AddWebhookSubscriptions() - .UseMongoDb(mongo => mongo.UseMultiTenant()); + .UseMongoDb("WebhookSubscriptions"); ``` * The `AddWebhooks` method activates the webhook services for the webhooks of type `IdentityWebhook`. + * The `IdentityWebhook` is a custom webhook type implemented in the scope of this sample, and it is used to send notifications to a webhook endpoint when a user is created. + * When calling the `AddWebhooks` method without arguments, the webhook services are activated for the default type, that is a `Webhook` oject. + * The `AddNotifier` method configures the default webhook notifier services, activating a default implementation of the sender service. + * The `UseWebhookFactory` method registers the webhook factory that is used to create the webhook for the event `user.created`, that has a `UserCreatedEvent` as payload. + * More advanced implementations of an application might include a pipeline for the transormation of the incoming event, using the IEventDataFactory instances, to obtain an event that holds a Data property that can be handled by the webhook factory. + * The `UseMongoSubscriptionResolver` method configures the subscription resolver to use a MongoDB database to store the subscriptions - this comes by default with the Deveel.Webhooks.MongoDb package, and uses the `MongoWebhookSubscription` model to store and retrieve the subscriptions. -* The `AddWebhookSubscriptions` method activates the webhook subscription management services, using the `MongoWebhookSubscription` model to store and retrieve the subscriptions. -* The `UseMongoDb` method configures the MongoDB database as the storage for the subscriptions. -* The `UseMultiTenant` method configures the MongoDB database to be used in a multi-tenant application - this will require that Finbuckle.MultiTenant is configured in the application. + - Using MongoDB as the resolver of the subscriptions is a conventience choice for this sample application, but it can be replaced with any other implementation of the `IWebhookSubscriptionResolver` interface. + - Future implementations might use alternative methods to resolve subscriptions, like a Redis cache, a SQL storage or even a remote service. -## Notes +* The `AddWebhookSubscriptions` method activates the webhook subscription management services, using the `MongoWebhookSubscription` model to store and retrieve the subscriptions. -* **Multi-tenant Storage** - At the present time, the notifier is limited to the scenarios of webhook notifications to subscribers of a tenant, that requires the configuration of a multi-tenant storage. Future implementations will overcome this limitation, by allowing to store the subscriptions in a single-tenant storage, and to resolve the tenant of the subscriber at the time of the notification. \ No newline at end of file +* The `UseMongoDb` method configures the MongoDB database as the storage for the subscriptions, using the given connection string. diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HandlerExecutionMode.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HandlerExecutionMode.cs index e95a90b..ed29b35 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HandlerExecutionMode.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HandlerExecutionMode.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2022-2023 Deveel +// +// 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; namespace Deveel.Webhooks { /// diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookHandlingOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookHandlingOptions.cs index aa0d714..b495102 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookHandlingOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookHandlingOptions.cs @@ -1,4 +1,18 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// 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 { /// /// Defines the options for the handling of a received webhook /// from a single instance of a middleware of an application. diff --git a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.xml b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.xml index 0456027..84efcec 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.xml +++ b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.xml @@ -926,6 +926,22 @@ Returns the builder to continue the configuration. + + + Registers the MongoDB storage for resolving + webhook subscriptions to the notifier. + + + The type of the webhook to be notified. + + + The builder of the notifier service where to register + the resolver. + + + Returns the builder to continue the configuration. + + Provides extensions to the diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs index 021bf80..9d1401b 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/IWebhookConverter.cs @@ -1,4 +1,18 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// 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 { /// /// A service that is used to convert a webhook to an /// object that can be stored in a MongoDB database. diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookOptions.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookOptions.cs index 9cda8f9..fb142e5 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookOptions.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/MongoDbWebhookOptions.cs @@ -1,4 +1,18 @@ -using MongoDB.Driver; +// Copyright 2022-2023 Deveel +// +// 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 MongoDB.Driver; using MongoFramework; diff --git a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/WebhookNotifierBuilderExtensions.cs b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/WebhookNotifierBuilderExtensions.cs index 563250a..0206a38 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Webhooks/WebhookNotifierBuilderExtensions.cs +++ b/src/Deveel.Webhooks.Service.MongoDb/Webhooks/WebhookNotifierBuilderExtensions.cs @@ -34,7 +34,29 @@ public static class WebhookNotifierBuilderExtensions { /// public static WebhookNotifierBuilder UseMongoSubscriptionResolver(this WebhookNotifierBuilder builder) where TWebhook : class { - return builder.UseDefaultSubscriptionResolver(typeof(MongoWebhookSubscription)); + return builder + .UseDefaultSubscriptionResolver(typeof(MongoWebhookSubscription)); } + + /// + /// Registers the MongoDB storage for resolving + /// webhook subscriptions to the notifier. + /// + /// + /// The type of the webhook to be notified. + /// + /// + /// The builder of the notifier service where to register + /// the resolver. + /// + /// + /// Returns the builder to continue the configuration. + /// + public static WebhookNotifierBuilder UseMongoTenantSubscriptionResolver(this WebhookNotifierBuilder builder) + where TWebhook : class { + return builder + .UseDefaultTenantSubscriptionResolver(typeof(MongoWebhookSubscription)); + } + } } diff --git a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.xml b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.xml index ad7ef15..9228d71 100644 --- a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.xml +++ b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.xml @@ -45,6 +45,21 @@ Returns a list of subscriptions that are listening for the given event type + + + Attempts to get a list of subscriptions that are + listening for the given event type + + + The event type that the subscriptions are listening for + + + A cancellation token used to cancel the operation + + + Returns a list of subscriptions that are listening for the given event type + + Evicts from the cache all the subscriptions that are @@ -63,6 +78,21 @@ Returns a task that completes when the operation is done + + + Evicts from the cache all the subscriptions that are + listening for the given event type + + + The event type that the subscriptions are listening for + + + A cancellation token used to cancel the operation + + + Returns a task that completes when the operation is done + + Sets the given subscription in the cache @@ -91,34 +121,6 @@ Returns a task that completes when the operation is done - - - A default implementation of that - uses a registered store provider to retrieve the subscriptions. - - - The type of the subscription to be resolved. - - - - - Constructs a - backed by a given store provider. - - - The provider of the store to be used to retrieve the subscriptions. - - - An optional cache of the subscriptions to be used to speed up the - resolution process. - - - An optional logger to be used to log the operations. - - - - - Provides a contract for a store that can retrieve pages @@ -610,12 +612,52 @@ Returns the builder used to configure the service. + + + A default implementation of that + uses a registered store provider to retrieve the subscriptions. + + + The type of the subscription to be resolved. + + + + + Constructs a + backed by a given store provider. + + + The provider of the store to be used to retrieve the subscriptions. + + + An optional cache of the subscriptions to be used to speed up the + resolution process. + + + An optional logger to be used to log the operations. + + + + + Extends the class to register the default subscription resolver + + + Registers the default subscription resolver for the given webhook type + and that is based on the given subscription type. + + + + + + + + Registers the default subscription resolver for the given webhook type @@ -684,7 +726,7 @@ Returns this instance of the . - + @@ -942,6 +984,34 @@ + + + A default implementation of that + uses a registered store to retrieve the subscriptions. + + + The type of the subscription to be resolved. + + + + + Constructs a + backed by a given store. + + + The store to be used to retrieve the subscriptions. + + + An optional cache of the subscriptions to be used to speed up the + resolution process. + + + An optional logger to be used to log the operations. + + + + + An exception that is thrown during the validation diff --git a/src/Deveel.Webhooks.Service/Webhooks/Caching/IWebhookSubscriptionCache.cs b/src/Deveel.Webhooks.Service/Webhooks/Caching/IWebhookSubscriptionCache.cs index 7102ac5..7b3ebf8 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/Caching/IWebhookSubscriptionCache.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/Caching/IWebhookSubscriptionCache.cs @@ -53,6 +53,21 @@ public interface IWebhookSubscriptionCache { /// Task> GetByEventTypeAsync(string tenantId, string eventType, CancellationToken cancellationToken); + /// + /// Attempts to get a list of subscriptions that are + /// listening for the given event type + /// + /// + /// The event type that the subscriptions are listening for + /// + /// + /// A cancellation token used to cancel the operation + /// + /// + /// Returns a list of subscriptions that are listening for the given event type + /// + Task> GetByEventTypeAsync(string eventType, CancellationToken cancellationToken); + /// /// Evicts from the cache all the subscriptions that are /// listening for the given event type @@ -71,6 +86,21 @@ public interface IWebhookSubscriptionCache { /// Task EvictByEventTypeAsync(string tenantId, string eventType, CancellationToken cancellationToken); + /// + /// Evicts from the cache all the subscriptions that are + /// listening for the given event type + /// + /// + /// The event type that the subscriptions are listening for + /// + /// + /// A cancellation token used to cancel the operation + /// + /// + /// Returns a task that completes when the operation is done + /// + Task EvictByEventTypeAsync(string eventType, CancellationToken cancellationToken); + /// /// Sets the given subscription in the cache /// diff --git a/src/Deveel.Webhooks.Service/Webhooks/IWebhookDeliveryResultQueryableStore.cs b/src/Deveel.Webhooks.Service/Webhooks/IWebhookDeliveryResultQueryableStore.cs index 492dc87..ea84541 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/IWebhookDeliveryResultQueryableStore.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/IWebhookDeliveryResultQueryableStore.cs @@ -1,4 +1,18 @@ -namespace Deveel.Webhooks { +// Copyright 2022-2023 Deveel +// +// 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 { /// /// Provides a queryable store for the /// diff --git a/src/Deveel.Webhooks.Service/Webhooks/DefaultWebhookSubscriptionResolver_T.cs b/src/Deveel.Webhooks.Service/Webhooks/TenantWebhookSubscriptionResolver.cs similarity index 87% rename from src/Deveel.Webhooks.Service/Webhooks/DefaultWebhookSubscriptionResolver_T.cs rename to src/Deveel.Webhooks.Service/Webhooks/TenantWebhookSubscriptionResolver.cs index 9a3d996..a02ad68 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/DefaultWebhookSubscriptionResolver_T.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/TenantWebhookSubscriptionResolver.cs @@ -19,20 +19,20 @@ namespace Deveel.Webhooks { /// - /// A default implementation of that + /// A default implementation of that /// uses a registered store provider to retrieve the subscriptions. /// /// /// The type of the subscription to be resolved. /// - public class WebhookSubscriptionResolver : IWebhookSubscriptionResolver + public class TenantWebhookSubscriptionResolver : ITenantWebhookSubscriptionResolver where TSubscription : class, IWebhookSubscription { private readonly IWebhookSubscriptionStoreProvider storeProvider; private readonly IWebhookSubscriptionCache? cache; private ILogger logger; /// - /// Constructs a + /// Constructs a /// backed by a given store provider. /// /// @@ -45,13 +45,13 @@ public class WebhookSubscriptionResolver : IWebhookSubscriptionRe /// /// An optional logger to be used to log the operations. /// - public WebhookSubscriptionResolver( + public TenantWebhookSubscriptionResolver( IWebhookSubscriptionStoreProvider storeProvider, IWebhookSubscriptionCache? cache = null, - ILogger>? logger = null) { + ILogger>? logger = null) { this.storeProvider = storeProvider; this.cache = cache; - this.logger = logger ?? NullLogger>.Instance; + this.logger = logger ?? NullLogger>.Instance; } private async Task?> GetCachedAsync(string tenantId, string eventType, CancellationToken cancellationToken) { diff --git a/src/Deveel.Webhooks.Service/Webhooks/WebhookNotifierBuilderExtensions.cs b/src/Deveel.Webhooks.Service/Webhooks/WebhookNotifierBuilderExtensions.cs index e380807..d59be4f 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/WebhookNotifierBuilderExtensions.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/WebhookNotifierBuilderExtensions.cs @@ -26,6 +26,25 @@ namespace Deveel.Webhooks { /// to register the default subscription resolver /// public static class WebhookNotifierBuilderExtensions { + /// + /// Registers the default subscription resolver for the given webhook type + /// and that is based on the given subscription type. + /// + /// + /// + /// + /// + /// + /// + public static WebhookNotifierBuilder UseDefaultTenantSubscriptionResolver(this WebhookNotifierBuilder builder, Type subscriptionType, ServiceLifetime lifetime = ServiceLifetime.Scoped) + where TWebhook : class { + if (!typeof(IWebhookSubscription).IsAssignableFrom(subscriptionType)) + throw new ArgumentException("The type specified is not a subscription type", nameof(subscriptionType)); + + var resolverType = typeof(TenantWebhookSubscriptionResolver<>).MakeGenericType(subscriptionType); + return builder.UseTenantSubscriptionResolver(resolverType, lifetime); + } + /// /// Registers the default subscription resolver for the given webhook type /// and that is based on the given subscription type. @@ -44,5 +63,6 @@ public static WebhookNotifierBuilder UseDefaultSubscriptionResolver).MakeGenericType(subscriptionType); return builder.UseSubscriptionResolver(resolverType, lifetime); } + } } diff --git a/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionBuilder.cs b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionBuilder.cs index 2c5ce1b..0ebd88b 100644 --- a/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionBuilder.cs +++ b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionBuilder.cs @@ -54,6 +54,7 @@ private void RegisterDefaults() { Services.TryAddSingleton, WebhookSubscriptionValidator>(); Services.TryAddScoped>(); + // Services.TryAddScoped>(); } /// @@ -68,7 +69,7 @@ private void RegisterDefaults() { /// /// Returns this instance of the . /// - /// + /// public WebhookSubscriptionBuilder UseNotifier(Action> configure) where TWebhook : class, IWebhook { Services.AddWebhookNotifier(configure); diff --git a/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs new file mode 100644 index 0000000..41437e1 --- /dev/null +++ b/src/Deveel.Webhooks.Service/Webhooks/WebhookSubscriptionResolver.cs @@ -0,0 +1,93 @@ +// Copyright 2022-2023 Deveel +// +// 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 Deveel.Webhooks.Caching; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deveel.Webhooks { + /// + /// A default implementation of that + /// uses a registered store to retrieve the subscriptions. + /// + /// + /// The type of the subscription to be resolved. + /// + public class WebhookSubscriptionResolver : IWebhookSubscriptionResolver + where TSubscription : class, IWebhookSubscription { + private readonly IWebhookSubscriptionStore store; + private readonly IWebhookSubscriptionCache? cache; + private ILogger logger; + + /// + /// Constructs a + /// backed by a given store. + /// + /// + /// The store to be used to retrieve the subscriptions. + /// + /// + /// An optional cache of the subscriptions to be used to speed up the + /// resolution process. + /// + /// + /// An optional logger to be used to log the operations. + /// + public WebhookSubscriptionResolver( + IWebhookSubscriptionStore store, + IWebhookSubscriptionCache? cache = null, + ILogger>? logger = null) { + this.store = store; + this.cache = cache; + this.logger = logger ?? NullLogger>.Instance; + } + + private async Task?> GetCachedAsync(string eventType, CancellationToken cancellationToken) { + try { + if (cache == null) { + logger.LogTrace("No webhook subscriptions cache was set"); + return null; + } + + logger.LogTrace("Trying to retrieve webhook subscriptions to event {EventType}", eventType); + + return await cache.GetByEventTypeAsync(eventType, cancellationToken); + } catch (Exception ex) { + logger.LogError(ex, "Could not get the cached webhook subscriptions to event {EventType}", eventType); + return null; + } + } + + /// + public async Task> ResolveSubscriptionsAsync(string eventType, bool activeOnly, CancellationToken cancellationToken) { + try { + 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); + + var result = await store.GetByEventTypeAsync(eventType, activeOnly, cancellationToken); + list = result.Cast().ToList(); + } + + return list; + } catch (Exception ex) { + logger.LogError(ex, "Error occurred while trying to resolve webhook subscriptions to event {EventType}", eventType); + throw; + } + } + + } +} diff --git a/src/Deveel.Webhooks/Deveel.Webhooks.xml b/src/Deveel.Webhooks/Deveel.Webhooks.xml index f695251..84ac97e 100644 --- a/src/Deveel.Webhooks/Deveel.Webhooks.xml +++ b/src/Deveel.Webhooks/Deveel.Webhooks.xml @@ -4,6 +4,24 @@ Deveel.Webhooks + + + A default implementation of that + will iterate through all the registered + instances and transform an input event. + + + + + Constructs the pipeline with the given transformers. + + + The collection of the registered transformers to use to transform the event. + + + + + A default implementation of the @@ -88,16 +106,19 @@ The type of event that was triggered - The data provided by the event + The data carried by the event An optional unique identifier of the event - An optional time-stamp of the time the event occurred + An optional time-stamp of the time the event occurred. When not + provided the event is assumed to be occurred at the time of the + initialization of this instance. - If the is null or an empty string + If either the or are + null or an empty string If the data provided are null @@ -141,13 +162,13 @@ Returns a new instance of this event with the given data - + - A service that handles a specific event - and transforms its data + A service that handles a specific event and + transforms its data - + Determines if the instance can handle the event given and transforms it. @@ -167,7 +188,7 @@ event give, false otherwise. - + Creates an object that is used to form the contents of a webhook. @@ -181,6 +202,97 @@ a webhook delivered to subscribers. + + + Provides a pipeline mechanism to transform the events + incoming to a notifier, before forming a webhook to send. + + + + + Transforms the given event into a new one, that will be + used to create the webhook to send. + + + The original event to transform. + + + A token used to cancel the operation. + + + Returns an instance of that represents + the final version of an event to use to create the webhook. + + + + + A service that resolves subscriptions to events, prepares + and delivers webhooks to the subscribers owned by + a specific tenant. + + The type of the webhook that is delivered + + Implementations of this interface resolve subscriptions to events + that are owned by a specific tenant, explicitly set. To make this + to happen the service might invoke a discovery service that + will first resolve the storage context of the tenant. + + + + + Notifies to the subscribers the occurrence of the + given event. + + The scope of the tenant holding the subscriptions + to the given event. + The ifnormation of the event that occurred. + + + Returns an object that describes the aggregated final result of + the notification process executed. + + + + + Provides a contract for the resolution of subscriptions to events + owned by a specific tenant. + + + This service is primarily used by the + implementation, to delegate the resolution of subscriptions to a specific + type of event types. + + + + + Resolves all the subscriptions to an event in the scope + of a given tenant. + + The identifier of the tenant owning + the subscriptions. + The type of event that has occurred. + A flag indicating whether only active + subscriptions should be returned. + + + Returns a list of instances + that are matching the given basic conditions. + + + + + Defines the contract for a resolver of a + that is specific to a given webhook type. + + + The type of the webhook that is scoped for the resolver. + + + Implementations of this version of the interface are + segregated to the scope of the webhook type. + + + Defines a service that is able to log the result of @@ -209,8 +321,8 @@ - Defines a factory that can create instances - given the subscription and the event information. + Defines a factory that can create + instances given the subscription and the event information. The type of the webhook instance to create. @@ -265,16 +377,24 @@ A service that resolves subscriptions to events, prepares and delivers webhooks to the subscribers. + The type of the webhook that is delivered + + Implementations of this interface resolve subscriptions to events + without any tenant scope explicitly set: despite of this condition, + the service might still resolve subscriptions to events that are + owned by tenants, if the discovery is performed by a service that + this resolver invokes. + - + Notifies to the subscribers the occurrence of the given event. - The scope of the tenant holding the subscriptions - to the given event. The ifnormation of the event that occurred. - + + A token that can be used to cancel the notification process. + Returns an object that describes the aggregated final result of the notification process executed. @@ -284,18 +404,23 @@ Provides a contract for the resolution of subscriptions to events. + + This service is primarily used by the + implementation, to delegate the resolution of subscriptions to a specific + type of event types. + - + Resolves all the subscriptions to an event in the scope of a given tenant. - The identifier of the tenant owning - the subscriptions. - The type of event that occurred. + The type of event that has occurred. A flag indicating whether only active subscriptions should be returned. - + + A token used to cancel the operation. + Returns a list of instances that are matching the given basic conditions. @@ -309,6 +434,10 @@ The type of the webhook that is scoped for the resolver. + + Implementations of this version of the interface are + segregated to the scope of the webhook type. + @@ -380,6 +509,60 @@ to configure the webhook service. + + + The default implementation of the webhook notifier + that addresses specific tenants + + + + + Constructs the notifier with the given services. + + + The service used to send the webhook. + + + A service used to resolve the subscriptions owned by a + tanant that will be notified + + + A service used to create the webhook to send. + + + A service used to transform the event data + + + A collection of services used to filter the webhooks to + be delivered + + + A service used to log the delivery result of the webhook. + + + A logger used to log the activity of the notifier. + + + + + Resolves the subscriptions that should be notified for the given event. + + + The identifier of the tenant for which the event was raised. + + + The information about the event that was raised. + + + A cancellation token that can be used to cancel the operation. + + + Returns a list of subscriptions that should be notified for the given event. + + + + + A default implementation of that can be used @@ -597,43 +780,60 @@ - The default implementation of the webhook notifier + A notification service that is scoped to a specific webhook type. + + The type of the webhook that is scoped for the notifier. + - + - Constructs the notifier with the given services. + Constructs the notifier with the given sender and factory. - The service used to send the webhook. - - - A service used to resolve the subscriptions to notify. + The service instance that will be used to send the notifications. - A service used to create the webhook to send. + A factory of webhooks that will be notified - - A collection of services used to create the data to send, - using the original data of the event. + + A service used to resolve the subscriptions to a given event + + + A service used to transform the event data - A collection of services used to filter the webhooks to - be delivered + A list of all the evaluators registered in the application context, + and that will be used to filter the webhooks to be notified. - A service used to log the delivery result of the webhook. + An optional service used to log the delivery result of the webhook. - A logger used to log the activity of the notifier. + A logger instance used to log the activity of the notifier. - + + + + + + + + + A base class that provides common functionality for a notifier + to reach out receivers that subscribed for a given event. + + + The type of webhook notified to the subscribers. + + + Gets the logger used to log the activity of the notifier. - + Creates a new webhook filter for the given subscription. @@ -644,7 +844,7 @@ Returns an instance of - + Transforms the data included in the event into an object that can be used to create a webhook. @@ -666,7 +866,7 @@ - + Gets the filter evaluator for the given format. @@ -679,7 +879,7 @@ found for the given format. - + Matches the given webhook against the given filter. @@ -707,7 +907,7 @@ Thrown when the filter format is not supported. - + A callback that is invoked after a webhook has been sent @@ -727,7 +927,7 @@ Returns a task that completes when the operation is done. - + A callback that is invoked after a webhook has been sent @@ -741,7 +941,7 @@ The result of the delivery operation. - + Logs the given delivery result. @@ -758,27 +958,25 @@ Returns a task that completes when the operation is done. - + - Resolves the subscriptions that should be notified for the given event. + Performs the notification of the given event to the subscriptions + resolved that are listening for it. - - The identifier of the tenant for which the event was raised. - - The information about the event that was raised. + The information about the event that is being notified. + + + The subscriptions that are listening for the event. A cancellation token that can be used to cancel the operation. - Returns a list of subscriptions that should be notified for the given event. + Returns a task that completes when the operation is done. - - - - + A callback that is invoked when a delivery error occurred during a notification @@ -799,7 +997,7 @@ Returns a task that can be awaited. - + A callback that is invoked when a delivery result @@ -813,7 +1011,7 @@ The error that occurred during the delivery. - + A callback that is invoked when a delivery result @@ -824,7 +1022,7 @@ Returns the result of a single delivery operation. - + Creates a new webhook for the given subscription and event. @@ -845,7 +1043,7 @@ - A builder used to configure the service. + A builder used to configure the webhook notification service. The type of the webhook to notify. @@ -924,6 +1122,29 @@ Returns an instance of the builder to allow chaining. + + + Registers a notifier service to use. + + + The type of the notifier to use. + + + An optional value that specifies the lifetime of the service (by default + set to ). + + + Returns an instance of the builder to allow chaining. + + + + + Registers the default notifier service to use. + + + Returns an instance of the builder to allow chaining. + + Registers a factory service to use to create the webhook. @@ -953,6 +1174,36 @@ Returns an instance of the builder to allow chaining. + + + Registers a service that resolves the subscriptions to the + notification of events. + + + The type of the resolver to register. + + + An optional value that specifies the lifetime of the service (by default + set to ). + + + Returns an instance of the builder to allow chaining. + + + + + Registers a service that resolves the subscriptions to the + notification of events. + + + The type of the resolver to register. + + + An optional value that specifies the lifetime of the service (by default + set to ). + + + Registers a service that resolves the subscriptions to the @@ -1021,7 +1272,7 @@ The main goal of this class is to provide a fluent API to configure - other services, such as the , + other services, such as the , the , aligning them in the bounded context of the given . diff --git a/src/Deveel.Webhooks/Webhooks/DefaultEventTransformerPipeline.cs b/src/Deveel.Webhooks/Webhooks/DefaultEventTransformerPipeline.cs new file mode 100644 index 0000000..294f7eb --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/DefaultEventTransformerPipeline.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + /// + /// A default implementation of that + /// will iterate through all the registered + /// instances and transform an input event. + /// + public class DefaultEventTransformerPipeline : IEventTransformerPipeline { + private readonly IEnumerable? dataTransformers; + + /// + /// Constructs the pipeline with the given transformers. + /// + /// + /// The collection of the registered transformers to use to transform the event. + /// + public DefaultEventTransformerPipeline(IEnumerable? dataTransformers) { + this.dataTransformers = dataTransformers; + } + + /// + public async Task TransformAsync(EventInfo eventInfo, CancellationToken cancellationToken) { + if (dataTransformers != null) { + try { + foreach (var dataTransformer in dataTransformers) { + if (dataTransformer.Handles(eventInfo)) { + var data = await dataTransformer.CreateDataAsync(eventInfo, cancellationToken); + eventInfo = eventInfo.WithData(data); + } + } + } catch (Exception ex) { + throw new WebhookException("Unable to factory the data for the event", ex); + } + } + + return eventInfo; + } + } +} diff --git a/src/Deveel.Webhooks/Webhooks/EventInfo.cs b/src/Deveel.Webhooks/Webhooks/EventInfo.cs index 93e9596..c575f2d 100644 --- a/src/Deveel.Webhooks/Webhooks/EventInfo.cs +++ b/src/Deveel.Webhooks/Webhooks/EventInfo.cs @@ -31,16 +31,19 @@ public readonly struct EventInfo { /// The type of event that was triggered /// /// - /// The data provided by the event + /// The data carried by the event /// /// /// An optional unique identifier of the event /// /// - /// An optional time-stamp of the time the event occurred + /// An optional time-stamp of the time the event occurred. When not + /// provided the event is assumed to be occurred at the time of the + /// initialization of this instance. /// /// - /// If the is null or an empty string + /// If either the or are + /// null or an empty string /// /// /// If the data provided are null diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookDataFactory.cs b/src/Deveel.Webhooks/Webhooks/IEventDataTransformer.cs similarity index 94% rename from src/Deveel.Webhooks/Webhooks/IWebhookDataFactory.cs rename to src/Deveel.Webhooks/Webhooks/IEventDataTransformer.cs index d6199d0..bf2f2af 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookDataFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/IEventDataTransformer.cs @@ -18,10 +18,10 @@ namespace Deveel.Webhooks { /// - /// A service that handles a specific event - /// and transforms its data + /// A service that handles a specific event and + /// transforms its data /// - public interface IWebhookDataFactory { + public interface IEventDataTransformer { /// /// Determines if the instance can handle /// the event given and transforms it. diff --git a/src/Deveel.Webhooks/Webhooks/IEventTransformerPipeline.cs b/src/Deveel.Webhooks/Webhooks/IEventTransformerPipeline.cs new file mode 100644 index 0000000..4cabcab --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/IEventTransformerPipeline.cs @@ -0,0 +1,23 @@ +namespace Deveel.Webhooks { + /// + /// Provides a pipeline mechanism to transform the events + /// incoming to a notifier, before forming a webhook to send. + /// + public interface IEventTransformerPipeline { + /// + /// Transforms the given event into a new one, that will be + /// used to create the webhook to send. + /// + /// + /// The original event to transform. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// Returns an instance of that represents + /// the final version of an event to use to create the webhook. + /// + Task TransformAsync(EventInfo eventInfo, CancellationToken cancellationToken); + } +} diff --git a/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs new file mode 100644 index 0000000..2bc402e --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/ITenantWebhookNotifier.cs @@ -0,0 +1,47 @@ +// Copyright 2022-2023 Deveel +// +// 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; +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + /// + /// A service that resolves subscriptions to events, prepares + /// and delivers webhooks to the subscribers owned by + /// a specific tenant. + /// + /// The type of the webhook that is delivered + /// + /// Implementations of this interface resolve subscriptions to events + /// that are owned by a specific tenant, explicitly set. To make this + /// to happen the service might invoke a discovery service that + /// will first resolve the storage context of the tenant. + /// + public interface ITenantWebhookNotifier where TWebhook : class { + /// + /// Notifies to the subscribers the occurrence of the + /// given event. + /// + /// The scope of the tenant holding the subscriptions + /// to the given event. + /// The ifnormation of the event that occurred. + /// + /// + /// Returns an object that describes the aggregated final result of + /// the notification process executed. + /// + Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken); + } +} diff --git a/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver.cs b/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver.cs new file mode 100644 index 0000000..fee3a38 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver.cs @@ -0,0 +1,46 @@ +// Copyright 2022-2023 Deveel +// +// 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.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + /// + /// Provides a contract for the resolution of subscriptions to events + /// owned by a specific tenant. + /// + /// + /// This service is primarily used by the + /// implementation, to delegate the resolution of subscriptions to a specific + /// type of event types. + /// + public interface ITenantWebhookSubscriptionResolver { + /// + /// Resolves all the subscriptions to an event in the scope + /// of a given tenant. + /// + /// The identifier of the tenant owning + /// the subscriptions. + /// The type of event that has occurred. + /// A flag indicating whether only active + /// subscriptions should be returned. + /// + /// + /// Returns a list of instances + /// that are matching the given basic conditions. + /// + Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken); + } +} diff --git a/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver_1.cs b/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver_1.cs new file mode 100644 index 0000000..63d1b64 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/ITenantWebhookSubscriptionResolver_1.cs @@ -0,0 +1,31 @@ +// Copyright 2022-2023 Deveel +// +// 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 { + /// + /// Defines the contract for a resolver of a + /// that is specific to a given webhook type. + /// + /// + /// The type of the webhook that is scoped for the resolver. + /// + /// + /// Implementations of this version of the interface are + /// segregated to the scope of the webhook type. + /// + /// + public interface ITenantWebhookSubscriptionResolver : ITenantWebhookSubscriptionResolver + where TWebhook : class { + } +} diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs index 626444a..38a6b08 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookFactory.cs @@ -18,8 +18,8 @@ namespace Deveel.Webhooks { /// - /// Defines a factory that can create instances - /// given the subscription and the event information. + /// Defines a factory that can create + /// instances given the subscription and the event information. /// /// /// The type of the webhook instance to create. diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs index 3bb9927..6dfc769 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookNotifier.cs @@ -13,7 +13,9 @@ // limitations under the License. using System; -using System.Threading; +using System.Collections.Generic; +using System.Linq; +using System.Text; using System.Threading.Tasks; namespace Deveel.Webhooks { @@ -21,19 +23,27 @@ namespace Deveel.Webhooks { /// A service that resolves subscriptions to events, prepares /// and delivers webhooks to the subscribers. /// + /// The type of the webhook that is delivered + /// + /// Implementations of this interface resolve subscriptions to events + /// without any tenant scope explicitly set: despite of this condition, + /// the service might still resolve subscriptions to events that are + /// owned by tenants, if the discovery is performed by a service that + /// this resolver invokes. + /// public interface IWebhookNotifier where TWebhook : class { /// /// Notifies to the subscribers the occurrence of the /// given event. /// - /// The scope of the tenant holding the subscriptions - /// to the given event. /// The ifnormation of the event that occurred. - /// + /// + /// A token that can be used to cancel the notification process. + /// /// /// Returns an object that describes the aggregated final result of /// the notification process executed. /// - Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken); + Task> NotifyAsync(EventInfo eventInfo, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver.cs b/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver.cs index 2ea9d1d..f9e47f2 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver.cs @@ -12,29 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - namespace Deveel.Webhooks { /// /// Provides a contract for the resolution of subscriptions to events. /// + /// + /// This service is primarily used by the + /// implementation, to delegate the resolution of subscriptions to a specific + /// type of event types. + /// public interface IWebhookSubscriptionResolver { /// /// Resolves all the subscriptions to an event in the scope /// of a given tenant. /// - /// The identifier of the tenant owning - /// the subscriptions. - /// The type of event that occurred. + /// The type of event that has occurred. /// A flag indicating whether only active /// subscriptions should be returned. - /// + /// + /// A token used to cancel the operation. + /// /// /// Returns a list of instances /// that are matching the given basic conditions. /// - Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken); + Task> ResolveSubscriptionsAsync(string eventType, bool activeOnly, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver_1.cs b/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver_1.cs index 26ac540..e08e770 100644 --- a/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver_1.cs +++ b/src/Deveel.Webhooks/Webhooks/IWebhookSubscriptionResolver_1.cs @@ -20,8 +20,11 @@ namespace Deveel.Webhooks { /// /// The type of the webhook that is scoped for the resolver. /// + /// + /// Implementations of this version of the interface are + /// segregated to the scope of the webhook type. + /// /// - public interface IWebhookSubscriptionResolver : IWebhookSubscriptionResolver - where TWebhook : class { + public interface IWebhookSubscriptionResolver : IWebhookSubscriptionResolver where TWebhook : class { } } diff --git a/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs new file mode 100644 index 0000000..72af2eb --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/TenantWebhookNotifier.cs @@ -0,0 +1,112 @@ +// Copyright 2022-2023 Deveel +// +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deveel.Webhooks { + /// + /// The default implementation of the webhook notifier + /// that addresses specific tenants + /// + public class TenantWebhookNotifier : WebhookNotifierBase, ITenantWebhookNotifier where TWebhook : class { + private readonly ITenantWebhookSubscriptionResolver? subscriptionResolver; + + /// + /// Constructs the notifier with the given services. + /// + /// + /// The service used to send the webhook. + /// + /// + /// A service used to resolve the subscriptions owned by a + /// tanant that will be notified + /// + /// + /// A service used to create the webhook to send. + /// + /// + /// A service used to transform the event data + /// + /// + /// A collection of services used to filter the webhooks to + /// be delivered + /// + /// + /// A service used to log the delivery result of the webhook. + /// + /// + /// A logger used to log the activity of the notifier. + /// + public TenantWebhookNotifier( + IWebhookSender sender, + IWebhookFactory webhookFactory, + ITenantWebhookSubscriptionResolver? subscriptionResolver = null, + IEventTransformerPipeline? eventTransformer = null, + IEnumerable>? filterEvaluators = null, + IWebhookDeliveryResultLogger? deliveryResultLogger = null, + ILogger>? logger = null) : base(sender, webhookFactory, eventTransformer, filterEvaluators, deliveryResultLogger, logger) { + this.subscriptionResolver = subscriptionResolver; + } + + /// + /// Resolves the subscriptions that should be notified for the given event. + /// + /// + /// The identifier of the tenant for which the event was raised. + /// + /// + /// The information about the event that was raised. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// Returns a list of subscriptions that should be notified for the given event. + /// + protected virtual async Task> ResolveSubscriptionsAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken) { + if (subscriptionResolver == null) + return new List(); + + try { + return await subscriptionResolver.ResolveSubscriptionsAsync(tenantId, eventInfo.EventType, true, cancellationToken); + } catch(WebhookException) { + throw; + } catch (Exception ex) { + throw new WebhookException("An error occurred while trying to resolve the subscriptions", ex); + } + } + + /// + public virtual async Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken) { + IEnumerable subscriptions; + + try { + subscriptions = await ResolveSubscriptionsAsync(tenantId, eventInfo, cancellationToken); + } catch (WebhookException ex) { + Logger.LogError(ex, "Error while resolving the subscriptions to event {EventType} for tenant '{TenantId}'", + eventInfo.EventType, tenantId); + throw; + } + + return await NotifySubscriptionsAsync(eventInfo, subscriptions, cancellationToken); + } + } +} diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs index dc25faa..68f8f7d 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifier.cs @@ -12,497 +12,76 @@ // 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.Threading; -using System.Threading.Tasks; - using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Deveel.Webhooks { /// - /// The default implementation of the webhook notifier + /// A notification service that is scoped to a specific webhook type. /// - public class WebhookNotifier : IWebhookNotifier where TWebhook : class { - private readonly IWebhookSubscriptionResolver? subscriptionResolver; - private readonly IWebhookDeliveryResultLogger deliveryResultLogger; - private readonly IWebhookSender sender; - private readonly IWebhookFactory webhookFactory; - private readonly IEnumerable? dataFactories; - private readonly IDictionary> filterEvaluators; - - #region .ctor + /// + /// The type of the webhook that is scoped for the notifier. + /// + public class WebhookNotifier : WebhookNotifierBase, IWebhookNotifier where TWebhook : class { + private readonly IWebhookSubscriptionResolver? subscriptionResolver; /// - /// Constructs the notifier with the given services. + /// Constructs the notifier with the given sender and factory. /// /// - /// The service used to send the webhook. - /// - /// - /// A service used to resolve the subscriptions to notify. + /// The service instance that will be used to send the notifications. /// /// - /// A service used to create the webhook to send. + /// A factory of webhooks that will be notified + /// + /// + /// A service used to resolve the subscriptions to a given event /// - /// - /// A collection of services used to create the data to send, - /// using the original data of the event. + /// + /// A service used to transform the event data /// /// - /// A collection of services used to filter the webhooks to - /// be delivered + /// A list of all the evaluators registered in the application context, + /// and that will be used to filter the webhooks to be notified. /// /// - /// A service used to log the delivery result of the webhook. + /// An optional service used to log the delivery result of the webhook. /// /// - /// A logger used to log the activity of the notifier. + /// A logger instance used to log the activity of the notifier. /// public WebhookNotifier( - IWebhookSender sender, + IWebhookSender sender, IWebhookFactory webhookFactory, IWebhookSubscriptionResolver? subscriptionResolver = null, - IEnumerable? dataFactories = null, - IEnumerable>? filterEvaluators = null, - IWebhookDeliveryResultLogger? deliveryResultLogger = null, - ILogger>? logger = null) { - this.sender = sender; + IEventTransformerPipeline? eventTransformer = null, + IEnumerable>? filterEvaluators = null, + IWebhookDeliveryResultLogger? deliveryResultLogger = null, + ILogger>? logger = null) + : base(sender, webhookFactory, eventTransformer, filterEvaluators, deliveryResultLogger, logger) { this.subscriptionResolver = subscriptionResolver; - this.webhookFactory = webhookFactory; - this.dataFactories = dataFactories; - this.filterEvaluators = GetFilterEvaluators(filterEvaluators); - this.deliveryResultLogger = deliveryResultLogger ?? NullWebhookDeliveryResultLogger.Instance; - Logger = logger ?? NullLogger>.Instance; - } - - #endregion - - /// - /// Gets the logger used to log the activity of the notifier. - /// - protected ILogger Logger { get; } - - private static IDictionary> GetFilterEvaluators(IEnumerable>? filterEvaluators) { - var evaluators = new Dictionary>(); - - if (filterEvaluators != null) { - foreach (var filterEvaluator in filterEvaluators) { - evaluators[filterEvaluator.Format] = filterEvaluator; - } - } - - return evaluators; - } - - /// - /// Creates a new webhook filter for the given subscription. - /// - /// - /// The subscription to create the filter for. - /// - /// - /// Returns an instance of - /// - protected virtual WebhookSubscriptionFilter? BuildSubscriptionFilter(IWebhookSubscription subscription) { - return subscription.AsFilter(); - } - - /// - /// Transforms the data included in the event into an - /// object that can be used to create a webhook. - /// - /// - /// The information about the event that triggered the notification. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// This method is called before a webhook is created and sent: the - /// generated data will be used to renew the instance of the - /// , that will then be constructed into the webhook. - /// - /// - /// Returns an object that will be used to renew the data of the event - /// before passing it to the factory. - /// - /// - protected virtual async Task GetWebhookDataAsync(EventInfo eventInfo, CancellationToken cancellationToken) { - Logger.LogDebug("GetWebhookDataAsync: getting data for an event"); - - var data = eventInfo.Data; - - var factory = dataFactories?.FirstOrDefault(x => x.Handles(eventInfo)); - if (factory != null) { - Logger.LogDebug("GetWebhookDataAsync: using a factory for the event of type {EventType} to generate the webhook data", - eventInfo.EventType); - - data = await factory.CreateDataAsync(eventInfo, cancellationToken); - } else { - Logger.LogDebug("GetWebhookDataAsync: using the data of the event"); - } - - return data; - } - - /// - /// Gets the filter evaluator for the given format. - /// - /// - /// The format of the filter evaluator to get. - /// - /// - /// Returns an instance of - /// that matches the given format, or null if no evaluator was - /// found for the given format. - /// - protected virtual IWebhookFilterEvaluator? GetFilterEvaluator(string format) { - return !filterEvaluators.TryGetValue(format, out var filterEvaluator) ? null : filterEvaluator; - } - - /// - /// Matches the given webhook against the given filter. - /// - /// - /// The subscription filter to match the webhook against. - /// - /// - /// The webhook to match against the filter. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// The default implementation of this method invokes a filter evaluator - /// if the following pre-conditions are met: - /// - /// The filer is not empty or null (it returns true) - /// The filter is not a wildcard (it returns true) - /// - /// - /// - /// Returns true if the webhook matches the filter, false otherwise. - /// - /// - /// Thrown when the filter format is not supported. - /// - protected virtual async Task MatchesAsync(WebhookSubscriptionFilter? filter, TWebhook webhook, CancellationToken cancellationToken) { - if (filter == null || filter.IsEmpty) { - Logger.LogTrace("The filter request was null or empty: accepting by default"); - return true; - } - - if (filter.IsWildcard) { - Logger.LogTrace("The whole filter request was a wildcard"); - return true; - } - - Logger.LogTrace("Selecting the filter evaluator for '{FilterFormat}' format", filter.FilterFormat); - - var filterEvaluator = GetFilterEvaluator(filter.FilterFormat); - - if (filterEvaluator == null) { - Logger.LogError("Could not resolve any filter evaluator for the format '{FilterFormat}'", filter.FilterFormat); - throw new NotSupportedException($"Filers of type '{filter.FilterFormat}' are not supported"); - } - - return await filterEvaluator.MatchesAsync(filter, webhook, cancellationToken); - } - - /// - /// A callback that is invoked after a webhook has been sent - /// - /// - /// The subscription that was used to send the webhook. - /// - /// - /// The webhook that was sent. - /// - /// - /// The result of the delivery operation. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// Returns a task that completes when the operation is done. - /// - protected virtual Task OnDeliveryResultAsync(IWebhookSubscription subscription, TWebhook webhook, WebhookDeliveryResult result, CancellationToken cancellationToken) { - OnDeliveryResult(subscription, webhook, result); - return Task.CompletedTask; - } - - /// - /// A callback that is invoked after a webhook has been sent - /// - /// - /// The subscription that was used to send the webhook. - /// - /// - /// The webhook that was sent. - /// - /// - /// The result of the delivery operation. - /// - protected virtual void OnDeliveryResult(IWebhookSubscription subscription, TWebhook webhook, WebhookDeliveryResult result) { - - } - - /// - /// Logs the given delivery result. - /// - /// - /// The subscription that was used to send the webhook. - /// - /// - /// The result of the delivery operation. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// Returns a task that completes when the operation is done. - /// - protected virtual async Task LogDeliveryResultAsync(IWebhookSubscription subscription, WebhookDeliveryResult deliveryResult, CancellationToken cancellationToken) { - try { - if (deliveryResultLogger != null) - await deliveryResultLogger.LogResultAsync(subscription, deliveryResult, cancellationToken); - } catch (Exception ex) { - // If an error occurs here, we report it, but we don't throw it... - Logger.LogError(ex, "Error while logging a delivery result for tenant {TenantId}", subscription.TenantId); - } - } - - private void TraceDeliveryResult(WebhookDeliveryResult deliveryResult) { - if (!deliveryResult.HasAttempted) { - Logger.LogTrace("The delivery was not attempted"); - } else if (deliveryResult.Successful) { - Logger.LogTrace("The delivery was successful after {AttemptCount} attempts", deliveryResult.Attempts.Count()); - } else { - Logger.LogTrace("The delivery failed after {AttemptCount} attempts", deliveryResult.Attempts.Count()); - } - - if (deliveryResult.HasAttempted) { - foreach (var attempt in deliveryResult.Attempts) { - if (attempt.Failed) { - Logger.LogTrace("Attempt {AttemptNumber} Failed - [{StartDate} - {EndDate}] {StatusCode}: {ErrorMessage}", - attempt.Number, attempt.StartedAt, attempt.CompletedAt, attempt.ResponseCode, attempt.ResponseMessage); - } else { - Logger.LogTrace("Attempt {AttemptNumber} Successful - [{StartDate} - {EndDate}] {StatusCode}", - attempt.Number, attempt.StartedAt, attempt.CompletedAt, attempt.ResponseCode); - } - } - } } - /// - /// Resolves the subscriptions that should be notified for the given event. - /// - /// - /// The identifier of the tenant for which the event was raised. - /// - /// - /// The information about the event that was raised. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// 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(EventInfo eventInfo, CancellationToken cancellationToken) { if (subscriptionResolver == null) return new List(); try { - return await subscriptionResolver.ResolveSubscriptionsAsync(tenantId, eventInfo.EventType, true, cancellationToken); - } catch(WebhookException) { + return await subscriptionResolver.ResolveSubscriptionsAsync(eventInfo.EventType, true, cancellationToken); + } catch (WebhookException) { throw; } catch (Exception ex) { throw new WebhookException("An error occurred while trying to resolve the subscriptions", ex); } - } - - /// - public virtual async Task> NotifyAsync(string tenantId, EventInfo eventInfo, CancellationToken cancellationToken) { - var result = new WebhookNotificationResult(eventInfo); - - try { - var subscriptions = await ResolveSubscriptionsAsync(tenantId, eventInfo, cancellationToken); - - if (subscriptions == null || subscriptions.Count == 0) { - Logger.LogTrace("No active subscription to event '{EventType}' found for Tenant '{TenantId}': skipping notification", eventInfo.EventType, tenantId); - return result; - } - - foreach (var subscription in subscriptions) { - if (String.IsNullOrWhiteSpace(subscription.SubscriptionId)) - throw new WebhookException("The subscription identifier is missing"); - - Logger.LogDebug("Evaluating subscription {SubscriptionId} to the event of type {EventType} of tenant {TenantId}", - subscription.SubscriptionId, eventInfo.EventType, tenantId); - - var webhook = await CreateWebhook(subscription, eventInfo, cancellationToken); - - if (webhook == null) { - Logger.LogWarning("It was not possible to generate the webhook for the event {EventType} to be delivered to subscription {SubscriptionName} ({SubscriptionId})", - eventInfo.EventType, subscription.Name, subscription.SubscriptionId); - continue; - } - - try { - var filter = BuildSubscriptionFilter(subscription); - - if (await MatchesAsync(filter, webhook, cancellationToken)) { - Logger.LogTrace("Delivering webhook for event {EventType} to subscription {SubscriptionId} of Tenant {TenantId}", - eventInfo.EventType, subscription.SubscriptionId, tenantId); - - var deliveryResult = await SendAsync(subscription, webhook, cancellationToken); - - result.AddDelivery(subscription.SubscriptionId, deliveryResult); - - await LogDeliveryResultAsync(subscription, deliveryResult, cancellationToken); - - TraceDeliveryResult(deliveryResult); - - try { - await OnDeliveryResultAsync(subscription, webhook, deliveryResult, cancellationToken); - } catch (Exception ex) { - Logger.LogError(ex, "The event handling on the delivery thrown an error"); - } - - } else { - Logger.LogTrace("The webhook for event {EventType} could not match the subscription {SubscriptionId} of Tenant {TenantId}", - eventInfo.EventType, subscription.SubscriptionId, tenantId); - } - } catch (Exception ex) { - Logger.LogError(ex, "Could not deliver a webhook for event {EventType} to subscription {SubscriptionId} of Tenant {TenantId}", - typeof(TWebhook), subscription.SubscriptionId, tenantId); - - await OnDeliveryErrorAsync(subscription, webhook, ex, cancellationToken); - - // result.AddDelivery(new WebhookDeliveryResult(destination, webhook)); - } - } - - return result; - } catch (Exception ex) { - Logger.LogError(ex, "An unknown error when trying to notify the event {EventType} to tenant {TenantId}", eventInfo.EventType, tenantId); - throw; - } - } - - /// - /// A callback that is invoked when a delivery error - /// occurred during a notification - /// - /// - /// The subscription that was being notified. - /// - /// - /// The webhook that was being delivered. - /// - /// - /// The error that occurred during the delivery. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// Returns a task that can be awaited. - /// - protected virtual Task OnDeliveryErrorAsync(IWebhookSubscription subscription, TWebhook webhook, Exception error, CancellationToken cancellationToken) { - OnDeliveryError(subscription, webhook, error); - return Task.CompletedTask; - } - - /// - /// A callback that is invoked when a delivery result - /// - /// - /// The subscription that was being notified. - /// - /// - /// The webhook that was being delivered. - /// - /// - /// The error that occurred during the delivery. - /// - protected virtual void OnDeliveryError(IWebhookSubscription subscription, TWebhook webhook, Exception error) { - - } - - /// - /// A callback that is invoked when a delivery result - /// - /// - /// - /// - /// - /// Returns the result of a single delivery operation. - /// - protected virtual Task> SendAsync(IWebhookSubscription subscription, TWebhook webhook, CancellationToken cancellationToken) { - try { - var destination = subscription.AsDestination(); - return sender.SendAsync(destination, webhook, cancellationToken); - } catch (WebhookSenderException ex) { - Logger.LogError(ex, "The webhook sender failed to send a webhook for event {EventType} to tenant {TenantId} because of an error", - typeof(TWebhook), subscription.TenantId); - throw; - } catch(Exception ex) { - Logger.LogError(ex, "An unknown error occurred when trying to send a webhook for event {EventType} to tenant {TenantId}", - typeof(TWebhook), subscription.TenantId); - - throw new WebhookException("An unknown error occurred when trying to send a webhook", ex); - } } - /// - /// Creates a new webhook for the given subscription and event. - /// - /// - /// The subscription that is being notified. - /// - /// - /// The information about the event that is being notified. - /// - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// - /// Returns a new webhook that can be delivered to the subscription, - /// or null if it was not possible to constuct the data. - /// - /// - protected virtual async Task CreateWebhook(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken) { - object data; - - try { - data = await GetWebhookDataAsync(eventInfo, cancellationToken); - } catch (Exception ex) { - Logger.LogError(ex, "Error setting the data for the event {EventType} to subscription {SubscriptionId}", - eventInfo.EventType, subscription.SubscriptionId); - - throw new WebhookException("An error occurred while trying to create the webhook data", ex); - } - - if (data == null) { - Logger.LogWarning("It was not possible to generate data for the event of type {EventType}", eventInfo.EventType); - return null; - } + /// + public async Task> NotifyAsync(EventInfo eventInfo, CancellationToken cancellationToken) { + var subscriptions = await ResolveSubscriptionsAsync(eventInfo, cancellationToken); - var newEvent = eventInfo.WithData(data); + return await NotifySubscriptionsAsync(eventInfo, subscriptions, cancellationToken); - try { - return await webhookFactory.CreateAsync(subscription, newEvent, cancellationToken); - } catch (Exception ex) { - throw new WebhookException("An error occurred while creating a new webhook", ex); - } - } } } diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs new file mode 100644 index 0000000..4b005a6 --- /dev/null +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBase.cs @@ -0,0 +1,462 @@ +// Copyright 2022-2023 Deveel +// +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; +using System.Reflection; + +namespace Deveel.Webhooks { + /// + /// A base class that provides common functionality for a notifier + /// to reach out receivers that subscribed for a given event. + /// + /// + /// The type of webhook notified to the subscribers. + /// + public abstract class WebhookNotifierBase where TWebhook : class { + private readonly IWebhookDeliveryResultLogger deliveryResultLogger; + private readonly IWebhookSender sender; + private readonly IWebhookFactory webhookFactory; + private readonly IEventTransformerPipeline? eventTransformer; + private readonly IDictionary> filterEvaluators; + + internal WebhookNotifierBase( + IWebhookSender sender, + IWebhookFactory webhookFactory, + IEventTransformerPipeline? eventTransformer = null, + IEnumerable>? filterEvaluators = null, + IWebhookDeliveryResultLogger? deliveryResultLogger = null, + ILogger>? logger = null) { + this.sender = sender; + this.webhookFactory = webhookFactory; + this.eventTransformer = eventTransformer; + this.filterEvaluators = GetFilterEvaluators(filterEvaluators); + this.deliveryResultLogger = deliveryResultLogger ?? NullWebhookDeliveryResultLogger.Instance; + Logger = logger ?? NullLogger>.Instance; + } + + /// + /// Gets the logger used to log the activity of the notifier. + /// + protected ILogger Logger { get; } + + private static IDictionary> GetFilterEvaluators(IEnumerable>? filterEvaluators) { + var evaluators = new Dictionary>(); + + if (filterEvaluators != null) { + foreach (var filterEvaluator in filterEvaluators) { + evaluators[filterEvaluator.Format] = filterEvaluator; + } + } + + return evaluators; + } + + /// + /// Creates a new webhook filter for the given subscription. + /// + /// + /// The subscription to create the filter for. + /// + /// + /// Returns an instance of + /// + protected virtual WebhookSubscriptionFilter? BuildSubscriptionFilter(IWebhookSubscription subscription) { + return subscription.AsFilter(); + } + + /// + /// Transforms the data included in the event into an + /// object that can be used to create a webhook. + /// + /// + /// The information about the event that triggered the notification. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// This method is called before a webhook is created and sent: the + /// generated data will be used to renew the instance of the + /// , that will then be constructed into the webhook. + /// + /// + /// Returns an object that will be used to renew the data of the event + /// before passing it to the factory. + /// + /// + protected virtual async Task GetWebhookDataAsync(EventInfo eventInfo, CancellationToken cancellationToken) { + var data = eventInfo.Data; + + if (eventTransformer != null) { + var result = await eventTransformer.TransformAsync(eventInfo, cancellationToken); + + data = result.Data; + } + + return data; + } + + /// + /// Gets the filter evaluator for the given format. + /// + /// + /// The format of the filter evaluator to get. + /// + /// + /// Returns an instance of + /// that matches the given format, or null if no evaluator was + /// found for the given format. + /// + protected virtual IWebhookFilterEvaluator? GetFilterEvaluator(string format) { + return !filterEvaluators.TryGetValue(format, out var filterEvaluator) ? null : filterEvaluator; + } + + /// + /// Matches the given webhook against the given filter. + /// + /// + /// The subscription filter to match the webhook against. + /// + /// + /// The webhook to match against the filter. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// The default implementation of this method invokes a filter evaluator + /// if the following pre-conditions are met: + /// + /// The filer is not empty or null (it returns true) + /// The filter is not a wildcard (it returns true) + /// + /// + /// + /// Returns true if the webhook matches the filter, false otherwise. + /// + /// + /// Thrown when the filter format is not supported. + /// + protected virtual async Task MatchesAsync(WebhookSubscriptionFilter? filter, TWebhook webhook, CancellationToken cancellationToken) { + if (filter == null || filter.IsEmpty) { + Logger.LogTrace("The filter request was null or empty: accepting by default"); + return true; + } + + if (filter.IsWildcard) { + Logger.LogTrace("The whole filter request was a wildcard"); + return true; + } + + Logger.LogTrace("Selecting the filter evaluator for '{FilterFormat}' format", filter.FilterFormat); + + var filterEvaluator = GetFilterEvaluator(filter.FilterFormat); + + if (filterEvaluator == null) { + Logger.LogError("Could not resolve any filter evaluator for the format '{FilterFormat}'", filter.FilterFormat); + throw new NotSupportedException($"Filers of type '{filter.FilterFormat}' are not supported"); + } + + return await filterEvaluator.MatchesAsync(filter, webhook, cancellationToken); + } + + /// + /// A callback that is invoked after a webhook has been sent + /// + /// + /// The subscription that was used to send the webhook. + /// + /// + /// The webhook that was sent. + /// + /// + /// The result of the delivery operation. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// Returns a task that completes when the operation is done. + /// + protected virtual Task OnDeliveryResultAsync(IWebhookSubscription subscription, TWebhook webhook, WebhookDeliveryResult result, CancellationToken cancellationToken) { + OnDeliveryResult(subscription, webhook, result); + return Task.CompletedTask; + } + + /// + /// A callback that is invoked after a webhook has been sent + /// + /// + /// The subscription that was used to send the webhook. + /// + /// + /// The webhook that was sent. + /// + /// + /// The result of the delivery operation. + /// + protected virtual void OnDeliveryResult(IWebhookSubscription subscription, TWebhook webhook, WebhookDeliveryResult result) { + + } + + /// + /// Logs the given delivery result. + /// + /// + /// The subscription that was used to send the webhook. + /// + /// + /// The result of the delivery operation. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// Returns a task that completes when the operation is done. + /// + protected virtual async Task LogDeliveryResultAsync(IWebhookSubscription subscription, WebhookDeliveryResult deliveryResult, CancellationToken cancellationToken) { + try { + if (deliveryResultLogger != null) + await deliveryResultLogger.LogResultAsync(subscription, deliveryResult, cancellationToken); + } catch (Exception ex) { + // If an error occurs here, we report it, but we don't throw it... + Logger.LogError(ex, "Error while logging a delivery result for tenant {TenantId}", subscription.TenantId); + } + } + + private void TraceDeliveryResult(WebhookDeliveryResult deliveryResult) { + if (!deliveryResult.HasAttempted) { + Logger.LogTrace("The delivery was not attempted"); + } else if (deliveryResult.Successful) { + Logger.LogTrace("The delivery was successful after {AttemptCount} attempts", deliveryResult.Attempts.Count()); + } else { + Logger.LogTrace("The delivery failed after {AttemptCount} attempts", deliveryResult.Attempts.Count()); + } + + if (deliveryResult.HasAttempted) { + foreach (var attempt in deliveryResult.Attempts) { + if (attempt.Failed) { + Logger.LogTrace("Attempt {AttemptNumber} Failed - [{StartDate} - {EndDate}] {StatusCode}: {ErrorMessage}", + attempt.Number, attempt.StartedAt, attempt.CompletedAt, attempt.ResponseCode, attempt.ResponseMessage); + } else { + Logger.LogTrace("Attempt {AttemptNumber} Successful - [{StartDate} - {EndDate}] {StatusCode}", + attempt.Number, attempt.StartedAt, attempt.CompletedAt, attempt.ResponseCode); + } + } + } + } + + private async Task NotifySubscription(WebhookNotificationResult result, EventInfo eventInfo, IWebhookSubscription subscription, CancellationToken cancellationToken) { + if (String.IsNullOrWhiteSpace(subscription.SubscriptionId)) + throw new WebhookException("The subscription identifier is missing"); + + Logger.LogDebug("Evaluating subscription {SubscriptionId} to the event of type {EventType}", + subscription.SubscriptionId, eventInfo.EventType); + + var webhook = await CreateWebhook(subscription, eventInfo, cancellationToken); + + if (webhook == null) { + Logger.LogWarning("It was not possible to generate the webhook for the event {EventType} to be delivered to subscription {SubscriptionName} ({SubscriptionId})", + eventInfo.EventType, subscription.Name, subscription.SubscriptionId); + + return; + } + + try { + var filter = BuildSubscriptionFilter(subscription); + + if (await MatchesAsync(filter, webhook, cancellationToken)) { + Logger.LogTrace("Delivering webhook for event {EventType} to subscription {SubscriptionId}", + eventInfo.EventType, subscription.SubscriptionId); + + var deliveryResult = await SendAsync(subscription, webhook, cancellationToken); + + result.AddDelivery(subscription.SubscriptionId, deliveryResult); + + await LogDeliveryResultAsync(subscription, deliveryResult, cancellationToken); + + TraceDeliveryResult(deliveryResult); + + try { + await OnDeliveryResultAsync(subscription, webhook, deliveryResult, cancellationToken); + } catch (Exception ex) { + Logger.LogError(ex, "The event handling on the delivery thrown an error"); + } + + } else { + Logger.LogTrace("The webhook for event {EventType} could not match the subscription {SubscriptionId}", + eventInfo.EventType, subscription.SubscriptionId); + } + } catch (Exception ex) { + Logger.LogError(ex, "Could not deliver a webhook for event {EventType} to subscription {SubscriptionId}", + typeof(TWebhook), subscription.SubscriptionId); + + await OnDeliveryErrorAsync(subscription, webhook, ex, cancellationToken); + + // result.AddDelivery(new WebhookDeliveryResult(destination, webhook)); + } + } + + /// + /// 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 subscriptions that are listening for the event. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// 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); + + // TODO: Make the parallel thread count configurable + var options = new ParallelOptions { + MaxDegreeOfParallelism = Environment.ProcessorCount - 1, + CancellationToken = cancellationToken + }; + + await Parallel.ForEachAsync(subscriptions, options, async (subscription, token) => { + await NotifySubscription(result, eventInfo, subscription, cancellationToken); + }); + + + return result; + + } + + /// + /// A callback that is invoked when a delivery error + /// occurred during a notification + /// + /// + /// The subscription that was being notified. + /// + /// + /// The webhook that was being delivered. + /// + /// + /// The error that occurred during the delivery. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// Returns a task that can be awaited. + /// + protected virtual Task OnDeliveryErrorAsync(IWebhookSubscription subscription, TWebhook webhook, Exception error, CancellationToken cancellationToken) { + OnDeliveryError(subscription, webhook, error); + return Task.CompletedTask; + } + + /// + /// A callback that is invoked when a delivery result + /// + /// + /// The subscription that was being notified. + /// + /// + /// The webhook that was being delivered. + /// + /// + /// The error that occurred during the delivery. + /// + protected virtual void OnDeliveryError(IWebhookSubscription subscription, TWebhook webhook, Exception error) { + + } + + /// + /// A callback that is invoked when a delivery result + /// + /// + /// + /// + /// + /// Returns the result of a single delivery operation. + /// + protected virtual Task> SendAsync(IWebhookSubscription subscription, TWebhook webhook, CancellationToken cancellationToken) { + try { + var destination = subscription.AsDestination(); + + return sender.SendAsync(destination, webhook, cancellationToken); + } catch (WebhookSenderException ex) { + Logger.LogError(ex, "The webhook sender failed to send a webhook for event {EventType} to tenant {TenantId} because of an error", + typeof(TWebhook), subscription.TenantId); + throw; + } catch (Exception ex) { + Logger.LogError(ex, "An unknown error occurred when trying to send a webhook for event {EventType} to tenant {TenantId}", + typeof(TWebhook), subscription.TenantId); + + throw new WebhookException("An unknown error occurred when trying to send a webhook", ex); + } + } + + /// + /// Creates a new webhook for the given subscription and event. + /// + /// + /// The subscription that is being notified. + /// + /// + /// The information about the event that is being notified. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// Returns a new webhook that can be delivered to the subscription, + /// or null if it was not possible to constuct the data. + /// + /// + protected virtual async Task CreateWebhook(IWebhookSubscription subscription, EventInfo eventInfo, CancellationToken cancellationToken) { + object data; + + try { + data = await GetWebhookDataAsync(eventInfo, cancellationToken); + } catch (Exception ex) { + Logger.LogError(ex, "Error setting the data for the event {EventType} to subscription {SubscriptionId}", + eventInfo.EventType, subscription.SubscriptionId); + + throw new WebhookException("An error occurred while trying to create the webhook data", ex); + } + + if (data == null) { + Logger.LogWarning("It was not possible to generate data for the event of type {EventType}", eventInfo.EventType); + return null; + } + + var newEvent = eventInfo.WithData(data); + + try { + return await webhookFactory.CreateAsync(subscription, newEvent, cancellationToken); + } catch (Exception ex) { + throw new WebhookException("An error occurred while creating a new webhook", ex); + } + + } + } +} diff --git a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs index 79b8ac1..046aa1a 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhookNotifierBuilder.cs @@ -22,7 +22,7 @@ namespace Deveel.Webhooks { /// - /// A builder used to configure the service. + /// A builder used to configure the webhook notification service. /// /// /// The type of the webhook to notify. @@ -54,8 +54,10 @@ public WebhookNotifierBuilder(IServiceCollection services) { private void RegisterDefaultServices() { Services.TryAddScoped, WebhookNotifier>(); + Services.TryAddScoped, WebhookSender>(); + Services.TryAddScoped(); // TODO: register the default filter evaluator } @@ -132,6 +134,41 @@ public WebhookNotifierBuilder UseNotifier(ServiceLifetime l public WebhookNotifierBuilder UseNotifier() => UseNotifier>(); + + + /// + /// Registers a notifier service to use. + /// + /// + /// The type of the notifier to use. + /// + /// + /// An optional value that specifies the lifetime of the service (by default + /// set to ). + /// + /// + /// Returns an instance of the builder to allow chaining. + /// + public WebhookNotifierBuilder UseTenantNotifier(ServiceLifetime lifetime = ServiceLifetime.Scoped) + where TNotifier : class, ITenantWebhookNotifier { + + Services.RemoveAll>(); + + Services.Add(new ServiceDescriptor(typeof(ITenantWebhookNotifier), typeof(TNotifier), lifetime)); + Services.TryAdd(new ServiceDescriptor(typeof(TNotifier), typeof(TNotifier), lifetime)); + + return this; + } + + /// + /// Registers the default notifier service to use. + /// + /// + /// Returns an instance of the builder to allow chaining. + /// + public WebhookNotifierBuilder UseTenantNotifier() + => UseTenantNotifier>(); + /// /// Registers a factory service to use to create the webhook. /// @@ -177,6 +214,51 @@ public WebhookNotifierBuilder AddFilterEvaluator(ServiceLi return this; } + /// + /// Registers a service that resolves the subscriptions to the + /// notification of events. + /// + /// + /// The type of the resolver to register. + /// + /// + /// An optional value that specifies the lifetime of the service (by default + /// set to ). + /// + /// + /// Returns an instance of the builder to allow chaining. + /// + public WebhookNotifierBuilder UseTenantSubscriptionResolver(Type resolverType, ServiceLifetime lifetime = ServiceLifetime.Scoped) { + if (typeof(ITenantWebhookSubscriptionResolver).IsAssignableFrom(resolverType)) { + Services.Add(new ServiceDescriptor(typeof(ITenantWebhookSubscriptionResolver), resolverType, lifetime)); + } else { + Func> factory = provider => { + var resolver = (ITenantWebhookSubscriptionResolver) provider.GetRequiredService(resolverType); + return new TenantWebhookSubscriptionResolverAdapter(resolver); + }; + Services.Add(new ServiceDescriptor(typeof(ITenantWebhookSubscriptionResolver), factory, lifetime)); + } + + Services.TryAdd(new ServiceDescriptor(resolverType, resolverType, lifetime)); + return this; + } + + /// + /// Registers a service that resolves the subscriptions to the + /// notification of events. + /// + /// + /// The type of the resolver to register. + /// + /// + /// An optional value that specifies the lifetime of the service (by default + /// set to ). + /// + /// + public WebhookNotifierBuilder UseTenantSubscriptionResolver(ServiceLifetime lifetime = ServiceLifetime.Scoped) + where TResolver : class, ITenantWebhookSubscriptionResolver + => UseTenantSubscriptionResolver(typeof(TResolver), lifetime); + /// /// Registers a service that resolves the subscriptions to the /// notification of events. @@ -196,7 +278,7 @@ public WebhookNotifierBuilder UseSubscriptionResolver(Type resolverTyp Services.Add(new ServiceDescriptor(typeof(IWebhookSubscriptionResolver), resolverType, lifetime)); } else { Func> factory = provider => { - var resolver = (IWebhookSubscriptionResolver) provider.GetRequiredService(resolverType); + var resolver = (IWebhookSubscriptionResolver)provider.GetRequiredService(resolverType); return new WebhookSubscriptionResolverAdapter(resolver); }; Services.Add(new ServiceDescriptor(typeof(IWebhookSubscriptionResolver), factory, lifetime)); @@ -222,6 +304,7 @@ public WebhookNotifierBuilder UseSubscriptionResolver(Servi where TResolver : class, IWebhookSubscriptionResolver => UseSubscriptionResolver(typeof(TResolver), lifetime); + /// /// Adds a service that logs the delivery results of webhooks. /// @@ -255,13 +338,24 @@ public WebhookNotifierBuilder AddDeliveryLogger(ServiceLifeti /// /// public WebhookNotifierBuilder AddDataTranformer(ServiceLifetime lifetime = ServiceLifetime.Scoped) - where TTransformer : class, IWebhookDataFactory { + where TTransformer : class, IEventDataTransformer { - Services.Add(new ServiceDescriptor(typeof(IWebhookDataFactory), typeof(TTransformer), lifetime)); + Services.Add(new ServiceDescriptor(typeof(IEventDataTransformer), typeof(TTransformer), lifetime)); Services.TryAdd(new ServiceDescriptor(typeof(TTransformer), typeof(TTransformer), lifetime)); return this; } + class TenantWebhookSubscriptionResolverAdapter : ITenantWebhookSubscriptionResolver { + private readonly ITenantWebhookSubscriptionResolver _resolver; + + public TenantWebhookSubscriptionResolverAdapter(ITenantWebhookSubscriptionResolver resolver) { + _resolver = resolver; + } + + public Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken) + => _resolver.ResolveSubscriptionsAsync(tenantId, eventType, activeOnly, cancellationToken); + } + class WebhookSubscriptionResolverAdapter : IWebhookSubscriptionResolver { private readonly IWebhookSubscriptionResolver _resolver; @@ -269,8 +363,9 @@ public WebhookSubscriptionResolverAdapter(IWebhookSubscriptionResolver resolver) _resolver = resolver; } - public Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken) - => _resolver.ResolveSubscriptionsAsync(tenantId, eventType, activeOnly, cancellationToken); + public Task> ResolveSubscriptionsAsync(string eventType, bool activeOnly, CancellationToken cancellationToken) + => _resolver.ResolveSubscriptionsAsync(eventType, activeOnly, cancellationToken); } + } } diff --git a/src/Deveel.Webhooks/Webhooks/WebhooksBuilder.cs b/src/Deveel.Webhooks/Webhooks/WebhooksBuilder.cs index 43af90b..ef80afc 100644 --- a/src/Deveel.Webhooks/Webhooks/WebhooksBuilder.cs +++ b/src/Deveel.Webhooks/Webhooks/WebhooksBuilder.cs @@ -1,4 +1,18 @@ -using System; +// Copyright 2022-2023 Deveel +// +// 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; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +27,7 @@ namespace Deveel.Webhooks { /// /// /// The main goal of this class is to provide a fluent API to configure - /// other services, such as the , + /// other services, such as the , /// the , aligning them in the /// bounded context of the given . /// diff --git a/test/Deveel.Events.Webhooks.XUnit/Webhooks/TenantWebhookNotificationTests.cs b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TenantWebhookNotificationTests.cs new file mode 100644 index 0000000..bad2639 --- /dev/null +++ b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TenantWebhookNotificationTests.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Deveel.Webhooks; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; +using Xunit.Abstractions; + +namespace Deveel.Webhooks { + [Trait("Category", "Webhooks")] + [Trait("Category", "Notification")] + public class TenantWebhookNotificationTests : WebhookServiceTestBase { + private const int TimeOutSeconds = 2; + private bool testTimeout = false; + + private readonly string tenantId = Guid.NewGuid().ToString(); + + private TestTenantSubscriptionResolver subscriptionResolver; + private ITenantWebhookNotifier notifier; + + private Webhook? lastWebhook; + private HttpResponseMessage? testResponse; + + public TenantWebhookNotificationTests(ITestOutputHelper outputHelper) : base(outputHelper) { + notifier = Services.GetRequiredService>(); + subscriptionResolver = Services.GetRequiredService(); + } + + protected override void ConfigureWebhookService(WebhookSubscriptionBuilder builder) { + builder + .UseSubscriptionManager() + .UseNotifier(config => config + .UseTenantNotifier() + .UseWebhookFactory() + .AddDataTranformer() + .UseLinqFilter() + .UseTenantSubscriptionResolver(ServiceLifetime.Singleton) + .UseSender(options => { + options.Retry.MaxRetries = 2; + options.Retry.Timeout = TimeSpan.FromSeconds(TimeOutSeconds); + })); + } + + 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; + subscription.TenantId = tenantId; + + subscriptionResolver.AddSubscription(subscription); + + return id; + } + + [Fact] + public async Task DeliverWebhookFromEvent() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(tenantId, 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-data", testData.GetProperty("data_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(tenantId, 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.data_type == \"test-data\"", "linq"), + new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(tenantId, 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(tenantId, 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 DeliverSignedWebhookFromEvent() { + var subscriptionId = CreateSubscription(new TestWebhookSubscription { + EventTypes = new[] { "data.created" }, + DestinationUrl = "https://callback.example.com", + Filters = new[] { new WebhookFilter("hook.data.data_type == \"test-data\"", "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(tenantId, 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.data_type == \"test-data\"", "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(tenantId, 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.data_type == \"test-data\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + testTimeout = true; + + var result = await notifier.NotifyAsync(tenantId, 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.data_type == \"test-data2\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task NoTenantMatches() { + CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + var result = await notifier.NotifyAsync(Guid.NewGuid().ToString("N"), notification, CancellationToken.None); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task NoTenantSet() { + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" + }); + + await Assert.ThrowsAsync(() => notifier.NotifyAsync(null, notification, CancellationToken.None)); + } + + + private class TestDataFactory : IEventDataTransformer { + public bool Handles(EventInfo eventInfo) => eventInfo.EventType == "data.created"; + + public Task CreateDataAsync(EventInfo eventInfo, CancellationToken cancellationToken) + => Task.FromResult(new { + creator = new { user_id = "1234", user_name = "antonello" }, + data_type = "test-data", + created_at = DateTimeOffset.UtcNow + }); + } + } +} diff --git a/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestSubscriptionResolver.cs b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestSubscriptionResolver.cs index 8be9516..b9eb7a8 100644 --- a/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestSubscriptionResolver.cs +++ b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestSubscriptionResolver.cs @@ -1,39 +1,26 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; namespace Deveel.Webhooks { - public class TestSubscriptionResolver : IWebhookSubscriptionResolver { - private readonly Dictionary> subscriptions; - - public TestSubscriptionResolver() { - subscriptions = new Dictionary>(); - } + public class TestSubscriptionResolver : IWebhookSubscriptionResolver { + private readonly IList subscriptions = new List(); public void AddSubscription(IWebhookSubscription subscription) { - if (!subscriptions.TryGetValue(subscription.TenantId!, out var list)) { - list = new List(); - subscriptions.Add(subscription.TenantId!, list); - } - - list.Add(subscription); + subscriptions.Add(subscription); } - public Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken) { - if (String.IsNullOrWhiteSpace(tenantId)) - throw new ArgumentNullException(nameof(tenantId)); - if (!subscriptions.TryGetValue(tenantId, out var list)) { - return Task.FromResult>(new List()); - } - - var result = list.Where(x => (!activeOnly || x.Status == WebhookSubscriptionStatus.Active) && + public Task> ResolveSubscriptionsAsync(string eventType, bool activeOnly, CancellationToken cancellationToken) { + var result = subscriptions.Where(x => (!activeOnly || x.Status == WebhookSubscriptionStatus.Active) && (x.EventTypes?.Any(y => y == eventType) ?? false)) .ToList(); return Task.FromResult>(result); } + } } diff --git a/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestTenantSubscriptionResolver.cs b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestTenantSubscriptionResolver.cs new file mode 100644 index 0000000..68da8f5 --- /dev/null +++ b/test/Deveel.Events.Webhooks.XUnit/Webhooks/TestTenantSubscriptionResolver.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Deveel.Webhooks { + public class TestTenantSubscriptionResolver : ITenantWebhookSubscriptionResolver { + private readonly Dictionary> subscriptions; + + public TestTenantSubscriptionResolver() { + subscriptions = new Dictionary>(); + } + + public void AddSubscription(IWebhookSubscription subscription) { + if (!subscriptions.TryGetValue(subscription.TenantId!, out var list)) { + list = new List(); + subscriptions.Add(subscription.TenantId!, list); + } + + list.Add(subscription); + } + + public Task> ResolveSubscriptionsAsync(string tenantId, string eventType, bool activeOnly, CancellationToken cancellationToken) { + if (String.IsNullOrWhiteSpace(tenantId)) + throw new ArgumentNullException(nameof(tenantId)); + + if (!subscriptions.TryGetValue(tenantId, out var list)) { + return Task.FromResult>(new List()); + } + + var result = list.Where(x => (!activeOnly || x.Status == WebhookSubscriptionStatus.Active) && + (x.EventTypes?.Any(y => y == eventType) ?? false)) + .ToList(); + + return Task.FromResult>(result); + } + } +} diff --git a/test/Deveel.Events.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs b/test/Deveel.Events.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs index d68ea75..be01821 100644 --- a/test/Deveel.Events.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs +++ b/test/Deveel.Events.Webhooks.XUnit/Webhooks/WebhookNotificationTests.cs @@ -4,26 +4,21 @@ using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Deveel.Webhooks; - using Microsoft.Extensions.DependencyInjection; using Xunit; using Xunit.Abstractions; namespace Deveel.Webhooks { - [Trait("Category", "Webhooks")] - [Trait("Category", "Notification")] public class WebhookNotificationTests : WebhookServiceTestBase { private const int TimeOutSeconds = 2; private bool testTimeout = false; - private readonly string tenantId = Guid.NewGuid().ToString(); - private TestSubscriptionResolver subscriptionResolver; private IWebhookNotifier notifier; @@ -68,8 +63,8 @@ protected override async Task OnRequestAsync(HttpRequestMes } private string CreateSubscription(string name, string eventType, params WebhookFilter[] filters) { - return CreateSubscription(new TestWebhookSubscription { - EventTypes = new[] { eventType }, + return CreateSubscription(new TestWebhookSubscription { + EventTypes = new[] { eventType }, DestinationUrl = "https://callback.example.com/webhook", Name = name, RetryCount = 3, @@ -83,7 +78,6 @@ private string CreateSubscription(TestWebhookSubscription subscription, bool ena var id = Guid.NewGuid().ToString(); subscription.SubscriptionId = id; - subscription.TenantId = tenantId; subscriptionResolver.AddSubscription(subscription); @@ -93,12 +87,12 @@ private string CreateSubscription(TestWebhookSubscription subscription, bool ena [Fact] public async Task DeliverWebhookFromEvent() { var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); - var notification = new EventInfo("test", "data.created", new { + var notification = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -132,12 +126,12 @@ public async Task DeliverWebhookFromEvent() { [Fact] public async Task DeliverWebhookFromEvent_NoTransformations() { var subscriptionId = CreateSubscription("Data Modified", "data.modified"); - var notification = new EventInfo("test", "data.modified", new { + var notification = new EventInfo("test", "data.modified", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -171,15 +165,15 @@ public async Task DeliverWebhookFromEvent_NoTransformations() { [Fact] public async Task DeliverWebhookWithMultipleFiltersFromEvent() { - var subscriptionId = CreateSubscription("Data Created", "data.created", - new WebhookFilter( "hook.data.data_type == \"test-data\"", "linq"), + var subscriptionId = CreateSubscription("Data Created", "data.created", + new WebhookFilter("hook.data.data_type == \"test-data\"", "linq"), new WebhookFilter("hook.data.creator.user_name == \"antonello\"", "linq")); - var notification = new EventInfo("test", "data.created", new { + var notification = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -204,12 +198,12 @@ public async Task DeliverWebhookWithMultipleFiltersFromEvent() { [Fact] public async Task DeliverWebhookWithoutFilter() { var subscriptionId = CreateSubscription("Data Created", "data.created"); - var notification = new EventInfo("test", "data.created", new { + var notification = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -232,7 +226,7 @@ public async Task DeliverWebhookWithoutFilter() { [Fact] public async Task DeliverSignedWebhookFromEvent() { - var subscriptionId = CreateSubscription(new TestWebhookSubscription { + var subscriptionId = CreateSubscription(new TestWebhookSubscription { EventTypes = new[] { "data.created" }, DestinationUrl = "https://callback.example.com", Filters = new[] { new WebhookFilter("hook.data.data_type == \"test-data\"", "linq") }, @@ -242,12 +236,12 @@ public async Task DeliverSignedWebhookFromEvent() { Status = WebhookSubscriptionStatus.Active }); - var notification = new EventInfo("test", "data.created", new { + var notification = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" }); - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -270,15 +264,15 @@ public async Task DeliverSignedWebhookFromEvent() { [Fact] public async Task FailToDeliver() { - var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter ("hook.data.data_type == \"test-data\"", "linq")); - var notification = new EventInfo("test", "data.created", new { - creationTime = DateTimeOffset.UtcNow, + var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "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(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -300,14 +294,14 @@ public async Task FailToDeliver() { [Fact] public async Task TimeOutWhileDelivering() { var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); - var notification = new EventInfo("test", "data.created", new { - creationTime = DateTimeOffset.UtcNow, - type = "test" + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" }); testTimeout = true; - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.NotEmpty(result); @@ -328,45 +322,20 @@ public async Task TimeOutWhileDelivering() { [Fact] public async Task NoSubscriptionMatches() { - CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data2\"", "linq")); - var notification = new EventInfo("test", "data.created", new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - var result = await notifier.NotifyAsync(tenantId, notification, CancellationToken.None); - - Assert.NotNull(result); - Assert.Empty(result); - } - - [Fact] - public async Task NoTenantMatches() { - CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); - var notification = new EventInfo("test", "data.created", new { - creationTime = DateTimeOffset.UtcNow, - type = "test" + CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data2\"", "linq")); + var notification = new EventInfo("test", "data.created", data: new { + creationTime = DateTimeOffset.UtcNow, + type = "test" }); - var result = await notifier.NotifyAsync(Guid.NewGuid().ToString("N"), notification, CancellationToken.None); + var result = await notifier.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(result); Assert.Empty(result); } - [Fact] - public async Task NoTenantSet() { - var subscriptionId = CreateSubscription("Data Created", "data.created", new WebhookFilter("hook.data.data_type == \"test-data\"", "linq")); - var notification = new EventInfo("test", "data.created", new { - creationTime = DateTimeOffset.UtcNow, - type = "test" - }); - - await Assert.ThrowsAsync(() => notifier.NotifyAsync(null, notification, CancellationToken.None)); - } - - private class TestDataFactory : IWebhookDataFactory { + private class TestDataFactory : IEventDataTransformer { public bool Handles(EventInfo eventInfo) => eventInfo.EventType == "data.created"; public Task CreateDataAsync(EventInfo eventInfo, CancellationToken cancellationToken) diff --git a/test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/WebhookDeliveryResultLoggingTests.cs b/test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/MultiTenantWebhookDeliveryResultLoggingTests.cs similarity index 91% rename from test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/WebhookDeliveryResultLoggingTests.cs rename to test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/MultiTenantWebhookDeliveryResultLoggingTests.cs index 2e48a09..7b7162f 100644 --- a/test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/WebhookDeliveryResultLoggingTests.cs +++ b/test/Deveel.Webhooks.MongoDb.XUnit/Webhooks/MultiTenantWebhookDeliveryResultLoggingTests.cs @@ -8,7 +8,7 @@ using Xunit.Abstractions; namespace Deveel.Webhooks { - public class WebhookDeliveryResultLoggingTests : MongoDbWebhookTestBase { + public class MultiTenantWebhookDeliveryResultLoggingTests : MongoDbWebhookTestBase { private const int TimeOutSeconds = 2; private bool testTimeout = false; @@ -16,16 +16,16 @@ public class WebhookDeliveryResultLoggingTests : MongoDbWebhookTestBase { private IWebhookSubscriptionStoreProvider webhookStoreProvider; private IWebhookDeliveryResultStoreProvider deliveryResultStoreProvider; - private IWebhookNotifier notifier; + private ITenantWebhookNotifier notifier; private Webhook? lastWebhook; private HttpResponseMessage? testResponse; - public WebhookDeliveryResultLoggingTests(MongoTestCluster mongo, ITestOutputHelper outputHelper) + public MultiTenantWebhookDeliveryResultLoggingTests(MongoTestCluster mongo, ITestOutputHelper outputHelper) : base(mongo, outputHelper) { webhookStoreProvider = Services.GetRequiredService>(); deliveryResultStoreProvider = Services.GetRequiredService>(); - notifier = Services.GetRequiredService>(); + notifier = Services.GetRequiredService>(); } protected override void ConfigureWebhookService(WebhookSubscriptionBuilder builder) { @@ -42,13 +42,14 @@ protected override void ConfigureWebhookService(WebhookSubscriptionBuilder(notifier => notifier + .UseTenantNotifier() .UseSender(options => { options.Timeout = TimeSpan.FromSeconds(TimeOutSeconds); options.Retry.MaxRetries = 2; }) .UseLinqFilter() .UseWebhookFactory() - .UseMongoSubscriptionResolver()) + .UseMongoTenantSubscriptionResolver()) .UseMongoDb(options => options .UseMultiTenant() .UseDeliveryResultLogger()); @@ -101,7 +102,7 @@ private async Task CreateSubscriptionAsync(MongoWebhookSubscription subs [Fact] public async Task DeliverWebhookFromEvent() { var subscriptionId = await CreateSubscriptionAsync("Data Created", "data.created"); - var notification = new EventInfo("test", "data.created", new { + var notification = new EventInfo("test", "data.created", data: new { creationTime = DateTimeOffset.UtcNow, type = "test" });