diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 7cb4da3..076a206 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -29,12 +29,7 @@ jobs: run: dotnet build KSFramework.sln --configuration Release --no-restore - name: Pack NuGet - run: dotnet pack src/KSFramework/KSFramework.csproj \ - --configuration Release \ - -p:PackageVersion=${{ env.PACKAGE_VERSION }} \ - --no-build -o out + run: dotnet pack src/KSFramework/KSFramework.csproj --configuration Release -p:PackageVersion=${{ env.PACKAGE_VERSION }} --no-build -o out - name: Push to NuGet - run: dotnet nuget push out/*.nupkg \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --source https://api.nuget.org/v3/index.json + run: dotnet nuget push out/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/KSFramework.sln b/KSFramework.sln index 37d31fa..4a7b249 100644 --- a/KSFramework.sln +++ b/KSFramework.sln @@ -7,6 +7,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KSFramework", "src\KSFramework\KSFramework.csproj", "{B335BD79-05C0-4F0E-A2DA-FC72DF07A18C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MediatorSampleApp", "MediatorSampleApp", "{B36C9166-6687-1700-0084-D88BD073518A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mediator", "Samples\MediatorSampleApp\Mediator.csproj", "{D849CD75-73AE-4F1A-80EA-0FC596C80723}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "KSFramework", "KSFramework", "{6F746C7E-99E8-D040-B792-CDA2514E83CE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KSFramework.UnitTests", "tests\KSFramework\KSFramework.UnitTests\KSFramework.UnitTests.csproj", "{48D1B7B2-D99B-4554-BBFB-95E75720B8E5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,11 +41,39 @@ Global {B335BD79-05C0-4F0E-A2DA-FC72DF07A18C}.Release|x64.Build.0 = Release|Any CPU {B335BD79-05C0-4F0E-A2DA-FC72DF07A18C}.Release|x86.ActiveCfg = Release|Any CPU {B335BD79-05C0-4F0E-A2DA-FC72DF07A18C}.Release|x86.Build.0 = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|x64.ActiveCfg = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|x64.Build.0 = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|x86.ActiveCfg = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Debug|x86.Build.0 = Debug|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|Any CPU.Build.0 = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|x64.ActiveCfg = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|x64.Build.0 = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|x86.ActiveCfg = Release|Any CPU + {D849CD75-73AE-4F1A-80EA-0FC596C80723}.Release|x86.Build.0 = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|x64.Build.0 = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Debug|x86.Build.0 = Debug|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|Any CPU.Build.0 = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|x64.ActiveCfg = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|x64.Build.0 = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|x86.ActiveCfg = Release|Any CPU + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {B335BD79-05C0-4F0E-A2DA-FC72DF07A18C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B36C9166-6687-1700-0084-D88BD073518A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {D849CD75-73AE-4F1A-80EA-0FC596C80723} = {B36C9166-6687-1700-0084-D88BD073518A} + {6F746C7E-99E8-D040-B792-CDA2514E83CE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {48D1B7B2-D99B-4554-BBFB-95E75720B8E5} = {6F746C7E-99E8-D040-B792-CDA2514E83CE} EndGlobalSection EndGlobal diff --git a/Samples/MediatorSampleApp/Mediator.csproj b/Samples/MediatorSampleApp/Mediator.csproj new file mode 100644 index 0000000..6a2e3de --- /dev/null +++ b/Samples/MediatorSampleApp/Mediator.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Samples/MediatorSampleApp/Program.cs b/Samples/MediatorSampleApp/Program.cs new file mode 100644 index 0000000..3857774 --- /dev/null +++ b/Samples/MediatorSampleApp/Program.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using KSFramework.Messaging; +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Behaviors; +using KSFramework.Messaging.Configuration; +using KSFramework.Messaging.Samples; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); + +// فقط یکبار mediator رو ثبت کن +services.AddScoped(); +services.AddScoped(sp => sp.GetRequiredService()); + +services.AddLogging(); + +services.AddValidatorsFromAssembly(typeof(Program).Assembly); +services.AddMessaging(typeof(MultiplyByTwoHandler).Assembly); + +// اگر رفتارهای pipeline داری، ثبت کن +services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestProcessorBehavior<,>)); +services.AddMessaging(typeof(ExceptionHandlingBehavior<,>).Assembly, typeof(MultiplyByTwoRequest).Assembly); + +var provider = services.BuildServiceProvider(); + +var mediator = provider.GetRequiredService(); + +var result = await mediator.Send(new MultiplyByTwoRequest(5)); +Console.WriteLine($"Result: {result}"); \ No newline at end of file diff --git a/src/KSFramework/Domain/IDomainEvent.cs b/src/KSFramework/Domain/IDomainEvent.cs index 7562255..09c10b2 100644 --- a/src/KSFramework/Domain/IDomainEvent.cs +++ b/src/KSFramework/Domain/IDomainEvent.cs @@ -1,4 +1,4 @@ -using MediatR; +using KSFramework.Messaging.Abstraction; namespace KSFramework.Domain; public interface IDomainEvent : INotification diff --git a/src/KSFramework/KSFramework.csproj b/src/KSFramework/KSFramework.csproj index 97b1869..d3bf821 100644 --- a/src/KSFramework/KSFramework.csproj +++ b/src/KSFramework/KSFramework.csproj @@ -28,8 +28,10 @@ - - + + + + @@ -38,6 +40,7 @@ + diff --git a/src/KSFramework/Messaging/Abstraction/IMediator.cs b/src/KSFramework/Messaging/Abstraction/IMediator.cs new file mode 100644 index 0000000..1df2a1a --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IMediator.cs @@ -0,0 +1,44 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Represents a mediator that supports sending requests and publishing notifications. +/// +public interface IMediator : ISender +{ + /// + /// Publishes a notification to all registered handlers. + /// + /// The notification type. + /// The notification instance. + /// Cancellation token. + /// A task representing the asynchronous operation. + Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification; + + /// + /// Sends a request without knowing the response type at compile time. + /// + /// The request to send. + /// Cancellation token. + /// A task that represents the response. + Task Send(object request, CancellationToken cancellationToken = default); + + /// + /// Publishes a notification without knowing the notification type at compile time. + /// + /// The notification instance. + /// Cancellation token. + /// A task representing the asynchronous operation. + Task Publish(object notification, CancellationToken cancellationToken = default); + + /// + /// Creates a stream of responses for a given stream request. + /// + /// Type of the streamed response. + /// The stream request instance. + /// Cancellation token. + /// An asynchronous stream of responses. + IAsyncEnumerable CreateStream( + IStreamRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/KSFramework/Messaging/Abstraction/INotification.cs b/src/KSFramework/Messaging/Abstraction/INotification.cs new file mode 100644 index 0000000..d49298a --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/INotification.cs @@ -0,0 +1,8 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Marker interface to represent a notification message. +/// +public interface INotification +{ +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/INotificationBehavior.cs b/src/KSFramework/Messaging/Abstraction/INotificationBehavior.cs new file mode 100644 index 0000000..0fd7dd8 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/INotificationBehavior.cs @@ -0,0 +1,15 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Delegate that represents the next notification handler or behavior in the pipeline. +/// +public delegate Task NotificationHandlerDelegate(); + +/// +/// Interface for notification pipeline behaviors. +/// +/// Type of the notification. +public interface INotificationBehavior where TNotification : INotification +{ + Task Handle(TNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next); +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/INotificationHandler.cs b/src/KSFramework/Messaging/Abstraction/INotificationHandler.cs new file mode 100644 index 0000000..ce192f6 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/INotificationHandler.cs @@ -0,0 +1,14 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Defines a handler for a specific type of notification. +/// +/// The notification type. +public interface INotificationHandler where TNotification : INotification +{ + /// + /// Handles the notification asynchronously. + /// + /// The notification instance. + Task Handle(TNotification notification, CancellationToken cancellationToken); +} diff --git a/src/KSFramework/Messaging/Abstraction/IPipelineBehavior.cs b/src/KSFramework/Messaging/Abstraction/IPipelineBehavior.cs new file mode 100644 index 0000000..6899e6f --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IPipelineBehavior.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace KSFramework.Messaging.Abstraction; +/// +/// Defines a pipeline behavior for requests that can run code before and after the handler is invoked. +/// +/// Type of the request. +/// Type of the response. +public interface IPipelineBehavior +{ + /// + /// Executes behavior around the handler invocation. + /// + /// The request instance. + /// Cancellation token. + /// The next delegate in the pipeline, i.e., the handler or next behavior. + /// The response from the next delegate. + Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next); +} + +/// +/// Delegate representing the next step in the pipeline. +/// +/// The response type. +/// A task that completes with the response. +public delegate Task RequestHandlerDelegate(); \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/IPostProcessor.cs b/src/KSFramework/Messaging/Abstraction/IPostProcessor.cs new file mode 100644 index 0000000..bcde205 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IPostProcessor.cs @@ -0,0 +1,9 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Defines a post-processor that runs after the request handler. +/// +public interface IRequestPostProcessor +{ + Task Process(TRequest request, TResponse response, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/IRequest.cs b/src/KSFramework/Messaging/Abstraction/IRequest.cs new file mode 100644 index 0000000..bff54c1 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IRequest.cs @@ -0,0 +1,7 @@ +namespace KSFramework.Messaging.Abstraction; + +public interface IRequest +{ +} + +public interface IRequest : IRequest { } diff --git a/src/KSFramework/Messaging/Abstraction/IRequestHandler.cs b/src/KSFramework/Messaging/Abstraction/IRequestHandler.cs new file mode 100644 index 0000000..bf13151 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IRequestHandler.cs @@ -0,0 +1,12 @@ +namespace KSFramework.Messaging.Abstraction; + +public interface IRequestHandler + where TRequest : IRequest +{ + Task Handle(TRequest request, CancellationToken cancellationToken); +} + +public interface IRequestHandler : IRequestHandler + where TRequest : IRequest +{ +} diff --git a/src/KSFramework/Messaging/Abstraction/IRequestPreProcessor.cs b/src/KSFramework/Messaging/Abstraction/IRequestPreProcessor.cs new file mode 100644 index 0000000..f11aa7f --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IRequestPreProcessor.cs @@ -0,0 +1,9 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Defines a pre-processor that runs before the request handler. +/// +public interface IRequestPreProcessor +{ + Task Process(TRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/ISender.cs b/src/KSFramework/Messaging/Abstraction/ISender.cs new file mode 100644 index 0000000..35b2c7a --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/ISender.cs @@ -0,0 +1,16 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Represents a sender that can send requests and return responses. +/// +public interface ISender +{ + /// + /// Sends a request asynchronously and returns the response. + /// + /// The type of the response. + /// The request to send. + /// Cancellation token. + /// The response from the request handler. + Task Send(IRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/IStreamRequest.cs b/src/KSFramework/Messaging/Abstraction/IStreamRequest.cs new file mode 100644 index 0000000..fb71bae --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IStreamRequest.cs @@ -0,0 +1,7 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Marker interface for a request that returns a stream of responses. +/// +/// Type of the streamed response. +public interface IStreamRequest : IRequest> { } \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/IStreamRequestHandler.cs b/src/KSFramework/Messaging/Abstraction/IStreamRequestHandler.cs new file mode 100644 index 0000000..9f354ad --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IStreamRequestHandler.cs @@ -0,0 +1,12 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Handles stream requests. +/// +/// The type of request. +/// The type of stream response. +public interface IStreamRequestHandler + where TRequest : IStreamRequest +{ + IAsyncEnumerable Handle(TRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Abstraction/Unit.cs b/src/KSFramework/Messaging/Abstraction/Unit.cs new file mode 100644 index 0000000..f8df51d --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/Unit.cs @@ -0,0 +1,6 @@ +namespace KSFramework.Messaging.Abstraction; + +public readonly struct Unit +{ + public static readonly Unit Value = new(); +} diff --git a/src/KSFramework/Messaging/Behaviors/ExceptionHandlingBehavior.cs b/src/KSFramework/Messaging/Behaviors/ExceptionHandlingBehavior.cs new file mode 100644 index 0000000..ebe2639 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/ExceptionHandlingBehavior.cs @@ -0,0 +1,32 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging.Behaviors +{ + /// + /// Behavior that catches exceptions during request handling, logs them, and rethrows. + /// + public class ExceptionHandlingBehavior : IPipelineBehavior + where TRequest : IRequest + { + private readonly ILogger> _logger; + + public ExceptionHandlingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + { + try + { + return await next(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception caught in {Behavior} handling request {RequestType}", nameof(ExceptionHandlingBehavior), typeof(TRequest).Name); + throw; + } + } + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/LoggingBehavior.cs b/src/KSFramework/Messaging/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..6ebce8f --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/LoggingBehavior.cs @@ -0,0 +1,30 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging.Behaviors; + +/// +/// Logs the request before and after it is handled. +/// +/// The type of request. +/// The type of response. +public class LoggingBehavior : IPipelineBehavior +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle( + TRequest request, + CancellationToken cancellationToken, + RequestHandlerDelegate next) + { + _logger.LogInformation("[Logging] Handling {Request}", typeof(TRequest).Name); + var response = await next(); + _logger.LogInformation("[Logging] Handled {Request}", typeof(TRequest).Name); + return response; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/LoggingPostProcessor.cs b/src/KSFramework/Messaging/Behaviors/LoggingPostProcessor.cs new file mode 100644 index 0000000..452e6a4 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/LoggingPostProcessor.cs @@ -0,0 +1,12 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Behaviors; + +public class LoggingPostProcessor : IRequestPostProcessor +{ + public Task Process(TRequest request, TResponse response, CancellationToken cancellationToken) + { + Console.WriteLine($"[PostProcessor] Handled request of type: {typeof(TRequest).Name} with response: {response}"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/LoggingPreProcessor.cs b/src/KSFramework/Messaging/Behaviors/LoggingPreProcessor.cs new file mode 100644 index 0000000..aa5b028 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/LoggingPreProcessor.cs @@ -0,0 +1,12 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Behaviors; + +public class LoggingPreProcessor : IRequestPreProcessor +{ + public Task Process(TRequest request, CancellationToken cancellationToken) + { + Console.WriteLine($"[PreProcessor] Handling request of type: {typeof(TRequest).Name}"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/NotificationLoggingBehavior.cs b/src/KSFramework/Messaging/Behaviors/NotificationLoggingBehavior.cs new file mode 100644 index 0000000..ab5d181 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/NotificationLoggingBehavior.cs @@ -0,0 +1,24 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging.Behaviors; + +public class NotificationLoggingBehavior : INotificationBehavior + where TNotification : INotification +{ + private readonly ILogger> _logger; + + public NotificationLoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle(TNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + _logger.LogInformation("Handling notification of type {NotificationType}", typeof(TNotification).Name); + + await next(); + + _logger.LogInformation("Handled notification of type {NotificationType}", typeof(TNotification).Name); + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs b/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs new file mode 100644 index 0000000..3979892 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs @@ -0,0 +1,29 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Behaviors; + +/// +/// Executes pre-processors and post-processors for the request. +/// +public class RequestProcessorBehavior( + IEnumerable> preProcessors, + IEnumerable> postProcessors) + : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle( + TRequest request, + CancellationToken cancellationToken, + RequestHandlerDelegate next) + { + foreach (var preProcessor in preProcessors) + await preProcessor.Process(request, cancellationToken); + + var response = await next(); + + foreach (var postProcessor in postProcessors) + await postProcessor.Process(request, response, cancellationToken); + + return response; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/RequestValidationBehavior.cs b/src/KSFramework/Messaging/Behaviors/RequestValidationBehavior.cs new file mode 100644 index 0000000..e066f1c --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/RequestValidationBehavior.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.Extensions.Logging; +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Behaviors +{ + /// + /// Pipeline behavior for validating requests using FluentValidation validators. + /// Throws ValidationException if validation fails. + /// + /// The type of the request. + /// The type of the response. + public class RequestValidationBehavior : IPipelineBehavior + where TRequest : IRequest // مطمئن شو این شرط در درخواستت وجود دارد + { + private readonly IEnumerable> Validators; + private readonly ILogger> Logger; + + public RequestValidationBehavior( + IEnumerable> validators, + ILogger> logger) + { + Validators = validators; + Logger = logger; + } + + public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + { + if (Validators.Any()) + { + var validationResults = await Task.WhenAll( + Validators.Select(v => v.ValidateAsync(request, cancellationToken))); + + var failures = validationResults + .Where(r => r != null) + .SelectMany(r => r.Errors ?? Enumerable.Empty()) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + Logger.LogWarning("Validation failed for {RequestType}: {Failures}", typeof(TRequest).Name, failures); + throw new ValidationException(failures); + } + } + + // اگر ولیدیتور نبود یا اعتبارسنجی رد نشد، اجرای هندر ادامه پیدا کند + return await next(); + } + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs b/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5af6b4a --- /dev/null +++ b/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Behaviors; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace KSFramework.Messaging.Configuration; + +/// +/// Extension methods to register messaging components into the service collection. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds messaging components (Mediator, Handlers, Behaviors, Processors) to the service collection. + /// + /// The service collection. + /// Assemblies to scan for handlers. + /// The updated service collection. + public static IServiceCollection AddMessaging(this IServiceCollection services, params Assembly[] assemblies) + { + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + services.Scan(scan => scan + .FromAssemblies(assemblies) + .AddClasses(classes => classes.AssignableTo(typeof(IRequestHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses(classes => classes.AssignableTo(typeof(INotificationHandler<>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses(classes => classes.AssignableTo(typeof(IStreamRequestHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses(classes => classes.AssignableTo(typeof(IPipelineBehavior<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses(classes => classes.AssignableTo(typeof(IRequestPreProcessor<>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + .AddClasses(classes => classes.AssignableTo(typeof(IRequestPostProcessor<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ExceptionHandlingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestProcessorBehavior<,>)); + + return services; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Mediator.cs b/src/KSFramework/Messaging/Mediator.cs new file mode 100644 index 0000000..29ea8d7 --- /dev/null +++ b/src/KSFramework/Messaging/Mediator.cs @@ -0,0 +1,220 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.DependencyInjection; + +namespace KSFramework.Messaging; + +/// +/// The default implementation of the mediator pattern. +/// +public class Mediator : IMediator +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider to resolve handlers and behaviors. + public Mediator(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + public async Task Send(IRequest request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var requestType = request.GetType(); + var responseType = typeof(TResponse); + + // Get the handler type + var handlerType = typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType); + var handler = _serviceProvider.GetService(handlerType); + if (handler == null) + throw new InvalidOperationException($"Handler for '{requestType.Name}' not found."); + + // Get all pipeline behaviors for this request/response + var behaviorType = typeof(IPipelineBehavior<,>).MakeGenericType(requestType, responseType); + var behaviors = _serviceProvider.GetServices(behaviorType).Cast().Reverse().ToList(); + + // Create the final handler delegate + RequestHandlerDelegate handlerDelegate = () => + { + var method = handlerType.GetMethod("Handle"); + return (Task)method.Invoke(handler, new object[] { request, cancellationToken }); + }; + + // Compose the pipeline by wrapping behaviors around the handler delegate + foreach (var behavior in behaviors) + { + var currentBehavior = behavior; + var next = handlerDelegate; + handlerDelegate = () => + { + var method = behaviorType.GetMethod("Handle"); + return (Task)method.Invoke(currentBehavior, new object[] { request, cancellationToken, next }); + }; + } + + // Execute the composed pipeline + return await handlerDelegate(); + } + + public async Task Send(object request, CancellationToken cancellationToken = default) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var requestType = request.GetType(); + + var requestInterface = requestType + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IRequest<>)); + + if (requestInterface == null) + throw new InvalidOperationException("Request does not implement IRequest<>"); + + var responseType = requestInterface.GetGenericArguments()[0]; + + var method = typeof(Mediator) + .GetMethods() + .FirstOrDefault(m => + m.Name == nameof(Send) + && m.IsGenericMethodDefinition + && m.GetGenericArguments().Length == 1 + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.IsGenericType + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IRequest<>)); + + if (method == null) + throw new InvalidOperationException("Unable to find the correct generic Send method."); + + var genericMethod = method.MakeGenericMethod(responseType); + + var task = (Task)genericMethod.Invoke(this, new object[] { request, cancellationToken })!; + await task.ConfigureAwait(false); + + var resultProperty = task.GetType().GetProperty("Result"); + return resultProperty?.GetValue(task); + } + + /// + public async Task Publish(object notification, CancellationToken cancellationToken = default) + { + if (notification == null) + throw new ArgumentNullException(nameof(notification)); + + var notificationType = notification.GetType(); + + if (!typeof(INotification).IsAssignableFrom(notificationType)) + throw new InvalidOperationException($"'{notificationType.Name}' does not implement INotification."); + + var method = typeof(Mediator) + .GetMethods() + .FirstOrDefault(m => + m.Name == nameof(Publish) && + m.IsGenericMethodDefinition && + m.GetGenericArguments().Length == 1 && + m.GetParameters().Length == 2); + + if (method == null) + throw new InvalidOperationException("Unable to find generic Publish method."); + + var genericMethod = method.MakeGenericMethod(notificationType); + await (Task)genericMethod.Invoke(this, new object[] { notification, cancellationToken })!; + } + + // /// + // public async Task Send(object request, CancellationToken cancellationToken = default) + // { + // if (request == null) + // throw new ArgumentNullException(nameof(request)); + // + // var requestType = request.GetType(); + // + // // پیدا کردن اینترفیس IRequest که روی request اعمال شده + // var interfaceType = requestType + // .GetInterfaces() + // .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequest<>)); + // + // if (interfaceType == null) + // throw new InvalidOperationException($"Request type '{requestType.Name}' does not implement IRequest<> interface."); + // + // var responseType = interfaceType.GetGenericArguments()[0]; + // + // // ساختن متد جنریک Send(IRequest) + // var method = typeof(IMediator) + // .GetMethods() + // .FirstOrDefault(m => + // m.Name == nameof(Send) && + // m.IsGenericMethodDefinition && + // m.GetParameters().Length == 2 && + // m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IRequest<>)); + // + // if (method == null) + // throw new InvalidOperationException("Unable to find generic Send method."); + // + // var genericMethod = method.MakeGenericMethod(responseType); + // + // return await (Task)genericMethod.Invoke(this, new object[] { request, cancellationToken }); + // } + + + /// + /// Publishes a notification to all registered handlers. + /// + /// The notification type. + /// The notification instance. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification + { + var notificationType = typeof(TNotification); + + var behaviorType = typeof(INotificationBehavior<>).MakeGenericType(notificationType); + var behaviors = _serviceProvider.GetServices(behaviorType).Cast().Reverse().ToList(); + + var handlers = _serviceProvider.GetServices>().ToList(); + + if (!handlers.Any()) + return; + + NotificationHandlerDelegate handlerDelegate = () => + { + var tasks = handlers.Select(h => h.Handle(notification, cancellationToken)); + return Task.WhenAll(tasks); + }; + + foreach (var behavior in behaviors) + { + var currentBehavior = behavior; + var next = handlerDelegate; + handlerDelegate = () => + { + var method = behavior.GetType().GetMethod("Handle"); + return (Task)method.Invoke(currentBehavior, new object[] { notification, cancellationToken, next }); + }; + } + + await handlerDelegate(); + } + + public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) + { + var handlerType = typeof(IStreamRequestHandler<,>).MakeGenericType(request.GetType(), typeof(TResponse)); + var handler = _serviceProvider.GetService(handlerType); + + if (handler == null) + throw new InvalidOperationException($"No stream handler registered for {request.GetType().Name}"); + + var method = handlerType.GetMethod("Handle"); + + var result = (IAsyncEnumerable)method.Invoke(handler, new object[] { request, cancellationToken }); + + return result; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/NotificationLoggingBehavior.cs b/src/KSFramework/Messaging/NotificationLoggingBehavior.cs new file mode 100644 index 0000000..54c0838 --- /dev/null +++ b/src/KSFramework/Messaging/NotificationLoggingBehavior.cs @@ -0,0 +1,22 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging; + +public class NotificationLoggingBehavior : INotificationBehavior + where TNotification : INotification +{ + private readonly ILogger> _logger; + + public NotificationLoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle(TNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + _logger.LogInformation($"[NotificationLogging] Handling {typeof(TNotification).Name} started."); + await next(); + _logger.LogInformation($"[NotificationLogging] Handling {typeof(TNotification).Name} finished."); + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/CounterStreamTest.cs b/src/KSFramework/Messaging/Samples/CounterStreamTest.cs new file mode 100644 index 0000000..dfde75b --- /dev/null +++ b/src/KSFramework/Messaging/Samples/CounterStreamTest.cs @@ -0,0 +1,25 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Samples; + +public class CounterStreamRequest : IStreamRequest +{ + public int CountTo { get; } + + public CounterStreamRequest(int countTo) + { + CountTo = countTo; + } +} + +public class CounterStreamHandler : IStreamRequestHandler +{ + public async IAsyncEnumerable Handle(CounterStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + for (int i = 1; i <= request.CountTo; i++) + { + yield return i; + await Task.Delay(200, cancellationToken); // simulate delay + } + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/LoggingNotificationBehavior.cs b/src/KSFramework/Messaging/Samples/LoggingNotificationBehavior.cs new file mode 100644 index 0000000..70c4ec0 --- /dev/null +++ b/src/KSFramework/Messaging/Samples/LoggingNotificationBehavior.cs @@ -0,0 +1,14 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Samples; + +public class LoggingNotificationBehavior : INotificationBehavior where TNotification : INotification +{ + public Task Handle(TNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + Console.WriteLine($"Before handling notification of type {typeof(TNotification).Name}"); + var task = next(); + Console.WriteLine($"After handling notification of type {typeof(TNotification).Name}"); + return task; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/MultiplyByTwoRequest.cs b/src/KSFramework/Messaging/Samples/MultiplyByTwoRequest.cs new file mode 100644 index 0000000..9116a5a --- /dev/null +++ b/src/KSFramework/Messaging/Samples/MultiplyByTwoRequest.cs @@ -0,0 +1,18 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Samples; + +public class MultiplyByTwoRequest : IRequest +{ + public int Input { get; } + + public MultiplyByTwoRequest(int input) => Input = input; +} + +public class MultiplyByTwoHandler : IRequestHandler +{ + public Task Handle(MultiplyByTwoRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(request.Input * 2); + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/NotificationTest.cs b/src/KSFramework/Messaging/Samples/NotificationTest.cs new file mode 100644 index 0000000..c84f2a3 --- /dev/null +++ b/src/KSFramework/Messaging/Samples/NotificationTest.cs @@ -0,0 +1,17 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Samples; + +public class TestNotification : INotification +{ + public string Message { get; set; } = default!; +} + +public class TestNotificationHandler : INotificationHandler +{ + public Task Handle(TestNotification notification, CancellationToken cancellationToken) + { + Console.WriteLine($"Received notification: {notification.Message}"); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/UserRegisteredHandler.cs b/src/KSFramework/Messaging/Samples/UserRegisteredHandler.cs new file mode 100644 index 0000000..3bbd816 --- /dev/null +++ b/src/KSFramework/Messaging/Samples/UserRegisteredHandler.cs @@ -0,0 +1,22 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging.Samples; + +public class SendWelcomeEmailHandler : INotificationHandler +{ + public Task Handle(UserRegisteredNotification notification, CancellationToken cancellationToken) + { + Console.WriteLine($"[Email] Welcome email sent to {notification.Username}"); + return Task.CompletedTask; + } +} + +public class LogUserRegistrationHandler : INotificationHandler +{ + public Task Handle(UserRegisteredNotification notification, CancellationToken cancellationToken) + { + Console.WriteLine($"[Log] User '{notification.Username}' has registered."); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Samples/UserRegisteredNotification.cs b/src/KSFramework/Messaging/Samples/UserRegisteredNotification.cs new file mode 100644 index 0000000..fcfba17 --- /dev/null +++ b/src/KSFramework/Messaging/Samples/UserRegisteredNotification.cs @@ -0,0 +1,16 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.Messaging.Samples; + +/// +/// Notification that is triggered when a user registers. +/// +public class UserRegisteredNotification : INotification +{ + public string Username { get; } + + public UserRegisteredNotification(string username) + { + Username = username; + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs new file mode 100644 index 0000000..f4372c7 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs @@ -0,0 +1,27 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Configuration; +using KSFramework.Messaging.Samples; +using Microsoft.Extensions.DependencyInjection; + +namespace KSFramework.UnitTests.Configuration; + +public class AddMessagingTests +{ + [Fact] + public void Should_Register_All_Handlers_And_Behaviors_From_Assembly() + { + // Arrange + var services = new ServiceCollection(); + + // اینجا مهمه: باید اسمبلی‌ای رو بده که MultiplyByTwoHandler توشه + services.AddMessaging(typeof(MultiplyByTwoRequest).Assembly); + + var provider = services.BuildServiceProvider(); + var handler = provider.GetService>(); + + // Assert + Assert.NotNull(handler); + var result = handler.Handle(new MultiplyByTwoRequest(5), CancellationToken.None).Result; + Assert.Equal(10, result); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/ExceptionHandlingBehaviorTests.cs b/tests/KSFramework/KSFramework.UnitTests/ExceptionHandlingBehaviorTests.cs new file mode 100644 index 0000000..7475f5d --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/ExceptionHandlingBehaviorTests.cs @@ -0,0 +1,42 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.UnitTests; + +using KSFramework.Messaging.Behaviors; +using Microsoft.Extensions.Logging; +using Moq; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +public class ExceptionHandlingBehaviorTests +{ + public class TestRequest : IRequest { } + + [Fact] + public async Task Handle_WhenHandlerThrowsException_ShouldLogAndRethrow() + { + // Arrange + var loggerMock = new Mock>>(); + var behavior = new ExceptionHandlingBehavior(loggerMock.Object); + + var request = new TestRequest(); + + // next delegate that throws exception + RequestHandlerDelegate next = () => throw new InvalidOperationException("Test exception"); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => behavior.Handle(request, CancellationToken.None, next)); + + // Verify that logger.LogError was called with the exception + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Exception caught")), + ex, + It.IsAny>()), + Times.Once); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/KSFramework.UnitTests.csproj b/tests/KSFramework/KSFramework.UnitTests/KSFramework.UnitTests.csproj new file mode 100644 index 0000000..0d84793 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/KSFramework.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/KSFramework/KSFramework.UnitTests/RequestValidationBehaviorTests.cs b/tests/KSFramework/KSFramework.UnitTests/RequestValidationBehaviorTests.cs new file mode 100644 index 0000000..cdf7810 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/RequestValidationBehaviorTests.cs @@ -0,0 +1,181 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.Extensions.Logging; +using Moq; +using KSFramework.Messaging.Behaviors; +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.UnitTests +{ + public class RequestValidationBehaviorTests + { + public class TestRequest : IRequest { } + + public class TestResponse + { + public string Message { get; set; } = "OK"; + } + + [Fact] + public async Task Handle_WithNoValidators_CallsNext() + { + var nextCalled = false; + Task Next() + { + nextCalled = true; + return Task.FromResult(new TestResponse()); + } + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + Array.Empty>(), + loggerMock.Object); + + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, Next); + + Assert.True(nextCalled); + Assert.Equal("OK", result.Message); + } + + [Fact] + public async Task Handle_WithValidRequest_CallsNext() + { + var validatorMock = new Mock>(); + validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult()); // no errors + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object); + + var nextCalled = false; + Task Next() + { + nextCalled = true; + return Task.FromResult(new TestResponse()); + } + + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, Next); + + Assert.True(nextCalled); + Assert.Equal("OK", result.Message); + } + + [Fact] + public async Task Handle_WithInvalidRequest_ThrowsValidationException() + { + var validatorMock = new Mock>(); + validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(new[] + { + new ValidationFailure("Prop", "Error") + })); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object); + + await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse()))); + } + + [Fact] + public async Task Handle_WithInvalidRequest_ThrowsValidationException_ContainsErrorMessage() + { + var invalidFailures = new[] + { + new ValidationFailure("Name", "Name is required") + }; + + var validationResult = new ValidationResult(invalidFailures); + + var validatorMock = new Mock>(); + validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validationResult); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object); + + var ex = await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse()))); + + Assert.Contains("Name is required", ex.Message); + } + + [Fact] + public async Task Handle_WithInvalidRequest_LogsWarning() + { + // Arrange + var invalidFailures = new List + { + new ValidationFailure("Name", "Name is required") + }; + + var validationResult = new ValidationResult(invalidFailures); + + var validatorMock = new Mock>(); + validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validationResult); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse()))); + + Assert.Contains("Name is required", ex.Message); + + // Verify that a Warning log was written containing the validation error message + loggerMock.Verify( + x => x.Log( + It.Is(l => l == LogLevel.Warning), + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Name is required")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Handle_WithMultipleValidators_AggregatesErrors() + { + var validator1 = new Mock>(); + validator1.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(new[] + { + new ValidationFailure("Name", "Name is required") + })); + + var validator2 = new Mock>(); + validator2.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(new[] + { + new ValidationFailure("Email", "Email is invalid") + })); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validator1.Object, validator2.Object }, + loggerMock.Object); + + var ex = await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse()))); + + Assert.Contains("Name is required", ex.Message); + Assert.Contains("Email is invalid", ex.Message); + } + } +} \ No newline at end of file