├── icon.png ├── docs └── schema.png ├── Harpoon.Common ├── HarpoonBuilder.cs ├── Assembly.cs ├── IHarpoonBuilder.cs ├── Sender │ ├── ISignatureService.cs │ ├── DefaultSignatureService.cs │ └── DefaultWebHookSender.cs ├── IQueuedProcessor.cs ├── IWebHookSender.cs ├── IWebHookNotification.cs ├── IWebHookFilter.cs ├── IWebHookService.cs ├── IWebHookStore.cs ├── WebHookNotification.cs ├── Background │ ├── BackgroundSender.cs │ ├── BackgroundQueue.cs │ └── QueuedHostedService.cs ├── DefaultWebHookService.cs ├── IWebHookWorkItem.cs ├── WebHookWorkItem.cs ├── IWebHook.cs ├── Harpoon.Common.csproj ├── DefaultNotificationProcessor.cs └── ServicesCollectionsExtensions.cs ├── Harpoon.Controllers ├── OpenApi.cs ├── Swashbuckle │ ├── WebHookSubscriptionPointAttribute.cs │ └── WebHookSubscriptionFilter.cs ├── Models │ ├── WebHookFilter.cs │ └── WebHook.cs ├── WebHookTriggersController.cs ├── Harpoon.Controllers.csproj ├── ServicesCollectionsExtensions.cs └── WebHooksController.cs ├── Harpoon.Registrations.EFStorage ├── WebHookFilter.cs ├── IRegistrationsContext.cs ├── Harpoon.Registrations.EFStorage.csproj ├── WebHookNotification.cs ├── WebHook.cs ├── WebHookLog.cs ├── EFNotificationProcessor.cs ├── WebHookStore.cs ├── WebHookReplayService.cs ├── EFWebHookSender.cs ├── ModelBuilderExtensions.cs └── ServicesCollectionsExtensions.cs ├── Harpoon.Registrations ├── IWebHookValidator.cs ├── IWebHookTriggerProvider.cs ├── ISecretProtector.cs ├── WebHookRegistrationStoreResult.cs ├── IPrincipalIdGetter.cs ├── Harpoon.Registrations.csproj ├── ServicesCollectionsExtensions.cs ├── WebHookTrigger.cs ├── DefaultSecretProtector.cs ├── DefaultPrincipalIdGetter.cs ├── IWebHookRegistrationStore.cs └── DefaultWebHookValidator.cs ├── Harpoon.Tests ├── DefaultSignatureServiceTests.cs ├── Fixtures │ ├── BackgroundSenderFixture.cs │ ├── DatabaseFixture.cs │ └── HostFixture.cs ├── Harpoon.Tests.csproj ├── BackgroundSenderTests.cs ├── WebHookStoreTests.cs ├── DefaultPrincipalIdGetterTests.cs ├── DefaultWebHookServiceTests.cs ├── WebHookSubscriptionFilterTests.cs ├── Mocks │ ├── TestContext.cs │ └── HttpClientMocker.cs ├── DefaultWebHookValidatorTests.cs ├── ServicesCollectionsExtensionsTests.cs ├── MassTransitTests.cs ├── DefaultSenderTests.cs └── ControllersTests.cs ├── Harpoon.MassTransit ├── Consumer.cs ├── Harpoon.MassTransit.csproj ├── PublisherService.cs └── ServicesCollectionsExtensions.cs ├── LICENSE ├── appveyor.yml ├── Harpoon.sln └── .gitignore /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poltuu/Harpoon/HEAD/icon.png -------------------------------------------------------------------------------- /docs/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Poltuu/Harpoon/HEAD/docs/schema.png -------------------------------------------------------------------------------- /Harpoon.Common/HarpoonBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Harpoon 4 | { 5 | class HarpoonBuilder : IHarpoonBuilder 6 | { 7 | public IServiceCollection Services { get; } 8 | 9 | internal HarpoonBuilder(IServiceCollection services) 10 | { 11 | Services = services; 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/OpenApi.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace Harpoon.Controllers 3 | { 4 | /// 5 | /// Class with OpenApi details 6 | /// 7 | public static class OpenApi 8 | { 9 | /// 10 | /// Group Name of the Harpoon controllers 11 | /// 12 | public const string GroupName = "WebHooks"; 13 | } 14 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/Swashbuckle/WebHookSubscriptionPointAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon.Controllers.Swashbuckle 4 | { 5 | /// 6 | /// This attributes informs Swashbuckle to generate a callback node on this method, matching the harpoon configuration 7 | /// 8 | [AttributeUsage(AttributeTargets.Method)] 9 | public class WebHookSubscriptionPointAttribute : Attribute { } 10 | } 11 | -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHookFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon.Registrations.EFStorage 4 | { 5 | /// 6 | /// Default implementation of 7 | /// 8 | public class WebHookFilter : IWebHookFilter 9 | { 10 | /// 11 | public Guid Id { get; set; } 12 | 13 | /// 14 | public string Trigger { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Harpoon.Common/Assembly.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("Harpoon.Tests")] 4 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] -------------------------------------------------------------------------------- /Harpoon.Common/IHarpoonBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Harpoon 4 | { 5 | /// 6 | /// Harpoon configuration builder. Extensions methods should come from the same namespace. 7 | /// 8 | public interface IHarpoonBuilder 9 | { 10 | /// 11 | /// Gets the used for registration 12 | /// 13 | IServiceCollection Services { get; } 14 | } 15 | } -------------------------------------------------------------------------------- /Harpoon.Common/Sender/ISignatureService.cs: -------------------------------------------------------------------------------- 1 | namespace Harpoon.Sender 2 | { 3 | /// 4 | /// Represents a class able to assign a signature to content using a secret 5 | /// 6 | public interface ISignatureService 7 | { 8 | /// 9 | /// Returns a unique signature for content using the provided secret 10 | /// 11 | /// 12 | /// 13 | /// 14 | string GetSignature(string secret, string content); 15 | } 16 | } -------------------------------------------------------------------------------- /Harpoon.Common/IQueuedProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Harpoon 5 | { 6 | /// 7 | /// Represents a class able to consume workItems 8 | /// 9 | /// 10 | public interface IQueuedProcessor 11 | { 12 | /// 13 | /// Process workItem into the webhook pipeline 14 | /// 15 | /// 16 | /// 17 | /// 18 | Task ProcessAsync(T workItem, CancellationToken cancellationToken); 19 | } 20 | } -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookSender.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Harpoon 5 | { 6 | /// 7 | /// Represents a class able to generates http calls from a 8 | /// 9 | public interface IWebHookSender 10 | { 11 | /// 12 | /// Generates an http call matching a 13 | /// 14 | /// 15 | /// 16 | /// 17 | Task SendAsync(IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken = default); 18 | } 19 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/IWebHookValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Harpoon.Registrations 5 | { 6 | /// 7 | /// Represents a class able to throws an exception if an is invalid. 8 | /// 9 | public interface IWebHookValidator 10 | { 11 | /// 12 | /// Throws an exception if the given is invalid. 13 | /// 14 | /// 15 | /// 16 | /// 17 | Task ValidateAsync(IWebHook webHook, CancellationToken cancellationToken = default); 18 | } 19 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/IWebHookTriggerProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Harpoon.Registrations 4 | { 5 | /// 6 | /// Represents a class able to returns valid available for registration. 7 | /// 8 | public interface IWebHookTriggerProvider 9 | { 10 | /// 11 | /// Returns valid available for registration. 12 | /// This is used to validate registration. 13 | /// Typical implementation is expected to be in-memory 14 | /// 15 | /// 16 | IReadOnlyDictionary GetAvailableTriggers(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookNotification.cs: -------------------------------------------------------------------------------- 1 | namespace Harpoon 2 | { 3 | /// 4 | /// Represents the content of an event that triggered 5 | /// 6 | public interface IWebHookNotification 7 | { 8 | /// 9 | /// Gets the name of the event. 10 | /// If pattern matching is used, this may contain parameters i.e. `client.23.creation` 11 | /// 12 | string TriggerId { get; } 13 | 14 | /// 15 | /// Gets an serializable object representing the payload to be sent to the registered webhooks 16 | /// This is serialized as-is using the default json serialization settings 17 | /// 18 | object Payload { get; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Harpoon.Registrations/ISecretProtector.cs: -------------------------------------------------------------------------------- 1 | namespace Harpoon.Registrations.EFStorage 2 | { 3 | /// 4 | /// A class able to protect and unprotect a piece of plain text data 5 | /// 6 | public interface ISecretProtector 7 | { 8 | /// 9 | /// Protects a piece of plain text data 10 | /// 11 | /// 12 | /// 13 | string Protect(string plaintext); 14 | 15 | /// 16 | /// Unprotects a piece of protectedData 17 | /// 18 | /// 19 | /// 20 | string Unprotect(string protectedData); 21 | } 22 | } -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon 4 | { 5 | /// 6 | /// Represents a filter on triggered events, i.e. an event type to listen to 7 | /// 8 | public interface IWebHookFilter 9 | { 10 | /// 11 | /// Gets or sets the unique identifier. 12 | /// 13 | Guid Id { get; set; } 14 | 15 | /// 16 | /// Gets a unique name for a listened event. 17 | /// Depending on the implementation, pattern matching can be used. 18 | /// For instance, '*.created' could refer to any event similar to the pattern. 19 | /// 20 | string Trigger { get; } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Harpoon 5 | { 6 | /// 7 | /// Represents a service able to handle an event and translate it appropriate webhooks. 8 | /// This is the entry point of a webhook pipeline. 9 | /// 10 | public interface IWebHookService 11 | { 12 | /// 13 | /// Start the notification process by adding the notification into a queue 14 | /// 15 | /// 16 | /// 17 | /// 18 | Task NotifyAsync(IWebHookNotification notification, CancellationToken cancellationToken = default); 19 | } 20 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/WebHookRegistrationStoreResult.cs: -------------------------------------------------------------------------------- 1 | namespace Harpoon.Registrations 2 | { 3 | /// 4 | /// This exposes the different possible outcomes during a write operation on a 5 | /// 6 | public enum WebHookRegistrationStoreResult 7 | { 8 | /// 9 | /// This value means that everything went well 10 | /// 11 | Success, 12 | /// 13 | /// This value means that a requested object was not present 14 | /// 15 | NotFound, 16 | /// 17 | /// This value means that an unexpected error was caught 18 | /// 19 | InternalError 20 | } 21 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/IPrincipalIdGetter.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Principal; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Harpoon.Registrations 6 | { 7 | /// 8 | /// Represents a class able to extract a string from a principal, that will be later used as an id for webhook registration 9 | /// 10 | public interface IPrincipalIdGetter 11 | { 12 | /// 13 | /// Extracts a string from given principal, later used as an id for webhook registration 14 | /// 15 | /// 16 | /// 17 | /// 18 | Task GetPrincipalIdAsync(IPrincipal principal, CancellationToken cancellationToken = default); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookStore.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Harpoon 6 | { 7 | /// 8 | /// Represents a class able to return the matching a specific 9 | /// 10 | public interface IWebHookStore 11 | { 12 | /// 13 | /// Returns the matching a specific 14 | /// 15 | /// 16 | /// 17 | /// 18 | Task> GetApplicableWebHooksAsync(IWebHookNotification notification, CancellationToken cancellationToken = default); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Harpoon.Common/WebHookNotification.cs: -------------------------------------------------------------------------------- 1 | namespace Harpoon 2 | { 3 | /// 4 | /// Represents the content of an event that triggered 5 | /// 6 | public class WebHookNotification : IWebHookNotification 7 | { 8 | /// 9 | public string TriggerId { get; set; } 10 | 11 | /// 12 | public object Payload { get; set; } 13 | 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | /// 18 | /// 19 | public WebHookNotification(string trigger, object payload) 20 | { 21 | TriggerId = trigger; 22 | Payload = payload; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/IRegistrationsContext.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace Harpoon.Registrations.EFStorage 4 | { 5 | /// 6 | /// Represents a DbContext able to expose WebHooks 7 | /// 8 | public interface IRegistrationsContext 9 | { 10 | /// 11 | /// Gets the queryable entry point 12 | /// 13 | IQueryable WebHooks { get; } 14 | 15 | /// 16 | /// Gets the queryable entry point 17 | /// 18 | IQueryable WebHookNotifications { get; } 19 | 20 | /// 21 | /// Gets the queryable entry point 22 | /// 23 | IQueryable WebHookLogs { get; } 24 | } 25 | } -------------------------------------------------------------------------------- /Harpoon.Tests/DefaultSignatureServiceTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Sender; 2 | using System; 3 | using Xunit; 4 | 5 | namespace Harpoon.Tests 6 | { 7 | public class DefaultSignatureServiceTests 8 | { 9 | const string ValidSecret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_"; 10 | 11 | [Fact] 12 | public void BadSecret() 13 | { 14 | var service = new DefaultSignatureService(); 15 | Assert.Throws(() => service.GetSignature(null, "content")); 16 | } 17 | 18 | [Theory] 19 | [InlineData(null)] 20 | [InlineData("")] 21 | [InlineData("content")] 22 | public void NormalScenario(string content) 23 | { 24 | var service = new DefaultSignatureService(); 25 | service.GetSignature(ValidSecret, content); 26 | Assert.True(true); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Harpoon.Common/Background/BackgroundSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Harpoon.Background 6 | { 7 | internal class BackgroundSender : IWebHookSender 8 | { 9 | private readonly BackgroundQueue _webHooksQueue; 10 | 11 | public BackgroundSender(BackgroundQueue webHooksQueue) 12 | { 13 | _webHooksQueue = webHooksQueue ?? throw new ArgumentNullException(nameof(webHooksQueue)); 14 | } 15 | 16 | public async Task SendAsync(IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken = default) 17 | { 18 | if (webHookWorkItem == null) 19 | { 20 | throw new ArgumentNullException(nameof(webHookWorkItem)); 21 | } 22 | 23 | await _webHooksQueue.QueueWebHookAsync(webHookWorkItem); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Harpoon.Common/Background/BackgroundQueue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Channels; 4 | using System.Threading.Tasks; 5 | 6 | namespace Harpoon.Background 7 | { 8 | internal class BackgroundQueue 9 | { 10 | private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions 11 | { 12 | SingleReader = true, 13 | SingleWriter = true 14 | }); 15 | 16 | public ValueTask QueueWebHookAsync(T workItem) 17 | { 18 | if (workItem == null) 19 | { 20 | throw new ArgumentException(nameof(workItem)); 21 | } 22 | 23 | return _channel.Writer.WriteAsync(workItem); 24 | } 25 | 26 | public ValueTask DequeueAsync(CancellationToken cancellationToken) 27 | { 28 | return _channel.Reader.ReadAsync(cancellationToken); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Harpoon.MassTransit/Consumer.cs: -------------------------------------------------------------------------------- 1 | using MassTransit; 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace Harpoon.MassTransit 6 | { 7 | /// 8 | /// Default message consumer 9 | /// 10 | /// 11 | public class Consumer : IConsumer 12 | where TMessage : class 13 | { 14 | private readonly IQueuedProcessor _processor; 15 | 16 | /// Initializes a new instance of the class. 17 | public Consumer(IQueuedProcessor processor) 18 | { 19 | _processor = processor ?? throw new ArgumentNullException(nameof(processor)); 20 | } 21 | 22 | /// 23 | public Task Consume(ConsumeContext context) 24 | => _processor.ProcessAsync(context.Message, context.CancellationToken); 25 | } 26 | } -------------------------------------------------------------------------------- /Harpoon.Common/DefaultWebHookService.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Background; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Harpoon 7 | { 8 | internal class DefaultWebHookService : IWebHookService 9 | { 10 | private readonly BackgroundQueue _webHooksQueue; 11 | 12 | public DefaultWebHookService(BackgroundQueue webHooksQueue) 13 | { 14 | _webHooksQueue = webHooksQueue ?? throw new ArgumentNullException(nameof(webHooksQueue)); 15 | } 16 | 17 | public async Task NotifyAsync(IWebHookNotification notification, CancellationToken cancellationToken = default) 18 | { 19 | if (notification == null) 20 | { 21 | throw new ArgumentNullException(nameof(notification)); 22 | } 23 | 24 | await _webHooksQueue.QueueWebHookAsync(notification); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /Harpoon.Common/IWebHookWorkItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon 4 | { 5 | /// 6 | /// Represents a unique work item generated by a 7 | /// 8 | public interface IWebHookWorkItem 9 | { 10 | /// 11 | /// Gets the object unique id. 12 | /// 13 | Guid Id { get; } 14 | /// 15 | /// Gets the object time stamp. 16 | /// 17 | DateTime Timestamp { get; } 18 | /// 19 | /// Gets the that generated this . 20 | /// 21 | IWebHookNotification Notification { get; } 22 | /// 23 | /// Gets the registered that matched the . 24 | /// 25 | IWebHook WebHook { get; } 26 | } 27 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/Models/WebHookFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon.Controllers.Models 4 | { 5 | /// 6 | /// Represents a filter on triggered events, i.e. an event type to listen to 7 | /// 8 | public class WebHookFilter : IWebHookFilter 9 | { 10 | /// 11 | public Guid Id { get; set; } 12 | /// 13 | public string? Trigger { get; set; } 14 | 15 | /// Initializes a new instance of the class. 16 | public WebHookFilter() { } 17 | /// Initializes a new instance of the class. 18 | public WebHookFilter(IWebHookFilter filter) 19 | { 20 | if (filter == null) 21 | { 22 | throw new ArgumentNullException(nameof(filter)); 23 | } 24 | 25 | Id = filter.Id; 26 | Trigger = filter.Trigger; 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Harpoon.Common/Sender/DefaultSignatureService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | using System.Text; 4 | 5 | namespace Harpoon.Sender 6 | { 7 | /// 8 | /// A class able to assign a signature to content using a secret 9 | /// 10 | public class DefaultSignatureService : ISignatureService 11 | { 12 | /// 13 | /// The provider secret is not a 64 characters string 14 | public string GetSignature(string secret, string content) 15 | { 16 | if (secret?.Length != 64) 17 | { 18 | throw new ArgumentException("Secret needs to be a 64 characters string."); 19 | } 20 | 21 | var secretBytes = Encoding.UTF8.GetBytes(secret); 22 | var data = Encoding.UTF8.GetBytes(content ?? ""); 23 | 24 | using (var hasher = new HMACSHA256(secretBytes)) 25 | { 26 | return Convert.ToBase64String(hasher.ComputeHash(data)); 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Harpoon.Common/WebHookWorkItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon 4 | { 5 | /// 6 | /// Unique work item generated by a 7 | /// 8 | public class WebHookWorkItem : IWebHookWorkItem 9 | { 10 | /// 11 | public Guid Id { get; } 12 | /// 13 | public DateTime Timestamp { get; } 14 | /// 15 | public IWebHookNotification Notification { get; } 16 | /// 17 | public IWebHook WebHook { get; } 18 | 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | public WebHookWorkItem(Guid id, IWebHookNotification notification, IWebHook webHook) 23 | { 24 | Notification = notification ?? throw new ArgumentNullException(nameof(notification)); 25 | WebHook = webHook ?? throw new ArgumentNullException(nameof(webHook)); 26 | Timestamp = DateTime.UtcNow; 27 | Id = id; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Harpoon.Common/IWebHook.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Harpoon 5 | { 6 | /// 7 | /// Represents a webhook, i.e. a subscription to certain internal events that need to translate to url callbacks 8 | /// 9 | public interface IWebHook 10 | { 11 | /// 12 | /// Gets or sets the unique identifier. 13 | /// 14 | Guid Id { get; set; } 15 | 16 | /// 17 | /// Gets the url to be called in case an event matching the is triggered 18 | /// 19 | Uri? Callback { get; } 20 | 21 | /// 22 | /// Gets or sets the shared secret. The secret must be 64 character in default implementation. 23 | /// 24 | string Secret { get; set; } 25 | 26 | /// 27 | /// Gets a value indicating if the given is currently paused. 28 | /// 29 | bool IsPaused { get; } 30 | 31 | /// 32 | /// Gets a collection of describing what should trigger the given 33 | /// 34 | IReadOnlyCollection Filters { get; } 35 | } 36 | } -------------------------------------------------------------------------------- /Harpoon.MassTransit/Harpoon.MassTransit.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | latest 6 | enable 7 | 0.1.5 8 | 0.1.5 9 | Romain V 10 | Harpoon provides support for sending your own WebHooks. This package allows the queuing via MassTransit of notifications and webHookWorkItems. 11 | Unlicense 12 | https://raw.githubusercontent.com/Poltuu/Harpoon/master/icon.png 13 | webhook 14 | https://github.com/Poltuu/Harpoon 15 | true 16 | 17 | 18 | 19 | bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/Harpoon.Registrations.EFStorage.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | latest 6 | enable 7 | 0.1.5 8 | 0.1.5 9 | Romain V 10 | Harpoon provides support for sending your own WebHooks. This package contains the default for registration with EF Core. 11 | Unlicense 12 | https://raw.githubusercontent.com/Poltuu/Harpoon/master/icon.png 13 | webhook 14 | https://github.com/Poltuu/Harpoon 15 | true 16 | 17 | 18 | 19 | bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /Harpoon.Common/Harpoon.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | Harpoon 6 | latest 7 | enable 8 | 0.1.5 9 | 0.1.5 10 | Romain V 11 | Harpoon provides support for sending your own WebHooks. This package contains the base classes. 12 | Unlicense 13 | https://raw.githubusercontent.com/Poltuu/Harpoon/master/icon.png 14 | webhook 15 | true 16 | 17 | 18 | 19 | bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Harpoon.Registrations/Harpoon.Registrations.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | latest 6 | enable 7 | 0.1.5 8 | 0.1.5 9 | Romain V 10 | Harpoon provides support for sending your own WebHooks. This package contains the default for registration. 11 | Unlicense 12 | https://raw.githubusercontent.com/Poltuu/Harpoon/master/icon.png 13 | webhook 14 | https://github.com/Poltuu/Harpoon 15 | true 16 | 17 | 18 | 19 | bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Harpoon.MassTransit/PublisherService.cs: -------------------------------------------------------------------------------- 1 | using MassTransit; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Harpoon.MassTransit 7 | { 8 | /// 9 | /// This class provides an implementation of and via event publishing 10 | /// 11 | public class PublisherService : IWebHookService, IWebHookSender 12 | { 13 | private readonly IPublishEndpoint _publishEndpoint; 14 | 15 | /// Initializes a new instance of the class. 16 | public PublisherService(IPublishEndpoint publishEndpoint) 17 | { 18 | _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); 19 | } 20 | 21 | /// 22 | Task IWebHookService.NotifyAsync(IWebHookNotification notification, CancellationToken cancellationToken) 23 | => PublishAsync(notification, cancellationToken); 24 | 25 | /// 26 | Task IWebHookSender.SendAsync(IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 27 | => PublishAsync(webHookWorkItem, cancellationToken); 28 | 29 | private Task PublishAsync(T message, CancellationToken token) => _publishEndpoint.Publish(message, token); 30 | } 31 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/WebHookTriggersController.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace Harpoon.Controllers 8 | { 9 | /// 10 | /// allows the caller to get the list of triggers available for registration. 11 | /// 12 | [Authorize, ApiController, Route("api/webhooktriggers"), ApiExplorerSettings(GroupName = OpenApi.GroupName), Produces("application/json")] 13 | public class WebHookTriggersController : ControllerBase 14 | { 15 | private readonly IWebHookTriggerProvider _webHookTriggerProvider; 16 | 17 | /// Initializes a new instance of the class. 18 | public WebHookTriggersController(IWebHookTriggerProvider webHookTriggerProvider) 19 | { 20 | _webHookTriggerProvider = webHookTriggerProvider ?? throw new ArgumentNullException(nameof(webHookTriggerProvider)); 21 | } 22 | 23 | /// 24 | /// Returns available WebHookTriggers for WebHook registration. 25 | /// 26 | /// 27 | [HttpGet] 28 | public ActionResult> Get() 29 | => Ok(_webHookTriggerProvider.GetAvailableTriggers().Values); 30 | } 31 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/Harpoon.Controllers.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | latest 6 | enable 7 | 0.1.5 8 | 0.1.5 9 | Romain V 10 | Harpoon provides support for sending your own WebHooks. This package contains default web controllers. 11 | Unlicense 12 | https://raw.githubusercontent.com/Poltuu/Harpoon/master/icon.png 13 | webhook 14 | https://github.com/Poltuu/Harpoon 15 | true 16 | 17 | 18 | 19 | bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHookNotification.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Harpoon.Registrations.EFStorage 5 | { 6 | /// 7 | /// Represents the content of an event that triggered 8 | /// 9 | public class WebHookNotification : IWebHookNotification 10 | { 11 | /// 12 | /// Gets or sets the id. 13 | /// 14 | public Guid Id { get; set; } 15 | 16 | /// 17 | /// Gets or sets the time stamp when the notification was created 18 | /// 19 | public DateTime CreatedAt { get; set; } 20 | 21 | /// 22 | public string TriggerId { get; set; } 23 | 24 | /// 25 | public object Payload { get; set; } 26 | 27 | /// 28 | /// Gets or sets the number of applicable webhooks 29 | /// 30 | public int Count { get; set; } 31 | 32 | /// 33 | /// Gets or sets the associated collection of 34 | /// 35 | public List WebHookLogs { get; set; } 36 | 37 | /// Initializes a new instance of the class. 38 | public WebHookNotification() 39 | { 40 | Id = Guid.NewGuid(); 41 | WebHookLogs = new List(); 42 | CreatedAt = DateTime.UtcNow; 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/Models/WebHook.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Harpoon.Controllers.Models 6 | { 7 | /// 8 | /// Represents a webhook, i.e. a subscription to certain internal events that need to translate to url callbacks 9 | /// 10 | public class WebHook : IWebHook 11 | { 12 | /// 13 | public Guid Id { get; set; } 14 | /// 15 | public Uri? Callback { get; set; } 16 | /// 17 | public string? Secret { get; set; } 18 | /// 19 | public bool IsPaused { get; set; } 20 | 21 | /// 22 | public List Filters { get; set; } = new List(); 23 | 24 | IReadOnlyCollection IWebHook.Filters => Filters; 25 | 26 | /// Initializes a new instance of the class. 27 | public WebHook() { } 28 | /// Initializes a new instance of the class. 29 | public WebHook(IWebHook webHook) 30 | { 31 | if (webHook == null) 32 | { 33 | throw new ArgumentNullException(nameof(webHook)); 34 | } 35 | 36 | Id = webHook.Id; 37 | Callback = webHook.Callback; 38 | Secret = webHook.Secret; 39 | IsPaused = webHook.IsPaused; 40 | 41 | Filters = webHook.Filters.Select(f => new WebHookFilter(f)).ToList(); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Harpoon.Tests/Fixtures/BackgroundSenderFixture.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Background; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.Extensions.Logging; 6 | using Moq; 7 | using System; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace Harpoon.Tests.Fixtures 12 | { 13 | public class BackgroundSenderFixture : IDisposable 14 | { 15 | public class FakeWebHookSender : IQueuedProcessor 16 | { 17 | public Task ProcessAsync(IWebHookWorkItem workItem, CancellationToken token) 18 | { 19 | FakeWebHookSenderCount++; 20 | return Task.CompletedTask; 21 | } 22 | } 23 | 24 | public static int FakeWebHookSenderCount { get; private set; } 25 | 26 | private readonly IHost _host; 27 | public IServiceProvider Services => _host.Services; 28 | 29 | public BackgroundSenderFixture() 30 | { 31 | _host = new HostBuilder().ConfigureServices(services => 32 | { 33 | services.AddSingleton(new Mock>>().Object); 34 | services.AddScoped, FakeWebHookSender>(); 35 | services.AddHostedService>(); 36 | services.AddSingleton>(); 37 | }).Build(); 38 | _host.Start(); 39 | } 40 | 41 | public void Dispose() => Task.Run(() => _host.StopAsync()); 42 | } 43 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHook.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Harpoon.Registrations.EFStorage 5 | { 6 | /// 7 | /// Default implementation 8 | /// 9 | public class WebHook : IWebHook 10 | { 11 | /// 12 | public Guid Id { get; set; } 13 | 14 | /// 15 | Uri IWebHook.Callback => string.IsNullOrEmpty(Callback) ? null : new Uri(Callback); 16 | 17 | /// 18 | public string Callback { get; set; } 19 | 20 | /// 21 | public string Secret { get; set; } 22 | 23 | /// 24 | /// Gets or sets protected secret 25 | /// 26 | public string ProtectedSecret { get; set; } 27 | 28 | /// 29 | public string PrincipalId { get; set; } 30 | 31 | /// 32 | public bool IsPaused { get; set; } 33 | 34 | /// 35 | /// Gets or sets the associated collection of 36 | /// 37 | public List Filters { get; set; } 38 | 39 | /// 40 | /// Gets or sets the associated collection of 41 | /// 42 | public List WebHookLogs { get; set; } 43 | 44 | IReadOnlyCollection IWebHook.Filters => Filters; 45 | 46 | /// Initializes a new instance of the class. 47 | public WebHook() 48 | { 49 | Filters = new List(); 50 | WebHookLogs = new List(); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/ServicesCollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon; 2 | using Harpoon.Registrations; 3 | using Microsoft.Extensions.DependencyInjection.Extensions; 4 | using System; 5 | 6 | namespace Microsoft.Extensions.DependencyInjection 7 | { 8 | /// 9 | /// A set of extensions methods on 10 | /// 11 | public static class ServicesCollectionsExtensions 12 | { 13 | /// 14 | /// Registers services to use the default implementation. Necessary to use default controllers. 15 | /// 16 | /// 17 | /// 18 | public static IHarpoonBuilder UseDefaultValidator(this IHarpoonBuilder harpoon) => harpoon.UseDefaultValidator(b => { }); 19 | 20 | /// 21 | /// Registers services to use the default implementation. Necessary to use default controllers. 22 | /// 23 | /// 24 | /// 25 | /// 26 | public static IHarpoonBuilder UseDefaultValidator(this IHarpoonBuilder harpoon, Action validatorPolicy) 27 | { 28 | if (validatorPolicy == null) 29 | { 30 | throw new ArgumentNullException(nameof(validatorPolicy)); 31 | } 32 | 33 | harpoon.Services.TryAddScoped(); 34 | 35 | var builder = harpoon.Services.AddHttpClient(); 36 | validatorPolicy(builder); 37 | return harpoon; 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /Harpoon.Tests/Harpoon.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | latest 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Harpoon.Registrations/WebHookTrigger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon.Registrations 4 | { 5 | /// 6 | /// Represents an strongly-typed event template that may trigger a webhook 7 | /// 8 | public class WebHookTrigger : WebHookTrigger 9 | { 10 | /// 11 | /// Initializes a new instance of the class. 12 | /// 13 | public WebHookTrigger(string id, string description) 14 | : base(id, description, typeof(TPayload)) 15 | { 16 | } 17 | } 18 | 19 | /// 20 | /// Represents an event template that may trigger a webhook 21 | /// 22 | public class WebHookTrigger 23 | { 24 | /// 25 | /// Gets or sets a unique id for the event. This could typically look like `noun.verb`. 26 | /// If pattern matching is used, this could look like this `noun.verb.{value}` 27 | /// 28 | public string Id { get; private set; } 29 | 30 | /// 31 | /// Gets or sets a short description of the event 32 | /// 33 | public string Description { get; set; } 34 | 35 | private readonly Type _payloadType; 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// 40 | public WebHookTrigger(string id, string description, Type payloadType) 41 | { 42 | Id = id; 43 | Description = description; 44 | _payloadType = payloadType; 45 | } 46 | 47 | /// 48 | /// Gets the payload type for this trigger 49 | /// 50 | public Type GetPayloadType() => _payloadType; 51 | } 52 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/DefaultSecretProtector.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.DataProtection; 2 | using System; 3 | using System.Text; 4 | 5 | namespace Harpoon.Registrations.EFStorage 6 | { 7 | /// 8 | /// Default class able to protect and unprotect a piece of plain text data 9 | /// 10 | public class DefaultSecretProtector : ISecretProtector 11 | { 12 | /// 13 | /// Gets the default purpose 14 | /// 15 | public const string Purpose = "WebHookStorage"; 16 | 17 | private readonly IDataProtector _dataProtector; 18 | 19 | /// Initializes a new instance of the class. 20 | /// 21 | public DefaultSecretProtector(IDataProtectionProvider dataProtectionProvider) 22 | { 23 | _dataProtector = dataProtectionProvider?.CreateProtector(Purpose) ?? throw new ArgumentNullException(nameof(dataProtectionProvider)); 24 | } 25 | 26 | /// 27 | public string Protect(string plaintext) => _dataProtector.Protect(plaintext); 28 | 29 | /// 30 | public string Unprotect(string protectedData) 31 | { 32 | try 33 | { 34 | return _dataProtector.Unprotect(protectedData); 35 | } 36 | catch 37 | { 38 | if (!(_dataProtector is IPersistedDataProtector persistedProtector)) 39 | { 40 | throw; 41 | } 42 | 43 | return Encoding.UTF8.GetString(persistedProtector.DangerousUnprotect(Encoding.UTF8.GetBytes(protectedData), true, out var _, out var _)); 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHookLog.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Harpoon.Registrations.EFStorage 4 | { 5 | /// 6 | /// Represents a log for a callback call 7 | /// 8 | public class WebHookLog 9 | { 10 | /// 11 | /// Gets or sets the id. 12 | /// 13 | public int Id { get; set; } 14 | 15 | /// 16 | /// Gets or sets the time stamp when the treatment started 17 | /// 18 | public DateTime CreatedAt { get; set; } 19 | 20 | /// 21 | /// Gets or sets an error message 22 | /// 23 | public string? Error { get; set; } 24 | 25 | /// 26 | /// Gets a value representing if the log is in error 27 | /// 28 | public bool IsSuccess => string.IsNullOrEmpty(Error); 29 | 30 | /// 31 | /// Gets or sets the associated id 32 | /// 33 | public Guid WebHookNotificationId { get; set; } 34 | /// 35 | /// Gets or sets the associated 36 | /// 37 | public WebHookNotification WebHookNotification { get; set; } 38 | 39 | /// 40 | /// Gets or sets the associated id 41 | /// 42 | public Guid WebHookId { get; set; } 43 | /// 44 | /// Gets or sets the associated 45 | /// 46 | public WebHook WebHook { get; set; } 47 | 48 | /// Initializes a new instance of the class. 49 | public WebHookLog() 50 | { 51 | CreatedAt = DateTime.UtcNow; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/EFNotificationProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Harpoon.Registrations.EFStorage; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Harpoon.Sender.EF 10 | { 11 | /// 12 | /// implementation that logs everything into the context 13 | /// 14 | /// 15 | public class EFNotificationProcessor : DefaultNotificationProcessor 16 | where TContext : DbContext, IRegistrationsContext 17 | { 18 | private readonly TContext _context; 19 | 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | /// 24 | /// 25 | /// 26 | /// 27 | public EFNotificationProcessor(TContext context, IWebHookStore webHookStore, IWebHookSender webHookSender, ILogger logger) 28 | : base(webHookStore, webHookSender, logger) 29 | { 30 | _context = context ?? throw new ArgumentNullException(nameof(context)); 31 | } 32 | 33 | /// 34 | protected override async Task LogAsync(IWebHookNotification notification, IReadOnlyList webHooks, CancellationToken cancellationToken) 35 | { 36 | var notif = new Registrations.EFStorage.WebHookNotification 37 | { 38 | Payload = notification.Payload, 39 | TriggerId = notification.TriggerId, 40 | Count = webHooks.Count 41 | }; 42 | _context.Add(notif); 43 | 44 | await _context.SaveChangesAsync(); 45 | return notif.Id; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Harpoon.Tests/BackgroundSenderTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Background; 2 | using Harpoon.Registrations.EFStorage; 3 | using Harpoon.Tests.Fixtures; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Xunit; 9 | 10 | namespace Harpoon.Tests 11 | { 12 | public class BackgroundSenderTests : IClassFixture 13 | { 14 | private readonly BackgroundSenderFixture _fixture; 15 | 16 | public BackgroundSenderTests(BackgroundSenderFixture fixture) 17 | { 18 | _fixture = fixture; 19 | } 20 | 21 | [Fact] 22 | public async Task ArgNullAsync() 23 | { 24 | Assert.Throws(() => new BackgroundSender(null)); 25 | var service = new BackgroundSender(new BackgroundQueue()); 26 | await Assert.ThrowsAsync(() => service.SendAsync(null, CancellationToken.None)); 27 | } 28 | 29 | [Fact] 30 | public async Task NormalAsync() 31 | { 32 | var queue = new BackgroundQueue(); 33 | var service = new BackgroundSender(queue); 34 | var count = 0; 35 | 36 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), new WebHookNotification("", new object()), new WebHook()), CancellationToken.None); 37 | await queue.DequeueAsync(CancellationToken.None).AsTask().ContinueWith(t => count++); 38 | 39 | Assert.Equal(1, count); 40 | } 41 | 42 | [Fact] 43 | public async Task NormalWithHostedServiceAsync() 44 | { 45 | var service = new BackgroundSender(_fixture.Services.GetRequiredService>()); 46 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), new WebHookNotification("", new object()), new WebHook()), CancellationToken.None); 47 | 48 | await Task.Delay(10000); 49 | Assert.Equal(1, BackgroundSenderFixture.FakeWebHookSenderCount); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/DefaultPrincipalIdGetter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Security.Claims; 4 | using System.Security.Principal; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Harpoon.Registrations 9 | { 10 | /// 11 | /// Default class able to extract a string from a principal, that will be later used as an id for webhook registration 12 | /// 13 | public class DefaultPrincipalIdGetter : IPrincipalIdGetter 14 | { 15 | /// 16 | /// Current principal id could not be found 17 | public Task GetPrincipalIdAsync(IPrincipal principal, CancellationToken cancellationToken = default) 18 | => Task.FromResult(GetPrincipalIdForWebHookRegistration(principal)); 19 | 20 | private string GetPrincipalIdForWebHookRegistration(IPrincipal principal) 21 | { 22 | if (principal == null) 23 | { 24 | throw new ArgumentNullException(nameof(principal)); 25 | } 26 | 27 | if (principal is ClaimsPrincipal claimsPrincipal) 28 | { 29 | if (TryGetNotNullClaimValue(claimsPrincipal, ClaimTypes.Name, out var name)) 30 | { 31 | return name; 32 | } 33 | 34 | if (TryGetNotNullClaimValue(claimsPrincipal, ClaimTypes.NameIdentifier, out var nameIdentifier)) 35 | { 36 | return nameIdentifier; 37 | } 38 | } 39 | 40 | if (principal.Identity?.Name != null) 41 | { 42 | return principal.Identity.Name; 43 | } 44 | 45 | throw new ArgumentException("Current principal id could not be found."); 46 | } 47 | 48 | private bool TryGetNotNullClaimValue(ClaimsPrincipal principal, string claimType, [NotNullWhen(true)] out string? result) 49 | { 50 | result = principal.FindFirst(claimType)?.Value; 51 | return result != null; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /Harpoon.Common/Background/QueuedHostedService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Harpoon.Background 9 | { 10 | internal class QueuedHostedService : BackgroundService 11 | { 12 | private readonly IServiceProvider _services; 13 | private readonly ILogger> _logger; 14 | 15 | private readonly BackgroundQueue _webHooksQueue; 16 | 17 | public QueuedHostedService(IServiceProvider services, ILogger> logger, BackgroundQueue webHooksQueue) 18 | { 19 | _services = services ?? throw new ArgumentNullException(nameof(services)); 20 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 21 | _webHooksQueue = webHooksQueue ?? throw new ArgumentNullException(nameof(webHooksQueue)); 22 | } 23 | 24 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 25 | { 26 | _logger.LogInformation($"Queued Background Service of {typeof(TWorkItem).Name} is starting."); 27 | 28 | while (!stoppingToken.IsCancellationRequested) 29 | { 30 | var workItem = await _webHooksQueue.DequeueAsync(stoppingToken); 31 | 32 | try 33 | { 34 | using (var scope = _services.CreateScope()) 35 | { 36 | var service = scope.ServiceProvider.GetRequiredService>(); 37 | await service.ProcessAsync(workItem, stoppingToken); 38 | } 39 | } 40 | catch (Exception ex) 41 | { 42 | _logger.LogError(ex, $"Queued Hosted Service of {typeof(TWorkItem).Name} error."); 43 | } 44 | } 45 | 46 | _logger.LogWarning($"Queued Hosted Service of {typeof(TWorkItem).Name} has been canceled by token."); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /Harpoon.Tests/WebHookStoreTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations.EFStorage; 2 | using Harpoon.Tests.Mocks; 3 | using Moq; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | using Xunit; 8 | 9 | namespace Harpoon.Tests 10 | { 11 | public class WebHookStoreTests 12 | { 13 | [Fact] 14 | public async Task ArgNull() 15 | { 16 | Assert.Throws(() => new WebHookStore(null, new Mock().Object)); 17 | Assert.Throws(() => new WebHookStore(new InMemoryContext(), null)); 18 | 19 | var store = new WebHookStore(new InMemoryContext(), new Mock().Object); 20 | 21 | await Assert.ThrowsAsync(() => store.GetApplicableWebHooksAsync(null)); 22 | } 23 | 24 | [Fact] 25 | public async Task TriggerMatchingTests() 26 | { 27 | var notification = new WebHookNotification("something.interesting.happened", new object()); 28 | 29 | var context = new InMemoryContext(); 30 | context.Add(new WebHook 31 | { 32 | IsPaused = false, 33 | Filters = new List { new WebHookFilter { Trigger = "something.interesting.happened" } } 34 | }); 35 | context.Add(new WebHook 36 | { 37 | IsPaused = true, 38 | Filters = new List { new WebHookFilter { Trigger = "something.interesting.happened" } } 39 | }); 40 | context.SaveChanges(); 41 | 42 | var secret = "http://www.example.org"; 43 | var protector = new Mock(); 44 | protector.Setup(p => p.Unprotect(It.IsAny())).Returns(secret); 45 | 46 | var store = new WebHookStore(context, protector.Object); 47 | 48 | var result = await store.GetApplicableWebHooksAsync(notification); 49 | Assert.Equal(1, result.Count); 50 | 51 | var webhook = result[0]; 52 | Assert.False(webhook.IsPaused); 53 | 54 | Assert.Equal(secret, webhook.Secret); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /Harpoon.Tests/Fixtures/DatabaseFixture.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using Harpoon.Registrations.EFStorage; 3 | using Harpoon.Tests.Mocks; 4 | using Microsoft.AspNetCore.DataProtection; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Logging; 7 | using Moq; 8 | using System; 9 | using System.Security.Principal; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | namespace Harpoon.Tests.Fixtures 14 | { 15 | public class DatabaseFixture : IDisposable 16 | { 17 | private IServiceProvider _provider; 18 | 19 | public IServiceProvider Provider => _provider ??= GetProvider(); 20 | 21 | private IServiceProvider GetProvider() 22 | { 23 | var services = new ServiceCollection(); 24 | services.AddEntityFrameworkSqlServer().AddDbContext(); 25 | 26 | var getter = new Mock(); 27 | getter.Setup(g => g.GetPrincipalIdAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult("principal1")); 28 | services.AddSingleton(getter.Object); 29 | 30 | var protector = new Mock(); 31 | protector.Setup(p => p.Protect(It.IsAny())).Returns(v => v); 32 | protector.Setup(p => p.Unprotect(It.IsAny())).Returns(v => v); 33 | protector.Setup(s => s.CreateProtector(It.IsAny())).Returns(protector.Object); 34 | services.AddSingleton(protector.Object); 35 | services.AddSingleton(); 36 | 37 | services.AddSingleton(new Mock>>().Object); 38 | services.AddSingleton>(); 39 | services.AddSingleton>(); 40 | 41 | var result = services.BuildServiceProvider(); 42 | 43 | result.GetRequiredService().Database.EnsureCreated(); 44 | 45 | return result; 46 | } 47 | 48 | public void Dispose() 49 | { 50 | if (_provider != null) 51 | { 52 | var context = _provider.GetRequiredService(); 53 | context.Database.EnsureDeleted(); 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHookStore.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Harpoon.Registrations.EFStorage 9 | { 10 | /// 11 | /// Default implementation using EF 12 | /// 13 | /// 14 | public class WebHookStore : IWebHookStore 15 | where TContext : DbContext, IRegistrationsContext 16 | { 17 | private readonly TContext _context; 18 | private readonly ISecretProtector _secretProtector; 19 | 20 | /// Initializes a new instance of the class. 21 | public WebHookStore(TContext context, ISecretProtector secretProtector) 22 | { 23 | _context = context ?? throw new ArgumentNullException(nameof(context)); 24 | _secretProtector = secretProtector ?? throw new ArgumentNullException(nameof(secretProtector)); 25 | } 26 | 27 | /// 28 | public async Task> GetApplicableWebHooksAsync(IWebHookNotification notification, CancellationToken cancellationToken = default) 29 | { 30 | if (notification == null) 31 | { 32 | throw new ArgumentNullException(nameof(notification)); 33 | } 34 | 35 | var webHooks = await FilterQuery(_context.WebHooks.AsNoTracking() 36 | .Where(w => !w.IsPaused) 37 | .Include(w => w.Filters), notification) 38 | .ToListAsync(cancellationToken); 39 | 40 | foreach (var webHook in webHooks) 41 | { 42 | webHook.Secret = _secretProtector.Unprotect(webHook.ProtectedSecret); 43 | } 44 | return webHooks; 45 | } 46 | 47 | /// 48 | /// Apply the SQL filter matching the current notification 49 | /// 50 | /// 51 | /// 52 | /// 53 | protected virtual IQueryable FilterQuery(IQueryable query, IWebHookNotification notification) 54 | => query.Where(w => w.Filters.Count == 0 || w.Filters.Any(f => f.Trigger == notification.TriggerId)); 55 | } 56 | } -------------------------------------------------------------------------------- /Harpoon.Tests/DefaultPrincipalIdGetterTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Security.Claims; 5 | using System.Threading.Tasks; 6 | using Xunit; 7 | 8 | namespace Harpoon.Tests 9 | { 10 | public class DefaultPrincipalIdGetterTests 11 | { 12 | [Fact] 13 | public async Task ArgNull() 14 | { 15 | var service = new DefaultPrincipalIdGetter(); 16 | 17 | await Assert.ThrowsAsync(() => service.GetPrincipalIdAsync(null)); 18 | } 19 | 20 | public static IEnumerable NameScenario => new List 21 | { 22 | new object[] { new ClaimsPrincipal(new ClaimsIdentity(new List 23 | { 24 | new Claim(ClaimTypes.Name, "first"), 25 | new Claim(ClaimTypes.NameIdentifier, "second"), 26 | new Claim("NameType", "third"), 27 | }, "scheme", "NameType", ClaimTypes.Role)), "first" }, 28 | new object[] { new ClaimsPrincipal(new ClaimsIdentity(new List 29 | { 30 | new Claim(ClaimTypes.NameIdentifier, "second"), 31 | new Claim("NameType", "third"), 32 | }, "scheme", "NameType", ClaimTypes.Role)), "second" }, 33 | new object[] { new ClaimsPrincipal(new ClaimsIdentity(new List 34 | { 35 | new Claim("NameType", "third"), 36 | }, "scheme", "NameType", ClaimTypes.Role)), "third" }, 37 | }; 38 | 39 | [Theory] 40 | [MemberData(nameof(NameScenario))] 41 | public async Task DefaultOrder(ClaimsPrincipal principal, string expectedName) 42 | { 43 | var service = new DefaultPrincipalIdGetter(); 44 | Assert.Equal(expectedName, await service.GetPrincipalIdAsync(principal)); 45 | 46 | principal = new ClaimsPrincipal(new ClaimsIdentity(new List { }, "scheme", "NameType", ClaimTypes.Role)); 47 | await Assert.ThrowsAsync(() => service.GetPrincipalIdAsync(principal)); 48 | } 49 | 50 | [Fact] 51 | public async Task NoNameThrows() 52 | { 53 | var service = new DefaultPrincipalIdGetter(); 54 | 55 | var principal = new ClaimsPrincipal(new ClaimsIdentity(new List { }, "scheme", "NameType", ClaimTypes.Role)); 56 | await Assert.ThrowsAsync(() => service.GetPrincipalIdAsync(principal)); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/WebHookReplayService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Harpoon.Registrations.EFStorage 7 | { 8 | /// 9 | /// A class able to replay failed notifications 10 | /// 11 | /// 12 | public class WebHookReplayService 13 | where TContext : DbContext, IRegistrationsContext 14 | { 15 | private readonly TContext _context; 16 | private readonly IWebHookSender _sender; 17 | private readonly ISecretProtector _secretProtector; 18 | 19 | /// Initializes a new instance of the class. 20 | public WebHookReplayService(TContext context, IWebHookSender sender, ISecretProtector secretProtector) 21 | { 22 | _context = context ?? throw new ArgumentNullException(nameof(context)); 23 | _sender = sender ?? throw new ArgumentNullException(nameof(sender)); 24 | _secretProtector = secretProtector ?? throw new ArgumentNullException(nameof(secretProtector)); 25 | } 26 | 27 | /// 28 | /// Replays every failed notification 29 | /// 30 | /// 31 | /// 32 | public async Task ReplayFailedNotification(DateTime start) 33 | { 34 | var failedNotifications = await _context.WebHookLogs 35 | .Where(l => l.Error != null && l.CreatedAt >= start) 36 | .Include(e => e.WebHookNotification) 37 | .Include(e => e.WebHook) 38 | .AsNoTracking() 39 | .ToListAsync(); 40 | 41 | foreach (var fail in failedNotifications) 42 | { 43 | var hasSuccesfulLogs = await _context.WebHookLogs 44 | .Where(l => l.WebHookNotificationId == fail.WebHookNotificationId 45 | && l.WebHookId == fail.WebHookId 46 | && l.CreatedAt > fail.CreatedAt 47 | && l.Error == null).AnyAsync(); 48 | 49 | if (!hasSuccesfulLogs) 50 | { 51 | fail.WebHook.Secret = _secretProtector.Unprotect(fail.WebHook.ProtectedSecret); 52 | await _sender.SendAsync(new WebHookWorkItem(fail.WebHookNotificationId, fail.WebHookNotification, fail.WebHook), default); 53 | } 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /Harpoon.Tests/DefaultWebHookServiceTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Background; 2 | using Microsoft.Extensions.Logging; 3 | using Moq; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Xunit; 9 | 10 | namespace Harpoon.Tests 11 | { 12 | public class DefaultWebHookProcessorTests 13 | { 14 | [Fact] 15 | public async Task ArgNullAsync() 16 | { 17 | var store = new Mock(); 18 | var sender = new Mock(); 19 | var logger = new Mock>(); 20 | 21 | Assert.Throws(() => new DefaultNotificationProcessor(null, sender.Object, logger.Object)); 22 | Assert.Throws(() => new DefaultNotificationProcessor(store.Object, null, logger.Object)); 23 | Assert.Throws(() => new DefaultNotificationProcessor(store.Object, sender.Object, null)); 24 | Assert.Throws(() => new DefaultWebHookService(null)); 25 | 26 | var service = new DefaultNotificationProcessor(store.Object, sender.Object, logger.Object); 27 | await Assert.ThrowsAsync(() => service.ProcessAsync(null, CancellationToken.None)); 28 | } 29 | 30 | [Fact] 31 | public async Task DefaultWebHookServiceAsync() 32 | { 33 | var queue = new BackgroundQueue(); 34 | var service = new DefaultWebHookService(queue); 35 | var count = 0; 36 | 37 | await service.NotifyAsync(new WebHookNotification("", new object())); 38 | await queue.DequeueAsync(CancellationToken.None).AsTask().ContinueWith(t => count++); 39 | 40 | Assert.Equal(1, count); 41 | } 42 | 43 | [Fact] 44 | public async Task NoWebHookAsync() 45 | { 46 | var store = new Mock(); 47 | store.Setup(s => s.GetApplicableWebHooksAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); 48 | var sender = new Mock(); 49 | var logger = new Mock>(); 50 | 51 | var service = new DefaultNotificationProcessor(store.Object, sender.Object, logger.Object); 52 | await service.ProcessAsync(new WebHookNotification("", new object()), CancellationToken.None); 53 | 54 | sender.Verify(s => s.SendAsync(It.IsAny(), It.IsAny()), Times.Never); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /Harpoon.Tests/WebHookSubscriptionFilterTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Controllers.Swashbuckle; 2 | using Harpoon.Registrations; 3 | using Microsoft.AspNetCore.Mvc.ApiExplorer; 4 | using Microsoft.OpenApi.Models; 5 | using Swashbuckle.AspNetCore.SwaggerGen; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text.Json; 10 | using Xunit; 11 | 12 | namespace Harpoon.Tests 13 | { 14 | public class WebHookSubscriptionFilterTests 15 | { 16 | class MyPayload 17 | { 18 | public Guid NotificationId { get; set; } 19 | } 20 | 21 | class TestWebHookTriggerProvider : IWebHookTriggerProvider 22 | { 23 | public IReadOnlyDictionary GetAvailableTriggers() 24 | => new Dictionary 25 | { 26 | ["trigger"] = new WebHookTrigger("trigger", "description") 27 | }; 28 | } 29 | 30 | [Fact] 31 | public void ArgNull() => Assert.Throws(() => new WebHookSubscriptionFilter(null)); 32 | 33 | [Fact] 34 | public void ApplyTests() 35 | { 36 | var filter = new WebHookSubscriptionFilter(new TestWebHookTriggerProvider()); 37 | 38 | var context = new OperationFilterContext(new ApiDescription() 39 | , new SchemaGenerator(new SchemaGeneratorOptions(), new JsonSerializerDataContractResolver(new JsonSerializerOptions())) 40 | , new SchemaRepository() 41 | , typeof(TestedController).GetMethod(nameof(TestedController.TestedMethod))); 42 | 43 | var operation = new OpenApiOperation(); 44 | filter.Apply(operation, context); 45 | 46 | Assert.NotNull(operation.Callbacks); 47 | Assert.NotEqual(0, operation.Callbacks.Count); 48 | Assert.Contains("trigger", operation.Callbacks.Keys); 49 | Assert.Contains("{$request.body#/callback}", operation.Callbacks["trigger"].PathItems.Keys.Select(k => k.Expression)); 50 | Assert.Contains(OperationType.Post, operation.Callbacks["trigger"].PathItems.First().Value.Operations.Keys); 51 | 52 | var op = operation.Callbacks["trigger"].PathItems.First().Value.Operations[OperationType.Post]; 53 | 54 | Assert.Equal("trigger", op.OperationId); 55 | Assert.Equal("description", op.Description); 56 | Assert.Equal(4, op.Parameters.Count); 57 | Assert.Equal(4, op.Responses.Count); 58 | } 59 | } 60 | 61 | class TestedController 62 | { 63 | [WebHookSubscriptionPoint] 64 | public void TestedMethod() 65 | { 66 | 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2019 2 | 3 | version: 0.0.0.{build} 4 | skip_branch_with_pr: true 5 | 6 | environment: 7 | rabbitmq_installer_path: "C:\\Users\\appveyor\\rabbitmq-server-3.7.6.exe" 8 | rabbitmq_installer_download_url: "https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.7.6/rabbitmq-server-3.7.6.exe" 9 | APPVEYOR_SAVE_CACHE_ON_ERROR: true 10 | global: 11 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 12 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 13 | Harpoon_Connection_String: "Server=(local)\\SQL2017;Database=TEST_HARPOON_{0};User ID=sa;Password=Password12!;Trusted_Connection=True;MultipleActiveResultSets=true" 14 | matrix: 15 | - IS_TAG: true 16 | - IS_TAG: false 17 | 18 | configuration: Release 19 | 20 | cache: 21 | - C:\ProgramData\chocolatey\bin -> appveyor.yml 22 | - C:\ProgramData\chocolatey\lib -> appveyor.yml 23 | - '%USERPROFILE%\.nuget\packages -> **\*.csproj' 24 | - '%LocalAppData%\NuGet\v3-cache -> **\*.csproj' 25 | - '%rabbitmq_installer_path% -> appveyor.yml' 26 | 27 | services: 28 | - mssql2017 29 | 30 | install: 31 | - choco install opencover.portable codecov 32 | 33 | for: 34 | - 35 | matrix: 36 | only: 37 | - IS_TAG: false 38 | 39 | skip_tags: true 40 | - 41 | matrix: 42 | only: 43 | - IS_TAG: true 44 | 45 | skip_non_tags: true 46 | 47 | dotnet_csproj: 48 | patch: true 49 | file: '**\*.csproj' 50 | version: $(appveyor_repo_tag_name) 51 | package_version: $(appveyor_repo_tag_name) 52 | assembly_version: $(appveyor_repo_tag_name) 53 | file_version: $(appveyor_repo_tag_name) 54 | informational_version: $(appveyor_repo_tag_name) 55 | 56 | build: 57 | publish_nuget: true 58 | publish_nuget_symbols: false 59 | 60 | deploy: 61 | - provider: NuGet 62 | api_key: 63 | secure: RQvz2NHd2oqyiItbxLeZtsyCgM46T1CN4KwKyomsQ42tObHAM7T2McW6WbED/ICJ 64 | 65 | before_build: 66 | - dotnet restore 67 | 68 | build: 69 | verbosity: minimal 70 | 71 | before_test: 72 | - ps: if (-Not (Test-Path "$env:rabbitmq_installer_path")) { (New-Object Net.WebClient).DownloadFile("$env:rabbitmq_installer_download_url", "$env:rabbitmq_installer_path") } else { Write-Host "Found" $env:rabbitmq_installer_path "in cache." } 73 | - start /B /WAIT %rabbitmq_installer_path% /S 74 | - ps: $rabbitPath = 'C:\Program Files\RabbitMQ Server\rabbitmq_server-3.7.6' 75 | - ps: Start-Process -Wait "$rabbitPath\sbin\rabbitmq-service.bat" "install" 76 | - ps: Start-Process -Wait "$rabbitPath\sbin\rabbitmq-service.bat" "start" 77 | 78 | test_script: 79 | - OpenCover.Console.exe -returntargetcode -oldstyle -register:user -target:"C:\Program Files\dotnet\dotnet.exe" -targetargs:"test /p:DebugType=full -c Debug Harpoon.sln" -filter:"+[Harpoon*]* -[*Tests*]*" -output:Harpoon_coverage.xml 80 | - codecov -f Harpoon_coverage.xml -------------------------------------------------------------------------------- /Harpoon.Registrations/IWebHookRegistrationStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Security.Principal; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Harpoon.Registrations 8 | { 9 | /// 10 | /// Represents a class able to perform CRUD operations on 11 | /// 12 | public interface IWebHookRegistrationStore 13 | { 14 | /// 15 | /// Returns asynchronously the owned by the provided with the given id 16 | /// 17 | /// 18 | /// 19 | /// 20 | /// 21 | Task GetWebHookAsync(IPrincipal user, Guid id, CancellationToken cancellationToken = default); 22 | 23 | /// 24 | /// Returns asynchronously a collection of owned by the provided 25 | /// 26 | /// 27 | /// 28 | /// 29 | Task> GetWebHooksAsync(IPrincipal user, CancellationToken cancellationToken = default); 30 | 31 | /// 32 | /// Inserts asynchronously the provided owned by the provided 33 | /// 34 | /// 35 | /// 36 | /// 37 | /// 38 | Task InsertWebHookAsync(IPrincipal user, IWebHook webHook, CancellationToken cancellationToken = default); 39 | 40 | /// 41 | /// Updates asynchronously the provided owned by the provided 42 | /// 43 | /// 44 | /// 45 | /// 46 | /// 47 | Task UpdateWebHookAsync(IPrincipal user, IWebHook webHook, CancellationToken cancellationToken = default); 48 | 49 | /// 50 | /// Deletes asynchronously the owned by the provided with the given id 51 | /// 52 | /// 53 | /// 54 | /// 55 | /// 56 | Task DeleteWebHookAsync(IPrincipal user, Guid id, CancellationToken cancellationToken = default); 57 | 58 | /// 59 | /// Deletes asynchronously the ensemble of owned by the provided 60 | /// 61 | /// 62 | /// 63 | /// 64 | Task DeleteWebHooksAsync(IPrincipal user, CancellationToken cancellationToken = default); 65 | } 66 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/EFWebHookSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Harpoon.Registrations.EFStorage; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Harpoon.Sender.EF 10 | { 11 | /// 12 | /// implementation that automatically pauses webhooks on NotFound responses 13 | /// 14 | /// 15 | public class EFWebHookSender : DefaultWebHookSender 16 | where TContext : DbContext, IRegistrationsContext 17 | { 18 | private readonly TContext _context; 19 | 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | /// 24 | /// 25 | /// 26 | /// 27 | public EFWebHookSender(HttpClient httpClient, ISignatureService signatureService, ILogger logger, TContext context) 28 | : base(httpClient, signatureService, logger) 29 | { 30 | _context = context ?? throw new ArgumentNullException(nameof(context)); 31 | } 32 | 33 | /// 34 | protected override Task OnFailureAsync(HttpResponseMessage? response, Exception? exception, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 35 | => AddLogAsync(webHookWorkItem, $"WebHook {webHookWorkItem.WebHook.Id} failed. [{webHookWorkItem.WebHook.Callback}]: {exception?.Message}"); 36 | 37 | /// 38 | protected override Task OnSuccessAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 39 | => AddLogAsync(webHookWorkItem); 40 | 41 | /// 42 | protected override async Task OnNotFoundAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 43 | { 44 | var dbWebHook = await _context.WebHooks.FirstOrDefaultAsync(w => w.Id == webHookWorkItem.WebHook.Id); 45 | if (dbWebHook != null) 46 | { 47 | dbWebHook.IsPaused = true; 48 | } 49 | 50 | await AddLogAsync(webHookWorkItem, $"WebHook {webHookWorkItem.WebHook.Id} was paused. [{webHookWorkItem.WebHook.Callback}]"); 51 | } 52 | 53 | private async Task AddLogAsync(IWebHookWorkItem workItem, string? error = null) 54 | { 55 | var log = new WebHookLog 56 | { 57 | Error = error, 58 | WebHookId = workItem.WebHook.Id, 59 | WebHookNotificationId = workItem.Id 60 | }; 61 | _context.Add(log); 62 | 63 | try 64 | { 65 | await _context.SaveChangesAsync(); 66 | 67 | if (!string.IsNullOrEmpty(error)) 68 | { 69 | Logger.LogInformation(error); 70 | } 71 | } 72 | catch (Exception e) 73 | { 74 | if (!string.IsNullOrEmpty(error)) 75 | { 76 | Logger.LogError(error); 77 | } 78 | 79 | Logger.LogError($"Log failed for WebHook {workItem.WebHook.Id}. [{workItem.WebHook.Callback}]: {e.Message}"); 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /Harpoon.Common/DefaultNotificationProcessor.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Harpoon 9 | { 10 | /// 11 | /// Default implementation 12 | /// 13 | public class DefaultNotificationProcessor : IQueuedProcessor, IWebHookService 14 | { 15 | private readonly IWebHookStore _webHookStore; 16 | private readonly IWebHookSender _webHookSender; 17 | private readonly ILogger _logger; 18 | 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | /// 23 | /// 24 | /// 25 | public DefaultNotificationProcessor(IWebHookStore webHookStore, IWebHookSender webHookSender, ILogger logger) 26 | { 27 | _webHookStore = webHookStore ?? throw new ArgumentNullException(nameof(webHookStore)); 28 | _webHookSender = webHookSender ?? throw new ArgumentNullException(nameof(webHookSender)); 29 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 30 | } 31 | 32 | Task IWebHookService.NotifyAsync(IWebHookNotification notification, CancellationToken cancellationToken) 33 | => ProcessAsync(notification, cancellationToken); 34 | 35 | /// 36 | public async Task ProcessAsync(IWebHookNotification notification, CancellationToken cancellationToken) 37 | { 38 | if (notification == null) 39 | { 40 | throw new ArgumentNullException(nameof(notification)); 41 | } 42 | 43 | var webHooks = await _webHookStore.GetApplicableWebHooksAsync(notification, cancellationToken); 44 | var id = await LogAsync(notification, webHooks, cancellationToken); 45 | 46 | var tasks = webHooks.Select(w => new { Task = _webHookSender.SendAsync(new WebHookWorkItem(id, notification, w), cancellationToken), Name = w.Callback }); 47 | try 48 | { 49 | await Task.WhenAll(tasks.Select(t => t.Task)); 50 | } 51 | catch (TaskCanceledException) 52 | { 53 | var canceledWebHooks = tasks.Where(a => !a.Task.IsCompleted).Select(a => a.Name); 54 | _logger.LogError("The following urls have not been called due to a task cancellation: " + string.Join(Environment.NewLine, canceledWebHooks)); 55 | } 56 | catch 57 | { 58 | var canceledWebHooks = tasks.Where(a => !a.Task.IsCompleted).Select(a => a.Name); 59 | _logger.LogError("The following urls have not been called due to an error: " + string.Join(Environment.NewLine, canceledWebHooks)); 60 | } 61 | } 62 | 63 | /// 64 | /// Logs the current request 65 | /// 66 | /// 67 | /// 68 | /// 69 | /// 70 | protected virtual Task LogAsync(IWebHookNotification notification, IReadOnlyList webHooks, CancellationToken cancellationToken) 71 | => Task.FromResult(Guid.NewGuid()); 72 | } 73 | } -------------------------------------------------------------------------------- /Harpoon.Tests/Mocks/TestContext.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations.EFStorage; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Diagnostics; 4 | using System; 5 | using System.Linq; 6 | 7 | namespace Harpoon.Tests.Mocks 8 | { 9 | public class InMemoryContext : TestContext 10 | { 11 | public InMemoryContext() 12 | { 13 | } 14 | 15 | public InMemoryContext(DbContextOptions options) : base(options) 16 | { 17 | } 18 | 19 | protected override Guid DbName => Guid.NewGuid();//not used 20 | 21 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 22 | { 23 | optionsBuilder.EnableSensitiveDataLogging(); 24 | optionsBuilder.EnableDetailedErrors(); 25 | optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); 26 | } 27 | } 28 | public class TestContext1 : TestContext 29 | { 30 | public TestContext1() 31 | { 32 | } 33 | 34 | public TestContext1(DbContextOptions options) : base(options) 35 | { 36 | } 37 | 38 | private static Guid _guid = Guid.NewGuid(); 39 | protected override Guid DbName => _guid; 40 | } 41 | 42 | public class TestContext2 : TestContext 43 | { 44 | public TestContext2() 45 | { 46 | } 47 | 48 | public TestContext2(DbContextOptions options) : base(options) 49 | { 50 | } 51 | 52 | private static Guid _guid = Guid.NewGuid(); 53 | protected override Guid DbName => _guid; 54 | } 55 | 56 | public abstract class TestContext : DbContext, IRegistrationsContext 57 | { 58 | public DbSet WebHooks { get; set; } 59 | IQueryable IRegistrationsContext.WebHooks => WebHooks; 60 | 61 | public DbSet WebHookNotifications { get; set; } 62 | IQueryable IRegistrationsContext.WebHookNotifications => WebHookNotifications; 63 | 64 | public DbSet WebHookLogs { get; set; } 65 | IQueryable IRegistrationsContext.WebHookLogs => WebHookLogs; 66 | 67 | public DbSet WebHookFilters { get; set; } 68 | 69 | protected abstract Guid DbName { get; } 70 | 71 | 72 | public TestContext() 73 | { 74 | } 75 | 76 | public TestContext(DbContextOptions options) 77 | : base(options) 78 | { 79 | } 80 | 81 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 82 | { 83 | optionsBuilder.EnableSensitiveDataLogging(); 84 | optionsBuilder.EnableDetailedErrors(); 85 | #if NETCOREAPP2_2 86 | optionsBuilder.ConfigureWarnings(warnings => warnings.Throw(RelationalEventId.QueryClientEvaluationWarning)); 87 | #endif 88 | 89 | var connectionString = Environment.GetEnvironmentVariable("Harpoon_Connection_String"); 90 | if (connectionString == null) 91 | { 92 | optionsBuilder.UseSqlServer($@"Server=(localdb)\mssqllocaldb;Database=TEST_HARPOON_{DbName};Trusted_Connection=True;MultipleActiveResultSets=true"); 93 | } 94 | else 95 | { 96 | optionsBuilder.UseSqlServer(string.Format(connectionString, DbName)); 97 | } 98 | } 99 | 100 | protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddHarpoonDefaultMappings(); 101 | } 102 | } -------------------------------------------------------------------------------- /Harpoon.Tests/Mocks/HttpClientMocker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Web; 7 | 8 | namespace Harpoon.Tests.Mocks 9 | { 10 | public class HttpClientMocker 11 | { 12 | public class MoqHandler : DelegatingHandler 13 | { 14 | public HttpStatusCode Status { get; set; } 15 | public string Content { get; set; } 16 | 17 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 18 | => Task.FromResult(new HttpResponseMessage { StatusCode = Status, Content = new StringContent(Content) }); 19 | } 20 | 21 | public class QueryHandler : DelegatingHandler 22 | { 23 | public string Parameter { get; set; } 24 | 25 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 26 | { 27 | var response = HttpUtility.ParseQueryString(request.RequestUri.Query)[Parameter]; 28 | return Task.FromResult(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(response) }); 29 | } 30 | } 31 | 32 | public class Failer : DelegatingHandler 33 | { 34 | public Exception Exception { get; set; } 35 | 36 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 37 | => Task.FromException(Exception); 38 | } 39 | 40 | public class CallbackHandler : DelegatingHandler 41 | { 42 | public Func> Callback { get; set; } 43 | 44 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 45 | => (await Callback(request)) ?? new HttpResponseMessage(HttpStatusCode.OK); 46 | } 47 | 48 | public class CounterHandler : DelegatingHandler 49 | { 50 | private int _counter; 51 | public int Counter => _counter; 52 | 53 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 54 | { 55 | Interlocked.Increment(ref _counter); 56 | return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); 57 | } 58 | } 59 | 60 | public static HttpClient Callback(Func> callback) 61 | => new HttpClient(new CallbackHandler { Callback = callback }); 62 | 63 | public static HttpClient Callback(Func callback) 64 | => new HttpClient(new CallbackHandler { Callback = m => Task.FromResult(callback(m)) }); 65 | 66 | public static HttpClient Callback(Func callback) 67 | => new HttpClient(new CallbackHandler { Callback = async m => { await callback(m); return null; } }); 68 | 69 | public static HttpClient Callback(Action callback) 70 | => new HttpClient(new CallbackHandler { Callback = m => { callback(m); return Task.FromResult(null); } }); 71 | 72 | public static HttpClient Static(HttpStatusCode status, string content) 73 | => new HttpClient(new MoqHandler { Status = status, Content = content }); 74 | 75 | public static HttpClient ReturnQueryParam(string queryParameter) 76 | => new HttpClient(new QueryHandler { Parameter = queryParameter }); 77 | 78 | public static HttpClient AlwaysFail(Exception exception) 79 | => new HttpClient(new Failer { Exception = exception }); 80 | } 81 | } -------------------------------------------------------------------------------- /Harpoon.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28803.156 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.Common", "Harpoon.Common\Harpoon.Common.csproj", "{E87D9459-4872-4DF1-80D0-5DDDC99AEA6F}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.Registrations", "Harpoon.Registrations\Harpoon.Registrations.csproj", "{CAB92CAE-8D46-4AE3-A062-117C8F2AD9E7}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.Registrations.EFStorage", "Harpoon.Registrations.EFStorage\Harpoon.Registrations.EFStorage.csproj", "{19BCDEFB-C41F-400B-BA04-48B68B80D21A}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.Controllers", "Harpoon.Controllers\Harpoon.Controllers.csproj", "{F1C6393D-C9BA-43A8-AD96-87CBCDD316C5}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FA70A910-B4A6-43B6-826D-330C4D41F101}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.Tests", "Harpoon.Tests\Harpoon.Tests.csproj", "{43D43D29-5E50-41C2-83D4-742913690E34}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Harpoon.MassTransit", "Harpoon.MassTransit\Harpoon.MassTransit.csproj", "{00224C04-0E97-4F25-8038-A0119D206065}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {E87D9459-4872-4DF1-80D0-5DDDC99AEA6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {E87D9459-4872-4DF1-80D0-5DDDC99AEA6F}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {E87D9459-4872-4DF1-80D0-5DDDC99AEA6F}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {E87D9459-4872-4DF1-80D0-5DDDC99AEA6F}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {CAB92CAE-8D46-4AE3-A062-117C8F2AD9E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {CAB92CAE-8D46-4AE3-A062-117C8F2AD9E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {CAB92CAE-8D46-4AE3-A062-117C8F2AD9E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {CAB92CAE-8D46-4AE3-A062-117C8F2AD9E7}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {19BCDEFB-C41F-400B-BA04-48B68B80D21A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {19BCDEFB-C41F-400B-BA04-48B68B80D21A}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {19BCDEFB-C41F-400B-BA04-48B68B80D21A}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {19BCDEFB-C41F-400B-BA04-48B68B80D21A}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {F1C6393D-C9BA-43A8-AD96-87CBCDD316C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {F1C6393D-C9BA-43A8-AD96-87CBCDD316C5}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {F1C6393D-C9BA-43A8-AD96-87CBCDD316C5}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {F1C6393D-C9BA-43A8-AD96-87CBCDD316C5}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {43D43D29-5E50-41C2-83D4-742913690E34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {43D43D29-5E50-41C2-83D4-742913690E34}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {43D43D29-5E50-41C2-83D4-742913690E34}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {43D43D29-5E50-41C2-83D4-742913690E34}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {00224C04-0E97-4F25-8038-A0119D206065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {00224C04-0E97-4F25-8038-A0119D206065}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {00224C04-0E97-4F25-8038-A0119D206065}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {00224C04-0E97-4F25-8038-A0119D206065}.Release|Any CPU.Build.0 = Release|Any CPU 50 | EndGlobalSection 51 | GlobalSection(SolutionProperties) = preSolution 52 | HideSolutionNode = FALSE 53 | EndGlobalSection 54 | GlobalSection(NestedProjects) = preSolution 55 | {43D43D29-5E50-41C2-83D4-742913690E34} = {FA70A910-B4A6-43B6-826D-330C4D41F101} 56 | EndGlobalSection 57 | GlobalSection(ExtensibilityGlobals) = postSolution 58 | SolutionGuid = {FC8E9CF5-623E-4D65-9202-126CDCCEB5D0} 59 | EndGlobalSection 60 | EndGlobal 61 | -------------------------------------------------------------------------------- /Harpoon.Tests/Fixtures/HostFixture.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using Harpoon.Tests.Mocks; 3 | using Microsoft.AspNetCore; 4 | using Microsoft.AspNetCore.Authentication; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.TestHost; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Net.Http; 14 | using System.Security.Claims; 15 | using System.Text.Encodings.Web; 16 | using System.Threading.Tasks; 17 | 18 | namespace Harpoon.Tests.Fixtures 19 | { 20 | public class HostFixture : IDisposable 21 | { 22 | public class TestAuthenticationHandler : AuthenticationHandler 23 | { 24 | private readonly ClaimsPrincipal _principal = new ClaimsPrincipal(new ClaimsIdentity(new List { new Claim(ClaimTypes.Name, "name") }, "TEST")); 25 | 26 | public TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) 27 | : base(options, logger, encoder, clock) 28 | { 29 | } 30 | 31 | protected override Task HandleAuthenticateAsync() 32 | { 33 | var authenticationTicket = new AuthenticationTicket(_principal, new AuthenticationProperties(), "TEST"); 34 | return Task.FromResult(AuthenticateResult.Success(authenticationTicket)); 35 | } 36 | } 37 | 38 | class MyWebHookTriggerProvider : IWebHookTriggerProvider 39 | { 40 | public IReadOnlyDictionary GetAvailableTriggers() 41 | => new Dictionary { ["noun.verb"] = new WebHookTrigger("noun.verb", "desc", typeof(object)) }; 42 | } 43 | 44 | public class DefaultStartup 45 | { 46 | public void ConfigureServices(IServiceCollection services) 47 | { 48 | var builder = services.AddMvcCore(); 49 | #if NETCOREAPP2_2 50 | builder.AddJsonFormatters(); 51 | #endif 52 | #if NETCOREAPP3_1 53 | builder.AddHarpoonControllers(); 54 | services.AddAuthorization(); 55 | #endif 56 | services.AddEntityFrameworkSqlServer().AddDbContext(); 57 | 58 | services.AddHarpoon(h => 59 | { 60 | h.AddControllersWithDefaultValidator(); 61 | h.RegisterWebHooksUsingEfStorage(); 62 | h.UseDefaultDataProtection(p => { }, o => { }); 63 | }); 64 | 65 | services 66 | .AddAuthentication(o => o.DefaultScheme = "TEST") 67 | .AddScheme("TEST", "TEST", o => { }); 68 | } 69 | 70 | public void Configure(IApplicationBuilder app) 71 | { 72 | #if NETCOREAPP2_2 73 | app.UseAuthentication(); 74 | app.UseMvc(); 75 | #endif 76 | #if NETCOREAPP3_1 77 | app.UseRouting(); 78 | app.UseAuthentication(); 79 | app.UseAuthorization(); 80 | app.UseEndpoints(e => e.MapControllers()); 81 | #endif 82 | 83 | using (var scope = app.ApplicationServices.CreateScope()) 84 | { 85 | scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); 86 | } 87 | } 88 | } 89 | 90 | public readonly HttpClient Client; 91 | public readonly TestServer Server; 92 | 93 | public HostFixture() 94 | { 95 | var hostBuilder = WebHost.CreateDefaultBuilder(null).UseStartup(); 96 | Server = new TestServer(hostBuilder); 97 | Client = Server.CreateClient(); 98 | } 99 | 100 | public void Dispose() 101 | { 102 | if (Server?.Host?.Services != null) 103 | { 104 | using (var scope = Server.Host.Services.CreateScope()) 105 | { 106 | scope.ServiceProvider.GetRequiredService().Database.EnsureDeleted(); 107 | } 108 | } 109 | } 110 | } 111 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/ModelBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations.EFStorage; 2 | using Newtonsoft.Json; 3 | using System; 4 | 5 | namespace Microsoft.EntityFrameworkCore 6 | { 7 | /// 8 | /// A set of extensions methods on 9 | /// 10 | public static class ModelBuilderExtensions 11 | { 12 | /// 13 | /// Uses default mappings the build to webhook part of your model 14 | /// 15 | /// 16 | public static void AddHarpoonDefaultMappings(this ModelBuilder modelBuilder) 17 | { 18 | modelBuilder.AddWebHookDefaultMapping(); 19 | modelBuilder.AddWebHookFilterDefaultMapping(); 20 | modelBuilder.AddWebHookLogDefaultMapping(); 21 | modelBuilder.AddWebHookNotificationDefaultMapping(); 22 | } 23 | 24 | /// 25 | /// Uses default mappings for the class to build the webhook part of your model 26 | /// 27 | /// 28 | public static void AddWebHookDefaultMapping(this ModelBuilder modelBuilder) 29 | { 30 | if (modelBuilder == null) 31 | { 32 | throw new ArgumentNullException(nameof(modelBuilder)); 33 | } 34 | 35 | modelBuilder.Entity().Ignore(w => w.Secret); 36 | modelBuilder.Entity().Property(r => r.PrincipalId).IsRequired(); 37 | modelBuilder.Entity().Property(w => w.Id).ValueGeneratedOnAdd(); 38 | modelBuilder.Entity().Property(w => w.Callback).IsRequired(); 39 | modelBuilder.Entity().Property(w => w.ProtectedSecret).IsRequired(); 40 | modelBuilder.Entity().HasMany(w => w.Filters).WithOne().IsRequired().OnDelete(DeleteBehavior.Cascade); 41 | modelBuilder.Entity().HasMany(w => w.WebHookLogs).WithOne(l => l.WebHook).HasForeignKey(l => l.WebHookId).OnDelete(DeleteBehavior.Cascade); 42 | } 43 | 44 | /// 45 | /// Uses default mappings for the class to build the webhook part of your model 46 | /// 47 | /// 48 | public static void AddWebHookFilterDefaultMapping(this ModelBuilder modelBuilder) 49 | { 50 | if (modelBuilder == null) 51 | { 52 | throw new ArgumentNullException(nameof(modelBuilder)); 53 | } 54 | 55 | modelBuilder.Entity().ToTable("WebHookFilters"); 56 | modelBuilder.Entity().Property(f => f.Trigger).IsRequired(); 57 | } 58 | 59 | /// 60 | /// Uses default mappings for the class to build the webhook part of your model 61 | /// 62 | /// 63 | public static void AddWebHookLogDefaultMapping(this ModelBuilder modelBuilder) 64 | { 65 | if (modelBuilder == null) 66 | { 67 | throw new ArgumentNullException(nameof(modelBuilder)); 68 | } 69 | 70 | modelBuilder.Entity().HasOne(w => w.WebHook).WithMany(w => w.WebHookLogs).HasForeignKey(l => l.WebHookId).OnDelete(DeleteBehavior.Cascade); 71 | modelBuilder.Entity().HasOne(w => w.WebHookNotification).WithMany(l => l.WebHookLogs).HasForeignKey(l => l.WebHookNotificationId).OnDelete(DeleteBehavior.Cascade); 72 | } 73 | 74 | /// 75 | /// Uses default mappings for the class to build the webhook part of your model 76 | /// 77 | /// 78 | public static void AddWebHookNotificationDefaultMapping(this ModelBuilder modelBuilder) 79 | { 80 | if (modelBuilder == null) 81 | { 82 | throw new ArgumentNullException(nameof(modelBuilder)); 83 | } 84 | 85 | modelBuilder.Entity().Property(w => w.Id).ValueGeneratedOnAdd(); 86 | modelBuilder.Entity().Property(f => f.TriggerId).IsRequired().HasMaxLength(500); 87 | modelBuilder.Entity().Property(n => n.Payload).IsRequired().HasConversion(v => JsonConvert.SerializeObject(v), v => JsonConvert.DeserializeObject(v)); 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/ServicesCollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon; 2 | using Harpoon.Controllers; 3 | using Harpoon.Controllers.Swashbuckle; 4 | using Harpoon.Registrations; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.Extensions.DependencyInjection.Extensions; 7 | using Microsoft.OpenApi.Models; 8 | using Swashbuckle.AspNetCore.SwaggerGen; 9 | using Swashbuckle.AspNetCore.SwaggerUI; 10 | using System.IO; 11 | 12 | namespace Microsoft.Extensions.DependencyInjection 13 | { 14 | /// 15 | /// A set of extensions methods to allow the generation of the harpoon documentation 16 | /// 17 | public static class ServicesCollectionsExtensions 18 | { 19 | /// 20 | /// Registers necessary services for the controllers 21 | /// needs to be manually added to the 22 | /// 23 | /// 24 | /// 25 | public static IHarpoonBuilder AddControllers(this IHarpoonBuilder harpoon) 26 | where TWebHookTriggerProvider : class, IWebHookTriggerProvider 27 | { 28 | harpoon.Services.TryAddSingleton(); 29 | return harpoon; 30 | } 31 | 32 | /// 33 | /// Registers every necessary services for the controllers 34 | /// 35 | /// 36 | /// 37 | public static IHarpoonBuilder AddControllersWithDefaultValidator(this IHarpoonBuilder harpoon) 38 | where TWebHookTriggerProvider : class, IWebHookTriggerProvider 39 | { 40 | harpoon.Services.TryAddSingleton(); 41 | harpoon.UseDefaultValidator(); 42 | return harpoon; 43 | } 44 | 45 | /// 46 | /// Registers harpoon controllers into current 47 | /// 48 | /// 49 | /// 50 | public static IMvcCoreBuilder AddHarpoonControllers(this IMvcCoreBuilder mvcBuilder) 51 | => mvcBuilder.AddApplicationPart(typeof(WebHooksController).Assembly); 52 | 53 | /// 54 | /// Registers harpoon controllers into current 55 | /// 56 | /// 57 | /// 58 | public static IMvcBuilder AddHarpoonControllers(this IMvcBuilder mvcBuilder) 59 | => mvcBuilder.AddApplicationPart(typeof(WebHooksController).Assembly); 60 | 61 | /// 62 | /// Generates a new doc for harpoon webhooks 63 | /// 64 | /// 65 | /// 66 | public static SwaggerGenOptions AddHarpoonDocumentation(this SwaggerGenOptions options) 67 | { 68 | options.SwaggerDoc(Harpoon.Controllers.OpenApi.GroupName, new OpenApiInfo 69 | { 70 | Title = "WebHooks", 71 | Description = "WebHook documentation", 72 | Version = "v1" 73 | }); 74 | 75 | var currentPredicate = options.SwaggerGeneratorOptions.DocInclusionPredicate; 76 | options.DocInclusionPredicate((documentName, apiDescription) => 77 | { 78 | if (documentName == Harpoon.Controllers.OpenApi.GroupName) 79 | { 80 | return apiDescription.GroupName == documentName; 81 | } 82 | return currentPredicate(documentName, apiDescription); 83 | }); 84 | 85 | options.OperationFilter(); 86 | 87 | var path = Path.ChangeExtension(typeof(Harpoon.Controllers.OpenApi).Assembly.Location, ".xml"); 88 | if (File.Exists(path)) 89 | { 90 | options.IncludeXmlComments(path); 91 | } 92 | 93 | return options; 94 | } 95 | 96 | /// 97 | /// Add an endpoint for the harpoon documentation 98 | /// 99 | /// 100 | /// 101 | /// 102 | public static SwaggerUIOptions AddHarpoonEndpoint(this SwaggerUIOptions options, string? name = null) 103 | { 104 | options.SwaggerEndpoint($"/swagger/{Harpoon.Controllers.OpenApi.GroupName}/swagger.json", name ?? "WebHooks documentation"); 105 | return options; 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/Swashbuckle/WebHookSubscriptionFilter.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using Harpoon.Sender; 3 | using Microsoft.OpenApi.Expressions; 4 | using Microsoft.OpenApi.Models; 5 | using Swashbuckle.AspNetCore.SwaggerGen; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | 10 | namespace Harpoon.Controllers.Swashbuckle 11 | { 12 | /// 13 | /// This filter sets operation's callbacks if the given operation contains the 14 | /// 15 | public class WebHookSubscriptionFilter : IOperationFilter 16 | { 17 | private static readonly OpenApiResponses _responses = new OpenApiResponses 18 | { 19 | ["200"] = new OpenApiResponse { Description = "Your server must returns this code if it accepts the callback." }, 20 | ["404"] = new OpenApiResponse { Description = "If your server returns this code, the webhook might be paused." }, 21 | ["410"] = new OpenApiResponse { Description = "If your server returns this code, the webhook might be paused." }, 22 | ["500"] = new OpenApiResponse { Description = "If your server returns this code, the webhook will go through the error pipeline." }, 23 | }; 24 | 25 | private static readonly List _parameters = new List 26 | { 27 | new OpenApiParameter 28 | { 29 | In = ParameterLocation.Header, 30 | Name = DefaultWebHookSender.SignatureHeader, 31 | Schema = new OpenApiSchema { Type = "string", Format = "HMACSHA256" } 32 | }, 33 | new OpenApiParameter 34 | { 35 | In = ParameterLocation.Header, 36 | Name = DefaultWebHookSender.TimestampKey, 37 | Schema = new OpenApiSchema { Type = "string", Format = "date-time" } 38 | }, 39 | new OpenApiParameter 40 | { 41 | In = ParameterLocation.Header, 42 | Name = DefaultWebHookSender.TriggerKey, 43 | Schema = new OpenApiSchema { Type = "string" } 44 | }, 45 | new OpenApiParameter 46 | { 47 | In = ParameterLocation.Header, 48 | Name = DefaultWebHookSender.UniqueIdKey, 49 | Schema = new OpenApiSchema { Type = "string", Format = "uuid" } 50 | }, 51 | }; 52 | 53 | private readonly IWebHookTriggerProvider _webHookTriggerProvider; 54 | 55 | /// 56 | /// Initializes a new instance of the class. 57 | /// 58 | /// 59 | public WebHookSubscriptionFilter(IWebHookTriggerProvider webHookTriggerProvider) 60 | { 61 | _webHookTriggerProvider = webHookTriggerProvider ?? throw new ArgumentNullException(nameof(webHookTriggerProvider)); 62 | } 63 | 64 | /// 65 | /// Sets operation's callbacks if the operation contains the 66 | /// 67 | /// 68 | /// 69 | public void Apply(OpenApiOperation operation, OperationFilterContext context) 70 | { 71 | if (context.MethodInfo.GetCustomAttributes(true).OfType().Any()) 72 | { 73 | operation.Callbacks = _webHookTriggerProvider.GetAvailableTriggers().ToDictionary(t => t.Key, t => Generate(t.Value, context)); 74 | } 75 | } 76 | 77 | /// 78 | /// Generate a single matching the given 79 | /// 80 | /// 81 | /// 82 | /// 83 | protected OpenApiCallback Generate(WebHookTrigger trigger, OperationFilterContext context) 84 | { 85 | var schema = context.SchemaGenerator.GenerateSchema(trigger.GetPayloadType(), context.SchemaRepository); 86 | var result = new OpenApiCallback(); 87 | 88 | result.AddPathItem(RuntimeExpression.Build($"{{$request.body#/{PseudoCamelCase(nameof(IWebHook.Callback))}}}"), new OpenApiPathItem 89 | { 90 | Operations = new Dictionary 91 | { 92 | [OperationType.Post] = new OpenApiOperation 93 | { 94 | OperationId = trigger.Id, 95 | Description = trigger.Description, 96 | Responses = _responses, 97 | Parameters = _parameters, 98 | RequestBody = new OpenApiRequestBody 99 | { 100 | Required = true, 101 | Content = new Dictionary 102 | { 103 | ["application/json"] = new OpenApiMediaType 104 | { 105 | Schema = schema 106 | } 107 | } 108 | }, 109 | } 110 | } 111 | }); 112 | 113 | return result; 114 | } 115 | 116 | private string PseudoCamelCase(string input) => char.ToLowerInvariant(input[0]) + input.Substring(1); 117 | } 118 | } -------------------------------------------------------------------------------- /Harpoon.MassTransit/ServicesCollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon; 2 | using Harpoon.MassTransit; 3 | using MassTransit; 4 | using MassTransit.ConsumeConfigurators; 5 | using MassTransit.ExtensionsDependencyInjectionIntegration; 6 | using System; 7 | 8 | namespace Microsoft.Extensions.DependencyInjection 9 | { 10 | /// 11 | /// A set of extensions methods on to allow the usage of MassTransit 12 | /// 13 | public static class ServicesCollectionsExtensions 14 | { 15 | /// 16 | /// Registers every services allowing for a pipeline to treat webhooks via a messaging service. 17 | /// Some configuration that require a and/or a might still be needed. 18 | /// 19 | /// 20 | /// 21 | public static IServiceCollection UseAllMassTransitDefaults(this IHarpoonBuilder harpoon) => harpoon.UseAllMassTransitDefaults(b => { }); 22 | /// 23 | /// Registers every services allowing for a pipeline to treat webhooks via a messaging service, while allowing sender retry policy configuration 24 | /// Some configuration that require a and/or a might still be needed. 25 | /// 26 | /// 27 | /// 28 | /// 29 | public static IServiceCollection UseAllMassTransitDefaults(this IHarpoonBuilder harpoon, Action senderPolicy) 30 | => harpoon.SendNotificationsUsingMassTransit() 31 | .UseDefaultNotificationProcessor() 32 | .SendWebHookWorkItemsUsingMassTransit() 33 | .UseDefaultWebHookWorkItemProcessor(senderPolicy) 34 | .Services; 35 | 36 | /// 37 | /// Registers every services allowing for a pipeline to treat webhooks via a messaging service. 38 | /// Some configuration that require a and/or a might still be needed. 39 | /// 40 | /// 41 | /// 42 | public static IServiceCollectionConfigurator UseAllMassTransitDefaults(this IServiceCollectionConfigurator x) 43 | => x.ReceiveNotificationsUsingMassTransit() 44 | .ReceiveWebHookWorkItemsUsingMassTransit(); 45 | 46 | /// 47 | /// Registers as the default , allowing for a treatment of via messaging service 48 | /// 49 | /// 50 | /// 51 | public static IHarpoonBuilder SendNotificationsUsingMassTransit(this IHarpoonBuilder harpoon) 52 | { 53 | harpoon.Services.AddSingleton(); 54 | return harpoon; 55 | } 56 | 57 | /// 58 | /// Registers as a endpoint receiving , allowing for receiving and treating from other applications 59 | /// 60 | /// 61 | /// 62 | /// 63 | public static IServiceCollectionConfigurator ReceiveNotificationsUsingMassTransit(this IServiceCollectionConfigurator x, Action>>? configure = null) 64 | { 65 | x.AddConsumer(configure); 66 | return x; 67 | } 68 | 69 | /// 70 | /// Allow for the configuration of the 71 | /// 72 | /// 73 | /// 74 | /// 75 | /// 76 | public static void ConfigureNotificationsConsumer(this IBusFactoryConfigurator cfg, IServiceProvider p, string queueName, Action>>? configure = null) 77 | => cfg.ReceiveEndpoint(queueName, e => e.ConfigureConsumer(p, configure)); 78 | 79 | /// 80 | /// Registers as the default , allowing for a treatment of via messaging service 81 | /// 82 | /// 83 | /// 84 | public static IHarpoonBuilder SendWebHookWorkItemsUsingMassTransit(this IHarpoonBuilder harpoon) 85 | { 86 | harpoon.Services.AddSingleton(); 87 | return harpoon; 88 | } 89 | 90 | /// 91 | /// Registers as a endpoint receiving , allowing for receiving and treating from other applications 92 | /// 93 | /// 94 | /// 95 | /// 96 | public static IServiceCollectionConfigurator ReceiveWebHookWorkItemsUsingMassTransit(this IServiceCollectionConfigurator x, Action>>? configure = null) 97 | { 98 | x.AddConsumer(configure); 99 | return x; 100 | } 101 | 102 | /// 103 | /// Allow for the configuration of the 104 | /// 105 | /// 106 | /// 107 | /// 108 | /// 109 | public static void ConfigureWebHookWorkItemsConsumer(this IBusFactoryConfigurator cfg, IServiceProvider p, string queueName, Action>>? configure = null) 110 | => cfg.ReceiveEndpoint(queueName, e => e.ConfigureConsumer(p, configure)); 111 | } 112 | } -------------------------------------------------------------------------------- /Harpoon.Registrations.EFStorage/ServicesCollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon; 2 | using Harpoon.Registrations; 3 | using Harpoon.Registrations.EFStorage; 4 | using Harpoon.Sender; 5 | using Harpoon.Sender.EF; 6 | using Microsoft.AspNetCore.DataProtection; 7 | using Microsoft.EntityFrameworkCore; 8 | using Microsoft.Extensions.DependencyInjection.Extensions; 9 | using System; 10 | 11 | namespace Microsoft.Extensions.DependencyInjection 12 | { 13 | /// 14 | /// A set of extensions methods on to allow the usage of EF Core 15 | /// 16 | public static class ServicesCollectionsExtensions 17 | { 18 | /// 19 | /// Registers as and as . 20 | /// TWebHookTriggerProvider is registered as singleton 21 | /// 22 | /// 23 | /// 24 | /// 25 | /// 26 | public static IHarpoonBuilder RegisterWebHooksUsingEfStorage(this IHarpoonBuilder harpoon) 27 | where TContext : DbContext, IRegistrationsContext 28 | where TWebHookTriggerProvider : class, IWebHookTriggerProvider 29 | { 30 | harpoon.Services.TryAddSingleton(); 31 | return harpoon.RegisterWebHooksUsingEfStorage(); 32 | } 33 | 34 | /// 35 | /// Registers as and as . 36 | /// TWebHookTriggerProvider needs to be configured. 37 | /// 38 | /// 39 | /// 40 | /// 41 | public static IHarpoonBuilder RegisterWebHooksUsingEfStorage(this IHarpoonBuilder harpoon) 42 | where TContext : DbContext, IRegistrationsContext 43 | { 44 | harpoon.Services.TryAddScoped(); 45 | harpoon.Services.TryAddScoped>(); 46 | harpoon.Services.TryAddScoped>(); 47 | 48 | harpoon.Services.TryAddScoped>(); 49 | 50 | return harpoon; 51 | } 52 | 53 | /// 54 | /// Registers as 55 | /// 56 | /// 57 | /// 58 | /// 59 | /// 60 | public static IHarpoonBuilder UseDefaultDataProtection(this IHarpoonBuilder harpoon, Action dataProtection, Action setupAction) 61 | { 62 | if (dataProtection == null) 63 | { 64 | throw new ArgumentNullException("Data protection configuration is required.", nameof(dataProtection)); 65 | } 66 | 67 | harpoon.Services.TryAddScoped(); 68 | dataProtection(harpoon.Services.AddDataProtection(setupAction)); 69 | return harpoon; 70 | } 71 | 72 | /// 73 | /// Registers services to use as the default . 74 | /// To setup your own retry policy, use the second method signature. 75 | /// 76 | /// 77 | /// 78 | public static IHarpoonBuilder UseDefaultEFWebHookWorkItemProcessor(this IHarpoonBuilder harpoon) 79 | where TContext : DbContext, IRegistrationsContext 80 | => harpoon.UseDefaultEFWebHookWorkItemProcessor(b => { }); 81 | 82 | /// 83 | /// Registers services to use as the default . 84 | /// 85 | /// 86 | /// This parameter lets you define your retry policy 87 | /// 88 | public static IHarpoonBuilder UseDefaultEFWebHookWorkItemProcessor(this IHarpoonBuilder harpoon, Action senderPolicy) 89 | where TContext : DbContext, IRegistrationsContext 90 | { 91 | if (senderPolicy == null) 92 | { 93 | throw new ArgumentNullException(nameof(senderPolicy)); 94 | } 95 | 96 | harpoon.Services.TryAddSingleton(); 97 | harpoon.Services.TryAddScoped, EFWebHookSender>(); 98 | var builder = harpoon.Services.AddHttpClient, EFWebHookSender>(); 99 | senderPolicy(builder); 100 | return harpoon; 101 | } 102 | 103 | /// 104 | /// Registers as the default . 105 | /// 106 | /// 107 | /// 108 | /// 109 | public static IHarpoonBuilder UseDefaultEFNotificationProcessor(this IHarpoonBuilder harpoon) 110 | where TContext : DbContext, IRegistrationsContext 111 | { 112 | harpoon.Services.TryAddScoped, EFNotificationProcessor>(); 113 | return harpoon; 114 | } 115 | 116 | /// 117 | /// Registers as the default , allowing for a synchronous treatment of 118 | /// 119 | /// 120 | /// 121 | public static IHarpoonBuilder ProcessNotificationsSynchronouslyUsingEFDefault(this IHarpoonBuilder harpoon) 122 | where TContext : DbContext, IRegistrationsContext 123 | { 124 | harpoon.Services.TryAddScoped>(); 125 | return harpoon; 126 | } 127 | } 128 | } -------------------------------------------------------------------------------- /Harpoon.Tests/DefaultWebHookValidatorTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations; 2 | using Harpoon.Registrations.EFStorage; 3 | using Harpoon.Tests.Mocks; 4 | using Microsoft.Extensions.Logging; 5 | using Moq; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Net.Http; 9 | using System.Threading.Tasks; 10 | using Xunit; 11 | 12 | namespace Harpoon.Tests 13 | { 14 | public class DefaultWebHookValidatorTests 15 | { 16 | [Fact] 17 | public async Task ArgNullAsync() 18 | { 19 | var triggers = new Mock(); 20 | var logger = new Mock>(); 21 | var client = new Mock(); 22 | 23 | Assert.Throws(() => new DefaultWebHookValidator(null, logger.Object, client.Object)); 24 | Assert.Throws(() => new DefaultWebHookValidator(triggers.Object, null, client.Object)); 25 | Assert.Throws(() => new DefaultWebHookValidator(triggers.Object, logger.Object, null)); 26 | 27 | var service = new DefaultWebHookValidator(triggers.Object, logger.Object, client.Object); 28 | await Assert.ThrowsAsync(() => service.ValidateAsync(null)); 29 | } 30 | 31 | [Fact] 32 | public async Task InvalidCasesAsync() 33 | { 34 | var triggersById = new Dictionary 35 | { 36 | ["valid"] = new WebHookTrigger("valid", "desc", typeof(object)) 37 | }; 38 | var triggers = new Mock(); 39 | triggers.Setup(s => s.GetAvailableTriggers()).Returns(triggersById); 40 | var logger = new Mock>(); 41 | var client = HttpClientMocker.Static(System.Net.HttpStatusCode.NotFound, "fail"); 42 | var service = new DefaultWebHookValidator(triggers.Object, logger.Object, client); 43 | 44 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Secret = "too short" })); 45 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Secret = "toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_toolong_" })); 46 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook())); 47 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List() })); 48 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "invalid" } } })); 49 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "valid" } } })); 50 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "valid" } }, Callback = "c:/data" })); 51 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "valid" } }, Callback = "ftp://data" })); 52 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "valid" } }, Callback = "http://www.example.com" })); 53 | 54 | service = new DefaultWebHookValidator(triggers.Object, logger.Object, HttpClientMocker.AlwaysFail(new Exception())); 55 | await Assert.ThrowsAsync(() => service.ValidateAsync(new WebHook { Filters = new List { new WebHookFilter { Trigger = "valid" } }, Callback = "http://www.example.com" })); 56 | } 57 | 58 | public static IEnumerable ValidCasesData => new List 59 | { 60 | new object[] { new WebHook { 61 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 62 | Callback = "http://www.example.com?noecho" 63 | } }, 64 | new object[] { new WebHook { 65 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 66 | Callback = "https://www.example.com?noecho" 67 | } }, 68 | new object[] { new WebHook { 69 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 70 | Callback = "http://www.example.com" 71 | } }, 72 | new object[] { new WebHook { 73 | Id = Guid.NewGuid(), 74 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 75 | Callback = "http://www.example.com" 76 | } }, 77 | new object[] { new WebHook { 78 | Id = Guid.NewGuid(), 79 | Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_", 80 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 81 | Callback = "https://www.example.com" 82 | } }, 83 | new object[] { new WebHook { 84 | Id = Guid.NewGuid(), 85 | Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_", 86 | Filters = new List { new WebHookFilter { Trigger = "valid" } }, 87 | Callback = "http://www.example.com" 88 | } }, 89 | }; 90 | 91 | [Theory] 92 | [MemberData(nameof(ValidCasesData))] 93 | public async Task ValidCasesAsync(WebHook validWebHook) 94 | { 95 | var triggersById = new Dictionary 96 | { 97 | ["valid"] = new WebHookTrigger("valid", "desc", typeof(object)), 98 | }; 99 | 100 | var triggers = new Mock(); 101 | triggers.Setup(s => s.GetAvailableTriggers()).Returns(triggersById); 102 | var logger = new Mock>(); 103 | var client = HttpClientMocker.ReturnQueryParam("echo"); 104 | var service = new DefaultWebHookValidator(triggers.Object, logger.Object, client); 105 | 106 | await service.ValidateAsync(validWebHook); 107 | 108 | Assert.NotEqual(default, validWebHook.Id); 109 | Assert.Equal(64, validWebHook.Secret.Length); 110 | Assert.NotNull(validWebHook.Filters); 111 | Assert.NotEmpty(validWebHook.Filters); 112 | foreach (var filter in validWebHook.Filters) 113 | { 114 | Assert.True(triggersById.ContainsKey(filter.Trigger)); 115 | } 116 | Assert.NotNull(validWebHook.Callback); 117 | Assert.True(((IWebHook)validWebHook).Callback.IsAbsoluteUri && validWebHook.Callback.ToString().ToLowerInvariant().StartsWith("http")); 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | 332 | *.xml 333 | -------------------------------------------------------------------------------- /Harpoon.Tests/ServicesCollectionsExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Background; 2 | using Harpoon.Registrations; 3 | using Harpoon.Sender; 4 | using Harpoon.Tests.Mocks; 5 | using Microsoft.AspNetCore.DataProtection; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Moq; 10 | using Swashbuckle.AspNetCore.SwaggerGen; 11 | using System; 12 | using System.Collections.Generic; 13 | using Xunit; 14 | 15 | namespace Harpoon.Tests 16 | { 17 | public class ServicesCollectionsExtensionsTests 18 | { 19 | class TestWebHookTriggerProvider : IWebHookTriggerProvider 20 | { 21 | public IReadOnlyDictionary GetAvailableTriggers() 22 | => throw new NotImplementedException(); 23 | } 24 | 25 | [Fact] 26 | public void NullArgTests() 27 | { 28 | var services = new ServiceCollection(); 29 | Assert.Throws(() => services.AddHarpoon(null)); 30 | Assert.Throws(() => services.AddHarpoon(b => b.UseDefaultWebHookWorkItemProcessor(null))); 31 | Assert.Throws(() => services.AddHarpoon(b => b.UseDefaultValidator(null))); 32 | Assert.Throws(() => services.AddHarpoon(b => b.UseDefaultDataProtection(null, c => { }))); 33 | Assert.Throws(() => services.AddHarpoon(b => b.UseDefaultDataProtection(c => { }, null))); 34 | Assert.Throws(() => services.AddHarpoon(b => b.UseDefaultEFWebHookWorkItemProcessor(null))); 35 | 36 | Assert.Throws(() => ModelBuilderExtensions.AddWebHookDefaultMapping(null)); 37 | Assert.Throws(() => ModelBuilderExtensions.AddWebHookFilterDefaultMapping(null)); 38 | Assert.Throws(() => ModelBuilderExtensions.AddWebHookLogDefaultMapping(null)); 39 | Assert.Throws(() => ModelBuilderExtensions.AddWebHookNotificationDefaultMapping(null)); 40 | } 41 | 42 | [Fact] 43 | public void AddHarpoonUseAllSynchronousDefaultsTests() 44 | { 45 | var services = new ServiceCollection(); 46 | services.AddHarpoon(b => b.UseAllSynchronousDefaults()); 47 | services.AddSingleton(new Mock().Object); 48 | 49 | var provider = services.BuildServiceProvider(); 50 | Assert.NotNull(provider.GetRequiredService()); 51 | Assert.NotNull(provider.GetRequiredService>()); 52 | Assert.NotNull(provider.GetRequiredService()); 53 | Assert.NotNull(provider.GetRequiredService>()); 54 | Assert.NotNull(provider.GetRequiredService()); 55 | } 56 | 57 | [Fact] 58 | public void AddHarpoonUseAllLocalDefaultsTests() 59 | { 60 | var services = new ServiceCollection(); 61 | services.AddHarpoon(b => b.UseAllLocalDefaults()); 62 | services.AddSingleton(new Mock().Object); 63 | 64 | var provider = services.BuildServiceProvider(); 65 | Assert.NotNull(provider.GetRequiredService()); 66 | Assert.NotNull(provider.GetRequiredService>()); 67 | Assert.NotNull(provider.GetRequiredService>()); 68 | Assert.NotNull(provider.GetRequiredService>()); 69 | Assert.NotNull(provider.GetRequiredService()); 70 | Assert.NotNull(provider.GetRequiredService>()); 71 | Assert.NotNull(provider.GetRequiredService>()); 72 | Assert.NotNull(provider.GetRequiredService()); 73 | } 74 | 75 | [Fact] 76 | public void AddHarpoonRegisterWebHooksUsingEfStorageTests() 77 | { 78 | var services = new ServiceCollection(); 79 | services.AddEntityFrameworkSqlServer().AddDbContext(); 80 | services.AddHarpoon(h => 81 | { 82 | h.RegisterWebHooksUsingEfStorage(); 83 | h.UseDefaultDataProtection(b => b.UseEphemeralDataProtectionProvider(), o => { }); 84 | h.UseDefaultValidator(); 85 | }); 86 | 87 | services.AddSingleton(new Mock().Object); 88 | 89 | var provider = services.BuildServiceProvider(); 90 | Assert.NotNull(provider.GetRequiredService()); 91 | Assert.NotNull(provider.GetRequiredService()); 92 | Assert.NotNull(provider.GetRequiredService()); 93 | Assert.NotNull(provider.GetRequiredService()); 94 | Assert.NotNull(provider.GetRequiredService()); 95 | } 96 | 97 | [Fact] 98 | public void AddHarpoonWithEfSenderTests() 99 | { 100 | var services = new ServiceCollection(); 101 | services.AddEntityFrameworkSqlServer().AddDbContext(); 102 | services.AddHarpoon(h => h.UseDefaultEFWebHookWorkItemProcessor()); 103 | 104 | var provider = services.BuildServiceProvider(); 105 | Assert.NotNull(provider.GetRequiredService>()); 106 | Assert.NotNull(provider.GetRequiredService()); 107 | } 108 | 109 | [Fact] 110 | public void AddHarpoonWithEfProcessorTests() 111 | { 112 | var services = new ServiceCollection(); 113 | services.AddEntityFrameworkSqlServer().AddDbContext(); 114 | services.AddHarpoon(h => h.UseDefaultEFNotificationProcessor()); 115 | services.AddSingleton(new Mock().Object); 116 | services.AddSingleton(new Mock().Object); 117 | 118 | var provider = services.BuildServiceProvider(); 119 | Assert.NotNull(provider.GetRequiredService>()); 120 | } 121 | 122 | [Fact] 123 | public void AddHarpoonWithSynchronousEfProcessorTests() 124 | { 125 | var services = new ServiceCollection(); 126 | services.AddEntityFrameworkSqlServer().AddDbContext(); 127 | services.AddHarpoon(h => h.ProcessNotificationsSynchronouslyUsingEFDefault()); 128 | services.AddSingleton(new Mock().Object); 129 | services.AddSingleton(new Mock().Object); 130 | 131 | var provider = services.BuildServiceProvider(); 132 | Assert.NotNull(provider.GetRequiredService()); 133 | } 134 | 135 | [Fact] 136 | public void AddHarpoonDocumentation() 137 | { 138 | var options = new SwaggerGenOptions(); 139 | options.AddHarpoonDocumentation(); 140 | 141 | //do not know what to test here. 142 | } 143 | } 144 | } -------------------------------------------------------------------------------- /Harpoon.Common/Sender/DefaultWebHookSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Serialization; 4 | using System; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace Harpoon.Sender 12 | { 13 | /// 14 | /// Default implementation 15 | /// 16 | public class DefaultWebHookSender : IWebHookSender, IQueuedProcessor 17 | { 18 | /// 19 | /// Http header key used to pass the trigger id. 20 | /// 21 | public const string TriggerKey = "X-WebHook-Trigger"; 22 | /// 23 | /// Http header key used to pass the time stamp. 24 | /// 25 | public const string TimestampKey = "X-WebHook-Timestamp"; 26 | /// 27 | /// Http header key used to pass a unique id. 28 | /// 29 | public const string UniqueIdKey = "X-WebHook-NotificationId"; 30 | /// 31 | /// Http header key used to pass the current payload signature 32 | /// 33 | public const string SignatureHeader = "X-WebHook-Signature"; 34 | 35 | private static readonly JsonSerializerSettings _settings = new JsonSerializerSettings 36 | { 37 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 38 | }; 39 | 40 | /// 41 | /// Gets the used for sending request 42 | /// 43 | protected HttpClient HttpClient { get; private set; } 44 | /// 45 | /// Gets the used to sign payloads 46 | /// 47 | protected ISignatureService SignatureService { get; private set; } 48 | /// 49 | /// Gets the 50 | /// 51 | protected ILogger Logger { get; private set; } 52 | 53 | /// 54 | /// Initializes a new instance of the class. 55 | /// 56 | /// 57 | /// 58 | /// 59 | public DefaultWebHookSender(HttpClient httpClient, ISignatureService signatureService, ILogger logger) 60 | { 61 | HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); 62 | SignatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService)); 63 | Logger = logger ?? throw new ArgumentNullException(nameof(logger)); 64 | } 65 | 66 | Task IQueuedProcessor.ProcessAsync(IWebHookWorkItem workItem, CancellationToken cancellationToken) 67 | => SendAsync(workItem, cancellationToken); 68 | 69 | /// 70 | public async Task SendAsync(IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken = default) 71 | { 72 | if (webHookWorkItem == null) 73 | { 74 | throw new ArgumentNullException(nameof(webHookWorkItem)); 75 | } 76 | 77 | try 78 | { 79 | var request = CreateRequest(webHookWorkItem); 80 | var response = await HttpClient.SendAsync(request, cancellationToken); 81 | 82 | if (response.IsSuccessStatusCode) 83 | { 84 | Logger.LogInformation($"WebHook {webHookWorkItem.WebHook.Id} send. Status: {response.StatusCode}. [{webHookWorkItem.WebHook.Callback}]"); 85 | await OnSuccessAsync(response, webHookWorkItem, cancellationToken); 86 | } 87 | else if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Gone) 88 | { 89 | Logger.LogInformation($"WebHook {webHookWorkItem.WebHook.Id} send. Status: {response.StatusCode}. [{webHookWorkItem.WebHook.Callback}]"); 90 | await OnNotFoundAsync(response, webHookWorkItem, cancellationToken); 91 | } 92 | else 93 | { 94 | Logger.LogError($"WebHook {webHookWorkItem.WebHook.Id} failed. Status: {response.StatusCode}. [{webHookWorkItem.WebHook.Callback}]"); 95 | await OnFailureAsync(response, null, webHookWorkItem, cancellationToken); 96 | } 97 | } 98 | catch (Exception e) 99 | { 100 | Logger.LogError(e, $"WebHook {webHookWorkItem.WebHook.Id} failed: {e.Message}. [{webHookWorkItem.WebHook.Callback}]"); 101 | await OnFailureAsync(null, e, webHookWorkItem, cancellationToken); 102 | } 103 | } 104 | 105 | /// 106 | /// Callback on request success 107 | /// 108 | /// 109 | /// 110 | /// 111 | /// 112 | protected virtual Task OnSuccessAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 113 | => Task.CompletedTask; 114 | 115 | /// 116 | /// Callback on request 404 or 410 (Gone) 117 | /// 118 | /// 119 | /// 120 | /// 121 | /// 122 | protected virtual Task OnNotFoundAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 123 | => Task.CompletedTask; 124 | 125 | /// 126 | /// Callback on request failure. 127 | /// 128 | /// May be null 129 | /// May be null 130 | /// 131 | /// 132 | /// 133 | protected virtual Task OnFailureAsync(HttpResponseMessage? response, Exception? exception, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 134 | => Task.CompletedTask; 135 | 136 | /// 137 | /// Generates the request to be send 138 | /// 139 | /// 140 | /// 141 | protected virtual HttpRequestMessage CreateRequest(IWebHookWorkItem webHookWorkItem) 142 | { 143 | var serializedBody = JsonConvert.SerializeObject(webHookWorkItem.Notification.Payload, _settings); 144 | 145 | var request = new HttpRequestMessage(HttpMethod.Post, webHookWorkItem.WebHook.Callback); 146 | AddHeaders(webHookWorkItem, request, SignatureService.GetSignature(webHookWorkItem.WebHook.Secret, serializedBody)); 147 | request.Content = new StringContent(serializedBody, Encoding.UTF8, "application/json"); 148 | 149 | return request; 150 | } 151 | 152 | private void AddHeaders(IWebHookWorkItem webHookWorkItem, HttpRequestMessage request, string secret) 153 | { 154 | request.Headers.Add(SignatureHeader, secret); 155 | request.Headers.Add(TriggerKey, webHookWorkItem.Notification.TriggerId); 156 | request.Headers.Add(TimestampKey, webHookWorkItem.Timestamp.ToUniversalTime().ToString("r")); 157 | request.Headers.Add(UniqueIdKey, webHookWorkItem.Id.ToString()); 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /Harpoon.Common/ServicesCollectionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Harpoon; 2 | using Harpoon.Background; 3 | using Harpoon.Sender; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | using System; 6 | 7 | namespace Microsoft.Extensions.DependencyInjection 8 | { 9 | /// 10 | /// A set of extensions methods on and 11 | /// 12 | public static class ServicesCollectionsExtensions 13 | { 14 | /// 15 | /// Adds essential Harpoon services to the specified . Further configuration is required using the from parameter. 16 | /// 17 | /// 18 | /// 19 | /// 20 | public static IServiceCollection AddHarpoon(this IServiceCollection services, Action configure) 21 | { 22 | if (configure == null) 23 | { 24 | throw new ArgumentNullException(nameof(configure)); 25 | } 26 | 27 | services.AddLogging(); 28 | 29 | var harpoon = new HarpoonBuilder(services); 30 | 31 | configure(harpoon); 32 | 33 | return harpoon.Services; 34 | } 35 | 36 | /// 37 | /// Registers as the default . 38 | /// 39 | /// 40 | /// 41 | public static IHarpoonBuilder UseDefaultNotificationProcessor(this IHarpoonBuilder harpoon) 42 | { 43 | harpoon.Services.TryAddScoped, DefaultNotificationProcessor>(); 44 | return harpoon; 45 | } 46 | 47 | /// 48 | /// Registers services to use as the default . 49 | /// To setup your own retry policy, use the second method signature. 50 | /// 51 | /// 52 | /// 53 | public static IHarpoonBuilder UseDefaultWebHookWorkItemProcessor(this IHarpoonBuilder harpoon) => harpoon.UseDefaultWebHookWorkItemProcessor(b => { }); 54 | /// 55 | /// Registers services to use as the default . 56 | /// 57 | /// 58 | /// This parameter lets you define your retry policy 59 | /// 60 | public static IHarpoonBuilder UseDefaultWebHookWorkItemProcessor(this IHarpoonBuilder harpoon, Action senderPolicy) 61 | { 62 | if (senderPolicy == null) 63 | { 64 | throw new ArgumentNullException(nameof(senderPolicy)); 65 | } 66 | 67 | harpoon.Services.TryAddSingleton(); 68 | 69 | harpoon.Services.TryAddScoped, DefaultWebHookSender>(); 70 | var builder = harpoon.Services.AddHttpClient, DefaultWebHookSender>(); 71 | senderPolicy(builder); 72 | return harpoon; 73 | } 74 | 75 | /// 76 | /// Registers every services allowing for a synchronous pipeline to treat webhooks locally. 77 | /// 78 | /// 79 | /// 80 | public static IServiceCollection UseAllSynchronousDefaults(this IHarpoonBuilder harpoon) => harpoon.UseAllSynchronousDefaults(b => { }); 81 | /// 82 | /// Registers every services allowing for a synchronous pipeline to treat webhooks locally, while allowing sender retry policy configuration 83 | /// 84 | /// 85 | /// 86 | /// 87 | public static IServiceCollection UseAllSynchronousDefaults(this IHarpoonBuilder harpoon, Action senderPolicy) 88 | => harpoon.ProcessNotificationsSynchronously() 89 | .UseDefaultNotificationProcessor() 90 | .ProcessWebHookWorkItemSynchronously() 91 | .UseDefaultWebHookWorkItemProcessor(senderPolicy) 92 | .Services; 93 | 94 | /// 95 | /// Registers every services allowing for an asynchronous pipeline to treat webhooks locally, using background services. 96 | /// 97 | /// 98 | /// 99 | public static IServiceCollection UseAllLocalDefaults(this IHarpoonBuilder harpoon) => harpoon.UseAllLocalDefaults(b => { }); 100 | /// 101 | /// Registers every services allowing for an asynchronous pipeline to treat webhooks locally, using background services, while allowing sender retry policy configuration 102 | /// 103 | /// 104 | /// 105 | /// 106 | public static IServiceCollection UseAllLocalDefaults(this IHarpoonBuilder harpoon, Action senderPolicy) 107 | => harpoon.ProcessNotificationsUsingLocalQueue() 108 | .UseDefaultNotificationProcessor() 109 | .ProcessWebHookWorkItemsUsingLocalQueue() 110 | .UseDefaultWebHookWorkItemProcessor(senderPolicy) 111 | .Services; 112 | 113 | /// 114 | /// Registers as the default , allowing for a synchronous treatment of 115 | /// 116 | /// 117 | /// 118 | public static IHarpoonBuilder ProcessNotificationsSynchronously(this IHarpoonBuilder harpoon) 119 | { 120 | harpoon.Services.TryAddScoped(); 121 | return harpoon; 122 | } 123 | 124 | /// 125 | /// Registers as the default , allowing for a synchronous treatment of 126 | /// 127 | /// 128 | /// 129 | public static IHarpoonBuilder ProcessWebHookWorkItemSynchronously(this IHarpoonBuilder harpoon) 130 | { 131 | harpoon.Services.TryAddScoped(p => p.GetRequiredService>() as IWebHookSender); 132 | return harpoon; 133 | } 134 | 135 | /// 136 | /// Registers as the default , allowing for a background treatment of 137 | /// 138 | /// 139 | /// 140 | public static IHarpoonBuilder ProcessNotificationsUsingLocalQueue(this IHarpoonBuilder harpoon) 141 | { 142 | harpoon.Services.TryAddSingleton(); 143 | harpoon.Services.TryAddSingleton>(); 144 | harpoon.Services.AddHostedService>(); 145 | return harpoon; 146 | } 147 | 148 | /// 149 | /// Registers as the default , allowing for a synchronous treatment of 150 | /// 151 | /// 152 | /// 153 | public static IHarpoonBuilder ProcessWebHookWorkItemsUsingLocalQueue(this IHarpoonBuilder harpoon) 154 | { 155 | harpoon.Services.TryAddSingleton(); 156 | harpoon.Services.TryAddSingleton>(); 157 | harpoon.Services.AddHostedService>(); 158 | return harpoon; 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /Harpoon.Registrations/DefaultWebHookValidator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Net.Http; 5 | using System.Security.Cryptography; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using System.Web; 10 | 11 | namespace Harpoon.Registrations 12 | { 13 | /// 14 | /// Default implementation 15 | /// 16 | public class DefaultWebHookValidator : IWebHookValidator 17 | { 18 | private static readonly HashSet ValidSchemes = new HashSet { Uri.UriSchemeHttp.ToString(), Uri.UriSchemeHttps.ToString() }; 19 | 20 | /// 21 | /// Gets the 22 | /// 23 | protected IWebHookTriggerProvider WebHookTriggerProvider { get; private set; } 24 | /// 25 | /// Gets the 26 | /// 27 | protected ILogger Logger { get; private set; } 28 | /// 29 | /// Gets the 30 | /// 31 | protected HttpClient HttpClient { get; private set; } 32 | 33 | /// 34 | /// Initializes a new instance of the class. 35 | /// 36 | /// 37 | /// 38 | /// 39 | public DefaultWebHookValidator(IWebHookTriggerProvider webHookTriggerProvider, ILogger logger, HttpClient httpClient) 40 | { 41 | WebHookTriggerProvider = webHookTriggerProvider ?? throw new ArgumentNullException(nameof(webHookTriggerProvider)); 42 | Logger = logger ?? throw new ArgumentNullException(nameof(logger)); 43 | HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); 44 | } 45 | 46 | /// 47 | public virtual async Task ValidateAsync(IWebHook webHook, CancellationToken cancellationToken = default) 48 | { 49 | if (webHook == null) 50 | { 51 | throw new ArgumentNullException(nameof(webHook)); 52 | } 53 | 54 | await VerifyIdAsync(webHook, cancellationToken); 55 | await VerifySecretAsync(webHook, cancellationToken); 56 | await VerifyFiltersAsync(webHook, cancellationToken); 57 | await VerifyCallbackAsync(webHook, cancellationToken); 58 | } 59 | 60 | /// 61 | /// Id validation 62 | /// 63 | /// 64 | /// 65 | /// 66 | protected virtual Task VerifyIdAsync(IWebHook webHook, CancellationToken cancellationToken) 67 | { 68 | if (webHook.Id == default) 69 | { 70 | webHook.Id = Guid.NewGuid(); 71 | } 72 | return Task.CompletedTask; 73 | } 74 | 75 | /// 76 | /// Secret validation 77 | /// 78 | /// 79 | /// 80 | /// 81 | /// Secret is not a 64 characters string 82 | protected virtual Task VerifySecretAsync(IWebHook webHook, CancellationToken cancellationToken) 83 | { 84 | if (string.IsNullOrEmpty(webHook.Secret)) 85 | { 86 | webHook.Secret = GetUniqueKey(64); 87 | return Task.CompletedTask; 88 | } 89 | 90 | if (webHook.Secret.Length != 64) 91 | { 92 | throw new ArgumentException("WebHooks secret needs to be set to a 64 characters string."); 93 | } 94 | 95 | return Task.CompletedTask; 96 | } 97 | 98 | private static string GetUniqueKey(int size) 99 | { 100 | var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_".ToCharArray(); 101 | var data = new byte[size]; 102 | using (var crypto = new RNGCryptoServiceProvider()) 103 | { 104 | crypto.GetBytes(data); 105 | } 106 | var result = new StringBuilder(size); 107 | foreach (var b in data) 108 | { 109 | result.Append(chars[b % chars.Length]); 110 | } 111 | return result.ToString(); 112 | } 113 | 114 | /// 115 | /// Filters validation 116 | /// 117 | /// 118 | /// 119 | /// 120 | /// No filter, or incorrect filters 121 | protected virtual Task VerifyFiltersAsync(IWebHook webHook, CancellationToken cancellationToken) 122 | { 123 | if (webHook.Filters == null || webHook.Filters.Count == 0) 124 | { 125 | throw new ArgumentException("WebHooks need to target at least one trigger. Wildcard is not allowed."); 126 | } 127 | 128 | var triggers = WebHookTriggerProvider.GetAvailableTriggers(); 129 | var errors = new List(); 130 | foreach (var filter in webHook.Filters) 131 | { 132 | if (!triggers.ContainsKey(filter.Trigger)) 133 | { 134 | errors.Add($" - Trigger '{filter.Trigger}' is not valid."); 135 | continue; 136 | } 137 | } 138 | 139 | if (errors.Count != 0) 140 | { 141 | throw new ArgumentException("WebHooks filters are incorrect :" + Environment.NewLine + string.Join(Environment.NewLine, errors)); 142 | } 143 | 144 | return Task.CompletedTask; 145 | } 146 | 147 | /// 148 | /// Callback validation. If noecho is used, the given url should not be called. 149 | /// The url is tested by sending a GET request containing a echo query parameter that should be echoed. 150 | /// 151 | /// 152 | /// 153 | /// 154 | /// Callback is invalid, not an http(s) url, of echo procedure failed 155 | protected virtual async Task VerifyCallbackAsync(IWebHook webHook, CancellationToken cancellationToken) 156 | { 157 | if (webHook.Callback == null) 158 | { 159 | throw new ArgumentException("WebHooks callback needs to be set."); 160 | } 161 | 162 | if (!(webHook.Callback.IsAbsoluteUri && ValidSchemes.Contains(webHook.Callback.Scheme))) 163 | { 164 | throw new ArgumentException("WebHooks callback needs to be a valid http(s) absolute Uri."); 165 | } 166 | 167 | var query = HttpUtility.ParseQueryString(webHook.Callback.Query); 168 | if (query["noecho"] != null) 169 | { 170 | Logger.LogInformation($"Webhook {webHook.Id} does not allow url verification (noecho query parameter has been found)."); 171 | return; 172 | } 173 | 174 | try 175 | { 176 | var expectedResult = Guid.NewGuid(); 177 | var echoUri = new UriBuilder(webHook.Callback) { Query = "echo=" + expectedResult }; 178 | var response = await HttpClient.GetStringAsync(echoUri.Uri); 179 | 180 | if (Guid.TryParse(response, out var responseResult) && responseResult == expectedResult) 181 | { 182 | return; 183 | } 184 | 185 | throw new ArgumentException($"WebHook {webHook.Id} callback verification failed. Response is incorrect: {response}.{Environment.NewLine}To cancel callback verification, add `noecho` as a query parameter."); 186 | } 187 | catch (Exception e) 188 | { 189 | var message = $"WebHook {webHook.Id} callback verification failed: {e.Message}.{Environment.NewLine}To cancel callback verification, add `noecho` as a query parameter."; 190 | Logger.LogError(e, message); 191 | throw new ArgumentException(message); 192 | } 193 | } 194 | } 195 | } -------------------------------------------------------------------------------- /Harpoon.Tests/MassTransitTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.MassTransit; 2 | using Harpoon.Registrations.EFStorage; 3 | using Harpoon.Sender; 4 | using Harpoon.Tests.Mocks; 5 | using MassTransit; 6 | using MassTransit.AspNetCoreIntegration; 7 | using MassTransit.ExtensionsDependencyInjectionIntegration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Moq; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Linq; 14 | using System.Net; 15 | using System.Net.Http; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | using Xunit; 19 | 20 | namespace Harpoon.Tests 21 | { 22 | public class MassTransitTests 23 | { 24 | class QueuedProcessor : IQueuedProcessor 25 | { 26 | public int Counter; 27 | 28 | public Task ProcessAsync(TMessage workItem, CancellationToken token) 29 | { 30 | Counter++; 31 | return Task.CompletedTask; 32 | } 33 | } 34 | 35 | [Fact(Skip = "does not work on CI")] 36 | public async Task NotificationSendTests() 37 | { 38 | var services = new ServiceCollection(); 39 | services.AddHarpoon(c => c.SendNotificationsUsingMassTransit()); 40 | 41 | services.AddMassTransit((IServiceCollectionConfigurator p) => 42 | { 43 | p.ReceiveNotificationsUsingMassTransit(); 44 | p.AddBus(p => Bus.Factory.CreateUsingRabbitMq(cfg => 45 | { 46 | var host = cfg.Host(new Uri("rabbitmq://localhost:5672"), hostConfigurator => 47 | { 48 | hostConfigurator.Username("guest"); 49 | hostConfigurator.Password("guest"); 50 | }); 51 | 52 | cfg.ConfigureNotificationsConsumer(p, "NotificationsQueue"); 53 | })); 54 | }); 55 | services.AddMassTransitHostedService(); 56 | 57 | var processor = new QueuedProcessor(); 58 | services.AddSingleton(typeof(IQueuedProcessor), processor); 59 | 60 | var provider = services.BuildServiceProvider(); 61 | 62 | try 63 | { 64 | await provider.GetRequiredService().StartAsync(default); 65 | 66 | var service = provider.GetRequiredService(); 67 | 68 | await service.NotifyAsync(new WebHookNotification("trigger", new Dictionary 69 | { 70 | ["key"] = "value" 71 | })); 72 | 73 | await Task.Delay(10000); 74 | } 75 | finally 76 | { 77 | await provider.GetRequiredService().StopAsync(default); 78 | } 79 | 80 | Assert.Equal(1, processor.Counter); 81 | } 82 | 83 | [Fact(Skip = "does not work on CI")] 84 | public async Task WebHookWorkItemSendTests() 85 | { 86 | var services = new ServiceCollection(); 87 | services.AddHarpoon(c => c.SendWebHookWorkItemsUsingMassTransit()); 88 | 89 | services.AddMassTransit((IServiceCollectionConfigurator p) => 90 | { 91 | p.ReceiveWebHookWorkItemsUsingMassTransit(); 92 | p.AddBus(p => Bus.Factory.CreateUsingRabbitMq(cfg => 93 | { 94 | var host = cfg.Host(new Uri("rabbitmq://localhost:5672"), hostConfigurator => 95 | { 96 | hostConfigurator.Username("guest"); 97 | hostConfigurator.Password("guest"); 98 | }); 99 | 100 | cfg.ConfigureWebHookWorkItemsConsumer(p, "WebHookWorkItemsQueue"); 101 | })); 102 | }); 103 | services.AddMassTransitHostedService(); 104 | 105 | var processor = new QueuedProcessor(); 106 | services.AddSingleton(typeof(IQueuedProcessor), processor); 107 | 108 | var provider = services.BuildServiceProvider(); 109 | 110 | try 111 | { 112 | await provider.GetRequiredService().StartAsync(default); 113 | 114 | var service = provider.GetRequiredService(); 115 | 116 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), new WebHookNotification("", new object()), new WebHook()), CancellationToken.None); 117 | 118 | await Task.Delay(10000); 119 | } 120 | finally 121 | { 122 | await provider.GetRequiredService().StopAsync(default); 123 | } 124 | 125 | Assert.Equal(1, processor.Counter); 126 | } 127 | 128 | class MyPayload 129 | { 130 | public Guid NotificationId { get; set; } 131 | public int Property { get; set; } 132 | } 133 | 134 | [Fact(Skip ="does not work on CI")] 135 | public async Task FullIntegrationMassTransitTests() 136 | { 137 | var guid = Guid.NewGuid(); 138 | var expectedContent = $@"{{""notificationId"":""{guid}"",""property"":23}}"; 139 | 140 | var counter = 0; 141 | var expectedWebHooksCount = 10; 142 | 143 | var handler = new HttpClientMocker.CallbackHandler 144 | { 145 | Callback = async m => 146 | { 147 | var content = await m.Content.ReadAsStringAsync(); 148 | Assert.Contains(expectedContent, content); 149 | Interlocked.Increment(ref counter); 150 | return new HttpResponseMessage(HttpStatusCode.OK); 151 | }, 152 | }; 153 | var services = new ServiceCollection(); 154 | 155 | var store = new Mock(); 156 | store.Setup(s => s.GetApplicableWebHooksAsync(It.IsAny(), It.IsAny())) 157 | .ReturnsAsync(() => Enumerable.Range(0, expectedWebHooksCount).Select(i => new WebHook { Callback = "http://www.example.org" }).ToList()); 158 | services.AddSingleton(store.Object); 159 | 160 | var protector = new Mock(); 161 | protector.Setup(p => p.GetSignature(It.IsAny(), It.IsAny())).Returns("secret"); 162 | services.AddSingleton(protector.Object); 163 | 164 | services.AddHarpoon(c => c.UseAllMassTransitDefaults(a => a.AddHttpMessageHandler(() => handler))); 165 | 166 | services.AddMassTransit((IServiceCollectionConfigurator p) => 167 | { 168 | p.UseAllMassTransitDefaults(); 169 | p.AddBus(p => Bus.Factory.CreateUsingRabbitMq(cfg => 170 | { 171 | var host = cfg.Host(new Uri("rabbitmq://localhost:5672"), hostConfigurator => 172 | { 173 | hostConfigurator.Username("guest"); 174 | hostConfigurator.Password("guest"); 175 | }); 176 | 177 | cfg.ConfigureNotificationsConsumer(p, "NotificationsFullTestsQueue"); 178 | cfg.ConfigureWebHookWorkItemsConsumer(p, "WebHookWorkItemsFullTestsQueue"); 179 | })); 180 | }); 181 | services.AddMassTransitHostedService(); 182 | 183 | var provider = services.BuildServiceProvider(); 184 | 185 | try 186 | { 187 | var hosts = provider.GetRequiredService>(); 188 | foreach (var host in hosts) 189 | { 190 | await host.StartAsync(default); 191 | } 192 | 193 | var notif = new WebHookNotification("noun.verb", new MyPayload 194 | { 195 | NotificationId = guid, 196 | Property = 23 197 | }); 198 | 199 | await provider.GetRequiredService().NotifyAsync(notif); 200 | 201 | await Task.Delay(15000); 202 | } 203 | finally 204 | { 205 | var hosts = provider.GetRequiredService>(); 206 | foreach (var host in hosts) 207 | { 208 | await host.StopAsync(default); 209 | } 210 | } 211 | 212 | Assert.Equal(expectedWebHooksCount, counter); 213 | } 214 | } 215 | } -------------------------------------------------------------------------------- /Harpoon.Controllers/WebHooksController.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Controllers.Models; 2 | using Harpoon.Controllers.Swashbuckle; 3 | using Harpoon.Registrations; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Extensions.Logging; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Threading.Tasks; 12 | 13 | namespace Harpoon.Controllers 14 | { 15 | /// 16 | /// REST interface to manage WebHooks 17 | /// 18 | [Authorize, ApiController, Route("api/webhooks"), ApiExplorerSettings(GroupName = OpenApi.GroupName), Produces("application/json")] 19 | public class WebHooksController : ControllerBase 20 | { 21 | /// 22 | /// Gets the name of the GetByIdAsyncAction 23 | /// 24 | public const string GetByIdAsyncActionName = "WebHooksController_GetByIdAsync"; 25 | 26 | private readonly IWebHookRegistrationStore _webHookRegistrationStore; 27 | private readonly ILogger _logger; 28 | private readonly IWebHookValidator _webHookValidator; 29 | 30 | /// Initializes a new instance of the class. 31 | public WebHooksController(IWebHookRegistrationStore webHookRegistrationStore, ILogger logger, IWebHookValidator webHookValidator) 32 | { 33 | _webHookRegistrationStore = webHookRegistrationStore ?? throw new ArgumentNullException(nameof(webHookRegistrationStore)); 34 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 35 | _webHookValidator = webHookValidator ?? throw new ArgumentNullException(nameof(webHookValidator)); 36 | } 37 | 38 | /// 39 | /// Gets all WebHooks belonging to the current user. 40 | /// 41 | [HttpGet] 42 | public async Task>> GetAsync() 43 | => Ok((await _webHookRegistrationStore.GetWebHooksAsync(User, HttpContext.RequestAborted)).Select(w => new WebHook(w))); 44 | 45 | /// 46 | /// Gets the WebHook belonging to the current user with the given . 47 | /// 48 | [HttpGet("{id}", Name = GetByIdAsyncActionName)] 49 | [ProducesResponseType(StatusCodes.Status404NotFound)] 50 | [ProducesResponseType(StatusCodes.Status200OK)] 51 | [ProducesDefaultResponseType] 52 | public async Task> GetByIdAsync(Guid id) 53 | { 54 | var webHook = await _webHookRegistrationStore.GetWebHookAsync(User, id, HttpContext.RequestAborted); 55 | if (webHook == null) 56 | { 57 | return NotFound(); 58 | } 59 | return Ok(new WebHook(webHook)); 60 | } 61 | 62 | /// 63 | /// Registers a new WebHook for the current user 64 | /// 65 | [HttpPost] 66 | [ProducesResponseType(StatusCodes.Status201Created)] 67 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 68 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 69 | [WebHookSubscriptionPoint] 70 | public async Task PostAsync([FromBody]WebHook webHook) 71 | { 72 | if (webHook == null) 73 | { 74 | return BadRequest("Body is missing"); 75 | } 76 | 77 | try 78 | { 79 | await _webHookValidator.ValidateAsync(webHook, HttpContext.RequestAborted); 80 | } 81 | catch (ArgumentException ex) 82 | { 83 | var message = $"New webhook validation failed: {ex.Message}"; 84 | _logger.LogInformation(message); 85 | return BadRequest(new { Message = message }); 86 | } 87 | catch (Exception ex) 88 | { 89 | _logger.LogError($"New webhook validation unexpected failure: {ex.Message}"); 90 | return StatusCode(500); 91 | } 92 | 93 | try 94 | { 95 | var result = await _webHookRegistrationStore.InsertWebHookAsync(User, webHook, HttpContext.RequestAborted); 96 | if (result == WebHookRegistrationStoreResult.Success) 97 | { 98 | return CreatedAtRoute(GetByIdAsyncActionName, new { id = webHook.Id }, webHook); 99 | } 100 | return GetActionFromResult(result); 101 | } 102 | catch (Exception ex) 103 | { 104 | _logger.LogError($"Registration insertion unexpected failure: {ex.Message}"); 105 | return StatusCode(500); 106 | } 107 | } 108 | 109 | /// 110 | /// Updates the WebHook with the given . 111 | /// 112 | [HttpPut("{id}")] 113 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 114 | [ProducesResponseType(StatusCodes.Status404NotFound)] 115 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 116 | public async Task PutAsync(Guid id, [FromBody]WebHook webHook) 117 | { 118 | if (webHook == null) 119 | { 120 | return BadRequest("Body is missing"); 121 | } 122 | 123 | if (webHook.Id != id) 124 | { 125 | return BadRequest("Id mismatch"); 126 | } 127 | 128 | try 129 | { 130 | await _webHookValidator.ValidateAsync(webHook, HttpContext.RequestAborted); 131 | } 132 | catch (ArgumentException ex) 133 | { 134 | var message = $"Webhook {id} update validation failed: {ex.Message}"; 135 | _logger.LogInformation(message); 136 | return BadRequest(new { Message = message }); 137 | } 138 | catch (Exception ex) 139 | { 140 | _logger.LogError($"Webhook {id} update validation unexpected failure: {ex.Message}"); 141 | return StatusCode(500); 142 | } 143 | 144 | try 145 | { 146 | var result = await _webHookRegistrationStore.UpdateWebHookAsync(User, webHook, HttpContext.RequestAborted); 147 | return GetActionFromResult(result); 148 | } 149 | catch (Exception ex) 150 | { 151 | _logger.LogError($"Webhook {id} modification unexpected failure: {ex.Message}"); 152 | return StatusCode(500); 153 | } 154 | } 155 | 156 | /// 157 | /// Deletes the WebHook with the given . 158 | /// 159 | [HttpDelete("{id}")] 160 | [ProducesResponseType(StatusCodes.Status404NotFound)] 161 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 162 | public async Task DeleteByIdAsync(Guid id) 163 | { 164 | try 165 | { 166 | var result = await _webHookRegistrationStore.DeleteWebHookAsync(User, id, HttpContext.RequestAborted); 167 | return GetActionFromResult(result); 168 | } 169 | catch (Exception ex) 170 | { 171 | _logger.LogError($"Webhook {id} deletion unexpected failure: {ex.Message}"); 172 | return StatusCode(500); 173 | } 174 | } 175 | 176 | /// 177 | /// Deletes all WebHooks of the current user. 178 | /// 179 | [HttpDelete()] 180 | [ProducesResponseType(StatusCodes.Status200OK)] 181 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 182 | public async Task DeleteAsync() 183 | { 184 | try 185 | { 186 | await _webHookRegistrationStore.DeleteWebHooksAsync(User, HttpContext.RequestAborted); 187 | return Ok(); 188 | } 189 | catch (Exception ex) 190 | { 191 | _logger.LogError($"Webhooks deletion unexpected failure: {ex.Message}"); 192 | return StatusCode(500); 193 | } 194 | } 195 | 196 | private ActionResult GetActionFromResult(WebHookRegistrationStoreResult result) => result switch 197 | { 198 | WebHookRegistrationStoreResult.Success => Ok(), 199 | WebHookRegistrationStoreResult.NotFound => NotFound(), 200 | _ => StatusCode(500), 201 | }; 202 | } 203 | } -------------------------------------------------------------------------------- /Harpoon.Tests/DefaultSenderTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Registrations.EFStorage; 2 | using Harpoon.Sender; 3 | using Harpoon.Sender.EF; 4 | using Harpoon.Tests.Mocks; 5 | using Microsoft.Extensions.Logging; 6 | using Moq; 7 | using Newtonsoft.Json; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Net.Http; 12 | using System.Threading; 13 | using System.Threading.Tasks; 14 | using Xunit; 15 | 16 | namespace Harpoon.Tests 17 | { 18 | public class EFNotificationProcessorTests 19 | { 20 | [Fact] 21 | public void ArgNullEfProcessor() 22 | { 23 | var store = new Mock(); 24 | var sender = new Mock(); 25 | var logger = new Mock>>(); 26 | Assert.Throws(() => new EFNotificationProcessor(null, store.Object, sender.Object, logger.Object)); 27 | } 28 | public class MyPayload 29 | { 30 | public Guid NotificationId { get; set; } 31 | public int Property { get; set; } 32 | } 33 | 34 | [Fact] 35 | public async Task EfProcessorLogsAsync() 36 | { 37 | var store = new Mock(); 38 | store.Setup(s => s.GetApplicableWebHooksAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { new WebHook { } }); 39 | var sender = new Mock(); 40 | var logger = new Mock>>(); 41 | var context = new InMemoryContext(); 42 | var processor = new EFNotificationProcessor(context, store.Object, sender.Object, logger.Object); 43 | 44 | await processor.ProcessAsync(new WebHookNotification("trigger", new MyPayload { NotificationId = Guid.NewGuid(), Property = 23 }), CancellationToken.None); 45 | 46 | Assert.Single(context.WebHookNotifications); 47 | } 48 | } 49 | 50 | public class DefaultSenderTests 51 | { 52 | class CounterDefaultWebHookSender : DefaultWebHookSender 53 | { 54 | public int Failures { get; private set; } 55 | public int NotFounds { get; private set; } 56 | public int Successes { get; private set; } 57 | 58 | public CounterDefaultWebHookSender(HttpClient httpClient, ISignatureService signatureService, ILogger logger) 59 | : base(httpClient, signatureService, logger) 60 | { 61 | } 62 | 63 | protected override Task OnFailureAsync(HttpResponseMessage response, Exception exception, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 64 | { 65 | Failures += 1; 66 | return Task.CompletedTask; 67 | } 68 | 69 | protected override Task OnNotFoundAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 70 | { 71 | NotFounds += 1; 72 | return Task.CompletedTask; 73 | } 74 | 75 | protected override Task OnSuccessAsync(HttpResponseMessage response, IWebHookWorkItem webHookWorkItem, CancellationToken cancellationToken) 76 | { 77 | Successes += 1; 78 | return Task.CompletedTask; 79 | } 80 | } 81 | 82 | [Fact] 83 | public void ArgNullEfSender() 84 | { 85 | var logger = new Mock>>(); 86 | var httpClient = HttpClientMocker.Static(System.Net.HttpStatusCode.OK, ""); 87 | var signature = new Mock(); 88 | Assert.Throws(() => new EFWebHookSender(httpClient, signature.Object, logger.Object, null)); 89 | } 90 | 91 | [Fact] 92 | public async Task ArgNullAsync() 93 | { 94 | var logger = new Mock>(); 95 | var httpClient = HttpClientMocker.Static(System.Net.HttpStatusCode.OK, ""); 96 | var signature = new Mock(); 97 | 98 | Assert.Throws(() => new DefaultWebHookSender(null, signature.Object, logger.Object)); 99 | Assert.Throws(() => new DefaultWebHookSender(httpClient, null, logger.Object)); 100 | Assert.Throws(() => new DefaultWebHookSender(httpClient, signature.Object, null)); 101 | 102 | var service = new DefaultWebHookSender(httpClient, signature.Object, logger.Object); 103 | await Assert.ThrowsAsync(() => service.SendAsync(null, CancellationToken.None)); 104 | } 105 | 106 | public class MyPayload 107 | { 108 | public Guid NotificationId { get; set; } 109 | public int Property { get; set; } 110 | } 111 | public static IEnumerable PayloadData => new List 112 | { 113 | new object[] { null }, 114 | new object[] { new Dictionary 115 | { 116 | ["NotificationId"] = Guid.NewGuid(), 117 | ["Property"] = 23 118 | } }, 119 | new object[] { new MyPayload 120 | { 121 | NotificationId = Guid.NewGuid(), 122 | Property = 23 123 | } }, 124 | }; 125 | 126 | [Theory] 127 | [MemberData(nameof(PayloadData))] 128 | public async Task NormalScenarioAsync(object payload) 129 | { 130 | var logger = new Mock>(); 131 | var signature = "FIXED_SIGNATURE"; 132 | var signatureService = new Mock(); 133 | signatureService.Setup(s => s.GetSignature(It.IsAny(), It.IsAny())).Returns(signature); 134 | 135 | var webHook = new WebHook { Callback = "http://www.example.com" }; 136 | var notif = new WebHookNotification("noun.verb", payload); 137 | 138 | var callbackHasBeenCalled = false; 139 | var httpClient = HttpClientMocker.Callback(async m => 140 | { 141 | callbackHasBeenCalled = true; 142 | Assert.Equal(HttpMethod.Post, m.Method); 143 | Assert.Equal(new Uri(webHook.Callback), m.RequestUri); 144 | 145 | var content = JsonConvert.DeserializeObject>(await m.Content.ReadAsStringAsync()); 146 | 147 | var headers = m.Headers.Select(kvp => kvp.Key).ToHashSet(); 148 | Assert.Contains(DefaultWebHookSender.TimestampKey, headers); 149 | Assert.Contains(DefaultWebHookSender.UniqueIdKey, headers); 150 | 151 | if (notif.Payload != null) 152 | { 153 | Assert.NotEqual(default, content["notificationId"]); 154 | Assert.Equal("23", content["property"].ToString()); 155 | } 156 | 157 | Assert.Contains(DefaultWebHookSender.TriggerKey, headers); 158 | Assert.Equal(notif.TriggerId, m.Headers.GetValues(DefaultWebHookSender.TriggerKey).First()); 159 | 160 | Assert.Contains(DefaultWebHookSender.SignatureHeader, headers); 161 | Assert.Equal(signature, m.Headers.GetValues(DefaultWebHookSender.SignatureHeader).First()); 162 | }); 163 | 164 | var service = new CounterDefaultWebHookSender(httpClient, signatureService.Object, logger.Object); 165 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), notif, webHook), CancellationToken.None); 166 | 167 | Assert.True(callbackHasBeenCalled); 168 | Assert.Equal(1, service.Successes); 169 | } 170 | 171 | [Theory] 172 | [InlineData(System.Net.HttpStatusCode.NotFound)] 173 | [InlineData(System.Net.HttpStatusCode.Gone)] 174 | public async Task NotFoundScenarioAsync(System.Net.HttpStatusCode code) 175 | { 176 | var logger = new Mock>(); 177 | var signature = new Mock(); 178 | 179 | var webHook = new WebHook { Callback = "http://www.example.com" }; 180 | var notif = new WebHookNotification("noun.verb", new object()); 181 | 182 | var httpClient = HttpClientMocker.Static(code, ""); 183 | 184 | var service = new CounterDefaultWebHookSender(httpClient, signature.Object, logger.Object); 185 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), notif, webHook), CancellationToken.None); 186 | 187 | Assert.Equal(1, service.NotFounds); 188 | } 189 | 190 | [Fact] 191 | public async Task ErrorScenarioAsync() 192 | { 193 | var logger = new Mock>(); 194 | var signature = new Mock(); 195 | 196 | var webHook = new WebHook { Callback = "http://www.example.com" }; 197 | var notif = new WebHookNotification("noun.verb", new object()); 198 | 199 | var httpClient = HttpClientMocker.AlwaysFail(new Exception()); 200 | 201 | var service = new CounterDefaultWebHookSender(httpClient, signature.Object, logger.Object); 202 | await service.SendAsync(new WebHookWorkItem(Guid.NewGuid(), notif, webHook), CancellationToken.None); 203 | 204 | Assert.Equal(1, service.Failures); 205 | } 206 | } 207 | } -------------------------------------------------------------------------------- /Harpoon.Tests/ControllersTests.cs: -------------------------------------------------------------------------------- 1 | using Harpoon.Controllers; 2 | using Harpoon.Controllers.Models; 3 | using Harpoon.Registrations; 4 | using Harpoon.Tests.Fixtures; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | using Moq; 9 | using Newtonsoft.Json; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Net; 13 | using System.Net.Http; 14 | using System.Security.Principal; 15 | using System.Text; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | using Xunit; 19 | 20 | namespace Harpoon.Tests 21 | { 22 | public class ControllersTests : IClassFixture 23 | { 24 | private readonly HostFixture _fixture; 25 | 26 | public ControllersTests(HostFixture fixture) 27 | { 28 | _fixture = fixture; 29 | } 30 | 31 | [Fact] 32 | public async Task FailCases() 33 | { 34 | var services = new ServiceCollection(); 35 | var failedStore = new Mock(); 36 | failedStore.Setup(s => s.InsertWebHookAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("")); 37 | failedStore.Setup(s => s.UpdateWebHookAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("")); 38 | failedStore.Setup(s => s.DeleteWebHookAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("")); 39 | failedStore.Setup(s => s.DeleteWebHooksAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("")); 40 | services.AddSingleton(failedStore.Object); 41 | 42 | var failedValidator = new Mock(); 43 | failedValidator.Setup(s => s.ValidateAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("")); 44 | services.AddSingleton(failedValidator.Object); 45 | 46 | var failController1 = new WebHooksController(failedStore.Object, new Mock>().Object, failedValidator.Object); 47 | Assert.Equal(500, ((await failController1.PostAsync(new WebHook())) as StatusCodeResult).StatusCode); 48 | Assert.Equal(500, ((await failController1.PutAsync(new Guid(), new WebHook())) as StatusCodeResult).StatusCode); 49 | 50 | var failController2 = new WebHooksController(failedStore.Object, new Mock>().Object, new Mock().Object); 51 | Assert.Equal(500, ((await failController2.PostAsync(new WebHook())) as StatusCodeResult).StatusCode); 52 | Assert.Equal(500, ((await failController2.PutAsync(new Guid(), new WebHook())) as StatusCodeResult).StatusCode); 53 | Assert.Equal(500, ((await failController2.DeleteByIdAsync(new Guid())) as StatusCodeResult).StatusCode); 54 | Assert.Equal(500, ((await failController2.DeleteAsync()) as StatusCodeResult).StatusCode); 55 | 56 | var failedStore2 = new Mock(); 57 | failedStore2.Setup(s => s.InsertWebHookAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(WebHookRegistrationStoreResult.InternalError); 58 | var failController3 = new WebHooksController(failedStore2.Object, new Mock>().Object, new Mock().Object); 59 | Assert.Equal(500, ((await failController3.PostAsync(new WebHook())) as StatusCodeResult).StatusCode); 60 | } 61 | 62 | [Fact] 63 | public async Task GetAllTestsAsync() 64 | { 65 | var response = await _fixture.Client.GetAsync("/api/webhooks"); 66 | Assert.True(response.IsSuccessStatusCode); 67 | } 68 | 69 | [Fact] 70 | public async Task GetAllTriggersTestsAsync() 71 | { 72 | var response = await _fixture.Client.GetAsync("/api/webhooktriggers"); 73 | Assert.True(response.IsSuccessStatusCode); 74 | } 75 | 76 | [Fact] 77 | public async Task CreateAndGetByIdTestsAsync() 78 | { 79 | var response = await _fixture.Client.GetAsync($"/api/webhooks/{Guid.NewGuid()}"); 80 | Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 81 | 82 | var badCreationResponse = await _fixture.Client.PostAsync("/api/webhooks", new StringContent("bug", Encoding.UTF8, "application/json")); 83 | Assert.Equal(HttpStatusCode.BadRequest, badCreationResponse.StatusCode); 84 | 85 | //invalid as no url 86 | var newWebHook = new WebHook 87 | { 88 | Id = Guid.NewGuid(), 89 | Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_", 90 | Filters = new List 91 | { 92 | new WebHookFilter 93 | { 94 | Trigger = "noun.verb" 95 | } 96 | } 97 | }; 98 | 99 | badCreationResponse = await _fixture.Client.PostAsync("/api/webhooks", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 100 | Assert.Equal(HttpStatusCode.BadRequest, badCreationResponse.StatusCode); 101 | 102 | newWebHook.Callback = new Uri("http://www.example.org?noecho="); 103 | var creationResponse = await _fixture.Client.PostAsync("/api/webhooks", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 104 | Assert.Equal(HttpStatusCode.Created, creationResponse.StatusCode); 105 | 106 | response = await _fixture.Client.GetAsync($"/api/webhooks/{newWebHook.Id}"); 107 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 108 | } 109 | 110 | 111 | [Fact] 112 | public async Task PutTestsAsync() 113 | { 114 | var id = Guid.NewGuid(); 115 | var newWebHook = new WebHook 116 | { 117 | Id = id, 118 | Callback = new Uri("http://www.example2.org?noecho="), 119 | Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_", 120 | Filters = new List 121 | { 122 | new WebHookFilter 123 | { 124 | Trigger = "noun.verb" 125 | } 126 | } 127 | }; 128 | 129 | await _fixture.Client.PostAsync("/api/webhooks", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 130 | 131 | 132 | var badResponse = await _fixture.Client.PutAsync($"/api/webhooks/{id}", new StringContent("incorrect input", Encoding.UTF8, "application/json")); 133 | Assert.Equal(HttpStatusCode.BadRequest, badResponse.StatusCode); 134 | 135 | newWebHook.Id = Guid.NewGuid(); 136 | badResponse = await _fixture.Client.PutAsync($"/api/webhooks/{newWebHook.Id}", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 137 | Assert.Equal(HttpStatusCode.NotFound, badResponse.StatusCode); 138 | 139 | badResponse = await _fixture.Client.PutAsync($"/api/webhooks/{id}", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 140 | Assert.Equal(HttpStatusCode.BadRequest, badResponse.StatusCode); 141 | newWebHook.Id = id; 142 | 143 | newWebHook.Secret = "wrong secret"; 144 | badResponse = await _fixture.Client.PutAsync($"/api/webhooks/{id}", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 145 | Assert.Equal(HttpStatusCode.BadRequest, badResponse.StatusCode); 146 | newWebHook.Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_"; 147 | 148 | var response = await _fixture.Client.PutAsync($"/api/webhooks/{id}", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 149 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 150 | } 151 | 152 | 153 | [Fact] 154 | public async Task DeleteTestsAsync() 155 | { 156 | var id = Guid.NewGuid(); 157 | var newWebHook = new WebHook 158 | { 159 | Id = id, 160 | Callback = new Uri("http://www.example2.org?noecho="), 161 | Secret = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_", 162 | Filters = new List 163 | { 164 | new WebHookFilter 165 | { 166 | Trigger = "noun.verb" 167 | } 168 | } 169 | }; 170 | 171 | await _fixture.Client.PostAsync("/api/webhooks", new StringContent(JsonConvert.SerializeObject(newWebHook), Encoding.UTF8, "application/json")); 172 | 173 | var badResponse = await _fixture.Client.DeleteAsync($"/api/webhooks/{Guid.NewGuid()}"); 174 | Assert.Equal(HttpStatusCode.NotFound, badResponse.StatusCode); 175 | 176 | var response = await _fixture.Client.DeleteAsync($"/api/webhooks/{id}"); 177 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 178 | 179 | response = await _fixture.Client.DeleteAsync($"/api/webhooks"); 180 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 181 | } 182 | } 183 | } --------------------------------------------------------------------------------