diff --git a/docs/guide/handlers/index.md b/docs/guide/handlers/index.md index 12c717078..ff73574ce 100644 --- a/docs/guide/handlers/index.md +++ b/docs/guide/handlers/index.md @@ -559,6 +559,39 @@ For **HTTP endpoints**, the behavior is different — Wolverine will create a `P +### Validation with RequirementResult + +For more structured validation, your `Validate` or `ValidateAsync` method can return a `RequirementResult` record that combines a `HandlerContinuation` branch with an array of string messages: + +```csharp +public record RequirementResult(HandlerContinuation Branch, string[] Messages); +``` + +If `Branch == Continue`, processing continues normally. If `Branch == Stop`: + +- For **message handlers**, Wolverine logs each message as a warning (or "Invalid Request" if no messages) and aborts the handler +- For **HTTP endpoints**, Wolverine returns a `ProblemDetails` with status 400. If messages are provided, they are included in the `errors` extension. If messages are empty, `ProblemDetails.Detail` is set to `"Invalid Request"` + +Synchronous validation: + + + + +Asynchronous validation: + + + + +Validation with empty messages (still stops processing): + + + + +For HTTP endpoints: + + + + ### Validation with HandlerContinuation For more control, your `Validate` method can return a `HandlerContinuation` to explicitly signal whether processing should continue or stop: diff --git a/src/Http/Wolverine.Http.Tests/requirement_result_validation_in_http_endpoints.cs b/src/Http/Wolverine.Http.Tests/requirement_result_validation_in_http_endpoints.cs new file mode 100644 index 000000000..e5d2043dc --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/requirement_result_validation_in_http_endpoints.cs @@ -0,0 +1,75 @@ +using Alba; +using Shouldly; +using WolverineWebApi; + +namespace Wolverine.Http.Tests; + +public class requirement_result_validation_in_http_endpoints : IntegrationContext +{ + public requirement_result_validation_in_http_endpoints(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task happy_path_with_requirement_result_validate() + { + await Scenario(x => + { + x.Post.Json(new RequirementResultHttpMessage(3)).ToUrl("/requirement-result/sync"); + }); + } + + [Fact] + public async Task sad_path_with_requirement_result_validate() + { + await Scenario(x => + { + x.Post.Json(new RequirementResultHttpMessage(20)).ToUrl("/requirement-result/sync"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task happy_path_with_async_requirement_result_validate() + { + await Scenario(x => + { + x.Post.Json(new RequirementResultHttpAsyncMessage(3)).ToUrl("/requirement-result/async"); + }); + } + + [Fact] + public async Task sad_path_with_async_requirement_result_validate() + { + await Scenario(x => + { + x.Post.Json(new RequirementResultHttpAsyncMessage(20)).ToUrl("/requirement-result/async"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task happy_path_with_empty_messages_requirement_result_validate() + { + await Scenario(x => + { + x.Post.Json(new RequirementResultHttpEmptyMessagesMessage(3)).ToUrl("/requirement-result/empty-messages"); + }); + } + + [Fact] + public async Task sad_path_with_empty_messages_returns_invalid_request() + { + var result = await Scenario(x => + { + x.Post.Json(new RequirementResultHttpEmptyMessagesMessage(20)).ToUrl("/requirement-result/empty-messages"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + + var json = result.ReadAsJson(); + json!.Detail.ShouldBe("Invalid Request"); + } +} diff --git a/src/Http/Wolverine.Http/CodeGen/RequirementResultHttpFrame.cs b/src/Http/Wolverine.Http/CodeGen/RequirementResultHttpFrame.cs new file mode 100644 index 000000000..4c90d365e --- /dev/null +++ b/src/Http/Wolverine.Http/CodeGen/RequirementResultHttpFrame.cs @@ -0,0 +1,62 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Wolverine.Http.CodeGen; + +/// +/// Frame that generates validation code for HTTP endpoints using RequirementResult. +/// Creates a ProblemDetails with status 400 and writes it to the response if Branch == Stop. +/// If Messages are empty, uses ProblemDetails.Detail = "Invalid Request". +/// +internal class RequirementResultHttpFrame : AsyncFrame +{ + private static int _count; + private readonly Variable _variable; + private Variable? _context; + + public RequirementResultHttpFrame(Variable variable) + { + _variable = variable; + _variable.OverrideName(_variable.Usage + ++_count); + uses.Add(_variable); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _context = chain.FindVariable(typeof(HttpContext)); + yield return _context; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Check RequirementResult and abort with ProblemDetails if Branch == Stop"); + writer.Write( + $"BLOCK:if ({_variable.Usage}.{nameof(RequirementResult.Branch)} == {typeof(HandlerContinuation).FullNameInCode()}.{nameof(HandlerContinuation.Stop)})"); + writer.Write( + $"var problemDetails{_count} = new {typeof(ProblemDetails).FullNameInCode()}();"); + writer.Write( + $"problemDetails{_count}.{nameof(ProblemDetails.Status)} = 400;"); + writer.Write( + $"problemDetails{_count}.{nameof(ProblemDetails.Title)} = \"Validation failed\";"); + writer.Write( + $"BLOCK:if ({_variable.Usage}.{nameof(RequirementResult.Messages)}.Length > 0)"); + writer.Write( + $"problemDetails{_count}.{nameof(ProblemDetails.Extensions)}[\"errors\"] = {_variable.Usage}.{nameof(RequirementResult.Messages)};"); + writer.FinishBlock(); + writer.Write("BLOCK:else"); + writer.Write( + $"problemDetails{_count}.{nameof(ProblemDetails.Detail)} = \"Invalid Request\";"); + writer.FinishBlock(); + writer.Write( + $"await {nameof(HttpHandler.WriteProblems)}(problemDetails{_count}, {_context!.Usage}).ConfigureAwait(false);"); + writer.Write("return;"); + writer.FinishBlock(); + writer.BlankLine(); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index f485a3cd6..51edd5229 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -308,6 +308,12 @@ public override bool HasAttribute() return new SimpleValidationHttpFrame(variable); } + public override Frame? CreateRequirementResultFrame(Variable variable) + { + Metadata.Produces(400, contentType: "application/problem+json"); + return new RequirementResultHttpFrame(variable); + } + public override Frame[] AddStopConditionIfNull(Variable variable) { return [new SetStatusCodeAndReturnIfEntityIsNullFrame(variable)]; diff --git a/src/Http/WolverineWebApi/RequirementResultValidationUsage.cs b/src/Http/WolverineWebApi/RequirementResultValidationUsage.cs new file mode 100644 index 000000000..220165025 --- /dev/null +++ b/src/Http/WolverineWebApi/RequirementResultValidationUsage.cs @@ -0,0 +1,70 @@ +using Wolverine; +using Wolverine.Http; + +namespace WolverineWebApi; + +#region sample_requirement_result_http_validation + +public record RequirementResultHttpMessage(int Number); + +public static class RequirementResultHttpEndpoint +{ + public static RequirementResult Validate(RequirementResultHttpMessage message) + { + if (message.Number > 10) + { + return new RequirementResult(HandlerContinuation.Stop, ["Number must be 10 or less"]); + } + + return new RequirementResult(HandlerContinuation.Continue, []); + } + + [WolverinePost("/requirement-result/sync")] + public static string Post(RequirementResultHttpMessage message) => "Ok"; +} + +#endregion + +#region sample_requirement_result_http_async + +public record RequirementResultHttpAsyncMessage(int Number); + +public static class RequirementResultHttpAsyncEndpoint +{ + public static Task ValidateAsync(RequirementResultHttpAsyncMessage message) + { + if (message.Number > 10) + { + return Task.FromResult(new RequirementResult(HandlerContinuation.Stop, ["Number must be 10 or less"])); + } + + return Task.FromResult(new RequirementResult(HandlerContinuation.Continue, [])); + } + + [WolverinePost("/requirement-result/async")] + public static string Post(RequirementResultHttpAsyncMessage message) => "Ok"; +} + +#endregion + +#region sample_requirement_result_http_empty_messages + +public record RequirementResultHttpEmptyMessagesMessage(int Number); + +public static class RequirementResultHttpEmptyMessagesEndpoint +{ + public static RequirementResult Validate(RequirementResultHttpEmptyMessagesMessage message) + { + if (message.Number > 10) + { + return new RequirementResult(HandlerContinuation.Stop, []); + } + + return new RequirementResult(HandlerContinuation.Continue, []); + } + + [WolverinePost("/requirement-result/empty-messages")] + public static string Post(RequirementResultHttpEmptyMessagesMessage message) => "Ok"; +} + +#endregion diff --git a/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs b/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs new file mode 100644 index 000000000..e3220b5f2 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs @@ -0,0 +1,12 @@ +using Marten; +using Marten.Services.BatchQuerying; +using Microsoft.Extensions.Logging; + +namespace Wolverine.Marten.Requirements; + +public interface IDataRequirement +{ + Task CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation); + Task CheckFromBatch(ILogger logger); + void RegisterInBatch(IBatchedQuery query); +} \ No newline at end of file diff --git a/src/Testing/CoreTests/Acceptance/requirement_result_validation_handlers.cs b/src/Testing/CoreTests/Acceptance/requirement_result_validation_handlers.cs new file mode 100644 index 000000000..3994f4d09 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/requirement_result_validation_handlers.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Wolverine.Tracking; +using Xunit; + +namespace CoreTests.Acceptance; + +public class requirement_result_validation_handlers +{ + [Fact] + public async Task happy_path_with_requirement_result_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + RequirementResultHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new RequirementResultMessage(3)); + + RequirementResultHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_requirement_result_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + RequirementResultHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new RequirementResultMessage(20)); + + RequirementResultHandler.Handled.ShouldBeFalse(); + } + + [Fact] + public async Task happy_path_with_async_requirement_result_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + AsyncRequirementResultHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new AsyncRequirementResultMessage(3)); + + AsyncRequirementResultHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_async_requirement_result_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + AsyncRequirementResultHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new AsyncRequirementResultMessage(20)); + + AsyncRequirementResultHandler.Handled.ShouldBeFalse(); + } + + [Fact] + public async Task sad_path_with_empty_messages_requirement_result_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + EmptyMessagesRequirementResultHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new EmptyMessagesRequirementResultMessage(20)); + + EmptyMessagesRequirementResultHandler.Handled.ShouldBeFalse(); + } +} + +#region sample_requirement_result_validation + +public record RequirementResultMessage(int Number); + +public static class RequirementResultHandler +{ + public static RequirementResult Validate(RequirementResultMessage message) + { + if (message.Number > 10) + { + return new RequirementResult(HandlerContinuation.Stop, ["Number must be 10 or less"]); + } + + return new RequirementResult(HandlerContinuation.Continue, []); + } + + public static void Handle(RequirementResultMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion + +#region sample_requirement_result_validation_async + +public record AsyncRequirementResultMessage(int Number); + +public static class AsyncRequirementResultHandler +{ + public static Task ValidateAsync(AsyncRequirementResultMessage message) + { + if (message.Number > 10) + { + return Task.FromResult(new RequirementResult(HandlerContinuation.Stop, ["Number must be 10 or less"])); + } + + return Task.FromResult(new RequirementResult(HandlerContinuation.Continue, [])); + } + + public static void Handle(AsyncRequirementResultMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion + +#region sample_requirement_result_validation_empty_messages + +public record EmptyMessagesRequirementResultMessage(int Number); + +public static class EmptyMessagesRequirementResultHandler +{ + public static RequirementResult Validate(EmptyMessagesRequirementResultMessage message) + { + if (message.Number > 10) + { + return new RequirementResult(HandlerContinuation.Stop, []); + } + + return new RequirementResult(HandlerContinuation.Continue, []); + } + + public static void Handle(EmptyMessagesRequirementResultMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs index 920b4f535..9d57cebe3 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -53,6 +53,11 @@ public abstract class Chain : IChain return new SimpleValidationHandlerFrame(variable); } + public virtual Frame? CreateRequirementResultFrame(Variable variable) + { + return new RequirementResultHandlerFrame(variable); + } + public bool IsTransactional { get; set; } public abstract bool ShouldFlushOutgoingMessages(); public abstract bool RequiresOutbox(); diff --git a/src/Wolverine/Configuration/IChain.cs b/src/Wolverine/Configuration/IChain.cs index 14af7c2ea..ce9070b3c 100644 --- a/src/Wolverine/Configuration/IChain.cs +++ b/src/Wolverine/Configuration/IChain.cs @@ -178,6 +178,14 @@ public interface IChain /// The variable containing validation messages /// A frame that checks for validation messages and aborts if any exist, or null if not supported Frame? CreateSimpleValidationFrame(Variable variable); + + /// + /// Create a Frame for validation based on a RequirementResult variable. + /// If Branch == Continue, processing continues. If Branch == Stop, processing aborts. + /// + /// The variable containing the RequirementResult + /// A frame that checks the RequirementResult and aborts if Branch == Stop, or null if not supported + Frame? CreateRequirementResultFrame(Variable variable); } #endregion \ No newline at end of file diff --git a/src/Wolverine/Middleware/ContinuationHandling.cs b/src/Wolverine/Middleware/ContinuationHandling.cs index 606b94ee2..9551149a0 100644 --- a/src/Wolverine/Middleware/ContinuationHandling.cs +++ b/src/Wolverine/Middleware/ContinuationHandling.cs @@ -16,7 +16,7 @@ public static List ContinuationStrategies(this Generation return list; } - return [new HandlerContinuationPolicy(), new SimpleValidationContinuationPolicy()]; + return [new HandlerContinuationPolicy(), new SimpleValidationContinuationPolicy(), new RequirementResultContinuationPolicy()]; } /// @@ -37,6 +37,7 @@ public static List ContinuationStrategies(this Generation [ new HandlerContinuationPolicy(), new SimpleValidationContinuationPolicy(), + new RequirementResultContinuationPolicy(), new T() ]; rules.Properties[Continuations] = list; diff --git a/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs b/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs new file mode 100644 index 000000000..11a098782 --- /dev/null +++ b/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs @@ -0,0 +1,117 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.Extensions.Logging; +using Wolverine.Configuration; + +namespace Wolverine.Middleware; + +/// +/// Continuation strategy that detects Validate/ValidateAsync methods returning +/// RequirementResult and generates appropriate validation handling code. +/// If Branch == Continue, processing continues. If Branch == Stop, messages are +/// logged and the handler is aborted. +/// +public class RequirementResultContinuationPolicy : IContinuationStrategy +{ + /// + /// Helper used by generated code to log requirement result messages and return + /// whether processing should stop. + /// + public static bool ShouldStop(ILogger logger, RequirementResult result) + { + if (result.Branch == HandlerContinuation.Continue) return false; + + if (result.Messages.Length > 0) + { + foreach (var message in result.Messages) + { + logger.LogWarning("Validation failure: {ValidationMessage}", message); + } + } + else + { + logger.LogWarning("Validation failure: Invalid Request"); + } + + return true; + } + + public bool TryFindContinuationHandler(IChain chain, MethodCall call, out Frame? frame) + { + if (call.Method.Name != "Validate" && call.Method.Name != "ValidateAsync") + { + frame = null; + return false; + } + + var variable = FindRequirementResultVariable(call); + if (variable == null) + { + frame = null; + return false; + } + + frame = chain.CreateRequirementResultFrame(variable); + return frame != null; + } + + internal static Variable? FindRequirementResultVariable(MethodCall call) + { + foreach (var variable in call.Creates) + { + if (variable.VariableType == typeof(RequirementResult)) + { + return variable; + } + } + + return null; + } +} + +/// +/// Frame that generates validation code for message handlers using RequirementResult. +/// Logs validation messages and returns if Branch == Stop. +/// +internal class RequirementResultHandlerFrame : SyncFrame +{ + private static int _count; + private readonly Variable _variable; + private Variable? _logger; + + public RequirementResultHandlerFrame(Variable variable) + { + _variable = variable; + _variable.OverrideName(_variable.Usage + ++_count); + uses.Add(_variable); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _logger = chain.FindVariable(typeof(ILogger)); + yield return _logger; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Check RequirementResult and abort if Branch == Stop"); + writer.Write( + $"BLOCK:if ({typeof(RequirementResultContinuationPolicy).FullNameInCode()}.{nameof(RequirementResultContinuationPolicy.ShouldStop)}({_logger!.Usage}, {_variable.Usage}))"); + + if (method.AsyncMode == AsyncMode.AsyncTask) + { + writer.Write("return;"); + } + else + { + writer.Write($"return {typeof(Task).FullNameInCode()}.{nameof(Task.CompletedTask)};"); + } + + writer.FinishBlock(); + writer.BlankLine(); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Wolverine/RequirementResult.cs b/src/Wolverine/RequirementResult.cs new file mode 100644 index 000000000..ed8d92ccb --- /dev/null +++ b/src/Wolverine/RequirementResult.cs @@ -0,0 +1,8 @@ +namespace Wolverine; + +/// +/// Used in Wolverine to denote the correctness of a data requirement or other validation rule +/// +/// +/// +public record RequirementResult(HandlerContinuation Branch, string[] Messages); \ No newline at end of file