diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 9bf5e4bf1..6b38235e0 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -403,5 +403,6 @@ services: - IdentityUrl=http://10.0.75.1:5105 - CallBackUrl=http://localhost:5114 - WebhooksUrl=http://webhooks.api + - SelfUrl=http://webhooks.client/ ports: - "5114:80" \ No newline at end of file diff --git a/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEvent.cs b/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEvent.cs new file mode 100644 index 000000000..10693eb8b --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEvent.cs @@ -0,0 +1,22 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Webhooks.API.IntegrationEvents +{ + public class OrderStatusChangedToShippedIntegrationEvent : IntegrationEvent + { + public int OrderId { get; private set; } + public string OrderStatus { get; private set; } + public string BuyerName { get; private set; } + + public OrderStatusChangedToShippedIntegrationEvent(int orderId, string orderStatus, string buyerName) + { + OrderId = orderId; + OrderStatus = orderStatus; + BuyerName = buyerName; + } + } +} diff --git a/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEventHandler.cs b/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEventHandler.cs new file mode 100644 index 000000000..688e05055 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/IntegrationEvents/OrderStatusChangedToShippedIntegrationEventHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.eShopOnContainers.BuildingBlocks.EventBus.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Webhooks.API.Model; +using Webhooks.API.Services; + +namespace Webhooks.API.IntegrationEvents +{ + public class OrderStatusChangedToShippedIntegrationEventHandler : IIntegrationEventHandler + { + private readonly IWebhooksRetriever _retriever; + private readonly IWebhooksSender _sender; + public OrderStatusChangedToShippedIntegrationEventHandler(IWebhooksRetriever retriever, IWebhooksSender sender ) + { + _retriever = retriever; + _sender = sender; + } + + public async Task Handle(OrderStatusChangedToShippedIntegrationEvent @event) + { + var subscriptions = await _retriever.GetSubscriptionsOfType(WebhookType.OrderShipped); + + var whook = new WebhookData(WebhookType.OrderShipped, @event); + await _sender.SendAll(subscriptions, whook); + } + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Model/WebhookData.cs b/src/Services/Webhooks/Webhooks.API/Model/WebhookData.cs new file mode 100644 index 000000000..ecf17c65d --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/Model/WebhookData.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Webhooks.API.Model +{ + public class WebhookData + { + public DateTime When { get; } + + public string Payload { get; } + + public string Type { get; } + + public WebhookData(WebhookType hookType, object data) + { + When = DateTime.UtcNow; + Type = hookType.ToString(); + Payload = JsonConvert.SerializeObject(data); + } + + + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Model/WebhookType.cs b/src/Services/Webhooks/Webhooks.API/Model/WebhookType.cs index 42de0673e..0f287cfcf 100644 --- a/src/Services/Webhooks/Webhooks.API/Model/WebhookType.cs +++ b/src/Services/Webhooks/Webhooks.API/Model/WebhookType.cs @@ -7,6 +7,7 @@ namespace Webhooks.API.Model { public enum WebhookType { - CatalogItemPriceChange = 1 + CatalogItemPriceChange = 1, + OrderShipped = 2 } } diff --git a/src/Services/Webhooks/Webhooks.API/Services/IWebhooksRetriever.cs b/src/Services/Webhooks/Webhooks.API/Services/IWebhooksRetriever.cs new file mode 100644 index 000000000..ed75cfc64 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/Services/IWebhooksRetriever.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Webhooks.API.Model; + +namespace Webhooks.API.Services +{ + public interface IWebhooksRetriever + { + + Task> GetSubscriptionsOfType(WebhookType type); + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Services/IWebhooksSender.cs b/src/Services/Webhooks/Webhooks.API/Services/IWebhooksSender.cs new file mode 100644 index 000000000..a0a412ef8 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/Services/IWebhooksSender.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Webhooks.API.Model; + +namespace Webhooks.API.Services +{ + public interface IWebhooksSender + { + Task SendAll(IEnumerable receivers, WebhookData data); + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Services/WebhooksRetriever.cs b/src/Services/Webhooks/Webhooks.API/Services/WebhooksRetriever.cs new file mode 100644 index 000000000..7caa76220 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/Services/WebhooksRetriever.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Webhooks.API.Infrastructure; +using Webhooks.API.Model; + +namespace Webhooks.API.Services +{ + public class WebhooksRetriever : IWebhooksRetriever + { + private readonly WebhooksContext _db; + public WebhooksRetriever(WebhooksContext db) + { + _db = db; + } + public async Task> GetSubscriptionsOfType(WebhookType type) + { + var data = await _db.Subscriptions.Where(s => s.Type == type).ToListAsync(); + return data; + } + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Services/WebhooksSender.cs b/src/Services/Webhooks/Webhooks.API/Services/WebhooksSender.cs new file mode 100644 index 000000000..62128411c --- /dev/null +++ b/src/Services/Webhooks/Webhooks.API/Services/WebhooksSender.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Webhooks.API.Model; + +namespace Webhooks.API.Services +{ + public class WebhooksSender : IWebhooksSender + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + public WebhooksSender(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task SendAll(IEnumerable receivers, WebhookData data) + { + var client = _httpClientFactory.CreateClient(); + var json = JsonConvert.SerializeObject(data); + var tasks = receivers.Select(r => OnSendData(r, json, client)); + await Task.WhenAll(tasks.ToArray()); + } + + private Task OnSendData(WebhookSubscription subs, string jsonData, HttpClient client) + { + var request = new HttpRequestMessage() + { + RequestUri = new Uri(subs.DestUrl, UriKind.Absolute), + Method = HttpMethod.Post, + Content = new StringContent(jsonData, Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrWhiteSpace(subs.Token)) + { + request.Headers.Add("X-eshop-whtoken", subs.Token); + } + _logger.LogDebug($"Sending hook to {subs.DestUrl} of type {subs.Type.ToString()}"); + return client.SendAsync(request); + } + + } +} diff --git a/src/Services/Webhooks/Webhooks.API/Startup.cs b/src/Services/Webhooks/Webhooks.API/Startup.cs index f4de10841..36c3d6dfc 100644 --- a/src/Services/Webhooks/Webhooks.API/Startup.cs +++ b/src/Services/Webhooks/Webhooks.API/Startup.cs @@ -61,7 +61,9 @@ namespace Webhooks.API .AddCustomAuthentication(Configuration) .AddSingleton() .AddTransient() - .AddTransient(); + .AddTransient() + .AddTransient() + .AddTransient(); var container = new ContainerBuilder(); container.Populate(services); @@ -125,6 +127,7 @@ namespace Webhooks.API { var eventBus = app.ApplicationServices.GetRequiredService(); eventBus.Subscribe(); + eventBus.Subscribe(); } } @@ -287,19 +290,11 @@ namespace Webhooks.API public static IServiceCollection AddHttpClientServices(this IServiceCollection services, IConfiguration configuration) { services.AddSingleton(); - - - //register delegating handlers - //services.AddTransient(); - - //InfinteTimeSpan -> See: https://github.com/aspnet/HttpClientFactory/issues/194 services.AddHttpClient("extendedhandlerlifetime").SetHandlerLifetime(Timeout.InfiniteTimeSpan); - //add http client services services.AddHttpClient("GrantClient") .SetHandlerLifetime(TimeSpan.FromMinutes(5)); //.AddHttpMessageHandler(); - return services; } diff --git a/src/Web/WebhookClient/Extensions/ISessionExtensions.cs b/src/Web/WebhookClient/Extensions/ISessionExtensions.cs new file mode 100644 index 000000000..9f9576ede --- /dev/null +++ b/src/Web/WebhookClient/Extensions/ISessionExtensions.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http +{ + static class ISessionExtensions + { + public static void Set(this ISession session, string key, T value) + { + session.SetString(key, JsonConvert.SerializeObject(value)); + } + + public static T Get(this ISession session, string key) + { + var value = session.GetString(key); + + return value == null ? default(T) : + JsonConvert.DeserializeObject(value); + } + } +} diff --git a/src/Web/WebhookClient/HttpClientAuthorizationDelegatingHandler.cs b/src/Web/WebhookClient/HttpClientAuthorizationDelegatingHandler.cs new file mode 100644 index 000000000..c4333707f --- /dev/null +++ b/src/Web/WebhookClient/HttpClientAuthorizationDelegatingHandler.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace WebhookClient +{ + public class HttpClientAuthorizationDelegatingHandler + : DelegatingHandler + { + private readonly IHttpContextAccessor _httpContextAccesor; + + public HttpClientAuthorizationDelegatingHandler(IHttpContextAccessor httpContextAccesor) + { + _httpContextAccesor = httpContextAccesor; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var authorizationHeader = _httpContextAccesor.HttpContext + .Request.Headers["Authorization"]; + + if (!string.IsNullOrEmpty(authorizationHeader)) + { + request.Headers.Add("Authorization", new List() { authorizationHeader }); + } + + var token = await GetToken(); + + if (token != null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return await base.SendAsync(request, cancellationToken); + } + + async Task GetToken() + { + const string ACCESS_TOKEN = "access_token"; + + return await _httpContextAccesor.HttpContext + .GetTokenAsync(ACCESS_TOKEN); + } + } +} diff --git a/src/Web/WebhookClient/Models/WebHookReceived.cs b/src/Web/WebhookClient/Models/WebHookReceived.cs new file mode 100644 index 000000000..8affb48f0 --- /dev/null +++ b/src/Web/WebhookClient/Models/WebHookReceived.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace WebhookClient.Models +{ + public class WebHookReceived + { + public DateTime When { get; set; } + public string Data { get; set; } + } +} diff --git a/src/Web/WebhookClient/Models/WebhookResponse.cs b/src/Web/WebhookClient/Models/WebhookResponse.cs new file mode 100644 index 000000000..464afe85b --- /dev/null +++ b/src/Web/WebhookClient/Models/WebhookResponse.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace WebhookClient.Models +{ + public class WebhookResponse + { + public DateTime Date { get; set; } + public string DestUrl { get; set; } + public string Token { get; set; } + } +} diff --git a/src/Web/WebhookClient/Models/WebhookSubscriptionRequest.cs b/src/Web/WebhookClient/Models/WebhookSubscriptionRequest.cs new file mode 100644 index 000000000..4e4ea2b57 --- /dev/null +++ b/src/Web/WebhookClient/Models/WebhookSubscriptionRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace WebhookClient.Models +{ + public class WebhookSubscriptionRequest + { + public string Url { get; set; } + public string Token { get; set; } + public string Event { get; set; } + public string GrantUrl { get; set; } + } +} diff --git a/src/Web/WebhookClient/Pages/Index.cshtml b/src/Web/WebhookClient/Pages/Index.cshtml index ad0eba4f6..bc2f88fa0 100644 --- a/src/Web/WebhookClient/Pages/Index.cshtml +++ b/src/Web/WebhookClient/Pages/Index.cshtml @@ -7,13 +7,23 @@

Welcome

eShopOnContainers - Webhook client

- Login + Login

Why I need to login? You only need to login to setup a new webhook.

@if (User.Identity.IsAuthenticated) { -
- Your current Webhooks: -
+ +
+

Current webhooks invoked

+ + @foreach (var webhook in Model.WebHooksReceived) + { + + + + + } +
@webhook.When
@webhook.Data
+
} diff --git a/src/Web/WebhookClient/Pages/Index.cshtml.cs b/src/Web/WebhookClient/Pages/Index.cshtml.cs index 52c659bd8..f38857bc4 100644 --- a/src/Web/WebhookClient/Pages/Index.cshtml.cs +++ b/src/Web/WebhookClient/Pages/Index.cshtml.cs @@ -4,14 +4,20 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Http; +using WebhookClient.Models; namespace WebhookClient.Pages { + public class IndexModel : PageModel { + + public IEnumerable WebHooksReceived { get; private set; } + public void OnGet() { - + WebHooksReceived = HttpContext.Session.Get>("webhooks.received") ?? Enumerable.Empty(); } } } diff --git a/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml b/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml new file mode 100644 index 000000000..6c1cff7fb --- /dev/null +++ b/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml @@ -0,0 +1,14 @@ +@page +@model WebhookClient.Pages.RegisterWebhookModel +@{ + ViewData["Title"] = "RegisterWebhook"; +} + +

Register webhook

+ +

This page registers the "OrderShipped" Webhook by sending a POST to webhooks.

+ +
+

Token:

+ +
\ No newline at end of file diff --git a/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml.cs b/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml.cs new file mode 100644 index 000000000..81ebd38b9 --- /dev/null +++ b/src/Web/WebhookClient/Pages/RegisterWebhook.cshtml.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Formatting; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; +using WebhookClient.Models; + +namespace WebhookClient.Pages +{ + + public class RegisterWebhookModel : PageModel + { + private readonly Settings _settings; + private readonly IHttpClientFactory _httpClientFactory; + + [BindProperty] public string Token { get; set; } + + + public RegisterWebhookModel(IOptions settings, IHttpClientFactory httpClientFactory) + { + _settings = settings.Value; + _httpClientFactory = httpClientFactory; + } + + public void OnGet() + { + Token = _settings.Token; + } + + public async Task OnPost() + { + var protocol = Request.IsHttps ? "https" : "http"; + var selfurl = !string.IsNullOrEmpty(_settings.SelfUrl) ? _settings.SelfUrl : $"{protocol}://{Request.Host}/{Request.PathBase}"; + var granturl = $"{selfurl}check"; + var url = $"{selfurl}webhook"; + var client = _httpClientFactory.CreateClient("GrantClient"); + + var payload = new WebhookSubscriptionRequest() + { + Event = "OrderShipped", + GrantUrl = granturl, + Url = url, + Token = Token + }; + var response = await client.PostAsync(_settings.WebhooksUrl + "/api/v1/webhooks", payload, new JsonMediaTypeFormatter()); + + RedirectToPage("Index"); + } + } +} \ No newline at end of file diff --git a/src/Web/WebhookClient/Pages/WebhooksList.cshtml b/src/Web/WebhookClient/Pages/WebhooksList.cshtml new file mode 100644 index 000000000..00913db73 --- /dev/null +++ b/src/Web/WebhookClient/Pages/WebhooksList.cshtml @@ -0,0 +1,28 @@ +@page +@model WebhookClient.Pages.WebhooksListModel +@{ + ViewData["Title"] = "WebhooksList"; +} + +

List of Webhooks registered by user @User.Identity.Name

+ + + + + + + + + + + @foreach (var whr in Model.Webhooks) + { + + + + + + } + +
DateDestination UrlValidation token
@whr.Date@whr.DestUrl@whr.Token
+ diff --git a/src/Web/WebhookClient/Pages/WebhooksList.cshtml.cs b/src/Web/WebhookClient/Pages/WebhooksList.cshtml.cs new file mode 100644 index 000000000..7a523c644 --- /dev/null +++ b/src/Web/WebhookClient/Pages/WebhooksList.cshtml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using WebhookClient.Models; +using WebhookClient.Services; + +namespace WebhookClient.Pages +{ + public class WebhooksListModel : PageModel + { + private readonly IWebhooksClient _webhooksClient; + + public IEnumerable Webhooks { get; private set; } + + public WebhooksListModel(IWebhooksClient webhooksClient) + { + _webhooksClient = webhooksClient; + } + + public async Task OnGet() + { + Webhooks = await _webhooksClient.LoadWebhooks(); + } + } +} \ No newline at end of file diff --git a/src/Web/WebhookClient/Services/IWebhooksClient.cs b/src/Web/WebhookClient/Services/IWebhooksClient.cs new file mode 100644 index 000000000..5d14077cc --- /dev/null +++ b/src/Web/WebhookClient/Services/IWebhooksClient.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using WebhookClient.Models; + +namespace WebhookClient.Services +{ + public interface IWebhooksClient + { + Task> LoadWebhooks(); + } +} diff --git a/src/Web/WebhookClient/Services/WebhooksClient.cs b/src/Web/WebhookClient/Services/WebhooksClient.cs new file mode 100644 index 000000000..1e4f42414 --- /dev/null +++ b/src/Web/WebhookClient/Services/WebhooksClient.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using WebhookClient.Models; + +namespace WebhookClient.Services +{ + public class WebhooksClient : IWebhooksClient + { + public async Task> LoadWebhooks() + { + return new[]{ + new WebhookResponse() + { + Date = DateTime.Now, + DestUrl = "http://aaaaa.me", + Token = "3282832as2" + }, + new WebhookResponse() + { + Date = DateTime.Now.Subtract(TimeSpan.FromSeconds(392)), + DestUrl = "http://bbbbb.me", + Token = "ds2" + } + }; + + } + } +} diff --git a/src/Web/WebhookClient/Settings.cs b/src/Web/WebhookClient/Settings.cs new file mode 100644 index 000000000..9a2f9619b --- /dev/null +++ b/src/Web/WebhookClient/Settings.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace WebhookClient +{ + public class Settings + { + public string Token { get; set; } + public string IdentityUrl { get; set; } + public string CallBackUrl { get; set; } + public string WebhooksUrl { get; set; } + public string SelfUrl { get; set; } + + } +} diff --git a/src/Web/WebhookClient/Startup.cs b/src/Web/WebhookClient/Startup.cs index 7aafa5753..b7fa19c34 100644 --- a/src/Web/WebhookClient/Startup.cs +++ b/src/Web/WebhookClient/Startup.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.DependencyInjection; using System; using System.Linq; using System.Net; +using System.Threading; +using WebhookClient.Services; namespace WebhookClient { @@ -25,7 +27,14 @@ namespace WebhookClient public void ConfigureServices(IServiceCollection services) { services + .AddSession(opt => + { + opt.Cookie.Name = ".eShopWebhooks.Session"; + }) + .AddConfiguration(Configuration) + .AddHttpClientServices(Configuration) .AddCustomAuthentication(Configuration) + .AddTransient() .AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); } @@ -42,7 +51,6 @@ namespace WebhookClient // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } - app.UseAuthentication(); app.UseHttpsRedirection(); app.Map("/check", capp => @@ -69,14 +77,20 @@ namespace WebhookClient } }); }); - app.UseStaticFiles(); + app.UseSession(); app.UseMvcWithDefaultRoute(); } } static class ServiceExtensions { + public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions(); + services.Configure(configuration); + return services; + } public static IServiceCollection AddCustomAuthentication(this IServiceCollection services, IConfiguration configuration) { var identityUrl = configuration.GetValue("IdentityUrl"); @@ -107,5 +121,19 @@ namespace WebhookClient return services; } + + public static IServiceCollection AddHttpClientServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + services.AddTransient(); + services.AddHttpClient("extendedhandlerlifetime").SetHandlerLifetime(Timeout.InfiniteTimeSpan); + + //add http client services + services.AddHttpClient("GrantClient") + .SetHandlerLifetime(TimeSpan.FromMinutes(5)) + .AddHttpMessageHandler(); + + return services; + } } } diff --git a/src/Web/WebhookClient/WebhookClient.csproj b/src/Web/WebhookClient/WebhookClient.csproj index 869d95cc4..fa4376c9d 100644 --- a/src/Web/WebhookClient/WebhookClient.csproj +++ b/src/Web/WebhookClient/WebhookClient.csproj @@ -12,6 +12,7 @@ +