diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj b/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj index b29a5be4..fc4d2f1c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/BuildingBlocks.Abstractions.csproj @@ -25,6 +25,7 @@ + diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs index 5a06ec06..8144b97c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Domain/AggregateId.cs @@ -32,8 +32,5 @@ protected AggregateId(long value) return new AggregateId(value); } - public static implicit operator long(AggregateId id) - { - return id.Value; - } + public static implicit operator long(AggregateId? id) => id?.Value ?? default; } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelopeMetadata.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelopeMetadata.cs index f8fe1774..db21fc0c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelopeMetadata.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelopeMetadata.cs @@ -1,13 +1,26 @@ namespace BuildingBlocks.Abstractions.Events; -public interface IEventEnvelopeMetadata +public record EventEnvelopeMetadata( + Guid MessageId, + Guid CorrelationId, + string MessageType, + string Name, + // Causation ID identifies messages that cause other messages to be published. In simple terms, it's used to see what causes what. The first message in a message conversation typically doesn't have a causation ID. Downstream messages get their causation IDs by copying message IDs from messages, causing downstream messages to be published + Guid? CausationId +) { - Guid MessageId { get; init; } - string MessageType { get; init; } - string Name { get; init; } - Guid? CausationId { get; init; } - Guid CorrelationId { get; init; } - DateTime Created { get; init; } - long? CreatedUnixTime { get; init; } - IDictionary Headers { get; init; } + public IDictionary Headers { get; init; } = new Dictionary(); + public DateTime Created { get; init; } = DateTime.Now; + public long? CreatedUnixTime { get; init; } = DateTimeHelper.ToUnixTimeSecond(DateTime.Now); + + internal static class DateTimeHelper + { + private static readonly DateTime _epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public static long ToUnixTimeSecond(DateTime datetime) + { + var unixTime = (datetime.ToUniversalTime() - _epoch).TotalSeconds; + return (long)unixTime; + } + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelope.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/IEventEnvelope.cs similarity index 91% rename from src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelope.cs rename to src/BuildingBlocks/BuildingBlocks.Abstractions/Events/IEventEnvelope.cs index 6d52710f..15b5a480 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/EventEnvelope.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Events/IEventEnvelope.cs @@ -12,12 +12,12 @@ namespace BuildingBlocks.Abstractions.Events; // Ref: https://www.enterpriseintegrationpatterns.com/patterns/messaging/EnvelopeWrapper.html public interface IEventEnvelope { - object Data { get; } - IEventEnvelopeMetadata? Metadata { get; } + object Message { get; } + EventEnvelopeMetadata Metadata { get; } } public interface IEventEnvelope : IEventEnvelope where T : notnull { - new T Data { get; } + new T Message { get; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusDirectPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusDirectPublisher.cs new file mode 100644 index 00000000..6de9fc35 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusDirectPublisher.cs @@ -0,0 +1,26 @@ +using BuildingBlocks.Abstractions.Events; + +namespace BuildingBlocks.Abstractions.Messaging; + +public interface IBusDirectPublisher +{ + Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) + where TMessage : IMessage; + + Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default); + + public Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage; + + public Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusProducer.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusProducer.cs deleted file mode 100644 index abd80445..00000000 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusProducer.cs +++ /dev/null @@ -1,18 +0,0 @@ -using BuildingBlocks.Abstractions.Events; - -namespace BuildingBlocks.Abstractions.Messaging; - -public interface IBusProducer -{ - public Task PublishAsync(TMessage message, CancellationToken cancellationToken = default) - where TMessage : IMessage; - - public Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default); - - public Task PublishAsync( - IEventEnvelope eventEnvelope, - string? exchangeOrTopic = null, - string? queue = null, - CancellationToken cancellationToken = default - ); -} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusPublisher.cs new file mode 100644 index 00000000..6f336629 --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IBusPublisher.cs @@ -0,0 +1,28 @@ +using BuildingBlocks.Abstractions.Events; + +namespace BuildingBlocks.Abstractions.Messaging; + +public interface IBusPublisher +{ + public Task PublishAsync(TMessage message, CancellationToken cancellationToken = default) + where TMessage : IMessage; + + Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) + where TMessage : IMessage; + + public Task PublishAsync( + TMessage message, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage; + + public Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IExternalEventBus.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IExternalEventBus.cs index 824b4c59..30d28b3a 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IExternalEventBus.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/IExternalEventBus.cs @@ -1,3 +1,3 @@ namespace BuildingBlocks.Abstractions.Messaging; -public interface IExternalEventBus : IBusProducer, IBusConsumer; +public interface IExternalEventBus : IBusPublisher, IBusConsumer; diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs index 1568cea0..0f1d8270 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Messaging/PersistMessage/IMessagePersistenceService.cs @@ -20,30 +20,23 @@ Task> GetByFilterAsync( CancellationToken cancellationToken = default ); - Task AddPublishMessageAsync( - TEventEnvelope eventEnvelope, + Task AddPublishMessageAsync( + IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default ) - where TEventEnvelope : IEventEnvelope; - - Task AddPublishMessageAsync( - TEventEnvelope eventEnvelope, - CancellationToken cancellationToken = default - ) - where TEventEnvelope : IEventEnvelope where TMessage : IMessage; - Task AddReceivedMessageAsync( - TMessageEnvelope messageEnvelope, + Task AddReceivedMessageAsync( + IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default ) - where TMessageEnvelope : IEventEnvelope; + where TMessage : IMessage; Task AddInternalMessageAsync( TInternalCommand internalCommand, CancellationToken cancellationToken = default ) - where TInternalCommand : class, IInternalCommand; + where TInternalCommand : IInternalCommand; Task AddNotificationAsync( TDomainNotification notification, diff --git a/src/BuildingBlocks/BuildingBlocks.Abstractions/Serialization/IMessageSerilizer.cs b/src/BuildingBlocks/BuildingBlocks.Abstractions/Serialization/IMessageSerilizer.cs index 2e2ea506..045b4170 100644 --- a/src/BuildingBlocks/BuildingBlocks.Abstractions/Serialization/IMessageSerilizer.cs +++ b/src/BuildingBlocks/BuildingBlocks.Abstractions/Serialization/IMessageSerilizer.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Abstractions.Messaging; namespace BuildingBlocks.Abstractions.Serialization; @@ -12,12 +13,23 @@ public interface IMessageSerializer /// a messageEnvelope that implement IMessage interface. /// a json string for serialized messageEnvelope. string Serialize(IEventEnvelope eventEnvelope); + string Serialize(IEventEnvelope eventEnvelope) + where T : IMessage; /// /// Deserialize the given payload into a . /// /// a json data to deserialize to a messageEnvelope. + /// the type of message inside event-envelope. /// return a messageEnvelope type. - IEventEnvelope? Deserialize(string eventEnvelope); IEventEnvelope? Deserialize(string eventEnvelope, Type messageType); + + /// + /// Deserialize the given payload into a IEventEnvelope" />. + /// + /// + /// + /// + IEventEnvelope? Deserialize(string eventEnvelope) + where T : IMessage; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditAggregate.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditAggregate.cs index f7459f04..8bab30d4 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditAggregate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditAggregate.cs @@ -9,6 +9,6 @@ public abstract class AuditAggregate : Aggregate, IAuditableEntity : AuditAggregate - where TIdentity : Identity { } + where TIdentity : Identity; -public abstract class AuditAggregate : AuditAggregate, long> { } +public abstract class AuditAggregate : AuditAggregate, long>; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditableEntity.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditableEntity.cs index 286d5419..84e40ef2 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditableEntity.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/AuditableEntity.cs @@ -4,11 +4,11 @@ namespace BuildingBlocks.Core.Domain; public class AuditableEntity : Entity, IAuditableEntity { - public DateTime? LastModified { get; protected set; } = default!; - public int? LastModifiedBy { get; protected set; } = default!; + public DateTime? LastModified { get; init; } = default!; + public int? LastModifiedBy { get; init; } = default!; } public abstract class AuditableEntity : AuditableEntity - where TIdentity : Identity { } + where TIdentity : Identity; -public class AuditableEntity : AuditableEntity, long> { } +public class AuditableEntity : AuditableEntity, long>; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs index 989f6413..c7b81e4e 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/Entity.cs @@ -4,12 +4,12 @@ namespace BuildingBlocks.Core.Domain; public abstract class Entity : IEntity { - public TId Id { get; protected set; } = default!; - public DateTime Created { get; private set; } = default!; - public int? CreatedBy { get; private set; } = default!; + public TId Id { get; init; } = default!; + public DateTime Created { get; init; } = default!; + public int? CreatedBy { get; init; } = default!; } public abstract class Entity : Entity - where TIdentity : Identity { } + where TIdentity : Identity; -public abstract class Entity : Entity, IEntity { } +public abstract class Entity : Entity, IEntity; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs index 4a8cc5ff..bfc5897d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Address.cs @@ -55,5 +55,5 @@ public PostalCode() { } // validations should be placed here instead of constructor public static PostalCode Of(string? postalCode) => new() { Value = postalCode.NotBeNullOrWhiteSpace() }; - public static implicit operator string(PostalCode postalCode) => postalCode.Value; + public static implicit operator string(PostalCode? postalCode) => postalCode?.Value ?? string.Empty; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs index 26ddfff0..0cf68fe3 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Amount.cs @@ -29,7 +29,7 @@ public static Amount Of([NotNull] decimal? value) return Of(value.Value); } - public static Amount Of(decimal value) + public static Amount Of([NotNull] decimal value) { value.NotBeNegativeOrZero(); @@ -42,7 +42,7 @@ public static Amount Of(decimal value) return new Amount(value); } - public static implicit operator decimal(Amount value) => value.Value; + public static implicit operator decimal(Amount? value) => value?.Value ?? default; public static bool operator >(Amount a, Amount b) => a.Value > b.Value; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs index 4fc0380c..467a8837 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/BirthDate.cs @@ -41,7 +41,7 @@ public static BirthDate Of(DateTime value) return new BirthDate { Value = value }; } - public static implicit operator DateTime(BirthDate value) => value.Value; + public static implicit operator DateTime(BirthDate? value) => value?.Value ?? default; // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs index 55bb3ce4..961448c9 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Currency.cs @@ -28,7 +28,7 @@ public static Currency Of([NotNull] string? value) return new Currency(value); } - public static implicit operator string(Currency value) => value.Value; + public static implicit operator string(Currency? value) => value?.Value ?? string.Empty; // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs index 0b33df04..8cc673c5 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/Email.cs @@ -29,7 +29,7 @@ public static Email Of([NotNull] string? value) return new Email(value); } - public static implicit operator string(Email value) => value.Value; + public static implicit operator string(Email? value) => value?.Value ?? string.Empty; // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs index 3132bc08..2f929705 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/MobileNumber.cs @@ -27,7 +27,7 @@ public static MobileNumber Of([NotNull] string? value) return new MobileNumber(value); } - public static implicit operator string(MobileNumber phoneNumber) => phoneNumber.Value; + public static implicit operator string(MobileNumber? phoneNumber) => phoneNumber?.Value ?? string.Empty; // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs index c28ac4ae..5562d6c1 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Domain/ValueObjects/PhoneNumber.cs @@ -27,7 +27,7 @@ public static PhoneNumber Of([NotNull] string? value) return new PhoneNumber(value); } - public static implicit operator string(PhoneNumber phoneNumber) => phoneNumber.Value; + public static implicit operator string(PhoneNumber? phoneNumber) => phoneNumber?.Value ?? string.Empty; // https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/deconstruct#user-defined-types // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#positional-syntax-for-property-definition diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelope.cs b/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelope.cs index bb347440..68de98d6 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelope.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelope.cs @@ -1,4 +1,3 @@ -using System.Reflection; using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Core.Types; @@ -7,36 +6,51 @@ namespace BuildingBlocks.Core.Events; -public record EventEnvelope(T Data, IEventEnvelopeMetadata? Metadata = null) : IEventEnvelope - where T : notnull +// For deserialization of EventEnvelope, EventEnvelopeMetadata should be a class here +public record EventEnvelope(T Message, EventEnvelopeMetadata Metadata) : IEventEnvelope + where T : IMessage { - object IEventEnvelope.Data => Data; + object IEventEnvelope.Message => Message; } public static class EventEnvelope { - public static IEventEnvelope From(object data, IEventEnvelopeMetadata? metadata = null) + public static IEventEnvelope From(object data, EventEnvelopeMetadata metadata) { var type = typeof(EventEnvelope<>).MakeGenericType(data.GetType()); return (IEventEnvelope)Activator.CreateInstance(type, data, metadata)!; } - public static IEventEnvelope From(TMessage data, IEventEnvelopeMetadata? metadata) - where TMessage : IMessage + public static IEventEnvelope From(T data, EventEnvelopeMetadata metadata) + where T : IMessage { - return new EventEnvelope(data, metadata); + return new EventEnvelope(data, metadata); } - public static IEventEnvelope From(object data, Guid correlationId, Guid? cautionId = null) + public static IEventEnvelope From( + object data, + Guid correlationId, + Guid? cautionId = null, + IDictionary? headers = null + ) { - var methodInfo = typeof(EventEnvelope).GetMethod(nameof(From), BindingFlags.NonPublic | BindingFlags.Static); + var methodInfo = typeof(EventEnvelope) + .GetMethods() + .FirstOrDefault(x => + x.Name == nameof(From) && x.GetGenericArguments().Length != 0 && x.GetParameters().Length == 4 + ); var genericMethod = methodInfo.MakeGenericMethod(data.GetType()); - return (IEventEnvelope)genericMethod.Invoke(null, new object[] { data, correlationId, cautionId }); + return (IEventEnvelope)genericMethod.Invoke(null, new object[] { data, correlationId, cautionId, headers }); } - public static IEventEnvelope From(TMessage data, Guid correlationId, Guid? cautionId = null) - where TMessage : IMessage + public static IEventEnvelope From( + T data, + Guid correlationId, + Guid? cautionId = null, + IDictionary? headers = null + ) + where T : IMessage { var envelopeMetadata = new EventEnvelopeMetadata( data.MessageId, @@ -47,6 +61,7 @@ public static IEventEnvelope From(TMessage data, Guid correl ) { CreatedUnixTime = DateTime.Now.ToUnixTimeSecond(), + Headers = headers ?? new Dictionary() }; return From(data, envelopeMetadata); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelopeMetadata.cs b/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelopeMetadata.cs deleted file mode 100644 index dc6bb295..00000000 --- a/src/BuildingBlocks/BuildingBlocks.Core/Events/EventEnvelopeMetadata.cs +++ /dev/null @@ -1,18 +0,0 @@ -using BuildingBlocks.Abstractions.Events; -using BuildingBlocks.Core.Types.Extensions; - -namespace BuildingBlocks.Core.Events; - -public record EventEnvelopeMetadata( - Guid MessageId, - Guid CorrelationId, - string MessageType, - string Name, - // Causation ID identifies messages that cause other messages to be published. In simple terms, it's used to see what causes what. The first message in a message conversation typically doesn't have a causation ID. Downstream messages get their causation IDs by copying message IDs from messages, causing downstream messages to be published - Guid? CausationId -) : IEventEnvelopeMetadata -{ - public IDictionary Headers { get; init; } = new Dictionary(); - public DateTime Created { get; init; } = DateTime.Now; - public long? CreatedUnixTime { get; init; } = DateTime.Now.ToUnixTimeSecond(); -} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Events/Extensions/DependencyInjectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Events/Extensions/DependencyInjectionExtensions.cs index c176a8ea..12400d5f 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Events/Extensions/DependencyInjectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Events/Extensions/DependencyInjectionExtensions.cs @@ -21,8 +21,7 @@ internal static IServiceCollection AddEventBus(this IServiceCollection services, services .AddTransient() .AddTransient() - .AddTransient() - .AddTransient(); + .AddTransient(); services.AddTransient(); services.AddScoped(); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Events/Internal/IntegrationEventWrapper.cs b/src/BuildingBlocks/BuildingBlocks.Core/Events/Internal/IntegrationEventWrapper.cs index 80f9a54b..f12150e8 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Events/Internal/IntegrationEventWrapper.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Events/Internal/IntegrationEventWrapper.cs @@ -1,7 +1,9 @@ using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Core.Messaging; +using MassTransit; namespace BuildingBlocks.Core.Events.Internal; +[ExcludeFromTopology] public record IntegrationEventWrapper : IntegrationEvent where TDomainEventType : IDomainEvent; diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Events/InternalEventBus.cs b/src/BuildingBlocks/BuildingBlocks.Core/Events/InternalEventBus.cs index 4b1430bf..11309559 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Events/InternalEventBus.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Events/InternalEventBus.cs @@ -33,7 +33,7 @@ await policy.ExecuteAsync( c => { // TODO: using metadata for tracing ang monitoring here - return mediator.Publish(eventEnvelope.Data, c); + return mediator.Publish(eventEnvelope.Message, c); }, ct ); @@ -43,7 +43,7 @@ public Task Publish(IEventEnvelope eventEnvelope, CancellationToken ct) { // calling generic `Publish` in `InternalEventBus` class var genericPublishMethod = _publishMethods.GetOrAdd( - eventEnvelope.Data.GetType(), + eventEnvelope.Message.GetType(), eventType => typeof(InternalEventBus) .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs index b5fb3171..eaaf3307 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ConfigurationExtensions.cs @@ -13,8 +13,13 @@ public static class ConfigurationExtensions /// The given bind model. /// The configuration instance to bind. /// The configuration section. + /// /// The new instance of . - public static TOptions BindOptions(this IConfiguration configuration, string section) + public static TOptions BindOptions( + this IConfiguration configuration, + string section, + Action? configurator = null + ) where TOptions : new() { // note: with using Get<>() if there is no configuration in appsettings it just returns default value (null) for the configuration type @@ -25,6 +30,8 @@ public static TOptions BindOptions(this IConfiguration configuration, var optionsSection = configuration.GetSection(section); optionsSection.Bind(options); + configurator?.Invoke(options); + return options; } @@ -33,10 +40,14 @@ public static TOptions BindOptions(this IConfiguration configuration, /// /// The given bind model. /// The configuration instance to bind. + /// /// The new instance of . - public static TOptions BindOptions(this IConfiguration configuration) + public static TOptions BindOptions( + this IConfiguration configuration, + Action? configurator = null + ) where TOptions : new() { - return BindOptions(configuration, typeof(TOptions).Name); + return BindOptions(configuration, typeof(TOptions).Name, configurator); } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/Options.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/Options.cs index 585b9ab5..614bd63c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/Options.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ServiceCollection/Options.cs @@ -60,10 +60,7 @@ public static IServiceCollection AddValidatedOptions( ) where T : class { - if (validator is null) - { - validator = RequiredConfigurationValidator.Validate; - } + validator ??= RequiredConfigurationValidator.Validate; // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options // https://thecodeblogger.com/2021/04/21/options-pattern-in-net-ioptions-ioptionssnapshot-ioptionsmonitor/ diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs index a3204f24..2276b3b6 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Extensions/ValidationExtensions.cs @@ -225,7 +225,9 @@ public static string NotBeInvalidPhoneNumber( ) { // Use Regex to validate phone number format - var regex = new Regex(@"^[+]?(\d{1,2})?[\s.-]?(\d{3})[\s.-]?(\d{4})[\s.-]?(\d{4})$"); + // valid phones: +10---------- , (+10)---------- + var regex = new Regex(@"^[+]?[(]?[+]?[0-9]{1,4}[)]?[-\s./0-9]{9,12}$"); + if (!regex.IsMatch(phoneNumber)) { throw new ValidationException($"{argumentName} is not a valid phone number."); @@ -240,7 +242,8 @@ public static string NotBeInvalidMobileNumber( ) { // Use Regex to validate mobile number format - var regex = new Regex(@"^(?:(?:\+|00)([1-9]{1,3}))?([1-9]\d{9})$"); + var regex = new Regex(@"^(?:\+|00)?(\(\d{1,3}\)|\d{1,3})?([1-9]\d{9})$"); + if (!regex.IsMatch(mobileNumber)) { throw new ValidationException($"{argumentName} is not a valid mobile number."); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessageHeaders.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessageHeaders.cs index 77f63d9e..5aaa806c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessageHeaders.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessageHeaders.cs @@ -11,4 +11,6 @@ public static class MessageHeaders public const string Name = "name"; public const string Type = "type"; public const string Created = "created"; + public const string ExchangeOrTopic = "exchange-topic"; + public const string Queue = "queue"; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs index d27c7ed2..87348539 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagePersistence/MessagePersistenceService.cs @@ -1,11 +1,11 @@ using System.Linq.Expressions; +using System.Reflection; using BuildingBlocks.Abstractions.Commands; using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Abstractions.Events.Internal; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; using BuildingBlocks.Abstractions.Serialization; -using BuildingBlocks.Core.Events; using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Types; using MediatR; @@ -18,7 +18,7 @@ public class MessagePersistenceService( IMessagePersistenceRepository messagePersistenceRepository, IMessageSerializer messageSerializer, IMediator mediator, - IExternalEventBus bus, + IBusDirectPublisher busDirectPublisher, ISerializer serializer ) : IMessagePersistenceService { @@ -30,43 +30,37 @@ public Task> GetByFilterAsync( return messagePersistenceRepository.GetByFilterAsync(predicate ?? (_ => true), cancellationToken); } - public async Task AddPublishMessageAsync( - TEventEnvelope eventEnvelope, + public async Task AddPublishMessageAsync( + IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default ) - where TEventEnvelope : IEventEnvelope - { - await AddMessageCore(eventEnvelope, MessageDeliveryType.Outbox, cancellationToken); - } - - public async Task AddPublishMessageAsync( - TEventEnvelope eventEnvelope, - CancellationToken cancellationToken = default - ) - where TEventEnvelope : IEventEnvelope where TMessage : IMessage { await AddMessageCore(eventEnvelope, MessageDeliveryType.Outbox, cancellationToken); } - public async Task AddReceivedMessageAsync( - TEventEnvelope messageEnvelope, + public async Task AddReceivedMessageAsync( + IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default ) - where TEventEnvelope : IEventEnvelope + where TMessage : IMessage { - await AddMessageCore(messageEnvelope, MessageDeliveryType.Inbox, cancellationToken); + await AddMessageCore(eventEnvelope, MessageDeliveryType.Inbox, cancellationToken); } - public async Task AddInternalMessageAsync( - TCommand internalCommand, + public async Task AddInternalMessageAsync( + TInternalCommand internalCommand, CancellationToken cancellationToken = default ) - where TCommand : class, IInternalCommand + where TInternalCommand : IInternalCommand { - await AddMessageCore( - EventEnvelope.From(internalCommand, metadata: null), - MessageDeliveryType.Internal, + await messagePersistenceRepository.AddAsync( + new StoreMessage( + internalCommand.InternalCommandId, + TypeMapper.GetFullTypeName(internalCommand.GetType()), // same process so we use full type name + serializer.Serialize(internalCommand), + MessageDeliveryType.Internal + ), cancellationToken ); } @@ -94,26 +88,14 @@ private async Task AddMessageCore( CancellationToken cancellationToken = default ) { - eventEnvelope.Data.NotBeNull(); + eventEnvelope.Message.NotBeNull(); - Guid id; - if (eventEnvelope.Data is IMessage im) - { - id = im.MessageId; - } - else if (eventEnvelope.Data is IInternalCommand command) - { - id = command.InternalCommandId; - } - else - { - id = Guid.NewGuid(); - } + var id = eventEnvelope.Message is IMessage im ? im.MessageId : Guid.NewGuid(); await messagePersistenceRepository.AddAsync( new StoreMessage( id, - TypeMapper.GetFullTypeName(eventEnvelope.Data.GetType()), // because each service has its own persistence and same process (outbox,inbox), full name message type but in microservices we should just use type name for message + TypeMapper.GetFullTypeName(eventEnvelope.Message.GetType()), // because each service has its own persistence and inbox and outbox processor will run in the same process we can use full type name messageSerializer.Serialize(eventEnvelope), deliveryType ), @@ -166,14 +148,16 @@ public async Task ProcessAllAsync(CancellationToken cancellationToken = default) private async Task ProcessOutbox(StoreMessage storeMessage, CancellationToken cancellationToken) { var messageType = TypeMapper.GetType(storeMessage.DataType); + var eventEnvelope = messageSerializer.Deserialize(storeMessage.Data, messageType); - var messageEnvelope = messageSerializer.Deserialize(storeMessage.Data, messageType); - - if (messageEnvelope is null) + if (eventEnvelope is null) return; + // eventEnvelope.Metadata.Headers.TryGetValue(MessageHeaders.ExchangeOrTopic, out var exchange); + // eventEnvelope.Metadata.Headers.TryGetValue(MessageHeaders.Queue, out var queue); + // we should pass an object type message or explicit our message type, not cast to IMessage (data is IMessage integrationEvent) because masstransit doesn't work with IMessage cast. - await bus.PublishAsync(messageEnvelope, cancellationToken); + await busDirectPublisher.PublishAsync(eventEnvelope, cancellationToken); logger.LogInformation( "Message with id: {MessageId} and delivery type: {DeliveryType} processed from the persistence message store", @@ -184,12 +168,13 @@ private async Task ProcessOutbox(StoreMessage storeMessage, CancellationToken ca private async Task ProcessInternal(StoreMessage storeMessage, CancellationToken cancellationToken) { - var messageEnvelope = messageSerializer.Deserialize(storeMessage.Data); + var messageType = TypeMapper.GetType(storeMessage.DataType); + var internalMessage = serializer.Deserialize(storeMessage.Data, messageType); - if (messageEnvelope is null) + if (internalMessage is null) return; - if (messageEnvelope.Data is IDomainNotificationEvent domainNotificationEvent) + if (internalMessage is IDomainNotificationEvent domainNotificationEvent) { await mediator.Publish(domainNotificationEvent, cancellationToken); @@ -200,7 +185,7 @@ private async Task ProcessInternal(StoreMessage storeMessage, CancellationToken ); } - if (messageEnvelope.Data is IInternalCommand internalCommand) + if (internalMessage is IInternalCommand internalCommand) { await mediator.Send(internalCommand, cancellationToken); @@ -214,6 +199,9 @@ private async Task ProcessInternal(StoreMessage storeMessage, CancellationToken private Task ProcessInbox(StoreMessage storeMessage, CancellationToken cancellationToken) { + var messageType = TypeMapper.GetType(storeMessage.DataType); + var messageEnvelope = messageSerializer.Deserialize(storeMessage.Data, messageType); + return Task.CompletedTask; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingConstants.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingConstants.cs new file mode 100644 index 00000000..13360a9a --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingConstants.cs @@ -0,0 +1,6 @@ +namespace BuildingBlocks.Core.Messaging; + +public static class MessagingConstants +{ + public const string PrimaryExchangePostfix = "_primary_exchange"; +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingOptions.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingOptions.cs new file mode 100644 index 00000000..46c4199c --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/MessagingOptions.cs @@ -0,0 +1,8 @@ +namespace BuildingBlocks.Core.Messaging; + +public class MessagingOptions +{ + public bool OutboxEnabled { get; set; } = true; + public bool InboxEnabled { get; set; } = true; + public bool AutoConfigEndpoints { get; set; } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/NullExternalEventBus.cs b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/NullExternalEventBus.cs index bf57d6a3..18978bd1 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Messaging/NullExternalEventBus.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Messaging/NullExternalEventBus.cs @@ -11,17 +11,33 @@ public Task PublishAsync(TMessage message, CancellationToken cancellat return Task.CompletedTask; } - public Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) + public Task PublishAsync( + TMessage message, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage { return Task.CompletedTask; } - public Task PublishAsync( - IEventEnvelope eventEnvelope, + public Task PublishAsync( + IEventEnvelope eventEnvelope, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage + { + return Task.CompletedTask; + } + + public Task PublishAsync( + IEventEnvelope eventEnvelope, string? exchangeOrTopic = null, string? queue = null, CancellationToken cancellationToken = default ) + where TMessage : IMessage { return Task.CompletedTask; } diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventEnvelope.cs b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventEnvelope.cs index a00187e7..f3b09f37 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventEnvelope.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Persistence/EventStore/StreamEventEnvelope.cs @@ -1,4 +1,3 @@ -using System.Reflection; using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Abstractions.Persistence.EventStore; @@ -26,11 +25,12 @@ public static IStreamEventEnvelope From(TMessage data, IStre public static IStreamEventEnvelope From(object data, string eventId, ulong streamPosition, ulong logPosition) { - var methodInfo = typeof(StreamEventEnvelope).GetMethod( - nameof(From), - BindingFlags.NonPublic | BindingFlags.Static - ); - var genericMethod = methodInfo!.MakeGenericMethod(data.GetType()); + var methodInfo = typeof(StreamEventEnvelope) + .GetMethods() + .FirstOrDefault(x => + x.Name == nameof(From) && x.GetGenericArguments().Length != 0 && x.GetParameters().Length == 4 + ); + var genericMethod = methodInfo.MakeGenericMethod(data.GetType()); return (IStreamEventEnvelope) genericMethod.Invoke(null, new object[] { data, eventId, streamPosition, logPosition }); diff --git a/src/BuildingBlocks/BuildingBlocks.Core/Serialization/NewtonsoftMessageSerializer.cs b/src/BuildingBlocks/BuildingBlocks.Core/Serialization/NewtonsoftMessageSerializer.cs index 0e11bb2a..fc3a334e 100644 --- a/src/BuildingBlocks/BuildingBlocks.Core/Serialization/NewtonsoftMessageSerializer.cs +++ b/src/BuildingBlocks/BuildingBlocks.Core/Serialization/NewtonsoftMessageSerializer.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Serialization; using BuildingBlocks.Core.Events; using Newtonsoft.Json; @@ -14,9 +15,10 @@ public string Serialize(IEventEnvelope eventEnvelope) return JsonConvert.SerializeObject(eventEnvelope, settings); } - public IEventEnvelope? Deserialize(string eventEnvelope) + public string Serialize(IEventEnvelope eventEnvelope) + where T : IMessage { - return JsonConvert.DeserializeObject>(eventEnvelope, settings); + return JsonConvert.SerializeObject(eventEnvelope, settings); } public IEventEnvelope? Deserialize(string eventEnvelope, Type messageType) @@ -27,4 +29,10 @@ public string Serialize(IEventEnvelope eventEnvelope) return JsonConvert.DeserializeObject(eventEnvelope, eventEnvelopGenericType, settings) as IEventEnvelope; } + + public IEventEnvelope? Deserialize(string eventEnvelope) + where T : IMessage + { + return JsonConvert.DeserializeObject>(eventEnvelope, settings); + } } diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/CustomEntityNameFormatter.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/CustomEntityNameFormatter.cs index 47e54873..cca72ce0 100644 --- a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/CustomEntityNameFormatter.cs +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/CustomEntityNameFormatter.cs @@ -1,12 +1,29 @@ +using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Messaging; using Humanizer; using MassTransit; namespace BuildingBlocks.Integration.MassTransit; +/// +/// Setting primary exchange name for each entity type globally. +/// public class CustomEntityNameFormatter : IEntityNameFormatter { public string FormatEntityName() { - return typeof(T).Name.Underscore(); + // Check if T implements IEventEnvelope + if (typeof(IEventEnvelope).IsAssignableFrom(typeof(T))) + { + var messageProperty = typeof(T).GetProperty(nameof(IEventEnvelope.Message)); + if (typeof(IMessage).IsAssignableFrom(messageProperty!.PropertyType)) + { + return $"{messageProperty.PropertyType.Name.Underscore()}{MessagingConstants.PrimaryExchangePostfix}"; + } + } + + // Return a default value if T does not implement IEventEnvelop or Message property is not found + return $"{typeof(T).Name.Underscore()}{MessagingConstants.PrimaryExchangePostfix}"; } } diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/DependencyInjectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/DependencyInjectionExtensions.cs index ef4eac97..f632170d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/DependencyInjectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/DependencyInjectionExtensions.cs @@ -1,15 +1,19 @@ using System.Reflection; +using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Persistence; +using BuildingBlocks.Core.Events; using BuildingBlocks.Core.Exception.Types; using BuildingBlocks.Core.Extensions; +using BuildingBlocks.Core.Extensions.ServiceCollection; using BuildingBlocks.Core.Messaging; using BuildingBlocks.Core.Reflection; -using BuildingBlocks.Validation; +using BuildingBlocks.Core.Reflection.Extensions; +using Humanizer; using MassTransit; using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using RabbitMQ.Client; using IExternalEventBus = BuildingBlocks.Abstractions.Messaging.IExternalEventBus; namespace BuildingBlocks.Integration.MassTransit; @@ -20,10 +24,13 @@ public static WebApplicationBuilder AddCustomMassTransit( this WebApplicationBuilder builder, Action? configureReceiveEndpoints = null, Action? configureBusRegistration = null, - bool autoConfigEndpoints = false, + Action? configureMessagingOptions = null, params Assembly[] scanAssemblies ) { + builder.Services.AddValidationOptions(configureMessagingOptions); + var messagingOptions = builder.Configuration.BindOptions(configureMessagingOptions); + // - should be placed out of action delegate for getting correct calling assembly // - Assemblies are lazy loaded so using AppDomain.GetAssemblies is not reliable (it is possible to get ReflectionTypeLoadException, because some dependent type assembly are lazy and not loaded yet), so we use `GetAllReferencedAssemblies` and it // loads all referenced assemblies explicitly. @@ -67,7 +74,7 @@ void ConfiguratorAction(IBusRegistrationConfigurator busRegistrationConfigurator cfg.PublishTopology.BrokerTopologyOptions = PublishBrokerTopologyOptions.FlattenHierarchy; - if (autoConfigEndpoints) + if (messagingOptions.AutoConfigEndpoints) { // https://masstransit-project.com/usage/consumers.html#consumer cfg.ConfigureEndpoints(context); @@ -81,11 +88,21 @@ void ConfiguratorAction(IBusRegistrationConfigurator busRegistrationConfigurator "/", hostConfigurator => { + hostConfigurator.PublisherConfirmation = true; hostConfigurator.Username(rabbitMqOptions.UserName); hostConfigurator.Password(rabbitMqOptions.Password); } ); + // for setting exchange name for message type as default. masstransit by default uses fully message type name for primary exchange name. + cfg.MessageTopology.SetEntityNameFormatter(new CustomEntityNameFormatter()); + + ApplyMessagesPublishTopology(cfg.PublishTopology, assemblies); + ApplyMessagesConsumeTopology(cfg, context, assemblies); + ApplyMessagesSendTopology(cfg.SendTopology, assemblies); + + configureReceiveEndpoints?.Invoke(context, cfg); + // https://masstransit-project.com/usage/exceptions.html#retry // https://markgossa.com/2022/06/masstransit-exponential-back-off.html cfg.UseMessageRetry(r => AddRetryConfiguration(r)); @@ -93,25 +110,161 @@ void ConfiguratorAction(IBusRegistrationConfigurator busRegistrationConfigurator // cfg.UseInMemoryOutbox(); // https: // github.com/MassTransit/MassTransit/issues/2018 + // https://github.com/MassTransit/MassTransit/issues/4831 cfg.Publish(p => p.Exclude = true); cfg.Publish(p => p.Exclude = true); cfg.Publish(p => p.Exclude = true); cfg.Publish(p => p.Exclude = true); cfg.Publish(p => p.Exclude = true); - - // for setting exchange name for message type as default. masstransit by default uses fully message type name for exchange name. - cfg.MessageTopology.SetEntityNameFormatter(new CustomEntityNameFormatter()); - - configureReceiveEndpoints?.Invoke(context, cfg); + cfg.Publish(p => p.Exclude = true); } ); } - builder.Services.TryAddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); return builder; } + private static void ApplyMessagesSendTopology( + IRabbitMqSendTopologyConfigurator sendTopology, + Assembly[] assemblies + ) { } + + private static void ApplyMessagesConsumeTopology( + IRabbitMqBusFactoryConfigurator rabbitMqBusFactoryConfigurator, + IBusRegistrationContext context, + Assembly[] assemblies + ) + { + var consumeTopology = rabbitMqBusFactoryConfigurator.ConsumeTopology; + + var messageTypes = ReflectionUtilities + .GetAllTypesImplementingInterface(assemblies) + .Where(x => !x.IsGenericType); + + foreach (var messageType in messageTypes) + { + var eventEnvelopeInterfaceMessageType = typeof(IEventEnvelope<>).MakeGenericType(messageType); + var eventEnvelopeInterfaceConfigurator = consumeTopology.GetMessageTopology( + eventEnvelopeInterfaceMessageType + ); + eventEnvelopeInterfaceConfigurator.ConfigureConsumeTopology = true; + + // none event-envelope message types + var messageConfigurator = consumeTopology.GetMessageTopology(messageType); + messageConfigurator.ConfigureConsumeTopology = true; + + var eventEnvelopeConsumerInterface = typeof(IConsumer<>).MakeGenericType(eventEnvelopeInterfaceMessageType); + var envelopeConsumerConcretedTypes = eventEnvelopeConsumerInterface + .GetAllTypesImplementingInterface(assemblies) + .Where(x => !x.FullName!.Contains(nameof(MassTransit))); + + var consumerType = envelopeConsumerConcretedTypes.SingleOrDefault(); + + if (consumerType is null) + { + var messageTypeConsumerInterface = typeof(IConsumer<>).MakeGenericType(messageType); + var messageTypeConsumerConcretedTypes = messageTypeConsumerInterface + .GetAllTypesImplementingInterface(assemblies) + .Where(x => !x.FullName!.Contains(nameof(MassTransit))); + var messageTypeConsumerType = messageTypeConsumerConcretedTypes.SingleOrDefault(); + + if (messageTypeConsumerType is null) + { + continue; + } + + consumerType = messageTypeConsumerType; + } + + ConfigureMessageReceiveEndpoint(rabbitMqBusFactoryConfigurator, context, messageType, consumerType); + } + } + + private static void ConfigureMessageReceiveEndpoint( + IRabbitMqBusFactoryConfigurator rabbitMqBusFactoryConfigurator, + IBusRegistrationContext context, + Type messageType, + Type consumerType + ) + { + // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/Transports/MassTransit.RabbitMqTransport/RabbitMqTransport/Configuration/RabbitMqReceiveEndpointBuilder.cs#L70 + // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + + // https://masstransit.io/documentation/transports/rabbitmq + // This `queueName` creates an `intermediary exchange` (default is Fanout, but we can change it with re.ExchangeType) with the same queue named which bound to this exchange + rabbitMqBusFactoryConfigurator.ReceiveEndpoint( + queueName: messageType.Name.Underscore(), + re => + { + re.Durable = true; + + // set intermediate exchange type + // intermediate exchange name will be the same as queue name + re.ExchangeType = ExchangeType.Fanout; + + // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + re.SetQuorumQueue(); + + // with setting `ConfigureConsumeTopology` to `false`, we should create `primary exchange` and its bounded exchange manually with using `re.Bind` otherwise with `ConfigureConsumeTopology=true` it get publish topology for message type `T` with `_publishTopology.GetMessageTopology()` and use its ExchangeType and ExchangeName based ofo default EntityFormatter + re.ConfigureConsumeTopology = true; + + // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // // masstransit uses `wire-tapping` pattern for defining exchanges. Primary exchange will send the message to intermediary fanout exchange + // // setup primary exchange and its type + // re.Bind( + // $"{type.Name.Underscore()}{MessagingConstants.PrimaryExchangePostfix}", + // e => + // { + // e.RoutingKey = type.Name.Underscore(); + // e.ExchangeType = ExchangeType.Direct; + // } + // ); + + // https://github.com/MassTransit/MassTransit/discussions/3117 + // https://masstransit-project.com/usage/configuration.html#receive-endpoints + re.ConfigureConsumer(context, consumerType); + + re.RethrowFaultedMessages(); + } + ); + } + + private static void ApplyMessagesPublishTopology( + IRabbitMqPublishTopologyConfigurator publishTopology, + Assembly[] assemblies + ) + { + // Get all types that implement the IMessage interface + var messageTypes = ReflectionUtilities + .GetAllTypesImplementingInterface(assemblies) + .Where(x => !x.IsGenericType); + + foreach (var messageType in messageTypes) + { + var eventEnvelopeInterfaceMessageType = typeof(IEventEnvelope<>).MakeGenericType(messageType); + var eventEnvelopeInterfaceConfigurator = publishTopology.GetMessageTopology( + eventEnvelopeInterfaceMessageType + ); + + // setup primary exchange + eventEnvelopeInterfaceConfigurator.Durable = true; + eventEnvelopeInterfaceConfigurator.ExchangeType = ExchangeType.Direct; + + var eventEnvelopeMessageType = typeof(EventEnvelope<>).MakeGenericType(messageType); + var eventEnvelopeMessageTypeConfigurator = publishTopology.GetMessageTopology(eventEnvelopeMessageType); + eventEnvelopeMessageTypeConfigurator.Durable = true; + eventEnvelopeMessageTypeConfigurator.ExchangeType = ExchangeType.Direct; + + // none event-envelope message types + var messageConfigurator = publishTopology.GetMessageTopology(messageType); + messageConfigurator.Durable = true; + messageConfigurator.ExchangeType = ExchangeType.Direct; + } + } + private static IRetryConfigurator AddRetryConfiguration(IRetryConfigurator retryConfigurator) { retryConfigurator diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MassTransitBus.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MassTransitBus.cs index 8c30c85b..11175848 100644 --- a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MassTransitBus.cs +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MassTransitBus.cs @@ -1,110 +1,116 @@ using BuildingBlocks.Abstractions.Events; using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Abstractions.Messaging.PersistMessage; using BuildingBlocks.Core.Events; -using MassTransit; +using BuildingBlocks.Core.Messaging; +using Humanizer; +using Microsoft.Extensions.Options; +using MessageHeaders = BuildingBlocks.Core.Messaging.MessageHeaders; namespace BuildingBlocks.Integration.MassTransit; public class MassTransitBus( - ISendEndpointProvider sendEndpointProvider, - IPublishEndpoint publishEndpoint, - IMessageMetadataAccessor messageMetadataAccessor + IBusDirectPublisher busDirectPublisher, + IMessageMetadataAccessor messageMetadataAccessor, + IMessagePersistenceService messagePersistenceService, + IOptions messagingOptions ) : IExternalEventBus { + private readonly MessagingOptions _messagingOptions = messagingOptions.Value; + public Task PublishAsync(TMessage message, CancellationToken cancellationToken = default) where TMessage : IMessage { var correlationId = messageMetadataAccessor.GetCorrelationId(); var cautionId = messageMetadataAccessor.GetCorrelationId(); - var envelopeMessage = EventEnvelope.From(message, correlationId, cautionId); - - return PublishAsync(envelopeMessage, cancellationToken); - } - - public async Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) - { - await publishEndpoint.Publish( - eventEnvelope, - envelopeWrapperContext => FillMasstransitContextInformation(eventEnvelope, envelopeWrapperContext), - cancellationToken + var messageTypeName = message.GetType().Name.Underscore(); + + var eventEnvelope = EventEnvelope.From( + message, + correlationId, + cautionId, + new Dictionary + { + { MessageHeaders.ExchangeOrTopic, $"{messageTypeName}{MessagingConstants.PrimaryExchangePostfix}" }, + { MessageHeaders.Queue, messageTypeName } + } ); + + return PublishAsync(eventEnvelope, cancellationToken); } - public async Task PublishAsync( - IEventEnvelope eventEnvelope, - string? exchangeOrTopic = null, - string? queue = null, + public async Task PublishAsync( + IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default ) + where TMessage : IMessage { - if (string.IsNullOrEmpty(queue) && string.IsNullOrEmpty(exchangeOrTopic)) + if (_messagingOptions.OutboxEnabled) { - await PublishAsync(eventEnvelope, cancellationToken); + await messagePersistenceService.AddPublishMessageAsync(eventEnvelope, cancellationToken); return; } - // Ref: https://stackoverflow.com/a/60269493/581476 - string endpointAddress = GetEndpointAddress(exchangeOrTopic, queue); - - var sendEndpoint = await sendEndpointProvider.GetSendEndpoint(new Uri(endpointAddress)); - await sendEndpoint.Send( - eventEnvelope, - envelopeWrapperContext => FillMasstransitContextInformation(eventEnvelope, envelopeWrapperContext), - cancellationToken - ); + await busDirectPublisher.PublishAsync(eventEnvelope, cancellationToken); } - private static void FillMasstransitContextInformation( - IEventEnvelope eventEnvelope, - PublishContext envelopeWrapperContext + public async Task PublishAsync( + TMessage message, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default ) + where TMessage : IMessage { - if (eventEnvelope.Metadata is null) + var correlationId = messageMetadataAccessor.GetCorrelationId(); + var cautionId = messageMetadataAccessor.GetCorrelationId(); + var messageTypeName = message.GetType().Name.Underscore(); + + var eventEnvelope = EventEnvelope.From( + message, + correlationId, + cautionId, + new Dictionary + { + { + MessageHeaders.ExchangeOrTopic, + exchangeOrTopic ?? $"{messageTypeName}{MessagingConstants.PrimaryExchangePostfix}" + }, + { MessageHeaders.Queue, queue ?? messageTypeName } + } + ); + + if (_messagingOptions.OutboxEnabled) { + await messagePersistenceService.AddPublishMessageAsync(eventEnvelope, cancellationToken); return; } - // https://masstransit.io/documentation/concepts/messages#message-headers - // https://www.enterpriseintegrationpatterns.com/patterns/messaging/EnvelopeWrapper.html - // Just for filling masstransit related field, but we have a separated envelope message. - envelopeWrapperContext.MessageId = eventEnvelope.Metadata.MessageId; - envelopeWrapperContext.CorrelationId = eventEnvelope.Metadata.CorrelationId; - - foreach (var header in eventEnvelope.Metadata.Headers) - { - envelopeWrapperContext.Headers.Set(header.Key, header.Value); - } + await busDirectPublisher.PublishAsync(eventEnvelope, exchangeOrTopic, queue, cancellationToken); } - private static void FillMasstransitContextInformation( - IEventEnvelope eventEnvelope, - SendContext envelopeWrapperContext + public async Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default ) + where TMessage : IMessage { - if (eventEnvelope.Metadata is null) - { - return; - } + var messageTypeName = eventEnvelope.Message.GetType().Name.Underscore(); - // https://masstransit.io/documentation/concepts/messages#message-headers - // https://www.enterpriseintegrationpatterns.com/patterns/messaging/EnvelopeWrapper.html - // Just for filling masstransit related field, but we have a separated envelope message. - envelopeWrapperContext.MessageId = eventEnvelope.Metadata.MessageId; - envelopeWrapperContext.CorrelationId = eventEnvelope.Metadata.CorrelationId; - - foreach (var header in eventEnvelope.Metadata.Headers) + if (_messagingOptions.OutboxEnabled) { - envelopeWrapperContext.Headers.Set(header.Key, header.Value); + await messagePersistenceService.AddPublishMessageAsync(eventEnvelope, cancellationToken); + return; } - } - private static string GetEndpointAddress(string? exchangeOrTopic, string? queue) - { - return !string.IsNullOrEmpty(queue) && !string.IsNullOrEmpty(exchangeOrTopic) - ? $"exchange:{exchangeOrTopic}?bind=true&queue={queue}" - : !string.IsNullOrEmpty(queue) - ? $"queue={queue}" - : $"exchange:{exchangeOrTopic}"; + await busDirectPublisher.PublishAsync( + eventEnvelope, + exchangeOrTopic ?? $"{messageTypeName}{MessagingConstants.PrimaryExchangePostfix}", + queue ?? messageTypeName, + cancellationToken + ); } public void Consume( diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MasstransitDirectPublisher.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MasstransitDirectPublisher.cs new file mode 100644 index 00000000..a7908b5a --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MasstransitDirectPublisher.cs @@ -0,0 +1,163 @@ +using System.Reflection; +using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Abstractions.Messaging; +using BuildingBlocks.Core.Messaging; +using Humanizer; +using MassTransit; +using RabbitMQ.Client; + +namespace BuildingBlocks.Integration.MassTransit; + +public class MasstransitDirectPublisher(IBus bus) : IBusDirectPublisher +{ + public async Task PublishAsync( + IEventEnvelope eventEnvelope, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage + { + // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/MassTransit/SqlTransport/SqlTransport/ConnectionContextSupervisor.cs#L35 + await bus.Publish( + eventEnvelope, + envelopeWrapperContext => FillMasstransitContextInformation(eventEnvelope, envelopeWrapperContext), + cancellationToken + ); + } + + public Task PublishAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) + { + var messageType = eventEnvelope.Message.GetType(); + + MethodInfo publishMethod = typeof(IBusDirectPublisher) + .GetMethods() + .FirstOrDefault(x => x.GetGenericArguments().Length != 0 && x.GetParameters().Length == 2)!; + MethodInfo genericPublishMethod = publishMethod.MakeGenericMethod(messageType); + + Task publishTask = (Task)genericPublishMethod.Invoke(this, new object[] { eventEnvelope, cancellationToken }); + + return publishTask!; + } + + public async Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + where TMessage : IMessage + { + var bindExchangeName = eventEnvelope.Message.GetType().Name.Underscore(); + + if (string.IsNullOrEmpty(exchangeOrTopic)) + { + exchangeOrTopic = $"{bindExchangeName}{MessagingConstants.PrimaryExchangePostfix}"; + } + + // Ref: https://stackoverflow.com/a/60269493/581476 + string endpointAddress = GetEndpointAddress( + exchangeOrTopic: exchangeOrTopic, + queue: queue, + bindExchange: bindExchangeName, + exchangeType: ExchangeType.Direct + ); + + var sendEndpoint = await bus.GetSendEndpoint(new Uri(endpointAddress)); + // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/MassTransit/SqlTransport/SqlTransport/ConnectionContextSupervisor.cs#L53 + await sendEndpoint.Send( + eventEnvelope, + envelopeWrapperContext => FillMasstransitContextInformation(eventEnvelope, envelopeWrapperContext), + cancellationToken + ); + } + + public Task PublishAsync( + IEventEnvelope eventEnvelope, + string? exchangeOrTopic = null, + string? queue = null, + CancellationToken cancellationToken = default + ) + { + var messageType = eventEnvelope.Message.GetType(); + + MethodInfo publishMethod = typeof(IBusDirectPublisher) + .GetMethods() + .FirstOrDefault(x => x.GetGenericArguments().Length != 0 && x.GetParameters().Length == 4)!; + MethodInfo genericPublishMethod = publishMethod.MakeGenericMethod(messageType); + + Task publishTask = (Task) + genericPublishMethod.Invoke( + this, + new object[] { eventEnvelope, exchangeOrTopic, queue, cancellationToken } + ); + + return publishTask!; + } + + private static void FillMasstransitContextInformation( + IEventEnvelope eventEnvelope, + PublishContext envelopeWrapperContext + ) + { + // https://masstransit.io/documentation/concepts/messages#message-headers + // https://www.enterpriseintegrationpatterns.com/patterns/messaging/EnvelopeWrapper.html + // Just for filling masstransit related field, but we have a separated envelope message. + envelopeWrapperContext.MessageId = eventEnvelope.Metadata.MessageId; + envelopeWrapperContext.CorrelationId = eventEnvelope.Metadata.CorrelationId; + envelopeWrapperContext.SetRoutingKey(eventEnvelope.Message.GetType().Name.Underscore()); + + foreach (var header in eventEnvelope.Metadata.Headers) + { + envelopeWrapperContext.Headers.Set(header.Key, header.Value); + } + } + + private static void FillMasstransitContextInformation( + IEventEnvelope eventEnvelope, + SendContext envelopeWrapperContext + ) + { + // https://masstransit.io/documentation/concepts/messages#message-headers + // https://www.enterpriseintegrationpatterns.com/patterns/messaging/EnvelopeWrapper.html + // Just for filling masstransit related field, but we have a separated envelope message. + envelopeWrapperContext.MessageId = eventEnvelope.Metadata.MessageId; + envelopeWrapperContext.CorrelationId = eventEnvelope.Metadata.CorrelationId; + + foreach (var header in eventEnvelope.Metadata.Headers) + { + envelopeWrapperContext.Headers.Set(header.Key, header.Value); + } + } + + private static string GetEndpointAddress( + string exchangeOrTopic, + string? queue, + string? bindExchange, + string? exchangeType = ExchangeType.Direct, + bool bindQueue = false + ) + { + // https://masstransit.io/documentation/concepts/producers#short-addresses + // https://github.com/MassTransit/MassTransit/blob/ac44867da9d7a93bb7d330680586af123c1ee0b7/src/Transports/MassTransit.RabbitMqTransport/RabbitMqEndpointAddress.cs#L63 + // https://github.com/MassTransit/MassTransit/blob/ac44867da9d7a93bb7d330680586af123c1ee0b7/src/Transports/MassTransit.RabbitMqTransport/RabbitMqEndpointAddress.cs#L98 + // Start with the base address + string endpoint = $"exchange:{exchangeOrTopic}?type={exchangeType}&durable=true"; + + // If there is a bindExchange, add it to the query parameters + if (!string.IsNullOrEmpty(bindExchange)) + { + endpoint += $"&bindexchange={bindExchange}"; + } + + if (!string.IsNullOrEmpty(queue)) + { + endpoint += $"&queue={queue}"; + } + + if (bindQueue) + { + endpoint += "&bind=true"; + } + + return endpoint; + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/PublishConvention.cs b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/PublishConvention.cs new file mode 100644 index 00000000..e083fddc --- /dev/null +++ b/src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/PublishConvention.cs @@ -0,0 +1,102 @@ +using MassTransit; +using MassTransit.Configuration; +using MassTransit.RabbitMqTransport.Configuration; +using RabbitMQ.Client; + +namespace BuildingBlocks.Integration.MassTransit; + +public class CustomPublishTopologyConvention : IPublishTopologyConvention +{ + public bool TryGetMessagePublishTopologyConvention(out IMessagePublishTopologyConvention convention) + where T : class + { + convention = new CustomMessagePublishTopologyConvention(); + return true; + } +} + +public class CustomMessagePublishTopologyConvention : IMessagePublishTopologyConvention + where T : class +{ + public bool TryGetMessagePublishTopology(out IMessagePublishTopology messagePublishTopology) + { + // Return a custom message publish topology + messagePublishTopology = new CustomMessagePublishTopology(); + return true; + } + + // This method is responsible for retrieving a publish topology convention for a given message type T1 + public bool TryGetMessagePublishTopologyConvention(out IMessagePublishTopologyConvention convention) + where T1 : class + { + // If T1 is the same as T, return this convention for T1 + if (typeof(T1) == typeof(T)) + { + convention = (IMessagePublishTopologyConvention)this; + return true; + } + + // If it's a different type, we cannot provide a topology convention + convention = null; + return false; + } +} + +// This class represents the custom publish topology for a specific message type T +public class CustomMessagePublishTopology(bool exclude = false) : IMessagePublishTopology + where T : class +{ + // A flag indicating whether this topology should be excluded + public bool Exclude { get; private set; } = exclude; + + // Apply custom publish topology using the provided builder + public void Apply(ITopologyPipeBuilder> builder) + { + // Here we apply the custom settings for the exchange + builder.AddFilter(new CustomPublishTopologyFilter()); + } + + // Attempt to get the publish address by adding the exchange name to the base address + public bool TryGetPublishAddress(Uri baseAddress, out Uri? publishAddress) + { + // The baseAddress is typically the base RabbitMQ URL (e.g., rabbitmq://localhost/) + // We want to append the exchange name to the base address for the specific message type + + // For example, you can derive the exchange name based on the message type T + var exchangeName = typeof(T).Name.ToLowerInvariant(); // Example: Exchange name based on message type + + // Build the final publish address (e.g., rabbitmq://localhost/exchange/my-message-type) + if (baseAddress != null) + { + var builder = new UriBuilder(baseAddress) { Path = $"exchange/{exchangeName}" }; + + publishAddress = builder.Uri; + return true; + } + + publishAddress = null; + return false; + } +} + +public class CustomPublishTopologyFilter : IFilter> + where T : class +{ + public async Task Send(PublishContext context, IPipe> next) + { + // This is where you configure the exchange for the message + var exchange = context.GetPayload(); + + // Set the exchange to be durable and of type 'direct' + exchange.Durable = true; + exchange.ExchangeType = ExchangeType.Direct; + + // Continue processing the next filters in the pipeline + await next.Send(context); + } + + public void Probe(ProbeContext context) + { + context.CreateFilterScope("CustomPublishTopologyFilter"); + } +} diff --git a/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/DependencyInjectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/DependencyInjectionExtensions.cs index acb3bff3..adba7d3d 100644 --- a/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/DependencyInjectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Messaging.Persistence.Postgres/Extensions/DependencyInjectionExtensions.cs @@ -19,11 +19,10 @@ public static void AddPostgresMessagePersistence( { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - var options = configuration.BindOptions(); - configurator?.Invoke(options); + var options = configuration.BindOptions(configurator); // add option to the dependency injection - services.AddValidationOptions(opt => configurator?.Invoke(opt)); + services.AddValidationOptions(configurator); services.TryAddScoped(sp => new NpgsqlMessagePersistenceConnectionFactory( options.ConnectionString.NotBeEmptyOrNull() diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/DependencyInjectionExtensions.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/DependencyInjectionExtensions.cs index ab1782ef..9ebd9693 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/DependencyInjectionExtensions.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.EfCore.Postgres/DependencyInjectionExtensions.cs @@ -28,11 +28,10 @@ params Assembly[] assembliesToScan { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - var options = configuration.BindOptions(); - configurator?.Invoke(options); + // Add option to the dependency injection + services.AddValidationOptions(configurator); - // add option to the dependency injection - services.AddValidationOptions(opt => configurator?.Invoke(opt)); + var options = configuration.BindOptions(configurator); services.TryAddScoped(sp => new NpgsqlConnectionFactory( options.ConnectionString.NotBeEmptyOrNull() diff --git a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs index 8b67a457..07d3954c 100644 --- a/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs +++ b/src/BuildingBlocks/BuildingBlocks.Persistence.Mongo/MongoDbContext.cs @@ -30,9 +30,6 @@ public IMongoCollection GetCollection(string? name = null) public void Dispose() { - while (Session is { IsInTransaction: true }) - Thread.Sleep(TimeSpan.FromMilliseconds(100)); - GC.SuppressFinalize(this); } diff --git a/src/BuildingBlocks/BuildingBlocks.Serialization/MemoryPack/MemoryPackMessageSerializer.cs b/src/BuildingBlocks/BuildingBlocks.Serialization/MemoryPack/MemoryPackMessageSerializer.cs index 28206f3f..573c1194 100644 --- a/src/BuildingBlocks/BuildingBlocks.Serialization/MemoryPack/MemoryPackMessageSerializer.cs +++ b/src/BuildingBlocks/BuildingBlocks.Serialization/MemoryPack/MemoryPackMessageSerializer.cs @@ -1,5 +1,6 @@ using System.Text; using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Serialization; using BuildingBlocks.Core.Events; using MemoryPack; @@ -16,11 +17,10 @@ public string Serialize(IEventEnvelope eventEnvelope) return Encoding.UTF8.GetString(MemoryPackSerializer.Serialize(eventEnvelope, options)); } - public IEventEnvelope? Deserialize(string eventEnvelope) + public string Serialize(IEventEnvelope eventEnvelope) + where T : IMessage { - ReadOnlySpan byteSpan = StringToReadOnlySpan(eventEnvelope); - - return MemoryPackSerializer.Deserialize>(byteSpan, options); + return Encoding.UTF8.GetString(MemoryPackSerializer.Serialize(eventEnvelope, options)); } public IEventEnvelope? Deserialize(string eventEnvelope, Type messageType) @@ -34,6 +34,14 @@ public string Serialize(IEventEnvelope eventEnvelope) return MemoryPackSerializer.Deserialize(eventEnvelopGenericType, byteSpan, options) as IEventEnvelope; } + public IEventEnvelope? Deserialize(string eventEnvelope) + where T : IMessage + { + ReadOnlySpan byteSpan = StringToReadOnlySpan(eventEnvelope); + + return MemoryPackSerializer.Deserialize>(byteSpan, options); + } + private static ReadOnlySpan StringToReadOnlySpan(string input) { // Choose the encoding diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ae5ff87d..82013843 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -16,6 +16,11 @@ All + + + true + + false diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index be29dbe9..67428ca6 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -159,6 +159,7 @@ + diff --git a/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs index 1beea7f5..86b1e03b 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers.Api/Program.cs @@ -57,11 +57,11 @@ if (app.Environment.IsDevelopment() || app.Environment.IsTest()) { - app.Services.ValidateDependencies( - builder.Services, - typeof(CustomersMetadata).Assembly, - Assembly.GetExecutingAssembly() - ); + // app.Services.ValidateDependencies( + // builder.Services, + // typeof(CustomersMetadata).Assembly, + // Assembly.GetExecutingAssembly() + // ); } /*----------------- Module Middleware Setup ------------------*/ diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs index a9f5a878..12a3dc21 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/CustomersMapping.cs @@ -6,6 +6,7 @@ using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1; using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Events.Domain; using FoodDelivery.Services.Customers.Customers.Features.UpdatingCustomer.v1.Read.Mongo; +using Riok.Mapperly.Abstractions; using Customer = FoodDelivery.Services.Customers.Customers.Models.Reads.Customer; namespace FoodDelivery.Services.Customers.Customers; @@ -49,10 +50,6 @@ public CustomersMapping() .ForMember(x => x.InternalCommandId, opt => opt.Ignore()) .ForMember(x => x.OccurredOn, opt => opt.MapFrom(x => x.Created)); - CreateMap() - .ForMember(x => x.Id, opt => opt.Ignore()) - .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)); - CreateMap() .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.Id.Value)) .ForMember(x => x.Id, opt => opt.Ignore()) @@ -80,7 +77,10 @@ public CustomersMapping() .ForMember(x => x.CustomerId, opt => opt.MapFrom(x => x.CustomerId)) .ForMember(x => x.Created, opt => opt.MapFrom(x => x.OccurredOn)); - CreateMap(); + CreateMap() + .ForMember(dest => dest.CustomerId, opt => opt.MapFrom(src => src.Id)) + .ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreatedAt)) + .ForMember(dest => dest.DetailAddress, opt => opt.MapFrom(src => src.Address)); CreateMap(); @@ -99,3 +99,21 @@ public CustomersMapping() ); } } + +// https://mapperly.riok.app/docs/configuration/static-mappers/ +[Mapper] +public static partial class CustomerCreatedMapper +{ + [MapProperty(nameof(CustomerCreated.Id), nameof(CreateCustomerRead.CustomerId))] + [MapProperty(nameof(CustomerCreated.CreatedAt), nameof(CreateCustomerRead.Created))] + [MapProperty(nameof(CustomerCreated.Address), nameof(CreateCustomerRead.DetailAddress))] + public static partial CreateCustomerRead ToCreateCustomerRead(this CustomerCreated customerCreated); +} + +[Mapper] +public static partial class CreateCustomerReadMapper +{ + [MapperIgnoreTarget(nameof(Customer.Id))] + [MapProperty(nameof(CreateCustomerRead.CustomerId), nameof(Customer.CustomerId))] + public static partial Customer ToCustomer(this CreateCustomerRead createCustomerRead); +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs deleted file mode 100644 index 67f3cb98..00000000 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/GuardExtensions.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace FoodDelivery.Services.Customers.Customers.Extensions; - -public static class GuardExtensions { } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs deleted file mode 100644 index 5edd040e..00000000 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Extensions/MassTransitExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; -using Humanizer; -using MassTransit; -using RabbitMQ.Client; - -namespace FoodDelivery.Services.Customers.Customers.Extensions; - -internal static class MassTransitExtensions -{ - internal static void AddCustomerPublishers(this IRabbitMqBusFactoryConfigurator cfg) - { - cfg.Message(e => - e.SetEntityName($"{nameof(CustomerCreatedV1).Underscore()}.input_exchange") - ); // name of the primary exchange - cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type - cfg.Send(e => - { - // route by message type to binding fanout exchange (exchange to exchange binding) - e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); - }); - } -} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs index a3070962..dafb4c2b 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Events/Domain/CustomerCreated.cs @@ -24,6 +24,8 @@ public record CustomerCreated( string PhoneNumber, Guid IdentityId, DateTime CreatedAt, + string? Country, + string? City, string? Address, DateTime? BirthDate, string? Nationality @@ -37,6 +39,8 @@ public static CustomerCreated Of( string? phoneNumber, Guid identityId, DateTime createdAt, + string? country, + string? city, string? address, DateTime? birthDate, string? nationality @@ -51,6 +55,8 @@ public static CustomerCreated Of( phoneNumber!, identityId, createdAt, + country, + city, address!, birthDate, nationality! @@ -99,7 +105,7 @@ internal class CustomerCreatedHandler(ICommandBus commandBus, IMapper mapper) : public Task Handle(CustomerCreated notification, CancellationToken cancellationToken) { notification.NotBeNull(); - var mongoReadCommand = mapper.Map(notification); + var mongoReadCommand = notification.ToCreateCustomerRead(); // https://github.com/kgrzybek/modular-monolith-with-ddd#38-internal-processing // Schedule multiple read sides to execute here diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs index ec1321be..ed519df8 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Features/CreatingCustomer/v1/Read/Mongo/CreateCustomerRead.cs @@ -80,8 +80,7 @@ public CreateCustomerReadValidator() } } -internal class CreateCustomerReadHandler(IMapper mapper, ICustomersReadUnitOfWork unitOfWork) - : ICommandHandler +internal class CreateCustomerReadHandler(ICustomersReadUnitOfWork unitOfWork) : ICommandHandler { // totally we don't need to unit test our handlers according jimmy bogard blogs and videos, and we should extract our business to domain or seperated class so we don't need repository pattern for test, but for a sample I use it here // https://www.reddit.com/r/dotnet/comments/rxuqrb/testing_mediator_handlers/ @@ -90,7 +89,7 @@ public async Task Handle(CreateCustomerRead command, CancellationToken cancellat { command.NotBeNull(); - var readModel = mapper.Map(command); + var readModel = command.ToCustomer(); await unitOfWork.CustomersRepository.AddAsync(readModel, cancellationToken: cancellationToken); await unitOfWork.CommitAsync(cancellationToken); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/MassTransitExtensions.cs new file mode 100644 index 00000000..c75814c2 --- /dev/null +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/MassTransitExtensions.cs @@ -0,0 +1,37 @@ +using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Core.Messaging; +using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; +using Humanizer; +using MassTransit; + +namespace FoodDelivery.Services.Customers.Customers; + +internal static class MassTransitExtensions +{ + internal static void AddCustomerPublishers(this IRabbitMqBusFactoryConfigurator cfg) + { + // https://masstransit.io/documentation/transports/rabbitmq + cfg.Message>(e => + { + // name of the `primary exchange` for type based message publishing and sending + e.SetEntityName($"{nameof(CustomerCreatedV1).Underscore()}{MessagingConstants.PrimaryExchangePostfix}"); + }); + + // configuration for MessagePublishTopologyConfiguration and using IPublishEndpoint + cfg.Publish>(e => + { + // we configured some shared settings for all publish message in masstransit publish topologies + + // // setup primary exchange + // e.Durable = true; + // e.ExchangeType = ExchangeType.Direct; + }); + + // configuration for MessageSendTopologyConfiguration and using ISendEndpointProvider + cfg.Send>(e => + { + // route by message type to binding fanout exchange (exchange to exchange binding) + e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); + }); + } +} diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs index b49c0713..d83bc561 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/Models/Customer.cs @@ -57,6 +57,8 @@ public static Customer Create( phoneNumber, identityId, DateTime.Now, + address?.Country, + address?.City, address?.Detail, birthDate!, nationality! diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs index acb05294..f4afdf1e 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/CustomerId.cs @@ -13,5 +13,5 @@ private CustomerId(long value) // validations should be placed here instead of constructor public static CustomerId Of(long id) => new(id.NotBeNegativeOrZero()); - public static implicit operator long(CustomerId id) => id.Value; + public static implicit operator long(CustomerId? id) => id?.Value ?? default; } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs index 9227d700..90697b18 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Customers/ValueObjects/Nationality.cs @@ -40,5 +40,5 @@ public static Nationality Of([NotNull] string? value) return new Nationality(value); } - public static implicit operator string(Nationality value) => value.Value; + public static implicit operator string(Nationality? value) => value?.Value ?? string.Empty; } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs index 93f189f1..531bdc60 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/CustomersMetadata.cs @@ -1,3 +1,8 @@ +using Riok.Mapperly.Abstractions; + +// https://mapperly.riok.app/docs/configuration/mapper/#default-mapper-configuration +[assembly: MapperDefaults(EnumMappingIgnoreCase = true, EnumMappingStrategy = EnumMappingStrategy.ByName)] + namespace FoodDelivery.Services.Customers; -public class CustomersMetadata { } +public class CustomersMetadata; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs index 6cade967..73bfa72d 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/CreatingProduct/v1/Events/Integration/External/ProductCreatedConsumer.cs @@ -1,12 +1,12 @@ -using BuildingBlocks.Core.Events; +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Shared.Catalogs.Products.Events.V1.Integration; using MassTransit; namespace FoodDelivery.Services.Customers.Products.Features.CreatingProduct.v1.Events.Integration.External; -public class ProductCreatedConsumer : IConsumer> +public class ProductCreatedConsumer : IConsumer> { - public Task Consume(ConsumeContext> context) + public Task Consume(ConsumeContext> context) { return Task.CompletedTask; } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs index 4b6851db..caf9cbcc 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/Features/ReplenishingProductStock/v1/Events/Integration/External/ProductStockReplenishedConsumer.cs @@ -1,5 +1,5 @@ using BuildingBlocks.Abstractions.Commands; -using BuildingBlocks.Core.Events; +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Customers.RestockSubscriptions.Features.ProcessingRestockNotification.v1; using FoodDelivery.Services.Shared.Catalogs.Products.Events.V1.Integration; using MassTransit; @@ -7,13 +7,13 @@ namespace FoodDelivery.Services.Customers.Products.Features.ReplenishingProductStock.v1.Events.Integration.External; public class ProductStockReplenishedConsumer(ICommandBus commandBus, ILogger logger) - : IConsumer> + : IConsumer> { // If this handler is called successfully, it will send a ACK to rabbitmq for removing message from the queue and if we have an exception it send an NACK to rabbitmq // and with NACK we can retry the message with re-queueing this message to the broker - public async Task Consume(ConsumeContext> context) + public async Task Consume(ConsumeContext> context) { - var productStockReplenished = context.Message.Data; + var productStockReplenished = context.Message.Message; await commandBus.SendAsync( ProcessRestockNotification.Of(productStockReplenished.ProductId, productStockReplenished.NewStock) diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs index 8fce9597..182e60e4 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Products/MassTransitExtensions.cs @@ -1,3 +1,4 @@ +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Customers.Products.Features.CreatingProduct.v1.Events.Integration.External; using FoodDelivery.Services.Customers.Products.Features.ReplenishingProductStock.v1.Events.Integration.External; using FoodDelivery.Services.Shared.Catalogs.Products.Events.V1.Integration; @@ -11,58 +12,79 @@ internal static class MassTransitExtensions { internal static void AddProductEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) { - cfg.ReceiveEndpoint( - nameof(ProductStockReplenishedV1).Underscore(), - re => - { - // turns off default fanout settings - re.ConfigureConsumeTopology = true; + // we configured some shared settings for all receive endpoint message in masstransit consuming topologies - // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ - re.SetQuorumQueue(); - - re.Bind( - $"{nameof(ProductStockReplenishedV1).Underscore()}.input_exchange", - e => - { - e.RoutingKey = nameof(ProductStockReplenishedV1).Underscore(); - e.ExchangeType = ExchangeType.Direct; - } - ); - - // https://github.com/MassTransit/MassTransit/discussions/3117 - // https://masstransit-project.com/usage/configuration.html#receive-endpoints - re.ConfigureConsumer(context); - - re.RethrowFaultedMessages(); - } - ); - - cfg.ReceiveEndpoint( - nameof(ProductCreatedV1).Underscore(), - re => - { - // turns off default fanout settings - re.ConfigureConsumeTopology = true; - - // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ - re.SetQuorumQueue(); - - re.Bind( - $"{nameof(ProductCreatedV1).Underscore()}.input_exchange", - e => - { - e.RoutingKey = nameof(ProductCreatedV1).Underscore(); - e.ExchangeType = ExchangeType.Direct; - } - ); - - // https://github.com/MassTransit/MassTransit/discussions/3117 - // https://masstransit-project.com/usage/configuration.html#receive-endpoints - re.ConfigureConsumer(context); - - re.RethrowFaultedMessages(); - } - ); + // // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/Transports/MassTransit.RabbitMqTransport/RabbitMqTransport/Configuration/RabbitMqReceiveEndpointBuilder.cs#L70 + // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // + // // https://masstransit.io/documentation/transports/rabbitmq + // // This `queueName` creates an `intermediary exchange` (default is Fanout, but we can change it with re.ExchangeType) with the same queue named which bound to this exchange + // cfg.ReceiveEndpoint( + // nameof(ProductStockReplenishedV1).Underscore(), + // re => + // { + // re.Durable = true; + // + // // set intermediate exchange type + // // intermediate exchange name will be the same as queue name + // re.ExchangeType = ExchangeType.Fanout; + // + // // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + // re.SetQuorumQueue(); + // + // // with setting `ConfigureConsumeTopology` to `false`, we should create `primary exchange` and its bounded exchange manually with using `re.Bind` otherwise with `ConfigureConsumeTopology=true` it get publish topology for message type `T` with `_publishTopology.GetMessageTopology()` and use its ExchangeType and ExchangeName based ofo default EntityFormatter + // re.ConfigureConsumeTopology = false; + // + // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // // masstransit uses `wire-tapping` pattern for defining exchanges. Primary exchange will send the message to intermediary fanout exchange + // // setup primary exchange and its type + // re.Bind>(e => + // { + // e.RoutingKey = nameof(ProductStockReplenishedV1).Underscore(); + // e.ExchangeType = ExchangeType.Direct; + // }); + // + // // https://github.com/MassTransit/MassTransit/discussions/3117 + // // https://masstransit-project.com/usage/configuration.html#receive-endpoints + // re.ConfigureConsumer(context); + // + // re.RethrowFaultedMessages(); + // } + // ); + // + // // https://masstransit.io/documentation/transports/rabbitmq + // // This `queueName` creates an `intermediary exchange` (default is Fanout, but we can change it with re.ExchangeType) with the same queue named which bound to this exchange + // cfg.ReceiveEndpoint( + // nameof(ProductCreatedV1).Underscore(), + // re => + // { + // re.Durable = true; + // + // // set intermediate exchange type + // // intermediate exchange name will be the same as queue name + // re.ExchangeType = ExchangeType.Fanout; + // + // // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + // re.SetQuorumQueue(); + // + // // with setting `ConfigureConsumeTopology` to `false`, we should create `primary exchange` and its bounded exchange manually with using `re.Bind` otherwise with `ConfigureConsumeTopology=true` it get publish topology for message type `T` with `_publishTopology.GetMessageTopology()` and use its ExchangeType and ExchangeName based ofo default EntityFormatter + // re.ConfigureConsumeTopology = false; + // + // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // // masstransit uses `wire-tapping` pattern for defining exchanges. Primary exchange will send the message to intermediary fanout exchange + // // setup primary exchange and its type + // re.Bind>(e => + // { + // e.RoutingKey = nameof(ProductCreatedV1).Underscore(); + // e.ExchangeType = ExchangeType.Direct; + // }); + // + // // https://github.com/MassTransit/MassTransit/discussions/3117 + // // https://masstransit-project.com/usage/configuration.html#receive-endpoints + // re.ConfigureConsumer(context); + // + // re.RethrowFaultedMessages(); + // } + // ); } } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs index f095a55c..e4776337 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Features/CreatingRestockSubscription/v1/Events/Domain/RestockSubscriptionCreated.cs @@ -5,6 +5,7 @@ using FoodDelivery.Services.Customers.Customers.Exceptions.Application; using FoodDelivery.Services.Customers.Customers.ValueObjects; using FoodDelivery.Services.Customers.Shared.Data; +using FoodDelivery.Services.Customers.Shared.Data.Extensions; using FoodDelivery.Services.Customers.Shared.Extensions; namespace FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1.Events.Domain; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs index 762cff4e..bf5f9a69 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/MassTransitExtensions.cs @@ -1,7 +1,8 @@ +using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Core.Messaging; using FoodDelivery.Services.Shared.Customers.RestockSubscriptions.Events.V1.Integration; using Humanizer; using MassTransit; -using RabbitMQ.Client; namespace FoodDelivery.Services.Customers.RestockSubscriptions; @@ -9,11 +10,26 @@ internal static class MassTransitExtensions { internal static void AddRestockSubscriptionPublishers(this IRabbitMqBusFactoryConfigurator cfg) { - cfg.Message(e => - e.SetEntityName($"{nameof(RestockSubscriptionCreatedV1).Underscore()}.input_exchange") - ); // name of the primary exchange - cfg.Publish(e => e.ExchangeType = ExchangeType.Direct); // primary exchange type - cfg.Send(e => + cfg.Message>(e => + { + // we configured some shared settings for all publish message in masstransit publish topologies + + // name of the primary exchange + e.SetEntityName( + $"{nameof(RestockSubscriptionCreatedV1).Underscore()}{MessagingConstants.PrimaryExchangePostfix}" + ); + }); + + cfg.Publish>(e => + { + // // we configured some shared settings for all publish message in masstransit publish topologies + // + // // primary exchange type + // e.ExchangeType = ExchangeType.Direct; + // e.Durable = true; + }); + + cfg.Send>(e => { // route by message type to binding fanout exchange (exchange to exchange binding) e.UseRoutingKeyFormatter(context => context.Message.GetType().Name.Underscore()); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs index 98e4ee74..50a00898 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/RestockSubscriptions/Models/Write/RestockSubscription.cs @@ -39,7 +39,8 @@ Email email { Id = id, CustomerId = customerId, - ProductInformation = productInformation + ProductInformation = productInformation, + Created = DateTime.Now }; restockSubscription.ChangeEmail(email); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs index 9c5a03af..dd1024b0 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/MappingProfile.cs @@ -2,7 +2,7 @@ using FoodDelivery.Services.Customers.Products.Models; using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; -using Microsoft.AspNetCore.Identity; +using FoodDelivery.Services.Customers.Users.Model; namespace FoodDelivery.Services.Customers.Shared.Clients; @@ -11,6 +11,6 @@ public class ClientsMappingProfile : Profile public ClientsMappingProfile() { CreateMap(); - CreateMap(); + CreateMap(); } } diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/WebApplicationBuilderExtensions.DependencyInjection.cs similarity index 96% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/WebApplicationBuilderExtensions.DependencyInjection.cs index c810d196..cccda6db 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/HttpClient.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Clients/WebApplicationBuilderExtensions.DependencyInjection.cs @@ -6,13 +6,12 @@ using FoodDelivery.Services.Customers.Shared.Clients.Identity; using Microsoft.Extensions.Options; -namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; +namespace FoodDelivery.Services.Customers.Shared.Clients; public static partial class WebApplicationBuilderExtensions { public static WebApplicationBuilder AddCustomHttpClients(this WebApplicationBuilder builder) { - builder.Services.AddValidatedOptions(); builder.Services.AddValidatedOptions(); AddCatalogsApiClient(builder); @@ -24,6 +23,7 @@ public static WebApplicationBuilder AddCustomHttpClients(this WebApplicationBuil private static void AddIdentityApiClient(WebApplicationBuilder builder) { + builder.Services.AddValidatedOptions(); builder.Services.AddHttpClient( (client, sp) => { diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/CustomersDbContextExtensions.cs similarity index 83% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/CustomersDbContextExtensions.cs index bc39b54f..64114a84 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/CustomersDbContextExtensions.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/CustomersDbContextExtensions.cs @@ -1,9 +1,8 @@ using FoodDelivery.Services.Customers.Customers.Models; using FoodDelivery.Services.Customers.Customers.ValueObjects; -using FoodDelivery.Services.Customers.Shared.Data; using Microsoft.EntityFrameworkCore; -namespace FoodDelivery.Services.Customers.Shared.Extensions; +namespace FoodDelivery.Services.Customers.Shared.Data.Extensions; public static class CustomersDbContextExtensions { diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationBuilderExtensions.DependencyInjection.cs similarity index 94% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationBuilderExtensions.DependencyInjection.cs index c193458b..abe18ee2 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Persistence.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationBuilderExtensions.DependencyInjection.cs @@ -6,11 +6,10 @@ using FoodDelivery.Services.Customers.Customers.Data.UOW.Mongo; using FoodDelivery.Services.Customers.RestockSubscriptions.Data.Repositories.Mongo; using FoodDelivery.Services.Customers.Shared.Contracts; -using FoodDelivery.Services.Customers.Shared.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; +namespace FoodDelivery.Services.Customers.Shared.Data.Extensions; public static partial class WebApplicationBuilderExtensions { diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationExtensions.Migration.cs similarity index 82% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationExtensions.Migration.cs index b5bc9200..fb225eed 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Migration.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Data/Extensions/WebApplicationExtensions.Migration.cs @@ -1,6 +1,6 @@ using BuildingBlocks.Abstractions.Persistence; -namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationExtensions; +namespace FoodDelivery.Services.Customers.Shared.Data.Extensions; public static partial class WebApplicationExtensions { diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/WebApplicationBuilderExtensions.Infrastructure.cs similarity index 93% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/WebApplicationBuilderExtensions.Infrastructure.cs index 8f782e3c..6fcd6567 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/WebApplicationBuilderExtensions.Infrastructure.cs @@ -3,16 +3,12 @@ using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Messaging; using BuildingBlocks.Core.Persistence.EfCore; -using BuildingBlocks.Core.Web.Extensions; using BuildingBlocks.Core.Web.HeaderPropagation.Extensions; using BuildingBlocks.Email; -using BuildingBlocks.HealthCheck; using BuildingBlocks.Integration.MassTransit; using BuildingBlocks.Logging; using BuildingBlocks.Messaging.Persistence.Postgres.Extensions; using BuildingBlocks.OpenTelemetry; -using BuildingBlocks.Persistence.EfCore.Postgres; -using BuildingBlocks.Persistence.Mongo; using BuildingBlocks.Security.Jwt; using BuildingBlocks.Swagger; using BuildingBlocks.Validation; @@ -20,13 +16,11 @@ using BuildingBlocks.Web.Extensions; using BuildingBlocks.Web.RateLimit; using BuildingBlocks.Web.Versioning; -using FoodDelivery.Services.Customers.Customers.Extensions; +using FoodDelivery.Services.Customers.Customers; using FoodDelivery.Services.Customers.Products; using FoodDelivery.Services.Customers.RestockSubscriptions; -using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; -using FoodDelivery.Services.Customers.Shared.Clients.Identity; +using FoodDelivery.Services.Customers.Shared.Clients; using FoodDelivery.Services.Customers.Users; -using Microsoft.Extensions.Diagnostics.HealthChecks; namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; @@ -142,7 +136,7 @@ public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder.AddCustomRateLimit(); builder.AddCustomMassTransit( - (context, cfg) => + configureReceiveEndpoints: (context, cfg) => { cfg.AddUsersEndpoints(context); cfg.AddProductEndpoints(context); @@ -150,7 +144,12 @@ public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder cfg.AddCustomerPublishers(); cfg.AddRestockSubscriptionPublishers(); }, - autoConfigEndpoints: false + configureMessagingOptions: msgCfg => + { + msgCfg.AutoConfigEndpoints = false; + msgCfg.OutboxEnabled = true; + msgCfg.InboxEnabled = true; + } ); builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/WebApplicationBuilderExtensions.ProblemDetails.cs similarity index 100% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/ProblemDetails.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationBuilderExtensions/WebApplicationBuilderExtensions.ProblemDetails.cs diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/WebApplicationExtensions.Infrastructure.cs similarity index 98% rename from src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs rename to src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/WebApplicationExtensions.Infrastructure.cs index cf11b28f..50c97a0d 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/Infrastructure.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/Extensions/WebApplicationExtensions/WebApplicationExtensions.Infrastructure.cs @@ -6,6 +6,7 @@ using BuildingBlocks.Web.Middlewares.CaptureException; using BuildingBlocks.Web.Middlewares.HeaderPropagation; using BuildingBlocks.Web.RateLimit; +using FoodDelivery.Services.Customers.Shared.Data.Extensions; using Serilog; namespace FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationExtensions; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs index cd3e3e0e..78d60d0d 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Shared/SharedModulesConfiguration.cs @@ -1,5 +1,6 @@ using BuildingBlocks.Abstractions.Web.Module; using BuildingBlocks.Core; +using FoodDelivery.Services.Customers.Shared.Data.Extensions; using FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationBuilderExtensions; using FoodDelivery.Services.Customers.Shared.Extensions.WebApplicationExtensions; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs index 331008ff..13c92cef 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Users/Features/RegisteringUser/v1/Events/Integration/External/UserRegisteredConsumer.cs @@ -1,16 +1,16 @@ using BuildingBlocks.Abstractions.Commands; -using BuildingBlocks.Core.Events; +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; using FoodDelivery.Services.Shared.Identity.Users.Events.V1.Integration; using MassTransit; namespace FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; -public class UserRegisteredConsumer(ICommandBus commandBus) : IConsumer> +public class UserRegisteredConsumer(ICommandBus commandBus) : IConsumer> { - public async Task Consume(ConsumeContext> context) + public async Task Consume(ConsumeContext> context) { - var userRegistered = context.Message.Data; + var userRegistered = context.Message.Message; if (userRegistered.Roles is null || !userRegistered.Roles.Contains(CustomersConstants.Role.User)) return; diff --git a/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs b/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs index 2de2eaaf..4f54b506 100644 --- a/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs +++ b/src/Services/Customers/FoodDelivery.Services.Customers/Users/MassTransitExtensions.cs @@ -1,3 +1,4 @@ +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; using FoodDelivery.Services.Shared.Identity.Users.Events.V1.Integration; using Humanizer; @@ -10,31 +11,44 @@ internal static class MassTransitExtensions { internal static void AddUsersEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) { - cfg.ReceiveEndpoint( - nameof(UserRegisteredV1).Underscore(), - re => - { - // turns off default fanout settings - re.ConfigureConsumeTopology = false; + // we configured some shared settings for all publish message in masstransit publish topologies - // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ - re.SetQuorumQueue(); - - re.Bind( - $"{nameof(UserRegisteredV1).Underscore()}.input_exchange", - e => - { - e.RoutingKey = nameof(UserRegisteredV1).Underscore(); - e.ExchangeType = ExchangeType.Direct; - } - ); - - // https://github.com/MassTransit/MassTransit/discussions/3117 - // https://masstransit-project.com/usage/configuration.html#receive-endpoints - re.ConfigureConsumer(context); - - re.RethrowFaultedMessages(); - } - ); + // // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/Transports/MassTransit.RabbitMqTransport/RabbitMqTransport/Configuration/RabbitMqReceiveEndpointBuilder.cs#L70 + // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // + // // https://masstransit.io/documentation/transports/rabbitmq + // // This `queueName` creates an intermediary exchange (default is Fanout, but we can change it with re.ExchangeType) with the same queue named which bound to this exchange + // cfg.ReceiveEndpoint( + // queueName: nameof(UserRegisteredV1).Underscore(), + // re => + // { + // re.Durable = true; + // + // // set intermediate exchange type + // // intermediate exchange name will be the same as queue name + // re.ExchangeType = ExchangeType.Fanout; + // + // // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ + // re.SetQuorumQueue(); + // + // // with setting `ConfigureConsumeTopology` to `false`, we should create `primary exchange` and its bounded exchange manually with using `re.Bind` otherwise with `ConfigureConsumeTopology=true` it get publish topology for message type `T` with `_publishTopology.GetMessageTopology()` and use its ExchangeType and ExchangeName based ofo default EntityFormatter + // re.ConfigureConsumeTopology = true; + // + // // // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // // // masstransit uses `wire-tapping` pattern for defining exchanges. Primary exchange will send the message to intermediary fanout exchange + // // // setup primary exchange and its type + // // re.Bind>(e => + // // { + // // e.RoutingKey = nameof(UserRegisteredV1).Underscore(); + // // e.ExchangeType = ExchangeType.Direct; + // // }); + // + // // https://github.com/MassTransit/MassTransit/discussions/3117 + // // https://masstransit-project.com/usage/configuration.html#receive-endpoints + // re.ConfigureConsumer(context); + // + // re.RethrowFaultedMessages(); + // } + // ); } } diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs index 81e5018a..4082fb44 100644 --- a/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -114,11 +114,16 @@ public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder builder.AddCustomRateLimit(); builder.AddCustomMassTransit( - (context, cfg) => + configureReceiveEndpoints: (context, cfg) => { cfg.AddUserPublishers(); }, - autoConfigEndpoints: false + configureMessagingOptions: msgCfg => + { + msgCfg.AutoConfigEndpoints = false; + msgCfg.OutboxEnabled = true; + msgCfg.InboxEnabled = true; + } ); builder.AddCustomEasyCaching(); diff --git a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs index dac974ad..bb8cd572 100644 --- a/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs +++ b/src/Services/Identity/FoodDelivery.Services.Identity/Users/Features/RegisteringUser/v1/RegisterUser.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Abstractions.Commands; +using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; using BuildingBlocks.Core.Events; using BuildingBlocks.Validation.Extensions; @@ -94,10 +95,8 @@ public RegisterUserValidator() // using transaction script instead of using domain business logic here // https://www.youtube.com/watch?v=PrJIMTZsbDw -internal class RegisterUserHandler( - UserManager userManager, - IMessagePersistenceService messagePersistenceService -) : ICommandHandler +internal class RegisterUserHandler(UserManager userManager, IExternalEventBus externalEventBus) + : ICommandHandler { public async Task Handle(RegisterUser request, CancellationToken cancellationToken) { @@ -136,10 +135,7 @@ public async Task Handle(RegisterUser request, CancellationT // publish our integration event and save to outbox should do in same transaction of our business logic actions. we could use TxBehaviour or ITxDbContextExecutes interface // This service is not DDD, so we couldn't use DomainEventPublisher to publish mapped integration events - await messagePersistenceService.AddPublishMessageAsync( - new EventEnvelope(userRegistered), - cancellationToken - ); + await externalEventBus.PublishAsync(userRegistered, cancellationToken); return new RegisterUserResult( new IdentityUserDto diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs index 2a83192d..0f70c885 100644 --- a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/Features/CreatingCustomer/v1/Events/External/CustomerCreatedConsumer.cs @@ -1,12 +1,12 @@ -using BuildingBlocks.Core.Events; +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; using MassTransit; namespace FoodDelivery.Services.Orders.Customers.Features.CreatingCustomer.V1.Events.External; -public class CustomerCreatedConsumer : IConsumer> +public class CustomerCreatedConsumer : IConsumer> { - public Task Consume(ConsumeContext> context) + public Task Consume(ConsumeContext> context) { return Task.CompletedTask; } diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs index 057e6e66..5053cf6b 100644 --- a/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Customers/MassTransitExtensions.cs @@ -1,3 +1,4 @@ +using BuildingBlocks.Abstractions.Events; using FoodDelivery.Services.Orders.Customers.Features.CreatingCustomer.V1.Events.External; using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; using Humanizer; @@ -10,24 +11,35 @@ internal static class MassTransitExtensions { internal static void AddCustomerEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IBusRegistrationContext context) { + // https://github.com/MassTransit/MassTransit/blob/eb3c9ee1007cea313deb39dc7c4eb796b7e61579/src/Transports/MassTransit.RabbitMqTransport/RabbitMqTransport/Configuration/RabbitMqReceiveEndpointBuilder.cs#L70 + // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + + // https://masstransit.io/documentation/transports/rabbitmq + // This `queueName` creates an `intermediary exchange` (default is Fanout, but we can change it with re.ExchangeType) with the same queue named which bound to this exchange cfg.ReceiveEndpoint( nameof(CustomerCreatedV1).Underscore(), re => { - // turns off default fanout settings - re.ConfigureConsumeTopology = false; + re.Durable = true; + + // set intermediate exchange type + // intermediate exchange name will be the same as queue name + re.ExchangeType = ExchangeType.Fanout; // a replicated queue to provide high availability and data safety. available in RMQ 3.8+ re.SetQuorumQueue(); - re.Bind( - $"{nameof(CustomerCreatedV1).Underscore()}.input_exchange", - e => - { - e.RoutingKey = nameof(CustomerCreatedV1).Underscore(); - e.ExchangeType = ExchangeType.Direct; - } - ); + // with setting `ConfigureConsumeTopology` to `false`, we should create `primary exchange` and its bounded exchange manually with using `re.Bind` otherwise with `ConfigureConsumeTopology=true` it get publish topology for message type `T` with `_publishTopology.GetMessageTopology()` and use its ExchangeType and ExchangeName based ofo default EntityFormatter + re.ConfigureConsumeTopology = false; + + // https://spring.io/blog/2011/04/01/routing-topologies-for-performance-and-scalability-with-rabbitmq + // masstransit uses `wire-tapping` pattern for defining exchanges. Primary exchange will send the message to intermediary fanout exchange + // setup primary exchange and its type + re.Bind>(e => + { + e.RoutingKey = nameof(CustomerCreatedV1).Underscore(); + e.ExchangeType = ExchangeType.Direct; + }); // https://github.com/MassTransit/MassTransit/discussions/3117 // https://masstransit-project.com/usage/configuration.html#receive-endpoints diff --git a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs index b3a7046a..e37bd2b2 100644 --- a/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs +++ b/src/Services/Orders/FoodDelivery.Services.Orders/Shared/Extensions/WebApplicationBuilderExtensions/Infrastructure.cs @@ -134,11 +134,16 @@ public static WebApplicationBuilder AddInfrastructure(this WebApplicationBuilder }); builder.AddCustomMassTransit( - (context, cfg) => + configureReceiveEndpoints: (context, cfg) => { cfg.AddCustomerEndpoints(context); }, - autoConfigEndpoints: false + configureMessagingOptions: msgCfg => + { + msgCfg.AutoConfigEndpoints = false; + msgCfg.OutboxEnabled = true; + msgCfg.InboxEnabled = true; + } ); builder.Services.AddCustomValidators(Assembly.GetExecutingAssembly()); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs index 6143748c..b4af2d46 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/CustomerServiceEndToEndTestBase.cs @@ -1,9 +1,10 @@ using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; -using Microsoft.Extensions.Configuration; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Tests.Shared.Fixtures; using Xunit.Abstractions; @@ -13,6 +14,37 @@ namespace FoodDelivery.Services.Customers.EndToEndTests; public class CustomerServiceEndToEndTestBase : EndToEndTestTestBase { + private IdentityServiceWireMock? _identityServiceWireMock; + private CatalogsServiceWireMock? _catalogsServiceWireMock; + + public IdentityServiceWireMock IdentityServiceWireMock + { + get + { + if (_identityServiceWireMock is null) + { + var option = SharedFixture.ServiceProvider.GetRequiredService>(); + _identityServiceWireMock = new IdentityServiceWireMock(SharedFixture.WireMockServer, option.Value); + } + + return _identityServiceWireMock; + } + } + + public CatalogsServiceWireMock CatalogsServiceWireMock + { + get + { + if (_catalogsServiceWireMock is null) + { + var option = SharedFixture.ServiceProvider.GetRequiredService>(); + _catalogsServiceWireMock = new CatalogsServiceWireMock(SharedFixture.WireMockServer, option.Value); + } + + return _catalogsServiceWireMock; + } + } + // We don't need to inject `CustomersServiceMockServersFixture` class fixture in the constructor because it initialized by `collection fixture` and its static properties are accessible in the codes public CustomerServiceEndToEndTestBase( SharedFixtureWithEfCoreAndMongo< @@ -29,12 +61,13 @@ ITestOutputHelper outputHelper // note2: add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ - SharedFixture.Configuration["IdentityApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture - .IdentityServiceMock - .Url; - SharedFixture.Configuration["CatalogsApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture - .CatalogsServiceMock - .Url; + sharedFixture.Factory.AddOverrideEnvKeyValues( + new Dictionary + { + { "IdentityApiClientOptions:BaseApiAddress", SharedFixture.WireMockServerUrl }, + { "CatalogsApiClientOptions:BaseApiAddress", SharedFixture.WireMockServerUrl }, + } + ); // var catalogApiOptions = Scope.ServiceProvider.GetRequiredService>(); // var identityApiOptions = Scope.ServiceProvider.GetRequiredService>(); @@ -43,27 +76,9 @@ ITestOutputHelper outputHelper // catalogApiOptions.Value.BaseApiAddress = MockServersFixture.CatalogsServiceMock.Url!; } - protected override void RegisterTestAppConfigurations( - IConfigurationBuilder builder, - IConfiguration configuration, - IHostEnvironment environment - ) - { - base.RegisterTestAppConfigurations(builder, configuration, environment); - } - protected override void RegisterTestConfigureServices(IServiceCollection services) { //// here we use same data seeder of service but if we need different data seeder for test for can replace it // services.ReplaceScoped(); } - - public override Task DisposeAsync() - { - // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests - CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); - CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); - - return base.DisposeAsync(); - } } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs index c5a21142..7591f9e1 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs @@ -1,5 +1,4 @@ using BuildingBlocks.Core.Exception.Types; -using BuildingBlocks.Validation; using FluentAssertions; using FoodDelivery.Services.Customers.Api; using FoodDelivery.Services.Customers.Customers.Exceptions.Application; @@ -7,7 +6,6 @@ using FoodDelivery.Services.Customers.Shared.Data; using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Requests; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using Microsoft.AspNetCore.Mvc; using Tests.Shared.Extensions; using Tests.Shared.Fixtures; @@ -32,9 +30,7 @@ ITestOutputHelper outputHelper public async Task can_returns_created_status_code_using_valid_dto_and_auth_credentials() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var fakeCreateCustomerRequest = new FakeCreateCustomerRequest(fakeIdentityUser!.Email).Generate(); var route = Constants.Routes.Customers.Create; @@ -50,9 +46,7 @@ public async Task can_returns_created_status_code_using_valid_dto_and_auth_crede public async Task can_returns_valid_response_using_valid_dto_and_auth_credentials() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var fakeCreateCustomerRequest = new FakeCreateCustomerRequest(fakeIdentityUser!.Email).Generate(); var route = Constants.Routes.Customers.Create; @@ -91,7 +85,6 @@ public async Task must_returns_conflict_status_code_when_customer_already_exists { Detail = $"Customer with email '{fakeCustomer.Email.Value}' already exists.", Title = nameof(CustomerAlreadyExistsException), - Type = "https://somedomain/application-error", } ) .And.Be409Conflict(); @@ -113,12 +106,7 @@ public async Task must_returns_bad_request_status_code_when_email_is_invalid() response .Should() .ContainsProblemDetail( - new ProblemDetails - { - Detail = "Email address is invalid.", - Title = nameof(ValidationException), - Type = "https://somedomain/input-validation-rules-error" - } + new ProblemDetails { Detail = "Email address is invalid.", Title = nameof(ValidationException), } ) .And.Be400BadRequest(); } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs index 1d1634e9..cf7055e7 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.EndToEndTests/EndToEndTestCollection.cs @@ -1,5 +1,4 @@ using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using Tests.Shared.Fixtures; namespace FoodDelivery.Services.Customers.EndToEndTests; @@ -10,8 +9,7 @@ namespace FoodDelivery.Services.Customers.EndToEndTests; public class EndToEndTestCollection : ICollectionFixture< SharedFixtureWithEfCoreAndMongo - >, - ICollectionFixture + > { public const string Name = "End-To-End Test"; } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs index d27c0df8..58b20ca4 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/CustomerServiceIntegrationTestBase.cs @@ -1,8 +1,10 @@ +using FoodDelivery.Services.Customers.Api; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Identity; using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; -using Microsoft.Extensions.Configuration; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Tests.Shared.Fixtures; using Tests.Shared.TestBase; using Xunit.Abstractions; @@ -13,15 +15,42 @@ namespace FoodDelivery.Services.Customers.IntegrationTests; // note: each class could have only one collection [Collection(IntegrationTestCollection.Name)] public class CustomerServiceIntegrationTestBase - : IntegrationTestBase + : IntegrationTestBase { + private IdentityServiceWireMock? _identityServiceWireMock; + private CatalogsServiceWireMock? _catalogsServiceWireMock; + + public IdentityServiceWireMock IdentityServiceWireMock + { + get + { + if (_identityServiceWireMock is null) + { + var option = SharedFixture.ServiceProvider.GetRequiredService>(); + _identityServiceWireMock = new IdentityServiceWireMock(SharedFixture.WireMockServer, option.Value); + } + + return _identityServiceWireMock; + } + } + + public CatalogsServiceWireMock CatalogsServiceWireMock + { + get + { + if (_catalogsServiceWireMock is null) + { + var option = SharedFixture.ServiceProvider.GetRequiredService>(); + _catalogsServiceWireMock = new CatalogsServiceWireMock(SharedFixture.WireMockServer, option.Value); + } + + return _catalogsServiceWireMock; + } + } + // We don't need to inject `CustomersServiceMockServersFixture` class fixture in the constructor because it initialized by `collection fixture` and its static properties are accessible in the codes public CustomerServiceIntegrationTestBase( - SharedFixtureWithEfCoreAndMongo< - Api.CustomersApiMetadata, - CustomersDbContext, - CustomersReadDbContext - > sharedFixture, + SharedFixtureWithEfCoreAndMongo sharedFixture, ITestOutputHelper outputHelper ) : base(sharedFixture, outputHelper) @@ -29,14 +58,15 @@ ITestOutputHelper outputHelper // https://pcholko.com/posts/2021-04-05/wiremock-integration-test/ // note1: for E2E test we use real identity service in on a TestContainer docker of this service, coordination with an external system is necessary in E2E - // note2: add in-memory configuration instead of using appestings.json and override existing settings and it is accessible via IOptions and Configuration + // note2: add in-memory configuration instead of using appestings.json and override existing settings, and it is accessible via IOptions and Configuration // https://blog.markvincze.com/overriding-configuration-in-asp-net-core-integration-tests/ - SharedFixture.Configuration["IdentityApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture - .IdentityServiceMock - .Url; - SharedFixture.Configuration["CatalogsApiClientOptions:BaseApiAddress"] = CustomersServiceMockServersFixture - .CatalogsServiceMock - .Url; + sharedFixture.Factory.AddOverrideEnvKeyValues( + new Dictionary + { + { "IdentityApiClientOptions:BaseApiAddress", SharedFixture.WireMockServerUrl }, + { "CatalogsApiClientOptions:BaseApiAddress", SharedFixture.WireMockServerUrl }, + } + ); // var catalogApiOptions = Scope.ServiceProvider.GetRequiredService>(); // var identityApiOptions = Scope.ServiceProvider.GetRequiredService>(); @@ -45,27 +75,9 @@ ITestOutputHelper outputHelper // catalogApiOptions.Value.BaseApiAddress = MockServersFixture.CatalogsServiceMock.Url!; } - protected override void RegisterTestAppConfigurations( - IConfigurationBuilder builder, - IConfiguration configuration, - IHostEnvironment environment - ) - { - base.RegisterTestAppConfigurations(builder, configuration, environment); - } - protected override void RegisterTestConfigureServices(IServiceCollection services) { //// here we use same data seeder of service but if we need different data seeder for test for can replace it // services.ReplaceScoped(); } - - public override Task DisposeAsync() - { - // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests - CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); - CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); - - return base.DisposeAsync(); - } } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs index 362289f7..8397eaba 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Customers/Features/CreatingCustomer/v1/CreateCustomerTests.cs @@ -1,10 +1,11 @@ +using BuildingBlocks.Abstractions.Events; +using BuildingBlocks.Core.Events; using BuildingBlocks.Core.Exception.Types; using FluentAssertions; using FoodDelivery.Services.Customers.Customers.Exceptions.Application; using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1; using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; @@ -25,9 +26,7 @@ ITestOutputHelper outputHelper public async Task can_create_new_customer_with_valid_input_in_postgres_db() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act @@ -69,9 +68,7 @@ await act.Should() public async Task must_throw_exception_when_customer_with_email_already_exists() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act @@ -89,9 +86,7 @@ public async Task must_throw_exception_when_customer_with_email_already_exists() public async Task can_save_mongo_customer_read_model_in_internal_persistence_message() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act @@ -106,9 +101,7 @@ public async Task can_save_mongo_customer_read_model_in_internal_persistence_mes public async Task can_create_new_mongo_customer_read_model_in_the_mongodb() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act @@ -133,9 +126,7 @@ await SharedFixture.WaitUntilConditionMet(async () => public async Task can_publish_customer_created_integration_event_to_the_broker() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act @@ -150,9 +141,7 @@ public async Task can_publish_customer_created_integration_event_to_the_broker() public async Task can_save_customer_created_integration_event_in_the_outbox() { // Arrange - var fakeIdentityUser = CustomersServiceMockServersFixture - .IdentityServiceMock.SetupGetUserByEmail() - .Response.UserIdentity; + var fakeIdentityUser = IdentityServiceWireMock.SetupGetUserByEmail().Response.UserIdentity; var command = new CreateCustomer(fakeIdentityUser!.Email); // Act diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs index 8ff835a3..760ad90e 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/IntegrationTestCollection.cs @@ -1,17 +1,15 @@ using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using Tests.Shared.Fixtures; namespace FoodDelivery.Services.Customers.IntegrationTests; // https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x -// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions +// note: each class could have only one collection, but it can implement multiple ICollectionFixture in its definitions [CollectionDefinition(Name)] public class IntegrationTestCollection : ICollectionFixture< SharedFixtureWithEfCoreAndMongo - >, - ICollectionFixture + > { public const string Name = "Integration Test"; } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs index 75710997..0031748d 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/RestockSubscriptions/Features/CreatingRestockSubscription/v1/CreateRestockSubscriptionTests.cs @@ -3,7 +3,6 @@ using FoodDelivery.Services.Customers.RestockSubscriptions.Features.CreatingRestockSubscription.v1; using FoodDelivery.Services.Customers.Shared.Data; using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Entities; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using Microsoft.EntityFrameworkCore; using Tests.Shared.Fixtures; using Tests.Shared.XunitCategories; @@ -11,20 +10,17 @@ namespace FoodDelivery.Services.Customers.IntegrationTests.RestockSubscriptions.Features.CreatingRestockSubscription.v1; -public class CreateRestockSubscriptionTests : CustomerServiceIntegrationTestBase +public class CreateRestockSubscriptionTests( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper +) : CustomerServiceIntegrationTestBase(sharedFixture, outputHelper) { - public CreateRestockSubscriptionTests( - SharedFixtureWithEfCoreAndMongo sharedFixture, - ITestOutputHelper outputHelper - ) - : base(sharedFixture, outputHelper) { } - [Fact] [CategoryTrait(TestCategory.Integration)] public async Task can_create_new_customer_restock_subscription_in_postgres_db() { // Arrange - var fakeProduct = CustomersServiceMockServersFixture.CatalogsServiceMock.SetupGetProductById().Response.Product; + var fakeProduct = CatalogsServiceWireMock.SetupGetProductById().Response.Product; var fakeCustomer = new FakeCustomer().Generate(); await SharedFixture.InsertEfDbContextAsync(fakeCustomer); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs index 0af41b1c..2b7ac5c1 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.IntegrationTests/Users/Features/RegisteringUser/v1/Events/External/UserRegisteredTests.cs @@ -2,7 +2,6 @@ using FoodDelivery.Services.Customers.Customers.Features.CreatingCustomer.v1.Read.Mongo; using FoodDelivery.Services.Customers.Shared.Data; using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; -using FoodDelivery.Services.Customers.TestShared.Fixtures; using FoodDelivery.Services.Customers.Users.Features.RegisteringUser.v1.Events.Integration.External; using FoodDelivery.Services.Shared.Customers.Customers.Events.V1.Integration; using FoodDelivery.Services.Shared.Identity.Users.Events.V1.Integration; @@ -27,7 +26,7 @@ ITestOutputHelper outputHelper { _userRegistered = new FakeUserRegisteredV1().Generate(); - CustomersServiceMockServersFixture.IdentityServiceMock.SetupGetUserByEmail(_userRegistered); + IdentityServiceWireMock.SetupGetUserByEmail(_userRegistered); } [Fact] diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs index bad604a5..c93dcda9 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogServiceMockTests.cs @@ -5,20 +5,21 @@ using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; using Tests.Shared.Helpers; using Tests.Shared.XunitCategories; +using WireMock.Server; namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; public class CatalogServiceMockTests { - private readonly CatalogsServiceMock _catalogsServiceMock = CatalogsServiceMock.Start( - ConfigurationHelper.BindOptions() - ); + private static readonly WireMockServer _wireMockServer = WireMockServer.Start(); + private readonly CatalogsServiceWireMock _catalogsServiceWireMock = + new(_wireMockServer, ConfigurationHelper.BindOptions()); [Fact] [CategoryTrait(TestCategory.Unit)] public async Task root_address() { - var client = new HttpClient { BaseAddress = new Uri(_catalogsServiceMock.Url!) }; + var client = new HttpClient { BaseAddress = new Uri(_wireMockServer.Url!) }; var res = await client.GetAsync("/"); res.EnsureSuccessStatusCode(); @@ -31,10 +32,10 @@ public async Task root_address() [CategoryTrait(TestCategory.Unit)] public async Task get_by_id() { - var (response, endpoint) = _catalogsServiceMock.SetupGetProductById(); + var (response, endpoint) = _catalogsServiceWireMock.SetupGetProductById(); var fakeProduct = response.Product; - var client = new HttpClient { BaseAddress = new Uri(_catalogsServiceMock.Url!) }; + var client = new HttpClient { BaseAddress = new Uri(_wireMockServer.Url!) }; var httpResponse = await client.GetAsync(endpoint); await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs deleted file mode 100644 index 487efc83..00000000 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceMock.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Net; -using FoodDelivery.Services.Customers.Products.Models; -using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; -using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; -using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; -using WireMock.RequestBuilders; -using WireMock.ResponseBuilders; -using WireMock.Server; -using WireMock.Settings; - -namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; - -//https://www.ontestautomation.com/api-mocking-in-csharp-with-wiremock-net/ -public class CatalogsServiceMock : WireMockServer -{ - private CatalogsApiClientOptions CatalogsApiClientOptions { get; init; } = default!; - - protected CatalogsServiceMock(WireMockServerSettings settings) - : base(settings) - { - //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching - Given(Request.Create().WithPath("/").UsingGet()) // we should put / in the beginning of the endpoint - .RespondWith(Response.Create().WithStatusCode(200).WithBody("Catalogs Service!")); - } - - public static CatalogsServiceMock Start(CatalogsApiClientOptions catalogsApiClientOptions, bool ssl = false) - { - // new WireMockServer() is equivalent to call WireMockServer.Start() - return new CatalogsServiceMock( - new WireMockServerSettings - { - UseSSL = ssl, - // we could use our option url here, but I use random port (Urls = new string[] {} also set a fix port 5000 we should not use this if we want a random port) - // Urls = new string[]{catalogsApiClientOptions.BaseApiAddress} - } - ) - { - CatalogsApiClientOptions = catalogsApiClientOptions - }; - } - - public (GetProductByIdClientDto Response, string Endpoint) SetupGetProductById(long id = 0) - { - var fakeProduct = new FakeProductDto().Generate(1).First(); - if (id > 0) - fakeProduct = fakeProduct with { Id = id }; - - fakeProduct = fakeProduct with { AvailableStock = 0 }; - - var response = new GetProductByIdClientDto(fakeProduct); - - //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching - // we should put / in the beginning of the endpoint - var endpointPath = $"/{CatalogsApiClientOptions.ProductsEndpoint}/{fakeProduct.Id}"; - - Given(Request.Create().UsingGet().WithPath(endpointPath)) - .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); - - return (response, endpointPath); - } -} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceWireMock.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceWireMock.cs new file mode 100644 index 00000000..95ae8524 --- /dev/null +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/CatalogsServiceWireMock.cs @@ -0,0 +1,36 @@ +using System.Net; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; +using FoodDelivery.Services.Customers.Shared.Clients.Catalogs.Dtos; +using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Dtos; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; + +//https://www.ontestautomation.com/api-mocking-in-csharp-with-wiremock-net/ +public class CatalogsServiceWireMock(WireMockServer wireMockServer, CatalogsApiClientOptions catalogsApiClientOption) +{ + private CatalogsApiClientOptions CatalogsApiClientOptions { get; } = catalogsApiClientOption; + + public (GetProductByIdClientDto Response, string Endpoint) SetupGetProductById(long id = 0) + { + var fakeProduct = new FakeProductDto().Generate(1).First(); + if (id > 0) + fakeProduct = fakeProduct with { Id = id }; + + fakeProduct = fakeProduct with { AvailableStock = 0 }; + + var response = new GetProductByIdClientDto(fakeProduct); + + //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching + // we should put / in the beginning of the endpoint + var endpointPath = $"/{CatalogsApiClientOptions.ProductsEndpoint}/{fakeProduct.Id}"; + + wireMockServer + .Given(Request.Create().UsingGet().WithPath(endpointPath)) + .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); + + return (response, endpointPath); + } +} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMock.cs similarity index 64% rename from tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs rename to tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMock.cs index 4cb80215..3af7a480 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMock.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMock.cs @@ -6,7 +6,6 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; -using WireMock.Settings; namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; @@ -14,35 +13,9 @@ namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; //https://github.com/WireMock-Net/WireMock.Net/wiki //https://pcholko.com/posts/2021-04-05/wiremock-integration-test/ //https://www.youtube.com/watch?v=YU3ohofu6UU -public class IdentityServiceMock : WireMockServer +public class IdentityServiceWireMock(WireMockServer wireMockServer, IdentityApiClientOptions identityApiClientOptions) { - private IdentityApiClientOptions IdentityApiClientOptions { get; init; } = default!; - - private IdentityServiceMock(WireMockServerSettings settings) - : base(settings) - { - //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching - Given(Request.Create().WithPath("/").UsingGet()) // we should put / in the beginning of the endpoint - .RespondWith(Response.Create().WithStatusCode(200).WithBody("Identity Service!")); - } - - public static IdentityServiceMock Start(IdentityApiClientOptions identityApiClientOptions, bool ssl = false) - { - // new WireMockServer() is equivalent to call WireMockServer.Start() - var mock = new IdentityServiceMock( - new WireMockServerSettings - { - UseSSL = ssl, - // we could use our option url here, but I use random port (Urls = new string[] {} also set a fix port 5000 we should not use this if we want a random port) - // Urls = new string[] {identityApiClientOptions.BaseApiAddress} - } - ) - { - IdentityApiClientOptions = identityApiClientOptions - }; - - return mock; - } + private IdentityApiClientOptions IdentityApiClientOptions { get; } = identityApiClientOptions; public (GetUserByEmailClientDto Response, string Endpoint) SetupGetUserByEmail(string? email = null) { @@ -56,7 +29,8 @@ public static IdentityServiceMock Start(IdentityApiClientOptions identityApiClie // we should put / in the beginning of the endpoint var endpointPath = $"/{IdentityApiClientOptions.UsersEndpoint}/by-email/{fakeIdentityUser.Email}"; - Given(Request.Create().UsingGet().WithPath(endpointPath)) + wireMockServer + .Given(Request.Create().UsingGet().WithPath(endpointPath)) .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); return (response, endpointPath); @@ -78,7 +52,8 @@ public static IdentityServiceMock Start(IdentityApiClientOptions identityApiClie //https://github.com/WireMock-Net/WireMock.Net/wiki/Request-Matching var endpointPath = $"/{IdentityApiClientOptions.UsersEndpoint}/by-email/{userRegisteredV1.Email}"; // we should put / in the beginning of the endpoint - Given(Request.Create().UsingGet().WithPath(endpointPath)) + wireMockServer + .Given(Request.Create().UsingGet().WithPath(endpointPath)) .RespondWith(Response.Create().WithBodyAsJson(response).WithStatusCode(HttpStatusCode.OK)); return (response, endpointPath); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMockTests.cs similarity index 70% rename from tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs rename to tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMockTests.cs index d65c46d8..c3509f4c 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceMockTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fakes/Shared/Servers/IdentityServiceWireMockTests.cs @@ -1,25 +1,25 @@ using System.Net.Http.Json; using BuildingBlocks.Core.Web.Extensions; -using BuildingBlocks.Web.Extensions; using FluentAssertions; using FoodDelivery.Services.Customers.Shared.Clients.Identity; using FoodDelivery.Services.Customers.Shared.Clients.Identity.Dtos; using FoodDelivery.Services.Customers.TestShared.Fakes.Customers.Events; using Tests.Shared.Helpers; using Tests.Shared.XunitCategories; +using WireMock.Server; namespace FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; -public class IdentityServiceMockTests +public class IdentityServiceWireMockTests { - private readonly IdentityServiceMock _identityServiceMock = IdentityServiceMock.Start( - ConfigurationHelper.BindOptions() - ); + private static readonly WireMockServer _wireMockServer = WireMockServer.Start(); + private readonly IdentityServiceWireMock _identityServiceWireWireMock = + new(_wireMockServer, ConfigurationHelper.BindOptions()); [Fact] public async Task root_address() { - var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var client = new HttpClient { BaseAddress = new Uri(_wireMockServer.Url!) }; var res = await client.GetAsync("/"); res.EnsureSuccessStatusCode(); @@ -32,10 +32,10 @@ public async Task root_address() [CategoryTrait(TestCategory.Unit)] public async Task get_by_email() { - var (response, endpoint) = _identityServiceMock.SetupGetUserByEmail(); + var (response, endpoint) = _identityServiceWireWireMock.SetupGetUserByEmail(); var fakeIdentityUser = response.UserIdentity; - var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var client = new HttpClient { BaseAddress = new Uri(_wireMockServer.Url!) }; var httpResponse = await client.GetAsync(endpoint); await httpResponse.EnsureSuccessStatusCodeWithDetailAsync(); @@ -49,10 +49,10 @@ public async Task get_by_email() public async Task get_by_email_and_user_registered() { var userRegistered = new FakeUserRegisteredV1().Generate(); - var (response, endpoint) = _identityServiceMock.SetupGetUserByEmail(userRegistered); + var (response, endpoint) = _identityServiceWireWireMock.SetupGetUserByEmail(userRegistered); var fakeIdentityUser = response.UserIdentity; - var client = new HttpClient { BaseAddress = new Uri(_identityServiceMock.Url!) }; + var client = new HttpClient { BaseAddress = new Uri(_wireMockServer.Url!) }; var res = await client.GetAsync(endpoint); res.EnsureSuccessStatusCode(); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs deleted file mode 100644 index a077a27a..00000000 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.TestShared/Fixtures/CustomersServiceMockServersFixture.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; -using FoodDelivery.Services.Customers.Shared.Clients.Identity; -using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; -using Tests.Shared.Helpers; - -namespace FoodDelivery.Services.Customers.TestShared.Fixtures; - -public class CustomersServiceMockServersFixture : IAsyncLifetime -{ - public static IdentityServiceMock IdentityServiceMock { get; private set; } = default!; - public static CatalogsServiceMock CatalogsServiceMock { get; private set; } = default!; - - public Task InitializeAsync() - { - IdentityServiceMock = IdentityServiceMock.Start(ConfigurationHelper.BindOptions()); - CatalogsServiceMock = CatalogsServiceMock.Start(ConfigurationHelper.BindOptions()); - - return Task.CompletedTask; - } - - public Task DisposeAsync() - { - IdentityServiceMock.Dispose(); - CatalogsServiceMock.Dispose(); - - return Task.CompletedTask; - } -} diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs index c76f7b64..8f68d4d6 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Common/CustomerServiceUnitTestBase.cs @@ -1,21 +1,17 @@ using AutoMapper; -using BuildingBlocks.Resiliency; using FoodDelivery.Services.Customers.Shared.Clients.Catalogs; using FoodDelivery.Services.Customers.Shared.Clients.Identity; using FoodDelivery.Services.Customers.Shared.Data; -using FoodDelivery.Services.Customers.TestShared.Fixtures; -using Microsoft.Extensions.Options; -using Tests.Shared.Helpers; +using NSubstitute; using Tests.Shared.XunitCategories; namespace FoodDelivery.Services.Customers.UnitTests.Common; [CollectionDefinition(nameof(QueryTestCollection))] -public class QueryTestCollection : ICollectionFixture { } +public class QueryTestCollection : ICollectionFixture; //https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x // note: each class could have only one collection -[Collection(UnitTestCollection.Name)] [CategoryTrait(TestCategory.Unit)] public class CustomerServiceUnitTestBase : IAsyncDisposable { @@ -25,47 +21,17 @@ public CustomerServiceUnitTestBase() Mapper = MapperFactory.Create(); CustomersDbContext = DbContextFactory.Create(); - //https://stackoverflow.com/questions/40876507/net-core-unit-testing-mock-ioptionst - IOptions identityClientOptions = Options.Create( - ConfigurationHelper.BindOptions() - ); - IOptions policyOptions = Options.Create( - new PolicyOptions - { - RetryCount = 1, - TimeOutDuration = 3, - BreakDuration = 5 - } - ); - IdentityApiClient = new IdentityApiClient( - new HttpClient { BaseAddress = new Uri(CustomersServiceMockServersFixture.IdentityServiceMock.Url!) }, - Mapper, - identityClientOptions, - policyOptions - ); - - //https://stackoverflow.com/questions/40876507/net-core-unit-testing-mock-ioptionst - IOptions catalogClientOptions = Options.Create( - ConfigurationHelper.BindOptions() - ); - CatalogApiClient = new CatalogApiClient( - new HttpClient { BaseAddress = new Uri(CustomersServiceMockServersFixture.CatalogsServiceMock.Url!) }, - Mapper, - catalogClientOptions, - policyOptions - ); + IdentityApiClient = Substitute.For(); + CatalogApiClient = Substitute.For(); } public IMapper Mapper { get; } public CustomersDbContext CustomersDbContext { get; } - public IdentityApiClient IdentityApiClient { get; } - public CatalogApiClient CatalogApiClient { get; } + public IIdentityApiClient IdentityApiClient { get; } + public ICatalogApiClient CatalogApiClient { get; } public async ValueTask DisposeAsync() { await DbContextFactory.Destroy(CustomersDbContext); - // we should reset mappings routes we define in each test in end of running each test, but wiremock server is up in whole of test collection and is active for all tests - CustomersServiceMockServersFixture.CatalogsServiceMock.Reset(); - CustomersServiceMockServersFixture.IdentityServiceMock.Reset(); } } diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs index 40368f03..5d95025a 100644 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs +++ b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/Customers/Features/CreatingCustomer/v1/Read/CreateCustomerTests.cs @@ -33,7 +33,7 @@ public async Task can_create_customer_read_with_valid_inputs() _customersReadUnitOfWork .CustomersRepository.AddAsync(Arg.Is(insertCustomer), Arg.Any()) .Returns(insertCustomer); - var handler = new CreateCustomerReadHandler(Mapper, _customersReadUnitOfWork); + var handler = new CreateCustomerReadHandler(_customersReadUnitOfWork); // Act await handler.Handle(fakeCreateCustomerReadCommand, CancellationToken.None); diff --git a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs b/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs deleted file mode 100644 index 1fa7d3d1..00000000 --- a/tests/Services/Customers/FoodDelivery.Services.Customers.UnitTests/UnitTestCollection.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FoodDelivery.Services.Customers.TestShared.Fakes.Shared.Servers; -using FoodDelivery.Services.Customers.TestShared.Fixtures; - -namespace FoodDelivery.Services.Customers.UnitTests; - -// https://stackoverflow.com/questions/43082094/use-multiple-collectionfixture-on-my-test-class-in-xunit-2-x -// note: each class could have only one collection, but it can implements multiple ICollectionFixture in its definitions -[CollectionDefinition(Name)] -public class UnitTestCollection : ICollectionFixture -{ - public const string Name = "UnitTest Test"; -} diff --git a/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs b/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs index 74ff3519..73bd8e9c 100644 --- a/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs +++ b/tests/Shared/Tests.Shared/Fixtures/SharedFixture.cs @@ -6,6 +6,7 @@ using BuildingBlocks.Abstractions.Messaging; using BuildingBlocks.Abstractions.Messaging.PersistMessage; using BuildingBlocks.Abstractions.Queries; +using BuildingBlocks.Core.Events; using BuildingBlocks.Core.Extensions; using BuildingBlocks.Core.Messaging.MessagePersistence; using BuildingBlocks.Core.Types; @@ -15,6 +16,7 @@ using FluentAssertions; using FluentAssertions.Extensions; using MassTransit; +using MassTransit.Context; using MassTransit.Testing; using MediatR; using Microsoft.AspNetCore.Hosting; @@ -94,7 +96,7 @@ public HttpClient GuestClient public HttpClient NormalUserHttpClient => _normalClient ??= CreateNormalUserHttpClient(); public WireMockServer WireMockServer { get; } - public string? WireMockServerUrl { get; } + public string WireMockServerUrl { get; } = null!; //https://github.com/xunit/xunit/issues/565 //https://github.com/xunit/xunit/pull/1705 @@ -144,7 +146,7 @@ public SharedFixture(IMessageSink messageSink) // new WireMockServer() is equivalent to call WireMockServer.Start() WireMockServer = WireMockServer.Start(); - WireMockServerUrl = WireMockServer.Url; + WireMockServerUrl = WireMockServer.Url!; Factory = new CustomWebApplicationFactory(); } @@ -201,10 +203,10 @@ public async Task InitializeAsync() ); // with `AddOverrideInMemoryConfig` config changes are accessible after services registration and build process - Factory.AddOverrideInMemoryConfig(new Dictionary() { }); + Factory.AddOverrideInMemoryConfig(new Dictionary()); Factory.ConfigurationAction += cfg => { - // Or we can override configuration explicitly and it is accessible via IOptions<> and Configuration + // Or we can override configuration explicitly, and it is accessible via IOptions<> and Configuration cfg["WireMockUrl"] = WireMockServerUrl; }; @@ -426,48 +428,127 @@ public async ValueTask WaitUntilConditionMet( } } - public async Task WaitForPublishing(CancellationToken cancellationToken = default) - where TMessage : class, IMessage + public async Task WaitForPublishing(CancellationToken cancellationToken = default) + where T : class, IMessage { - await WaitUntilConditionMet(async () => - { - // message has been published for this harness. - var published = await MasstransitHarness.Published.Any(cancellationToken); - // there is a fault when publishing for this harness. - var faulty = await MasstransitHarness.Published.Any>(cancellationToken); + // will block the thread until there is a publishing message + await MasstransitHarness.Published.Any( + message => + { + var messageFilter = new PublishedMessageFilter(); + var faultMessageFilter = new PublishedMessageFilter(); + + messageFilter.Includes.Add(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add(x => + (x.MessageObject as IEventEnvelope)!.Message.GetType() == typeof(T) + ); - return published & !faulty; - }); + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add(); + + var faulty = faultMessageFilter.Any(message); + var published = messageFilter.Any(message); + + return published & !faulty; + }, + cancellationToken + ); } - public async Task WaitForConsuming(CancellationToken cancellationToken = default) - where TMessage : class, IMessage + public async Task WaitForSending(CancellationToken cancellationToken = default) + where T : class, IMessage { - await WaitUntilConditionMet(async () => - { - //consumer consumed the message. - var consumed = await MasstransitHarness.Consumed.Any(cancellationToken); - //there was a fault when consuming for this harness. - var faulty = await MasstransitHarness.Consumed.Any>(cancellationToken); + // will block the thread until there is a publishing message + await MasstransitHarness.Sent.Any( + message => + { + var messageFilter = new SentMessageFilter(); + var faultMessageFilter = new SentMessageFilter(); + + messageFilter.Includes.Add(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add(x => + (x.MessageObject as IEventEnvelope)!.Message.GetType() == typeof(T) + ); - return consumed && !faulty; - }); + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add>(); + + var faulty = faultMessageFilter.Any(message); + var published = messageFilter.Any(message); + + return published & !faulty; + }, + cancellationToken + ); + } + + public async Task WaitForConsuming(CancellationToken cancellationToken = default) + where T : class, IMessage + { + // will block the thread until there is a consuming message + await MasstransitHarness.Consumed.Any( + message => + { + var messageFilter = new ReceivedMessageFilter(); + var faultMessageFilter = new ReceivedMessageFilter(); + + messageFilter.Includes.Add>(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add(x => + (x.MessageObject as IEventEnvelope)!.Message.GetType() == typeof(T) + ); + messageFilter.Includes.Add(); + + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add>(); + faultMessageFilter.Includes.Add>>(); + + var faulty = faultMessageFilter.Any(message); + var published = messageFilter.Any(message); + + return published & !faulty; + }, + cancellationToken + ); } public async Task WaitForConsuming(CancellationToken cancellationToken = default) - where TMessage : class + where TMessage : class, IMessage where TConsumedBy : class, IConsumer { var consumerHarness = ServiceProvider.GetRequiredService>(); - await WaitUntilConditionMet(async () => - { - //consumer consumed the message. - var consumed = await consumerHarness.Consumed.Any(cancellationToken); - //there was a fault when consuming for this harness. - var faulty = await consumerHarness.Consumed.Any>(cancellationToken); - return consumed && !faulty; - }); + // will block the thread until there is a consuming message + await consumerHarness.Consumed.Any( + message => + { + var messageFilter = new ReceivedMessageFilter(); + var faultMessageFilter = new ReceivedMessageFilter(); + + messageFilter.Includes.Add>(); + messageFilter.Includes.Add>(); + messageFilter.Includes.Add(x => + (x.MessageObject as IEventEnvelope)!.Message.GetType() == typeof(TMessage) + ); + messageFilter.Includes.Add(); + + faultMessageFilter.Includes.Add>>(); + faultMessageFilter.Includes.Add>(); + faultMessageFilter.Includes.Add>>(); + + var faulty = faultMessageFilter.Any(message); + var published = messageFilter.Any(message); + + return published & !faulty; + }, + cancellationToken + ); } // public async ValueTask> ShouldConsumeWithNewConsumer( diff --git a/tests/Shared/Tests.Shared/Helpers/HandlerFactory.cs b/tests/Shared/Tests.Shared/Helpers/HandlerFactory.cs index ae1c1cb2..b91dd3c1 100644 --- a/tests/Shared/Tests.Shared/Helpers/HandlerFactory.cs +++ b/tests/Shared/Tests.Shared/Helpers/HandlerFactory.cs @@ -36,7 +36,7 @@ this Observer observer ) where T : class, IMessage { - return (context, cancellationToken) => observer.Add(context.Data, cancellationToken); + return (context, cancellationToken) => observer.Add(context.Message, cancellationToken); } public static IMessageHandler AsMessageHandler( @@ -66,7 +66,7 @@ public async Task HandleAsync(IEventEnvelope eventEnvelope, CancellationToken } await handler.InvokeMethodWithoutResultAsync("HandleAsync", eventEnvelope, cancellationToken); - await observer.Add(eventEnvelope.Data, cancellationToken); + await observer.Add(eventEnvelope.Message, cancellationToken); } } @@ -75,7 +75,7 @@ internal class SimpleMessageConsumer(Observer observer) : IMessageHandler< { public Task HandleAsync(IEventEnvelope eventEnvelope, CancellationToken cancellationToken = default) { - return observer.Add(eventEnvelope.Data, cancellationToken); + return observer.Add(eventEnvelope.Message, cancellationToken); } } diff --git a/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs b/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs index 2c90a523..966fd147 100644 --- a/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs +++ b/tests/Shared/Tests.Shared/TestBase/IntegrationTestBase.cs @@ -15,10 +15,14 @@ namespace Tests.Shared.TestBase; public abstract class IntegrationTest : XunitContextBase, IAsyncLifetime where TEntryPoint : class { + private IServiceScope? _serviceScope; + protected CancellationToken CancellationToken => CancellationTokenSource.Token; protected CancellationTokenSource CancellationTokenSource { get; } protected int Timeout => 180; - protected IServiceScope Scope { get; } + + // Build Service Provider here + protected IServiceScope Scope => _serviceScope ??= SharedFixture.ServiceProvider.CreateScope(); protected SharedFixture SharedFixture { get; } protected IntegrationTest(SharedFixture sharedFixture, ITestOutputHelper outputHelper) @@ -38,9 +42,6 @@ protected IntegrationTest(SharedFixture sharedFixture, ITestOutputH RegisterTestAppConfigurations(configurationBuilder, context.Configuration, context.HostingEnvironment); } ); - - // Build Service Provider here - Scope = SharedFixture.ServiceProvider.CreateScope(); } // we use IAsyncLifetime in xunit instead of constructor when we have async operation @@ -55,7 +56,7 @@ public virtual async Task DisposeAsync() await SharedFixture.CleanupMessaging(CancellationToken); await SharedFixture.ResetDatabasesAsync(CancellationToken); - CancellationTokenSource.Cancel(); + await CancellationTokenSource.CancelAsync(); Scope.Dispose(); } @@ -94,35 +95,24 @@ IHostEnvironment environment ) { } } -public abstract class IntegrationTestBase : IntegrationTest +public abstract class IntegrationTestBase( + SharedFixtureWithEfCore sharedFixture, + ITestOutputHelper outputHelper +) : IntegrationTest(sharedFixture, outputHelper) where TEntryPoint : class where TContext : DbContext { - protected IntegrationTestBase( - SharedFixtureWithEfCore sharedFixture, - ITestOutputHelper outputHelper - ) - : base(sharedFixture, outputHelper) - { - SharedFixture = sharedFixture; - } - - public new SharedFixtureWithEfCore SharedFixture { get; } + public new SharedFixtureWithEfCore SharedFixture { get; } = sharedFixture; } -public abstract class IntegrationTestBase : IntegrationTest +public abstract class IntegrationTestBase( + SharedFixtureWithEfCoreAndMongo sharedFixture, + ITestOutputHelper outputHelper +) : IntegrationTest(sharedFixture, outputHelper) where TEntryPoint : class where TWContext : DbContext where TRContext : MongoDbContext { - protected IntegrationTestBase( - SharedFixtureWithEfCoreAndMongo sharedFixture, - ITestOutputHelper outputHelper - ) - : base(sharedFixture, outputHelper) - { - SharedFixture = sharedFixture; - } - - public new SharedFixtureWithEfCoreAndMongo SharedFixture { get; } + public new SharedFixtureWithEfCoreAndMongo SharedFixture { get; } = + sharedFixture; } diff --git a/tests/Shared/Tests.Shared/XunitFramework/CustomTestFramework.cs b/tests/Shared/Tests.Shared/XunitFramework/CustomTestFramework.cs index c673d04f..93cfebc7 100644 --- a/tests/Shared/Tests.Shared/XunitFramework/CustomTestFramework.cs +++ b/tests/Shared/Tests.Shared/XunitFramework/CustomTestFramework.cs @@ -17,15 +17,12 @@ public CustomTestFramework(IMessageSink messageSink) protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName) => new CustomExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink); - private class CustomExecutor : XunitTestFrameworkExecutor + private class CustomExecutor( + AssemblyName assemblyName, + ISourceInformationProvider sourceInformationProvider, + IMessageSink diagnosticMessageSink + ) : XunitTestFrameworkExecutor(assemblyName, sourceInformationProvider, diagnosticMessageSink) { - public CustomExecutor( - AssemblyName assemblyName, - ISourceInformationProvider sourceInformationProvider, - IMessageSink diagnosticMessageSink - ) - : base(assemblyName, sourceInformationProvider, diagnosticMessageSink) { } - protected override async void RunTestCases( IEnumerable testCases, IMessageSink executionMessageSink, @@ -43,17 +40,14 @@ ITestFrameworkExecutionOptions executionOptions } } - private class CustomAssemblyRunner : XunitTestAssemblyRunner + private class CustomAssemblyRunner( + ITestAssembly testAssembly, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageSink executionMessageSink, + ITestFrameworkExecutionOptions executionOptions + ) : XunitTestAssemblyRunner(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions) { - public CustomAssemblyRunner( - ITestAssembly testAssembly, - IEnumerable testCases, - IMessageSink diagnosticMessageSink, - IMessageSink executionMessageSink, - ITestFrameworkExecutionOptions executionOptions - ) - : base(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions) { } - protected override Task RunTestCollectionAsync( IMessageBus messageBus, ITestCollection testCollection,