Replace custom webhooks implementation with WebhooksCore (#5811)

* Replace custom webhooks implementation with WebhooksCore

Replaced the existing custom webhooks registration and dispatching system with the WebhooksCore library. This involved removing all previous custom webhook classes and interfaces, updating project dependencies, and modifying appsettings and program files to use the new configuration. The update simplifies the webhook handling architecture and leverages the features provided by WebhooksCore.

* Hide API key in Datadog Docker compose file

This change replaces the exposed Datadog API key with a placeholder "<HIDDEN>". This enhances security by preventing the API key from being visible in the version control system.
This commit is contained in:
Sipke Schoorstra 2024-07-21 16:48:38 +02:00 committed by GitHub
parent 7c34f22d56
commit b4cd4dd465
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 52 additions and 371 deletions

View file

@ -96,6 +96,7 @@
<PackageVersion Include="Testcontainers.Redis" Version="3.9.0" /> <PackageVersion Include="Testcontainers.Redis" Version="3.9.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="3.9.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="3.9.0" />
<PackageVersion Include="ThrottleDebounce" Version="2.0.0" /> <PackageVersion Include="ThrottleDebounce" Version="2.0.0" />
<PackageVersion Include="WebhooksCore" Version="0.0.1-preview.14" />
<PackageVersion Include="xunit" Version="2.9.0" /> <PackageVersion Include="xunit" Version="2.9.0" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" /> <PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.extensibility.core" Version="2.9.0" /> <PackageVersion Include="xunit.extensibility.core" Version="2.9.0" />

View file

@ -5,6 +5,7 @@
<clear /> <clear />
<add key="NuGet official package source" value="https://api.nuget.org/v3/index.json" /> <add key="NuGet official package source" value="https://api.nuget.org/v3/index.json" />
<add key="Elsa 3 Preview" value="https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json" /> <add key="Elsa 3 Preview" value="https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json" />
<add key="Webhooks Core Preview" value="https://f.feedz.io/personal/webhooks-core/nuget/index.json" />
</packageSources> </packageSources>
<packageSourceMapping> <packageSourceMapping>
<packageSource key="NuGet official package source"> <packageSource key="NuGet official package source">
@ -13,5 +14,9 @@
<packageSource key="Elsa 3 Preview"> <packageSource key="Elsa 3 Preview">
<package pattern="Elsa.*" /> <package pattern="Elsa.*" />
</packageSource> </packageSource>
<packageSource key="Webhooks Core Preview">
<package pattern="WebhooksCore" />
<package pattern="WebhooksCore.*" />
</packageSource>
</packageSourceMapping> </packageSourceMapping>
</configuration> </configuration>

View file

@ -76,7 +76,7 @@ services:
datadog-agent: datadog-agent:
image: datadog/agent:7 image: datadog/agent:7
environment: environment:
DD_API_KEY: "YOUR_API_KEY" DD_API_KEY: "<HIDDEN>"
DD_SITE: "datadoghq.eu" DD_SITE: "datadoghq.eu"
DD_LOGS_ENABLED: "true" DD_LOGS_ENABLED: "true"
DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL: "true" DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL: "true"

View file

@ -42,7 +42,7 @@ builder.Services.AddElsa(elsa =>
elsa.UseRealTimeWorkflows(); elsa.UseRealTimeWorkflows();
// Use Webhooks feature. // Use Webhooks feature.
elsa.UseWebhooks(webhooks => webhooks.WebhookOptions = options => builder.Configuration.GetSection("Webhooks").Bind(options)); elsa.UseWebhooks(webhooks => webhooks.ConfigureSinks = options => builder.Configuration.GetSection("Webhooks:Sinks").Bind(options));
}); });
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();

View file

@ -7,10 +7,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Webhooks": { "Webhooks": {
"Endpoints": [ "Sinks": [
{ {
"EventTypes": [ "Id": "1",
"RunTask" "Filters": [
{
"EventType": "RunTask"
}
], ],
"Url": "https://localhost:6001/api/webhooks/run-task" "Url": "https://localhost:6001/api/webhooks/run-task"
} }

View file

@ -21,7 +21,7 @@ builder.Services.AddElsa(elsa =>
}) })
.UseWorkflowsApi() .UseWorkflowsApi()
.UseDefaultAuthentication(auth => auth.UseAdminApiKey()) .UseDefaultAuthentication(auth => auth.UseAdminApiKey())
.UseWebhooks(webhooks => webhooks.WebhookOptions = options => builder.Configuration.GetSection("Webhooks").Bind(options)); .UseWebhooks(webhooks => webhooks.ConfigureSinks = options => builder.Configuration.GetSection("Webhooks:Sinks").Bind(options));
}); });
var app = builder.Build(); var app = builder.Build();

View file

@ -7,10 +7,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Webhooks": { "Webhooks": {
"Endpoints": [ "Sinks": [
{ {
"EventTypes": [ "Id": "1",
"RunTask" "Filters": [
{
"EventType": "RunTask"
}
], ],
"Url": "https://localhost:5002/webhooks/run-task" "Url": "https://localhost:5002/webhooks/run-task"
} }

View file

@ -105,7 +105,7 @@ services
http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options); http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options);
}) })
.UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)) .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options))
.UseWebhooks(webhooks => webhooks.WebhookOptions = options => builder.Configuration.GetSection("Webhooks").Bind(options)) .UseWebhooks(webhooks => webhooks.ConfigureSinks = options => builder.Configuration.GetSection("Webhooks:Sinks").Bind(options))
.UseWorkflowsApi() .UseWorkflowsApi()
.UseRealTimeWorkflows() .UseRealTimeWorkflows()
.AddActivitiesFrom<Program>() .AddActivitiesFrom<Program>()

View file

@ -85,7 +85,7 @@
"BasePath": "/api/workflows" "BasePath": "/api/workflows"
}, },
"Webhooks": { "Webhooks": {
"Endpoints": [] "Sinks": []
}, },
"CorsPolicy": { "CorsPolicy": {
"Origins": ["*"], "Origins": ["*"],

View file

@ -1,9 +0,0 @@
using Elsa.Mediator.Contracts;
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Commands;
/// <summary>
/// Represents a command to invoke all registered webhook endpoints.
/// </summary>
public record InvokeWebhook(WebhookRegistration WebhookRegistration, WebhookEvent WebhookEvent) : ICommand;

View file

@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" /> <PackageReference Include="Microsoft.Extensions.Http.Polly" />
<PackageReference Include="WebhooksCore" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,18 +1,14 @@
using Elsa.Extensions; using Elsa.Extensions;
using Elsa.Features.Abstractions; using Elsa.Features.Abstractions;
using Elsa.Features.Services; using Elsa.Features.Services;
using Elsa.Webhooks.Implementations;
using Elsa.Webhooks.Models;
using Elsa.Webhooks.Options;
using Elsa.Webhooks.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Polly; using Microsoft.Extensions.Options;
using WebhooksCore;
using WebhooksCore.Options;
namespace Elsa.Webhooks.Features; namespace Elsa.Webhooks.Features;
/// <summary> /// Installs and configures webhook services.
/// Installs and configures services that let the user register webhook endpoints.
/// </summary>
public class WebhooksFeature : FeatureBase public class WebhooksFeature : FeatureBase
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -20,58 +16,33 @@ public class WebhooksFeature : FeatureBase
{ {
} }
/// <summary> public Action<IOptions<WebhookSinksOptions>> ConfigureSinks { get; set; } = options => { };
/// A delegate that resolves the <see cref="IWebhookDispatcher"/> to use.
/// </summary>
public Func<IServiceProvider, IWebhookDispatcher> WebhookDispatcher { get; set; } = sp => sp.GetRequiredService<BackgroundWebhookDispatcher>();
/// <summary> /// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
/// A delegate that is invoked when configuring <see cref="Options.WebhookOptions"/>. public WebhooksFeature RegisterWebhookSink(Uri endpoint)
/// </summary>
public Action<WebhookOptions> WebhookOptions { get; set; } = _ => { };
/// <summary>
/// A delegate to configure the <see cref="System.Net.Http.HttpClient"/> used when invoking webhook endpoints.
/// </summary>
public Action<IServiceProvider, HttpClient> HttpClient { get; set; } = (_, _) => { };
/// <summary>
/// A delegate to configure the <see cref="IHttpClientBuilder"/>. For example, to configure Polly policies.
/// </summary>
public Action<IHttpClientBuilder> HttpClientBuilder { get; set; } = builder => builder.AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(3, retry => TimeSpan.FromSeconds(retry)));
/// <summary>
/// Registers the specified webhook with <see cref="Options.WebhookOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhook(Uri endpoint) => RegisterWebhook(new WebhookRegistration(endpoint));
/// <summary>
/// Registers the specified webhook with <see cref="Options.WebhookOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhook(WebhookRegistration registration) => RegisterWebhooks(registration);
/// <summary>
/// Registers the specified webhooks with <see cref="Options.WebhookOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhooks(params WebhookRegistration[] registrations)
{ {
Services.Configure<WebhookOptions>(options => options.Endpoints.AddRange(registrations)); var sink = new WebhookSink
{
Id = endpoint.ToString(),
Url = endpoint
};
return RegisterSink(sink);
}
/// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
public WebhooksFeature RegisterSink(WebhookSink sink) => RegisterSinks(sink);
/// Registers the specified webhooks with <see cref="WebhookSinksOptions"/>
public WebhooksFeature RegisterSinks(params WebhookSink[] sinks)
{
Services.Configure(ConfigureSinks);
Services.Configure<WebhookSinksOptions>(options => options.Sinks.AddRange(sinks));
return this; return this;
} }
/// <inheritdoc /> /// <inheritdoc />
public override void Apply() public override void Apply()
{ {
Services Services.AddWebhooksCore();
.AddHandlersFrom<WebhooksFeature>()
.AddScoped<BackgroundWebhookDispatcher>()
.AddScoped(WebhookDispatcher)
.AddScoped<IWebhookRegistrationService, DefaultWebhookRegistrationService>()
.AddSingleton<IWebhookRegistrationProvider, OptionsWebhookRegistrationProvider>();
Services.Configure(WebhookOptions);
var httpClientBuilder = Services.AddHttpClient<IWebhookInvoker, HttpWebhookInvoker>(HttpClient);
HttpClientBuilder(httpClientBuilder);
} }
} }

View file

@ -1,31 +0,0 @@
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Models;
using Elsa.Webhooks.Commands;
using Elsa.Webhooks.Services;
using JetBrains.Annotations;
namespace Elsa.Webhooks.Handlers;
/// <summary>
/// Handles the <see cref="InvokeWebhook"/> command.
/// </summary>
[UsedImplicitly]
public class InvokeWebhookHandler : ICommandHandler<InvokeWebhook>
{
private readonly IWebhookInvoker _webhookInvoker;
/// <summary>
/// Constructor.
/// </summary>
public InvokeWebhookHandler(IWebhookInvoker webhookInvoker)
{
_webhookInvoker = webhookInvoker;
}
/// <inheritdoc />
public async Task<Unit> HandleAsync(InvokeWebhook command, CancellationToken cancellationToken)
{
await _webhookInvoker.InvokeWebhookAsync(command.WebhookRegistration, command.WebhookEvent, cancellationToken);
return Unit.Instance;
}
}

View file

@ -1,30 +1,15 @@
using Elsa.Common.Contracts;
using Elsa.Mediator.Contracts; using Elsa.Mediator.Contracts;
using Elsa.Webhooks.Models; using Elsa.Webhooks.Models;
using Elsa.Webhooks.Services;
using Elsa.Workflows.Runtime.Notifications; using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations; using JetBrains.Annotations;
using WebhooksCore;
namespace Elsa.Webhooks.Handlers; namespace Elsa.Webhooks.Handlers;
/// <summary>
/// Handles the <see cref="RunTaskRequest"/> notification and asynchronously invokes all registered webhook endpoints. /// Handles the <see cref="RunTaskRequest"/> notification and asynchronously invokes all registered webhook endpoints.
/// </summary>
[UsedImplicitly] [UsedImplicitly]
public class RunTaskHandler : INotificationHandler<RunTaskRequest> public class RunTaskHandler(IWebhookEventBroadcaster webhookDispatcher) : INotificationHandler<RunTaskRequest>
{ {
private readonly IWebhookDispatcher _webhookDispatcher;
private readonly ISystemClock _systemClock;
/// <summary>
/// Constructor.
/// </summary>
public RunTaskHandler(IWebhookDispatcher webhookDispatcher, ISystemClock systemClock)
{
_webhookDispatcher = webhookDispatcher;
_systemClock = systemClock;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task HandleAsync(RunTaskRequest notification, CancellationToken cancellationToken) public async Task HandleAsync(RunTaskRequest notification, CancellationToken cancellationToken)
{ {
@ -46,8 +31,7 @@ public class RunTaskHandler : INotificationHandler<RunTaskRequest>
notification.TaskPayload notification.TaskPayload
); );
var now = _systemClock.UtcNow; var webhookEvent = new NewWebhookEvent("RunTask", payload);
var webhookEvent = new WebhookEvent("RunTask", payload, now); await webhookDispatcher.BroadcastAsync(webhookEvent, cancellationToken);
await _webhookDispatcher.DispatchAsync(webhookEvent, cancellationToken);
} }
} }

View file

@ -1,37 +0,0 @@
using Elsa.Mediator;
using Elsa.Mediator.Contracts;
using Elsa.Webhooks.Commands;
using Elsa.Webhooks.Models;
using Elsa.Webhooks.Services;
namespace Elsa.Webhooks.Implementations;
/// <summary>
/// Uses a background channel to asynchronously invoke each webhook url.
/// </summary>
public class BackgroundWebhookDispatcher : IWebhookDispatcher
{
private readonly ICommandSender _commandSender;
private readonly IWebhookRegistrationService _webhookRegistrationService;
/// <summary>
/// Constructor.
/// </summary>
public BackgroundWebhookDispatcher(ICommandSender commandSender, IWebhookRegistrationService webhookRegistrationService)
{
_commandSender = commandSender;
_webhookRegistrationService = webhookRegistrationService;
}
/// <inheritdoc />
public async Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken cancellationToken = default)
{
var registrations = await _webhookRegistrationService.ListByEventTypeAsync(webhookEvent.EventType, cancellationToken);
foreach (var registration in registrations)
{
var notification = new InvokeWebhook(registration, webhookEvent);
await _commandSender.SendAsync(notification, CommandStrategy.Background, cancellationToken);
}
}
}

View file

@ -1,35 +0,0 @@
using System.Runtime.CompilerServices;
using Elsa.Webhooks.Models;
using Elsa.Webhooks.Services;
namespace Elsa.Webhooks.Implementations;
/// <inheritdoc />
public class DefaultWebhookRegistrationService : IWebhookRegistrationService
{
private readonly IEnumerable<IWebhookRegistrationProvider> _providers;
/// <summary>
/// Constructor.
/// </summary>
/// <param name="providers"></param>
public DefaultWebhookRegistrationService(IEnumerable<IWebhookRegistrationProvider> providers)
{
_providers = providers;
}
/// <inheritdoc />
public async ValueTask<IEnumerable<WebhookRegistration>> ListByEventTypeAsync(string eventType, CancellationToken cancellationToken) =>
await EnumerateByEventTypeAsync(eventType, cancellationToken).ToListAsync(cancellationToken);
private async IAsyncEnumerable<WebhookRegistration> EnumerateByEventTypeAsync(string eventType, [EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var provider in _providers)
{
var registrations = await provider.ListAsync(eventType, cancellationToken);
foreach (var registration in registrations)
yield return registration;
}
}
}

View file

@ -1,37 +0,0 @@
using System.Net.Http.Json;
using Elsa.Webhooks.Models;
using Elsa.Webhooks.Services;
using Microsoft.Extensions.Logging;
namespace Elsa.Webhooks.Implementations;
/// <summary>
/// An implementation of <see cref="IWebhookInvoker"/> that uses a named <see cref="HttpClient"/>.
/// </summary>
public class HttpWebhookInvoker : IWebhookInvoker
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
/// <summary>
/// Constructor.
/// </summary>
public HttpWebhookInvoker(HttpClient httpClient, ILogger<HttpWebhookInvoker> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <inheritdoc />
public async Task InvokeWebhookAsync(WebhookRegistration registration, WebhookEvent webhookEvent, CancellationToken cancellationToken = default)
{
var url = registration.Url;
var response = await _httpClient.PostAsJsonAsync(url, webhookEvent, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Invoking webhook {Webhook} failed with status code {StatusCode} and content {Content}", registration.Url, response.StatusCode, content);
}
}
}

View file

@ -1,27 +0,0 @@
using Elsa.Webhooks.Models;
using Elsa.Webhooks.Options;
using Elsa.Webhooks.Services;
using Microsoft.Extensions.Options;
namespace Elsa.Webhooks.Implementations;
/// <summary>
/// Provides webhook registrations from the <see cref="WebhookOptions"/> options.
/// </summary>
public class OptionsWebhookRegistrationProvider : IWebhookRegistrationProvider
{
private readonly WebhookOptions _options;
/// <summary>
/// Constructor.
/// </summary>
/// <param name="options"></param>
public OptionsWebhookRegistrationProvider(IOptions<WebhookOptions> options)
{
_options = options.Value;
}
/// <inheritdoc />
public ValueTask<IEnumerable<WebhookRegistration>> ListAsync(string eventType, CancellationToken cancellationToken) =>
new(_options.Endpoints.Where(x => !x.EventTypes.Any() || x.EventTypes.Contains(eventType)));
}

View file

@ -1,6 +0,0 @@
namespace Elsa.Webhooks.Models;
/// <summary>
/// A payload sent to a webhook url.
/// </summary>
public record WebhookEvent(string EventType, object? Payload, DateTimeOffset Timestamp);

View file

@ -1,35 +0,0 @@
using System.Text.Json.Serialization;
namespace Elsa.Webhooks.Models;
/// <summary>
/// Represents a webhook url registration
/// </summary>
public class WebhookRegistration
{
/// <summary>
/// Constructor.
/// </summary>
[JsonConstructor]
public WebhookRegistration()
{
}
/// <summary>
/// Constructor.
/// </summary>
public WebhookRegistration(Uri url)
{
Url = url;
}
/// <summary>
/// The URL to deliver the webhook event to.
/// </summary>
public Uri Url { get; set; } = default!;
/// <summary>
/// A whitelist of event types to deliver. If empty, all events will be delivered.
/// </summary>
public HashSet<string> EventTypes { get; set; } = new();
}

View file

@ -1,14 +0,0 @@
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Options;
/// <summary>
/// Provides various options related to webhooks.
/// </summary>
public class WebhookOptions
{
/// <summary>
/// Stores a list of webhook registrations.
/// </summary>
public ICollection<WebhookRegistration> Endpoints { get; set; } = new List<WebhookRegistration>();
}

View file

@ -1,14 +0,0 @@
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Services;
/// <summary>
/// Asynchronously invokes all registered webhooks.
/// </summary>
public interface IWebhookDispatcher
{
/// <summary>
/// Dispatches the specified webhook event.
/// </summary>
Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken cancellationToken = default);
}

View file

@ -1,14 +0,0 @@
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Services;
/// <summary>
/// Invokes a single registered webhook url.
/// </summary>
public interface IWebhookInvoker
{
/// <summary>
/// Invokes the specified webhook registration with the specified webhook event..
/// </summary>
Task InvokeWebhookAsync(WebhookRegistration registration, WebhookEvent webhookEvent, CancellationToken cancellationToken = default);
}

View file

@ -1,14 +0,0 @@
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Services;
/// <summary>
/// Provides a list of webhook registrations.
/// </summary>
public interface IWebhookRegistrationProvider
{
/// <summary>
/// Returns a list of webhook registrations.
/// </summary>
ValueTask<IEnumerable<WebhookRegistration>> ListAsync(string eventType, CancellationToken cancellationToken);
}

View file

@ -1,14 +0,0 @@
using Elsa.Webhooks.Models;
namespace Elsa.Webhooks.Services;
/// <summary>
/// Provides a list of webhook registrations.
/// </summary>
public interface IWebhookRegistrationService
{
/// <summary>
/// Returns a list of webhook registrations matching the specified event.
/// </summary>
ValueTask<IEnumerable<WebhookRegistration>> ListByEventTypeAsync(string eventType, CancellationToken cancellationToken = default);
}