From 9167b534043a641e62f96137dbcd35b99955702c Mon Sep 17 00:00:00 2001 From: Bryan Boettcher Date: Tue, 26 Dec 2023 16:57:55 -0600 Subject: [PATCH] Adding support for Order as an integer property. Should resolve #987. Signed off by: Bryan Boettcher --- src/MediatR/Internal/HandlerExtensions.cs | 35 +++++ src/MediatR/Internal/ObjectDetails.cs | 29 +++- src/MediatR/Pipeline/IOrderableHandler.cs | 14 ++ .../Pipeline/IRequestExceptionAction.cs | 11 ++ .../Pipeline/IRequestExceptionHandler.cs | 11 ++ src/MediatR/Pipeline/IRequestPostProcessor.cs | 11 ++ src/MediatR/Pipeline/IRequestPreProcessor.cs | 11 ++ .../Pipeline/RequestPostProcessorBehavior.cs | 13 +- .../Pipeline/RequestPreProcessorBehavior.cs | 13 +- .../Ordering/OrderedExceptionTests.cs | 86 +++++++++++ .../Ordering/OrderedProcessorTests.cs | 138 ++++++++++++++++++ 11 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 src/MediatR/Internal/HandlerExtensions.cs create mode 100644 src/MediatR/Pipeline/IOrderableHandler.cs create mode 100644 test/MediatR.Tests/Pipeline/Ordering/OrderedExceptionTests.cs create mode 100644 test/MediatR.Tests/Pipeline/Ordering/OrderedProcessorTests.cs diff --git a/src/MediatR/Internal/HandlerExtensions.cs b/src/MediatR/Internal/HandlerExtensions.cs new file mode 100644 index 00000000..32790ee9 --- /dev/null +++ b/src/MediatR/Internal/HandlerExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Concurrent; +using System.Reflection; + +namespace MediatR.Internal; + +/// +/// Contains utility methods for working with implementations of request +/// handlers. +/// +public static class HandlerExtensions +{ + private static readonly ConcurrentDictionary PropertyCache = new(); + + /// + /// Inspects the parameter for a public Order property + /// that is gettable and an integer. If found, it uses the value of this property, + /// otherwise returning null. + /// + /// The value to inspect. + /// The value of the "Order" property or null if not found. + public static int? GetOrderIfExists(this object value) + { + var orderProperty = PropertyCache.GetOrAdd(value.GetType(), valueType => + { + var orderProperty = valueType.GetProperty("Order"); + return orderProperty ?? null; + }); + + var orderValue = orderProperty?.GetValue(value); + if (orderValue is not int i) return null; + + return i; + } +} diff --git a/src/MediatR/Internal/ObjectDetails.cs b/src/MediatR/Internal/ObjectDetails.cs index acf7bf4f..b33494cb 100644 --- a/src/MediatR/Internal/ObjectDetails.cs +++ b/src/MediatR/Internal/ObjectDetails.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Reflection; +using MediatR.Pipeline; namespace MediatR.Internal; @@ -40,7 +42,30 @@ public int Compare(ObjectDetails? x, ObjectDetails? y) return -1; } - return CompareByAssembly(x, y) ?? CompareByNamespace(x, y) ?? CompareByLocation(x, y); + return CompareByOrder(x, y) ?? CompareByAssembly(x, y) ?? CompareByNamespace(x, y) ?? CompareByLocation(x, y); + } + + /// + /// Compare two objects according to an optional Order property that has been declared on many of the + /// IRequestXXXHandler interfaces. + /// + /// First object to compare + /// Second object to compare + /// -1 if the left object should be ordered sooner, or 1 if the right object + /// should be ordered sooner. If either object does not support the Order + /// property, or their orders match, then null is returned to allow pass-through + /// handling. + private int? CompareByOrder(ObjectDetails x, ObjectDetails y) + { + var leftOrder = x.Value.GetOrderIfExists(); + var rightOrder = y.Value.GetOrderIfExists(); + + if (leftOrder is null && rightOrder is null) return null; + + if (leftOrder is null) return 1; // always sort null after values + if (rightOrder is null) return -1; // same + + return leftOrder.Value.CompareTo(rightOrder.Value); } /// @@ -112,7 +137,7 @@ public int Compare(ObjectDetails? x, ObjectDetails? y) /// First object to compare /// Second object to compare /// - /// An object has a higher priority if it location is part of the current location and the other is not; + /// An object has a higher priority if its location is part of the current location and the other is not; /// If both objects are part of the current location, the closest has higher priority; /// If none of the objects are part of the current location, they can be considered equal. /// diff --git a/src/MediatR/Pipeline/IOrderableHandler.cs b/src/MediatR/Pipeline/IOrderableHandler.cs new file mode 100644 index 00000000..1000363f --- /dev/null +++ b/src/MediatR/Pipeline/IOrderableHandler.cs @@ -0,0 +1,14 @@ +namespace MediatR.Pipeline; + +/// +/// Optional interface that can be used to decorate any +/// existing handlers to add an Order property +/// +public interface IOrderableHandler +{ + /// + /// Defines the order this handler should execute relative to others. Smaller numbers + /// execute sooner, and negative numbers are supported. + /// + int Order { get; } +} diff --git a/src/MediatR/Pipeline/IRequestExceptionAction.cs b/src/MediatR/Pipeline/IRequestExceptionAction.cs index 8be58c77..cc8dafc2 100644 --- a/src/MediatR/Pipeline/IRequestExceptionAction.cs +++ b/src/MediatR/Pipeline/IRequestExceptionAction.cs @@ -13,6 +13,17 @@ public interface IRequestExceptionAction where TRequest : notnull where TException : Exception { +#if NET8_0 + /// + /// Controls execution order of any implementations of this request exception-action. + /// All implementations are ordered by this field, and order of duplicate + /// numbers is not guaranteed. Not overriding this property has the behavior of + /// all implementations will be executed in the order they are returned by the DI + /// container. + /// + int Order => 0; +#endif + /// /// Called when the request handler throws an exception /// diff --git a/src/MediatR/Pipeline/IRequestExceptionHandler.cs b/src/MediatR/Pipeline/IRequestExceptionHandler.cs index 76363585..d62e16bf 100644 --- a/src/MediatR/Pipeline/IRequestExceptionHandler.cs +++ b/src/MediatR/Pipeline/IRequestExceptionHandler.cs @@ -14,6 +14,17 @@ public interface IRequestExceptionHandler where TRequest : notnull where TException : Exception { +#if NET8_0 + /// + /// Controls execution order of any implementations of this request exception-processor. + /// All implementations are ordered by this field, and order of duplicate + /// numbers is not guaranteed. Not overriding this property has the behavior of + /// all implementations will be executed in the order they are returned by the DI + /// container. + /// + int Order => 0; +#endif + /// /// Called when the request handler throws an exception /// diff --git a/src/MediatR/Pipeline/IRequestPostProcessor.cs b/src/MediatR/Pipeline/IRequestPostProcessor.cs index a363ce7c..b7d09603 100644 --- a/src/MediatR/Pipeline/IRequestPostProcessor.cs +++ b/src/MediatR/Pipeline/IRequestPostProcessor.cs @@ -10,6 +10,17 @@ namespace MediatR.Pipeline; /// Response type public interface IRequestPostProcessor where TRequest : notnull { +#if NET8_0 + /// + /// Controls execution order of any implementations of this request post-processor. + /// All implementations are ordered by this field, and order of duplicate + /// numbers is not guaranteed. Not overriding this property has the behavior of + /// all implementations will be executed in the order they are returned by the DI + /// container. + /// + int Order => 0; +#endif + /// /// Process method executes after the Handle method on your handler /// diff --git a/src/MediatR/Pipeline/IRequestPreProcessor.cs b/src/MediatR/Pipeline/IRequestPreProcessor.cs index 35079fe0..cb4add1d 100644 --- a/src/MediatR/Pipeline/IRequestPreProcessor.cs +++ b/src/MediatR/Pipeline/IRequestPreProcessor.cs @@ -9,6 +9,17 @@ namespace MediatR.Pipeline; /// Request type public interface IRequestPreProcessor where TRequest : notnull { +#if NET8_0 + /// + /// Controls execution order of any implementations of this request pre-processor. + /// All implementations are ordered by this field, and order of duplicate + /// numbers is not guaranteed. Not overriding this property has the behavior of + /// all implementations will be executed in the order they are returned by the DI + /// container. + /// + int Order => 0; +#endif + /// /// Process method executes before calling the Handle method on your handler /// diff --git a/src/MediatR/Pipeline/RequestPostProcessorBehavior.cs b/src/MediatR/Pipeline/RequestPostProcessorBehavior.cs index 2c5cee6f..baf9d2d9 100644 --- a/src/MediatR/Pipeline/RequestPostProcessorBehavior.cs +++ b/src/MediatR/Pipeline/RequestPostProcessorBehavior.cs @@ -1,3 +1,6 @@ +using System.Linq; +using MediatR.Internal; + namespace MediatR.Pipeline; using System.Collections.Generic; @@ -21,11 +24,19 @@ public async Task Handle(TRequest request, RequestHandlerDelegate arg) + { + if (arg is IOrderableHandler oh) + return oh.Order; + + return arg.GetOrderIfExists().GetValueOrDefault(0); + } } \ No newline at end of file diff --git a/src/MediatR/Pipeline/RequestPreProcessorBehavior.cs b/src/MediatR/Pipeline/RequestPreProcessorBehavior.cs index af09b048..2f565b59 100644 --- a/src/MediatR/Pipeline/RequestPreProcessorBehavior.cs +++ b/src/MediatR/Pipeline/RequestPreProcessorBehavior.cs @@ -1,3 +1,6 @@ +using System.Linq; +using MediatR.Internal; + namespace MediatR.Pipeline; using System.Collections.Generic; @@ -19,11 +22,19 @@ public RequestPreProcessorBehavior(IEnumerable> p public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { - foreach (var processor in _preProcessors) + foreach (var processor in _preProcessors.OrderBy(GetOrder)) { await processor.Process(request, cancellationToken).ConfigureAwait(false); } return await next().ConfigureAwait(false); } + + private static int GetOrder(IRequestPreProcessor arg) + { + if (arg is IOrderableHandler oh) + return oh.Order; + + return arg.GetOrderIfExists().GetValueOrDefault(0); + } } \ No newline at end of file diff --git a/test/MediatR.Tests/Pipeline/Ordering/OrderedExceptionTests.cs b/test/MediatR.Tests/Pipeline/Ordering/OrderedExceptionTests.cs new file mode 100644 index 00000000..d59e2b04 --- /dev/null +++ b/test/MediatR.Tests/Pipeline/Ordering/OrderedExceptionTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Lamar; +using MediatR.Internal; +using MediatR.Pipeline; +using Shouldly; +using Xunit; + +namespace MediatR.Tests.Pipeline.Ordering; +public class OrderedExceptionTests +{ + public class MyRequest : IRequest + { + public string Message { get; set; } = "initial"; + } + + public class MyResponse + { + public string? Message { get; set; } + } + + public class DefaultHandler : IRequestHandler + { + public Task Handle(MyRequest request, CancellationToken cancellationToken) => + throw new InvalidOperationException("this should break"); + } + + public class DefaultExceptionHandler : IRequestExceptionHandler + { + public Task Handle(MyRequest request, InvalidOperationException exception, RequestExceptionHandlerState state, + CancellationToken cancellationToken) + { + state.SetHandled(new MyResponse { Message = "default-exception" }); + return Task.CompletedTask; + } + } + + public class FirstExceptionHandler : IRequestExceptionHandler + { + public int Order => -100; + + public Task Handle(MyRequest request, InvalidOperationException exception, RequestExceptionHandlerState state, + CancellationToken cancellationToken) + { + state.SetHandled(new MyResponse { Message = "first-exception" }); + return Task.CompletedTask; + } + } + + + [Fact] + public async Task Should_run_exceptionprocessors() + { + var container = new Container(cfg => + { + cfg.Scan(scanner => + { + scanner.AssemblyContainingType(typeof(DefaultHandler)); + scanner.IncludeNamespaceContainingType(); + scanner.WithDefaultConventions(); + scanner.AddAllTypesOf(typeof(IRequestHandler<,>)); + scanner.AddAllTypesOf(typeof(IRequestExceptionHandler<,,>)); + }); + cfg.For(typeof(IPipelineBehavior<,>)).Add(typeof(RequestExceptionProcessorBehavior<,>)); + cfg.For().Use(); + }); + + var mediator = container.GetInstance(); + + var response = await mediator.Send(new MyRequest()); + + // the exception handler just resets the message, so this should be the correct one + response.Message.ShouldBe("first-exception"); + } + + [Fact] + public void Order_is_discoverable() + { + (new DefaultExceptionHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(0); + (new FirstExceptionHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(-100); + } +} diff --git a/test/MediatR.Tests/Pipeline/Ordering/OrderedProcessorTests.cs b/test/MediatR.Tests/Pipeline/Ordering/OrderedProcessorTests.cs new file mode 100644 index 00000000..6b3c8965 --- /dev/null +++ b/test/MediatR.Tests/Pipeline/Ordering/OrderedProcessorTests.cs @@ -0,0 +1,138 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR.Pipeline; +using Shouldly; +using Lamar; +using MediatR.Internal; +using Xunit; + +namespace MediatR.Tests.Pipeline.Ordering; + +public class OrderedProcessorTests +{ + public class MyRequest : IRequest + { + public string Message { get; set; } = "initial"; + } + + public class MyResponse + { + public string? Message { get; set; } + } + + public class DefaultHandler : IRequestHandler + { + public Task Handle(MyRequest request, CancellationToken cancellationToken) => + Task.FromResult(new MyResponse { Message = request.Message + " handler" }); + } + + public class DefaultPreHandler : IRequestPreProcessor + { + public Task Process(MyRequest request, CancellationToken cancellationToken) + { + request.Message += " default-prehandler"; + return Task.CompletedTask; + } + } + + public class FirstPreHandler : IRequestPreProcessor + { + public int Order => -100; + + public Task Process(MyRequest request, CancellationToken cancellationToken) + { + request.Message += " first-prehandler"; + return Task.CompletedTask; + } + } + + public class LastPreHandler : IRequestPreProcessor, IOrderableHandler + { + public int Order => 100; + + public Task Process(MyRequest request, CancellationToken cancellationToken) + { + request.Message += " last-prehandler"; + return Task.CompletedTask; + } + } + + public class DefaultPostHandler : IRequestPostProcessor + { + public Task Process(MyRequest request, MyResponse response, CancellationToken cancellationToken) + { + response.Message += " default-posthandler"; + return Task.CompletedTask; + } + } + + public class FirstPostHandler : IRequestPostProcessor + { + public int Order => -100; + + public Task Process(MyRequest request, MyResponse response, CancellationToken cancellationToken) + { + response.Message += " first-posthandler"; + return Task.CompletedTask; + } + } + + public class LastPostHandler : IRequestPostProcessor, IOrderableHandler + { + public int Order => 100; + + public Task Process(MyRequest request, MyResponse response, CancellationToken cancellationToken) + { + response.Message += " last-posthandler"; + return Task.CompletedTask; + } + } + + [Fact] + public async Task Should_run_preprocessors() + { + var container = new Container(cfg => + { + cfg.Scan(scanner => + { + scanner.AssemblyContainingType(typeof(DefaultHandler)); + scanner.IncludeNamespaceContainingType(); + scanner.WithDefaultConventions(); + scanner.AddAllTypesOf(typeof(IRequestHandler<,>)); + scanner.AddAllTypesOf(typeof(IRequestPreProcessor<>)); + scanner.AddAllTypesOf(typeof(IRequestPostProcessor<,>)); + }); + cfg.For(typeof(IPipelineBehavior<,>)).Add(typeof(RequestPreProcessorBehavior<,>)); + cfg.For(typeof(IPipelineBehavior<,>)).Add(typeof(RequestPostProcessorBehavior<,>)); + cfg.For().Use(); + }); + + var mediator = container.GetInstance(); + + var response = await mediator.Send(new MyRequest()); + + // proper execution order is: + // + // initial (set on the request) + // first-prehandler due to magic -100 Order sorting it first + // default-prehandler due to 0 Order (default) + // last-prehandler due to +100 Order from IOrderableHandler + // handler from the IRequestHandler itself + // first-posthandler due to magic -100 Order sorting it first + // default-posthandler due to 0 Order (default) + // last-posthandler due to +100 Order from IOrderableHandler + response.Message.ShouldBe("initial first-prehandler default-prehandler last-prehandler handler first-posthandler default-posthandler last-posthandler"); + } + + [Fact] + public void Order_is_discoverable() + { + (new DefaultPreHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(0); + (new FirstPreHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(-100); + (new LastPreHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(100); + + (new DefaultPostHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(0); + (new FirstPostHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(-100); + (new LastPostHandler()).GetOrderIfExists().GetValueOrDefault(0).ShouldBe(100); + } +}