diff --git a/docs/guide/http/endpoints.md b/docs/guide/http/endpoints.md index f923866bb..bc143ff3b 100644 --- a/docs/guide/http/endpoints.md +++ b/docs/guide/http/endpoints.md @@ -336,5 +336,34 @@ public static string GetNow(DateTimeOffset now) // using the custom parameter st snippet source | anchor +## Http Endpoint / Message Handler Combo + +Here's a common scenario that has come up from Wolverine users. Let's say that you have some kind of logical command message that +your system needs to handle that might come in from the outside from either HTTP clients or from asynchronous messaging. +Folks have frequently asked about how to reuse code between the message handling invocation and the HTTP endpoint. You've +got a handful of options: + +1. Build a message handler and have the HTTP endpoint just delegate to `IMessageBus.InvokeAsync()` with the message +2. Have both the message handler and HTTP endpoint delegate to shared code, whether that be a shared service, just a static method somewhere, or even + have the HTTP endpoint code directly call the concrete message handler +3. Use a hybrid Message Handler / HTTP Endpoint because Wolverine can do that! + +To make a single class and method be both a message handler and HTTP endpoint, just add a `[Wolverine{HttpVerb}]` attribute +with the route directly on your message handler. As long as that method follows Wolverine's normal naming rules for message +discovery, Wolverine will treat it as both a message handler and as an HTTP endpoint. Here's an example from our tests: + +snippet: sample_using_problem_details_in_message_handler + +If you are using Wolverine.HTTP in your application, Wolverine is able to treat `ProblemDetails` similar to the built in +`HandlerContinuation` when running inside of message handlers. + +If you have some middleware methods that should only apply specifically when running as a handler or when running as an HTTP endpoint, +you can utilize `MiddlewareScoping` directives with `[WolverineBefore]`, `[WolverineAfter]`, or `[WolverineFinally]` attributes to +limit the applicability of individual middleware methods. + +::: info +There is no runtime filtering here because the `MiddlewareScoping` impacts the generated code around your hybrid message handler / +HTTP endpoint method, and Wolverine already generates code separately for the two use cases. +::: diff --git a/src/Http/Wolverine.Http.Tests/combo_handler_and_endpoint.cs b/src/Http/Wolverine.Http.Tests/combo_handler_and_endpoint.cs index 17a896b86..856fb2dc2 100644 --- a/src/Http/Wolverine.Http.Tests/combo_handler_and_endpoint.cs +++ b/src/Http/Wolverine.Http.Tests/combo_handler_and_endpoint.cs @@ -14,17 +14,25 @@ public combo_handler_and_endpoint(AppFixture fixture) : base(fixture) [Fact] public async Task use_combo_with_problem_details_as_endpoint() { + // cleaning up expected change + NumberMessageHandler.CalledBeforeOnlyOnHttpEndpoints = false; + NumberMessageHandler.CalledBeforeOnlyOnMessageHandlers = false; + // Should be good await Host.Scenario(x => { - x.Post.Json(new WolverineWebApi.NumberMessage(3)).ToUrl("/problems"); + x.Post.Json(new WolverineWebApi.NumberMessage(3)).ToUrl("/problems2"); + x.StatusCodeShouldBe(204); }); + NumberMessageHandler.CalledBeforeOnlyOnHttpEndpoints.ShouldBeTrue(); + NumberMessageHandler.CalledBeforeOnlyOnMessageHandlers.ShouldBeFalse(); + // should return problem details because the number > 5 // Should be good var result = await Host.Scenario(x => { - x.Post.Json(new WolverineWebApi.NumberMessage(10)).ToUrl("/problems"); + x.Post.Json(new WolverineWebApi.NumberMessage(10)).ToUrl("/problems2"); x.ContentTypeShouldBe("application/problem+json"); x.StatusCodeShouldBe(400); }); @@ -32,11 +40,16 @@ await Host.Scenario(x => var details = result.ReadAsJson(); details.Detail.ShouldBe("Number is bigger than 5"); + + } [Fact] public async Task use_combo_as_handler_see_problem_details_catch() { + // cleaning up expected change + NumberMessageHandler.CalledBeforeOnlyOnHttpEndpoints = false; + NumberMessageHandler.CalledBeforeOnlyOnMessageHandlers = false; NumberMessageHandler.Handled = false; // This should be invalid and stop @@ -46,5 +59,8 @@ public async Task use_combo_as_handler_see_problem_details_catch() // should be good because we're < 5 await Host.InvokeAsync(new NumberMessage(3)); NumberMessageHandler.Handled.ShouldBeTrue(); + + NumberMessageHandler.CalledBeforeOnlyOnHttpEndpoints.ShouldBeFalse(); + NumberMessageHandler.CalledBeforeOnlyOnMessageHandlers.ShouldBeTrue(); } } \ No newline at end of file diff --git a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs index 93740aff9..c7aa8ee9f 100644 --- a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs +++ b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs @@ -126,6 +126,8 @@ public ApiDescription CreateApiDescription(string httpMethod) return apiDescription; } + public override MiddlewareScoping Scoping => MiddlewareScoping.HttpEndpoints; + public override void UseForResponse(MethodCall methodCall) { if (methodCall.ReturnVariable == null) diff --git a/src/Http/WolverineWebApi/ProblemDetailsUsage.cs b/src/Http/WolverineWebApi/ProblemDetailsUsage.cs index 7b0d23b8d..cae661d8c 100644 --- a/src/Http/WolverineWebApi/ProblemDetailsUsage.cs +++ b/src/Http/WolverineWebApi/ProblemDetailsUsage.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.AspNetCore.Mvc; +using Wolverine.Attributes; using Wolverine.Http; namespace WolverineWebApi; @@ -34,10 +35,10 @@ public record NumberMessage(int Number); #endregion +#region sample_using_problem_details_in_message_handler + public static class NumberMessageHandler { - #region sample_using_problem_details_in_message_handler - public static ProblemDetails Validate(NumberMessage message) { if (message.Number > 5) @@ -52,7 +53,24 @@ public static ProblemDetails Validate(NumberMessage message) // All good, keep on going! return WolverineContinue.NoProblems; } - + + // This "Before" method would only be utilized as + // an HTTP endpoint + [WolverineBefore(MiddlewareScoping.HttpEndpoints)] + public static void BeforeButOnlyOnHttp(HttpContext context) + { + Debug.WriteLine("Got an HTTP request for " + context.TraceIdentifier); + CalledBeforeOnlyOnHttpEndpoints = true; + } + + // This "Before" method would only be utilized as + // a message handler + [WolverineBefore(MiddlewareScoping.MessageHandlers)] + public static void BeforeButOnlyOnMessageHandlers() + { + CalledBeforeOnlyOnMessageHandlers = true; + } + // Look at this! You can use this as an HTTP endpoint too! [WolverinePost("/problems2")] public static void Handle(NumberMessage message) @@ -61,7 +79,10 @@ public static void Handle(NumberMessage message) Handled = true; } - #endregion + // These properties are just a cheap trick in Wolverine internal tests public static bool Handled { get; set; } -} \ No newline at end of file + public static bool CalledBeforeOnlyOnMessageHandlers { get; set; } + public static bool CalledBeforeOnlyOnHttpEndpoints { get; set; } +} +#endregion \ No newline at end of file diff --git a/src/Wolverine/Attributes/HandlerMethodAttributes.cs b/src/Wolverine/Attributes/HandlerMethodAttributes.cs index 00e5d7159..66e289d55 100644 --- a/src/Wolverine/Attributes/HandlerMethodAttributes.cs +++ b/src/Wolverine/Attributes/HandlerMethodAttributes.cs @@ -1,18 +1,68 @@ namespace Wolverine.Attributes; +public enum MiddlewareScoping +{ + /// + /// This middleware always applies + /// + Anywhere, + + /// + /// This middleware should only be applied when used for message handling + /// + MessageHandlers, + + /// + /// This middleware should only be applied when running in an HTTP endpoint + /// + HttpEndpoints +} + +public abstract class ScopedMiddlewareAttribute : Attribute +{ + public MiddlewareScoping Scoping { get; set; } = MiddlewareScoping.Anywhere; + + public ScopedMiddlewareAttribute(MiddlewareScoping scoping) + { + Scoping = scoping; + } + + protected ScopedMiddlewareAttribute() + { + } +} + /// /// Marks a method on middleware types or handler types as a method /// that should be called before the actual handler /// [AttributeUsage(AttributeTargets.Method)] -public class WolverineBeforeAttribute : Attribute; +public class WolverineBeforeAttribute : ScopedMiddlewareAttribute +{ + public WolverineBeforeAttribute(MiddlewareScoping scoping) : base(scoping) + { + } + + public WolverineBeforeAttribute() + { + } +} /// /// Marks a method on middleware types or handler types as a method /// that should be called after the actual handler /// [AttributeUsage(AttributeTargets.Method)] -public class WolverineAfterAttribute : Attribute; +public class WolverineAfterAttribute : ScopedMiddlewareAttribute +{ + public WolverineAfterAttribute(MiddlewareScoping scoping) : base(scoping) + { + } + + public WolverineAfterAttribute() + { + } +} /// /// Marks a method on middleware types or handler types as a method @@ -20,4 +70,13 @@ public class WolverineAfterAttribute : Attribute; /// a try/finally block around the message handlers /// [AttributeUsage(AttributeTargets.Method)] -public class WolverineFinallyAttribute : Attribute; \ No newline at end of file +public class WolverineFinallyAttribute : ScopedMiddlewareAttribute +{ + public WolverineFinallyAttribute(MiddlewareScoping scoping) : base(scoping) + { + } + + public WolverineFinallyAttribute() + { + } +} \ No newline at end of file diff --git a/src/Wolverine/Attributes/MiddlewareAttribute.cs b/src/Wolverine/Attributes/MiddlewareAttribute.cs index ee8ec7140..e69ac942e 100644 --- a/src/Wolverine/Attributes/MiddlewareAttribute.cs +++ b/src/Wolverine/Attributes/MiddlewareAttribute.cs @@ -17,9 +17,15 @@ public MiddlewareAttribute(params Type[] frameTypes) _frameTypes = frameTypes; } + /// + /// Use this to optionally restrict the application of this middleware to only message handlers + /// or HTTP endpoints. Default is Anywhere + /// + public MiddlewareScoping Scoping { get; set; } = MiddlewareScoping.Anywhere; + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) { - var applications = _frameTypes.Select(type => new MiddlewarePolicy.Application(type, _ => true)).ToList(); + var applications = _frameTypes.Select(type => new MiddlewarePolicy.Application(chain, type, _ => true)).ToList(); MiddlewarePolicy.ApplyToChain(applications, rules, chain); } } \ No newline at end of file diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs index bdd99be97..af24ef4fb 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -28,6 +28,8 @@ public abstract class Chain : IChain public abstract void ApplyParameterMatching(MethodCall call); public List Middleware { get; } = []; + public abstract MiddlewareScoping Scoping { get; } + public List Postprocessors { get; } = []; [IgnoreDescription] @@ -272,7 +274,7 @@ public void ApplyImpliedMiddlewareFromHandlers(GenerationRules generationRules) var handlerTypes = HandlerCalls().Select(x => x.HandlerType).Distinct(); foreach (var handlerType in handlerTypes) { - var befores = MiddlewarePolicy.FilterMethods(handlerType.GetMethods(), + var befores = MiddlewarePolicy.FilterMethods(this, handlerType.GetMethods(), MiddlewarePolicy.BeforeMethodNames); foreach (var before in befores) @@ -299,7 +301,7 @@ public void ApplyImpliedMiddlewareFromHandlers(GenerationRules generationRules) } } - var afters = MiddlewarePolicy.FilterMethods(handlerType.GetMethods(), + var afters = MiddlewarePolicy.FilterMethods(this, handlerType.GetMethods(), MiddlewarePolicy.AfterMethodNames).ToArray(); if (afters.Any()) diff --git a/src/Wolverine/Configuration/IChain.cs b/src/Wolverine/Configuration/IChain.cs index f9d1419a6..53d5f7d3c 100644 --- a/src/Wolverine/Configuration/IChain.cs +++ b/src/Wolverine/Configuration/IChain.cs @@ -10,6 +10,24 @@ namespace Wolverine.Configuration; +internal static class ChainExtensions +{ + public static bool MatchesScope(this IChain chain, MethodInfo method) + { + if (chain == null) return true; + + if (method.TryGetAttribute(out var att)) + { + if (att.Scoping == MiddlewareScoping.Anywhere) return true; + + return att.Scoping == chain.Scoping; + } + + // All good if no attribute + return true; + } +} + #region sample_IChain /// @@ -18,6 +36,8 @@ namespace Wolverine.Configuration; /// public interface IChain { + MiddlewareScoping Scoping { get; } + void ApplyParameterMatching(MethodCall call); /// diff --git a/src/Wolverine/Middleware/MiddlewarePolicy.cs b/src/Wolverine/Middleware/MiddlewarePolicy.cs index 75c70240e..7f62b3b26 100644 --- a/src/Wolverine/Middleware/MiddlewarePolicy.cs +++ b/src/Wolverine/Middleware/MiddlewarePolicy.cs @@ -74,33 +74,38 @@ internal static void ApplyToChain(List applications, GenerationRule public Application AddType(Type middlewareType, Func? filter = null) { filter ??= _ => true; - var application = new Application(middlewareType, filter); + var application = new Application(null, middlewareType, filter); _applications.Add(application); return application; } - public static IEnumerable FilterMethods(IEnumerable methods, string[] validNames) - where T : Attribute + public static IEnumerable FilterMethods(IChain? chain, IEnumerable methods, + string[] validNames) + where T : ScopedMiddlewareAttribute { + // MatchesScope watches out for null chain return methods - .Where(x => !x.HasAttribute()) + .Where(x => !x.HasAttribute() && chain!.MatchesScope(x)) .Where(x => validNames.Contains(x.Name) || x.HasAttribute()); } public class Application { + private readonly IChain? _chain; private readonly MethodInfo[] _afters; private readonly MethodInfo[] _befores; private readonly ConstructorInfo? _constructor; private readonly MethodInfo[] _finals; - - public Application(Type middlewareType, Func filter) + + public Application(IChain? chain, Type middlewareType, Func filter) { if (!middlewareType.IsPublic && !middlewareType.IsVisible) { throw new InvalidWolverineMiddlewareException(middlewareType); } + _chain = chain; + if (!middlewareType.IsStatic()) { var constructors = middlewareType.GetConstructors(); @@ -115,11 +120,11 @@ public Application(Type middlewareType, Func filter) MiddlewareType = middlewareType; Filter = filter; - var methods = middlewareType.GetMethods().ToArray(); + var methods = middlewareType.GetMethods().Where(x => x.DeclaringType != typeof(object)).ToArray(); - _befores = FilterMethods(methods, BeforeMethodNames).ToArray(); - _afters = FilterMethods(methods, AfterMethodNames).ToArray(); - _finals = FilterMethods(methods, FinallyMethodNames).ToArray(); + _befores = FilterMethods(chain, methods, BeforeMethodNames).ToArray(); + _afters = FilterMethods(chain, methods, AfterMethodNames).ToArray(); + _finals = FilterMethods(chain, methods, FinallyMethodNames).ToArray(); if (_befores.Length == 0 && _afters.Length == 0 && diff --git a/src/Wolverine/Runtime/Handlers/HandlerChain.cs b/src/Wolverine/Runtime/Handlers/HandlerChain.cs index 6261e8afa..aaae79c7d 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerChain.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerChain.cs @@ -164,6 +164,8 @@ public LogLevel ExecutionLogLevel /// public bool TelemetryEnabled { get; set; } = true; + public override MiddlewareScoping Scoping => MiddlewareScoping.MessageHandlers; + public override void ApplyParameterMatching(MethodCall call) { // Nothing