diff --git a/README.md b/README.md
index b105c0d..d9f0d1e 100644
--- a/README.md
+++ b/README.md
@@ -289,6 +289,11 @@ To use events, first install the [NuGet package](https://www.nuget.org/packages/
dotnet add package Luxoft.Bss.Platform.Events
```
+> [!NOTE]
+> For the new integration-events registration (`AddPlatformIntegrationEvents` overloads, input/output events,
+> failed event processing, breaking changes and the queue-name `.v1` switch) see the
+> [package README](src/Bss.Platform.Events/README.md).
+
### Domain Events
To use domain events, you need register it in DI
diff --git a/src/Bss.Platform.Events.Abstractions/IEventTypeProvider.cs b/src/Bss.Platform.Events.Abstractions/IEventTypeProvider.cs
new file mode 100644
index 0000000..5a6917b
--- /dev/null
+++ b/src/Bss.Platform.Events.Abstractions/IEventTypeProvider.cs
@@ -0,0 +1,14 @@
+namespace Bss.Platform.Events.Abstractions;
+
+public interface IEventTypeProvider
+{
+ ///
+ /// Internal events are sent to the Rabbit->CAP queue and handled within the target system
+ ///
+ IReadOnlyDictionary InputEvents { get; }
+
+ ///
+ /// External events are sent to the Rabbit exchange, but do not have a handler in the target system
+ ///
+ IReadOnlyDictionary OutputEvents { get; }
+}
diff --git a/src/Bss.Platform.Events.Abstractions/IFailedEventProcessor.cs b/src/Bss.Platform.Events.Abstractions/IFailedEventProcessor.cs
new file mode 100644
index 0000000..dd28863
--- /dev/null
+++ b/src/Bss.Platform.Events.Abstractions/IFailedEventProcessor.cs
@@ -0,0 +1,6 @@
+namespace Bss.Platform.Events.Abstractions;
+
+public interface IFailedEventProcessor
+{
+ Task HandleAsync(TInputEvent? value, Exception ex, string? rawMessageBody);
+}
diff --git a/src/Bss.Platform.Events.Abstractions/IIntegrationEventPublisher.cs b/src/Bss.Platform.Events.Abstractions/IIntegrationEventPublisher.cs
index 1e6cb80..d95f2ad 100644
--- a/src/Bss.Platform.Events.Abstractions/IIntegrationEventPublisher.cs
+++ b/src/Bss.Platform.Events.Abstractions/IIntegrationEventPublisher.cs
@@ -1,6 +1,8 @@
namespace Bss.Platform.Events.Abstractions;
-public interface IIntegrationEventPublisher
+public interface IIntegrationEventPublisher
{
- Task PublishAsync(IIntegrationEvent @event, CancellationToken cancellationToken);
+ Task PublishAsync(T @event, CancellationToken cancellationToken);
}
+
+public interface IIntegrationEventPublisher : IIntegrationEventPublisher;
diff --git a/src/Bss.Platform.Events/AssemblyInfo.cs b/src/Bss.Platform.Events/AssemblyInfo.cs
new file mode 100644
index 0000000..56d4936
--- /dev/null
+++ b/src/Bss.Platform.Events/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Tests.Unit")]
diff --git a/src/Bss.Platform.Events/Bss.Platform.Events.csproj b/src/Bss.Platform.Events/Bss.Platform.Events.csproj
index 7a22725..73ff420 100644
--- a/src/Bss.Platform.Events/Bss.Platform.Events.csproj
+++ b/src/Bss.Platform.Events/Bss.Platform.Events.csproj
@@ -5,11 +5,13 @@
+
+
diff --git a/src/Bss.Platform.Events/CapConsumerServiceSelector.cs b/src/Bss.Platform.Events/CapConsumerServiceSelector.cs
deleted file mode 100644
index dd33ba5..0000000
--- a/src/Bss.Platform.Events/CapConsumerServiceSelector.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Reflection;
-
-using Bss.Platform.Events.Abstractions;
-
-using DotNetCore.CAP;
-using DotNetCore.CAP.Internal;
-
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-
-namespace Bss.Platform.Events;
-
-public class CapConsumerServiceSelector(IServiceProvider serviceProvider, Assembly assembly)
- : ConsumerServiceSelector(serviceProvider)
-{
- protected override IEnumerable FindConsumersFromControllerTypes() => [];
-
- protected override IEnumerable FindConsumersFromInterfaceTypes(IServiceProvider provider)
- {
- var namePrefix = provider.GetRequiredService>().Value.TopicNamePrefix;
-
- return assembly
- .ExportedTypes
- .Where(x => typeof(IIntegrationEvent).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false })
- .Select(x => this.CreateExecutorDescriptor(typeof(CapConsumerExecutor<>).MakeGenericType(x), x, namePrefix));
- }
-
- private ConsumerExecutorDescriptor CreateExecutorDescriptor(Type executor, Type @event, string? namePrefix)
- {
- var subscribeAttribute = new CapSubscribeAttribute(@event.Name);
- this.SetSubscribeAttribute(subscribeAttribute);
-
- var methodInfo = executor
- .GetRuntimeMethods()
- .Single(x => x.Name.Contains(nameof(CapConsumerExecutor.HandleAsync)));
-
- var methodParameters = methodInfo.GetParameters();
- return new ConsumerExecutorDescriptor
- {
- Attribute = subscribeAttribute,
- ClassAttribute = null,
- MethodInfo = methodInfo,
- ImplTypeInfo = executor.GetTypeInfo(),
- ServiceTypeInfo = null,
- TopicNamePrefix = namePrefix,
- Parameters = new List
- {
- new() { ParameterType = methodParameters[0].ParameterType, IsFromCap = false },
- new() { ParameterType = methodParameters[1].ParameterType, IsFromCap = true }
- }
- };
- }
-}
diff --git a/src/Bss.Platform.Events/DependencyInjection.cs b/src/Bss.Platform.Events/DependencyInjection.cs
index f67d064..fa9c668 100644
--- a/src/Bss.Platform.Events/DependencyInjection.cs
+++ b/src/Bss.Platform.Events/DependencyInjection.cs
@@ -3,15 +3,22 @@
using Bss.Platform.Events.Abstractions;
using Bss.Platform.Events.Interfaces;
+using Bss.Platform.Events.Internal;
using Bss.Platform.Events.Models;
using Bss.Platform.Events.Publishers;
+using Bss.Platform.RabbitMq.JsonSchemaGeneratorBase;
using DotNetCore.CAP;
+using DotNetCore.CAP.Filter;
using DotNetCore.CAP.Internal;
+using DotNetCore.CAP.Messages;
+using DotNetCore.CAP.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Savorboard.CAP.InMemoryMessageQueue;
@@ -26,71 +33,228 @@ public static IServiceCollection AddPlatformIntegrationEvents(
this IServiceCollection services,
Assembly eventsAssembly,
Action? setup = null)
- where TEventProcessor : class, IIntegrationEventProcessor
+ where TEventProcessor : class, IIntegrationEventProcessor, IIntegrationEventProcessor
{
services
.AddSingleton()
- .AddSingleton(x => new CapConsumerServiceSelector(x, eventsAssembly))
- .AddScoped()
- .AddScoped(
- serviceProvider =>
- {
- var capTransaction = ActivatorUtilities.CreateInstance(serviceProvider);
- capTransaction.DbTransaction = serviceProvider.GetRequiredService();
- return capTransaction;
- })
- .AddCap(
- x =>
- {
- var eventsOptions = IntegrationEventsOptions.Default;
- setup?.Invoke(eventsOptions);
+ .AddSingleton(x => new(x, eventsAssembly));
+ services.AddPlatformIntegrationEventsInternal(setup);
+ services.TryAddLegacyEventPublisher();
+
+ return services;
+ }
+
+ ///
+ /// A new way to register integration events, required to set up internal and external events
+ /// Used Bss.Platform.Mediation
+ ///
+ /// Automatically registered Legacy IIntegrationEventPublisher
+ public static IServiceCollection AddPlatformIntegrationEvents(
+ this IServiceCollection services,
+ Action> setupEvents,
+ Action setupOptions)
+ where TEventProcessor : class, IIntegrationEventProcessor =>
+ services
+ .AddPlatformIntegrationEvents(setupEvents, setupOptions)
+ .TryAddLegacyEventPublisher();
- x.FailedRetryCount = eventsOptions.FailedRetryCount;
- x.SucceedMessageExpiredAfter = (int)TimeSpan.FromDays(eventsOptions.RetentionDays).TotalSeconds;
+ ///
+ /// A new way to register integration events, required to set up internal and external events
+ ///
+ /// Automatically registered IIntegrationEventPublisher<TEvent> wrap it if you need
+ public static IServiceCollection AddPlatformIntegrationEvents(
+ this IServiceCollection services,
+ Action> setupEvents,
+ Action setupOptions)
+ where TEventProcessor : class, IIntegrationEventProcessor
+ where TEvent : class =>
+ services.AddPlatformIntegrationEvents(setupEvents, setupOptions);
- x.UseSqlServer(
- o =>
- {
- o.ConnectionString = eventsOptions.SqlServer.ConnectionString;
- o.Schema = eventsOptions.SqlServer.Schema;
- });
+ ///
+ /// A new way to register integration events, required to set up internal and external events
+ ///
+ /// Automatically registered IIntegrationEventPublisher<TEvent> wrap it if you need
+ public static IServiceCollection AddPlatformIntegrationEvents(
+ this IServiceCollection services,
+ Action> setupEvents,
+ Action setupOptions)
+ where TEventProcessor : class, IIntegrationEventProcessor
+ where TInputEvent : class
+ where TOutputEvent : notnull
+ {
+ // TODO: via configuration
+ var typeProvider = new EventTypeProvider();
+ setupEvents.Invoke(typeProvider);
- x.UseDashboard(o =>
+ services
+ .AddSingleton(typeProvider)
+ .AddSingleton();
+ var eventsOptions = services.AddPlatformIntegrationEventsInternal(setupOptions);
+ services.Configure((RabbitMQOptions opt) =>
+ {
+ // NOTE: required for rabbit messages generated outside of CAP
+ opt.CustomHeadersBuilder = (msg, sp) =>
+ [
+ new(Headers.MessageId, sp.GetRequiredService().NextId().ToString()),
+ new(Headers.MessageName, msg.RoutingKey),
+ new(Headers.Type, typeof(TInputEvent).Name)
+ ];
+ })
+ .TryAddScoped, IntegrationEventPublisherNew>();
+
+ services.AddSingleton, TEventProcessor>();
+ // NOTE: register TEventProcessor for each type (required for CapConsumerExecutor)
+ typeProvider.InputEvents.Keys
+ .Select(t => typeof(IIntegrationEventProcessor<>).MakeGenericType(t))
+ .ToList()
+ .ForEach(x => services.AddSingleton(x, sp => sp.GetRequiredService>()));
+
+ if (eventsOptions.UseFailedEventProcessor)
+ {
+ services.AddSingleton();
+ var (exchange, queue) = eventsOptions.DeadLetterOptions;
+ services.AddSingleton(new DeadLetterBindingsInitializer(exchange, queue));
+ services.AddSingleton>(sp => sp.GetRequiredService());
+ services.RemoveAll(typeof(ISerializer));
+ services.AddSingleton();
+ services.AddScoped>();
+ }
+
+ if (eventsOptions.MessageQueue.Enable)
+ {
+ services.AddExternalSystemQueueBindings(eventsOptions.MessageQueue.ExternalSystemBindingsSectionPath);
+ services.AddSingleton();
+ services.AddSingleton();
+ if (eventsOptions.MessageQueue.SchemaExportSettings != null)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ services.AddHostedService();
+ }
+ return services;
+ }
+
+ private static IServiceCollection TryAddLegacyEventPublisher(this IServiceCollection services)
+ {
+ services.TryAddScoped();
+ services.TryAddScoped>(sp => sp.GetRequiredService());
+ return services;
+ }
+
+ ///
+ /// required for backward compatibility, added postfix ".v1" to queue name, like origin cap behavior without
+ ///
+ // TODO: remove or investigate when it's really really needed
+ private static Action SetLegacyQueueNameWithVersion(Action? setupOptions) =>
+ opt =>
+ {
+ setupOptions?.Invoke(opt);
+ var originMessageQueueName = opt.MessageQueue.QueueName;
+ opt.MessageQueue.QueueName = string.IsNullOrWhiteSpace(originMessageQueueName) ? $"{opt.MessageQueue.ExchangeName}.v1" : originMessageQueueName;
+ };
+
+ private static TOptions AddPlatformIntegrationEventsInternal(
+ this IServiceCollection services,
+ Action? setupEventOptions = null)
+ where TInputEvent : class
+ where TOptions : IntegrationEventsOptions, new()
+ {
+ var eventsOptions = new TOptions();
+ setupEventOptions?.Invoke(eventsOptions);
+ setupEventOptions ??= _ => { };
+ services.Configure(setupEventOptions);
+
+ services
+ .AddScoped(serviceProvider =>
+ {
+ var capTransaction = ActivatorUtilities.CreateInstance(serviceProvider);
+ capTransaction.DbTransaction = serviceProvider.GetRequiredService();
+ return capTransaction;
+ })
+ .AddCap(x =>
+ {
+ x.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
+ x.FailedRetryCount = eventsOptions.FailedRetryCount;
+ x.SucceedMessageExpiredAfter = (int)TimeSpan.FromDays(eventsOptions.RetentionDays).TotalSeconds;
+
+ if (!string.IsNullOrEmpty(eventsOptions.SqlServer.ConnectionString))
+ {
+ x.UseSqlServer(o =>
{
- o.PathMatch = eventsOptions.DashboardPath;
- if (eventsOptions.AuthorizationPredicate is { } authPredicate)
- {
- o.AllowAnonymousExplicit = false;
- o.AuthorizationPolicy = AddDashboardAuthorizationPolicy(services, authPredicate);
- }
+ o.ConnectionString = eventsOptions.SqlServer.ConnectionString;
+ o.Schema = eventsOptions.SqlServer.Schema;
});
+ }
- if (!eventsOptions.MessageQueue.Enable)
+ x.UseDashboard(o =>
+ {
+ o.PathMatch = eventsOptions.DashboardPath;
+ o.PathBase = eventsOptions.GatewayPrefix;
+ if (eventsOptions.AuthorizationPredicate is { } authPredicate)
{
- x.UseInMemoryMessageQueue();
- return;
+ o.AllowAnonymousExplicit = false;
+ o.AuthorizationPolicy = AddDashboardAuthorizationPolicy(services, authPredicate);
}
-
- x.DefaultGroupName = eventsOptions.MessageQueue.ExchangeName;
- x.UseRabbitMQ(
- o =>
- {
- o.HostName = eventsOptions.MessageQueue.Host;
- o.Port = eventsOptions.MessageQueue.Port;
- o.VirtualHost = eventsOptions.MessageQueue.VirtualHost;
- o.Password = eventsOptions.MessageQueue.Secret;
- o.UserName = eventsOptions.MessageQueue.UserName;
- o.ExchangeName = eventsOptions.MessageQueue.ExchangeName;
- o.BasicQosOptions = new RabbitMQOptions.BasicQos(1, true);
- });
});
- return services;
+ var rabbitSettings = eventsOptions.MessageQueue;
+ if (rabbitSettings.Enable)
+ {
+ x.DefaultGroupName = string.IsNullOrWhiteSpace(rabbitSettings.QueueName)
+ ? rabbitSettings.ExchangeName
+ : rabbitSettings.QueueName;
+
+ x.UseRabbitMQ(o =>
+ {
+ o.HostName = rabbitSettings.Host;
+ o.Port = rabbitSettings.Port;
+ o.VirtualHost = rabbitSettings.VirtualHost;
+ o.Password = rabbitSettings.Secret;
+ o.UserName = rabbitSettings.UserName;
+ o.ExchangeName = rabbitSettings.ExchangeName;
+ o.BasicQosOptions = new(1, true);
+ });
+ }
+ else
+ {
+ x.UseInMemoryMessageQueue();
+ }
+
+ eventsOptions.OverrideCapOptions?.Invoke(x);
+ });
+
+ return eventsOptions;
}
+ private static void AddExternalSystemQueueBindings(this IServiceCollection services, string? sectionPath)
+ {
+ var optionsBuilder = services.AddOptions();
+ if (!string.IsNullOrWhiteSpace(sectionPath))
+ {
+ optionsBuilder.BindConfiguration(sectionPath);
+ // ConfigurationBinder drops Dictionary entries whose bound value is null, and both `null` and `[]`
+ // bind to null for an array value - so BindConfiguration above silently loses SystemBindings keys
+ // that have no explicit routing keys. Rebuild that dictionary from IConfiguration directly instead.
+ // Uses Configure (not PostConfigure) so consumers can still override via PostConfigure regardless
+ // of call order relative to this setup method - PostConfigure always runs after every Configure.
+ optionsBuilder.Configure((options, configuration) =>
+ options.SystemBindings = ResolveSystemBindings(configuration, sectionPath));
+ }
+ }
+
+ internal static Dictionary ResolveSystemBindings(IConfiguration configuration, string sectionPath) =>
+ configuration
+ .GetSection(sectionPath)
+ .GetSection(nameof(ExternalSystemBindingsOptions.SystemBindings))
+ .GetChildren()
+ .ToDictionary(section => section.Key, section => section.Get());
+
private static string AddDashboardAuthorizationPolicy(IServiceCollection services, Func> authPredicate)
{
- const string policyName = "bss-platform-dashboard-auth";
+ const string policyName = "bss-platform-events-dashboard-auth";
services.AddAuthorizationBuilder()
.AddPolicy(
policyName,
diff --git a/src/Bss.Platform.Events/EventTypeProvider.cs b/src/Bss.Platform.Events/EventTypeProvider.cs
new file mode 100644
index 0000000..332c4bb
--- /dev/null
+++ b/src/Bss.Platform.Events/EventTypeProvider.cs
@@ -0,0 +1,79 @@
+using System.Reflection;
+
+using Bss.Platform.Events.Abstractions;
+using Bss.Platform.Events.Interfaces;
+
+namespace Bss.Platform.Events;
+
+public class EventTypeProvider : IEventTypeProvider, IIntegrationEventSetup
+{
+ public IReadOnlyDictionary InputEvents => this.inputTypes;
+ public IReadOnlyDictionary OutputEvents => this.outputTypes;
+
+ // TODO: reverse inputTypes to Dictionary
+ private readonly Dictionary inputTypes = [];
+ private readonly Dictionary outputTypes = [];
+
+ public IIntegrationEventSetup AddInputEvents(string prefix = "", params Assembly[] assemblies)
+ where TEvent : TIn
+ {
+ var newTypes = GetOrDefaultAssembly(assemblies)
+ .SelectMany(x => x.DefinedTypes)
+ .Where(this.IsAssignableAndSatisfyCondition)
+ .Except(this.inputTypes.Keys);
+
+ foreach (var newType in newTypes)
+ {
+ this.inputTypes[newType] = $"{prefix}{newType.Name}";
+ }
+
+ return this;
+ }
+
+ public IIntegrationEventSetup AddInputEvent(string routingKey)
+ where TEvent : class, TIn
+ {
+ var type = typeof(TEvent);
+ this.inputTypes[type] = routingKey;
+ return this;
+ }
+
+ public IIntegrationEventSetup AddOutputEvents(string prefix = "", params Assembly[] assemblies)
+ where TEvent : TOut
+ {
+ var newTypes = GetOrDefaultAssembly(assemblies)
+ .SelectMany(x => x.DefinedTypes)
+ .Where(this.IsAssignableAndSatisfyCondition)
+ .Except(this.outputTypes.Keys);
+
+ foreach (var newType in newTypes)
+ {
+ this.outputTypes[newType] = $"{prefix}{newType.Name}";
+ }
+
+ return this;
+ }
+
+ public IIntegrationEventSetup AddOutputEvent(string routingKey)
+ where TEvent : class, TOut
+ {
+ var type = typeof(TEvent);
+ this.outputTypes[type] = routingKey;
+ return this;
+ }
+
+ private static Assembly[] GetOrDefaultAssembly(Assembly[] assemblies)
+ {
+ if (assemblies.Length == 0)
+ {
+ assemblies = [typeof(TEvent).Assembly];
+ }
+
+ return assemblies;
+ }
+
+ public virtual bool IsAssignableAndSatisfyCondition(TypeInfo typeInfo) =>
+ typeInfo is { IsInterface: false, IsAbstract: false, IsNested: false }
+ && typeof(TAssignableTo).IsAssignableFrom(typeInfo)
+ && !typeInfo.Name.Contains('<');
+}
diff --git a/src/Bss.Platform.Events/Interfaces/IIntegrationEventProcessor.cs b/src/Bss.Platform.Events/Interfaces/IIntegrationEventProcessor.cs
index 25af453..8ff2064 100644
--- a/src/Bss.Platform.Events/Interfaces/IIntegrationEventProcessor.cs
+++ b/src/Bss.Platform.Events/Interfaces/IIntegrationEventProcessor.cs
@@ -2,7 +2,9 @@
namespace Bss.Platform.Events.Interfaces;
-public interface IIntegrationEventProcessor
+public interface IIntegrationEventProcessor : IIntegrationEventProcessor;
+
+public interface IIntegrationEventProcessor
{
- Task ProcessAsync(IIntegrationEvent @event, CancellationToken token);
+ Task ProcessAsync(T @event, CancellationToken token);
}
diff --git a/src/Bss.Platform.Events/Interfaces/IIntegrationEventSetup.cs b/src/Bss.Platform.Events/Interfaces/IIntegrationEventSetup.cs
new file mode 100644
index 0000000..34c45d8
--- /dev/null
+++ b/src/Bss.Platform.Events/Interfaces/IIntegrationEventSetup.cs
@@ -0,0 +1,41 @@
+using System.Reflection;
+
+namespace Bss.Platform.Events.Interfaces;
+
+public interface IIntegrationEventSetup
+{
+ ///
+ /// Add multiple events implemented or inherited TInternalBase with the prefix
+ ///
+ ///
+ /// prefix to add before type name
+ /// <TInternalBase>("INT.") -> INT.TInternal
+ ///
+ /// assemblies to find types, if not passed - will be used assembly contains TInternalBase
+ IIntegrationEventSetup AddInputEvents(string prefix = "", params Assembly[] assemblies)
+ where TInputBase : TIn;
+
+ ///
+ /// Add a single event with the routing key, overrides if it already exists (added by
+ /// )
+ ///
+ IIntegrationEventSetup AddInputEvent(string routingKey) where TInput : class, TIn;
+
+ ///
+ /// Add multiple events implemented or inherited TExternalBase with the prefix
+ ///
+ ///
+ /// prefix to add before type name
+ /// <TExternalBase>("SYS.") -> SYS.TExternal
+ ///
+ /// assemblies to find types, if not passed - will be used assembly contains TExternalBase
+ IIntegrationEventSetup AddOutputEvents(string prefix, params Assembly[] assemblies) where TOutputBase : TOut;
+
+ ///
+ /// Add a single event with the routing key, overrides if it already exists (added by
+ /// )
+ ///
+ IIntegrationEventSetup AddOutputEvent(string routingKey) where TOutput : class, TOut;
+
+ bool IsAssignableAndSatisfyCondition(TypeInfo typeInfo);
+}
diff --git a/src/Bss.Platform.Events/Interfaces/IRabbitInitializer.cs b/src/Bss.Platform.Events/Interfaces/IRabbitInitializer.cs
new file mode 100644
index 0000000..651363e
--- /dev/null
+++ b/src/Bss.Platform.Events/Interfaces/IRabbitInitializer.cs
@@ -0,0 +1,12 @@
+using RabbitMQ.Client;
+
+namespace Bss.Platform.Events.Interfaces;
+
+///
+/// Runs one-time RabbitMQ topology setup (queues, bindings, etc.) against a channel rented at startup.
+/// Register additional implementations via services.AddSingleton<IRabbitInitializer, TInitializer>().
+///
+public interface IRabbitInitializer
+{
+ Task InitializeAsync(IModel model, CancellationToken cancellationToken);
+}
diff --git a/src/Bss.Platform.Events/CapConsumerExecutor.cs b/src/Bss.Platform.Events/Internal/CapConsumerExecutor.cs
similarity index 65%
rename from src/Bss.Platform.Events/CapConsumerExecutor.cs
rename to src/Bss.Platform.Events/Internal/CapConsumerExecutor.cs
index ca29eb7..ff492fa 100644
--- a/src/Bss.Platform.Events/CapConsumerExecutor.cs
+++ b/src/Bss.Platform.Events/Internal/CapConsumerExecutor.cs
@@ -1,10 +1,8 @@
-using Bss.Platform.Events.Abstractions;
using Bss.Platform.Events.Interfaces;
-namespace Bss.Platform.Events;
+namespace Bss.Platform.Events.Internal;
-internal class CapConsumerExecutor(IIntegrationEventProcessor eventProcessor)
- where TEvent : IIntegrationEvent
+internal class CapConsumerExecutor(IIntegrationEventProcessor eventProcessor)
{
public Task HandleAsync(TEvent @event, CancellationToken cancellationToken) => eventProcessor.ProcessAsync(@event, cancellationToken);
}
diff --git a/src/Bss.Platform.Events/Internal/CapConsumerServiceSelector.cs b/src/Bss.Platform.Events/Internal/CapConsumerServiceSelector.cs
new file mode 100644
index 0000000..5d6e276
--- /dev/null
+++ b/src/Bss.Platform.Events/Internal/CapConsumerServiceSelector.cs
@@ -0,0 +1,83 @@
+using System.Reflection;
+
+using Bss.Platform.Events.Abstractions;
+
+using DotNetCore.CAP;
+using DotNetCore.CAP.Internal;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Bss.Platform.Events.Internal;
+
+public class CapConsumerServiceSelectorLegacy(IServiceProvider serviceProvider, Assembly assembly)
+ : CapConsumerServiceSelectorBase(serviceProvider)
+{
+ protected override IEnumerable GetInternalEventTypes() =>
+ assembly
+ .ExportedTypes
+ .Where(x => typeof(IIntegrationEvent).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false });
+
+ protected override CapSubscribeAttribute ProvideCapSubscribeAttribute(Type eventType)
+ {
+ var subscribeAttribute = new CapSubscribeAttribute(eventType.Name);
+ this.SetSubscribeAttribute(subscribeAttribute);
+ return subscribeAttribute;
+ }
+}
+
+public class CapConsumerServiceSelectorNew(IServiceProvider serviceProvider, IEventTypeProvider eventTypeProvider, IOptions capOptions)
+ : CapConsumerServiceSelectorBase(serviceProvider)
+{
+ private readonly string queueName = capOptions.Value.DefaultGroupName;
+ protected override IEnumerable GetInternalEventTypes() =>
+ eventTypeProvider.InputEvents.Keys;
+
+ protected override CapSubscribeAttribute ProvideCapSubscribeAttribute(Type eventType) =>
+ new(eventTypeProvider.InputEvents[eventType]) { Group = this.queueName };
+}
+
+public abstract class CapConsumerServiceSelectorBase(IServiceProvider serviceProvider)
+ : ConsumerServiceSelector(serviceProvider)
+{
+ protected abstract IEnumerable GetInternalEventTypes();
+
+ protected abstract CapSubscribeAttribute ProvideCapSubscribeAttribute(Type eventType);
+
+ protected override IEnumerable FindConsumersFromControllerTypes() =>
+ [];
+
+ protected override IEnumerable FindConsumersFromInterfaceTypes(IServiceProvider provider)
+ {
+ var namePrefix = provider.GetRequiredService>().Value.TopicNamePrefix;
+
+ return this.GetInternalEventTypes()
+ .Select(x => this.CreateExecutorDescriptor(typeof(CapConsumerExecutor<>).MakeGenericType(x), x, namePrefix));
+ }
+
+
+ private ConsumerExecutorDescriptor CreateExecutorDescriptor(Type executor, Type @event, string? namePrefix)
+ {
+ var subscribeAttribute = this.ProvideCapSubscribeAttribute(@event);
+
+ var methodInfo = executor
+ .GetRuntimeMethods()
+ .Single(x => x.Name.Contains(nameof(CapConsumerExecutor<>.HandleAsync)));
+
+ var methodParameters = methodInfo.GetParameters();
+ return new ConsumerExecutorDescriptor
+ {
+ Attribute = subscribeAttribute,
+ ClassAttribute = null,
+ MethodInfo = methodInfo,
+ ImplTypeInfo = executor.GetTypeInfo(),
+ ServiceTypeInfo = null,
+ TopicNamePrefix = namePrefix,
+ Parameters = new List
+ {
+ new() { ParameterType = methodParameters[0].ParameterType, IsFromCap = false },
+ new() { ParameterType = methodParameters[1].ParameterType, IsFromCap = true }
+ }
+ };
+ }
+}
diff --git a/src/Bss.Platform.Events/Internal/CapExceptionFilter.cs b/src/Bss.Platform.Events/Internal/CapExceptionFilter.cs
new file mode 100644
index 0000000..3f995da
--- /dev/null
+++ b/src/Bss.Platform.Events/Internal/CapExceptionFilter.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+
+using Bss.Platform.Events.Abstractions;
+
+using DotNetCore.CAP;
+using DotNetCore.CAP.Filter;
+using DotNetCore.CAP.Serialization;
+
+using Microsoft.Extensions.Options;
+
+namespace Bss.Platform.Events.Internal;
+
+internal sealed class CapExceptionFilter(
+ IOptions capOptions,
+ IEnumerable> failsProcessors,
+ ISerializer serializer)
+ : SubscribeFilter where TInputEvent : class
+{
+ private const string ErrorDetailsHeader = "x-cap-failure-details";
+
+ // NOTE: -1 value because the CAP incremented after filters and handle 0 retries case
+ private int LatestRetryCount => Math.Max(capOptions.Value.FailedRetryCount - 1, 0);
+
+ public override Task OnSubscribeExceptionAsync(ExceptionContext context)
+ {
+ if (context.MediumMessage.Retries != this.LatestRetryCount)
+ {
+ return Task.CompletedTask;
+ }
+
+ var ex = context.Exception;
+ var errorText = ex.GetBaseException().Message;
+
+ var details = new CapFailureDetails(ex.GetType().FullName ?? ex.GetType().Name, errorText, ex.StackTrace);
+ context.DeliverMessage.Headers[ErrorDetailsHeader] = JsonSerializer.Serialize(details);
+ context.DeliverMessage.Headers.TryGetValue(RawCapturingSerializer.RawBodyHeader, out var rawMessageBody);
+
+ var payloadParam = context.ConsumerDescriptor.Parameters.SingleOrDefault(p => !p.IsFromCap);
+ var value = context.DeliverMessage.Value;
+ var payload = payloadParam is not null && value is not null && serializer.IsJsonType(value)
+ ? serializer.Deserialize(value, payloadParam.ParameterType)
+ : value;
+
+ return Task.WhenAll(failsProcessors.Select(x => x.HandleAsync(payload as TInputEvent, ex, rawMessageBody)));
+ }
+
+ internal sealed record CapFailureDetails(string ExceptionType, string Message, string? StackTrace);
+}
diff --git a/src/Bss.Platform.Events/Internal/DeadLetterBindingsInitializer.cs b/src/Bss.Platform.Events/Internal/DeadLetterBindingsInitializer.cs
new file mode 100644
index 0000000..c63a4ce
--- /dev/null
+++ b/src/Bss.Platform.Events/Internal/DeadLetterBindingsInitializer.cs
@@ -0,0 +1,16 @@
+using Bss.Platform.Events.Interfaces;
+
+using RabbitMQ.Client;
+
+namespace Bss.Platform.Events.Internal;
+
+internal sealed class DeadLetterBindingsInitializer(string exchange, string queue) : IRabbitInitializer
+{
+ public Task InitializeAsync(IModel model, CancellationToken cancellationToken)
+ {
+ model.ExchangeDeclare(exchange, ExchangeType.Fanout, true);
+ model.QueueDeclare(queue, true, false, false, null);
+ model.QueueBind(queue, exchange, string.Empty);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Bss.Platform.Events/Internal/DeadLetterProcessor.cs b/src/Bss.Platform.Events/Internal/DeadLetterProcessor.cs
new file mode 100644
index 0000000..f7c9af2
--- /dev/null
+++ b/src/Bss.Platform.Events/Internal/DeadLetterProcessor.cs
@@ -0,0 +1,68 @@
+using System.Text;
+
+using Bss.Platform.Events.Abstractions;
+using Bss.Platform.Events.Models;
+
+using DotNetCore.CAP;
+using DotNetCore.CAP.RabbitMQ;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+using RabbitMQ.Client;
+
+namespace Bss.Platform.Events.Internal;
+
+internal sealed partial class DeadLetterProcessor(
+ IConnectionChannelPool connectionChannelPool,
+ IOptions capOptions,
+ IOptions eventOptions,
+ ILogger logger,
+ IEventTypeProvider registeredTypes) : IFailedEventProcessor