diff --git a/docs/guide/handlers/index.md b/docs/guide/handlers/index.md index 8167859b3..12c717078 100644 --- a/docs/guide/handlers/index.md +++ b/docs/guide/handlers/index.md @@ -525,3 +525,59 @@ public static class PingHandler snippet source | anchor +## Validation + +Wolverine provides several options for validating messages before they are processed by your handler logic. These range from lightweight, inline validation to full integration with popular validation libraries. + +### Lightweight Validation with String Messages + +The simplest approach is to add a `Validate` or `ValidateAsync` method to your handler class that returns validation error messages as strings. If any messages are returned, Wolverine will log them and abort the handler execution. + +Supported return types are `IEnumerable`, `string[]`, `Task`, and `ValueTask`. + +For **message handlers**, Wolverine will: +1. Check if there are any validation messages +2. If none, continue processing +3. If there are messages, log each one as a warning using `ILogger` +4. Abort the handler by returning early + + + + +You can also use synchronous `string[]` returns: + + + + +Or asynchronous validation: + + + + +For **HTTP endpoints**, the behavior is different — Wolverine will create a `ProblemDetails` response with a 400 status code containing the validation messages: + + + + +### Validation with HandlerContinuation + +For more control, your `Validate` method can return a `HandlerContinuation` to explicitly signal whether processing should continue or stop: + + + + +### Validation with ProblemDetails + +In HTTP endpoints (and message handlers with the Wolverine.Http package), you can return a `ProblemDetails` object for richer validation responses: + + + + +### FluentValidation Integration + +For more complex validation scenarios, Wolverine integrates with [FluentValidation](/guide/handlers/fluent-validation). This allows you to use FluentValidation's full feature set including rule builders, conditional rules, and custom validators. + +### Data Annotations Integration + +Wolverine also supports [.NET Data Annotations](/guide/handlers/dataannotations-validation) for validation, allowing you to use standard `[Required]`, `[Range]`, and other validation attributes on your message types. + diff --git a/src/Http/Wolverine.Http.Tests/simple_validation_in_http_endpoints.cs b/src/Http/Wolverine.Http.Tests/simple_validation_in_http_endpoints.cs new file mode 100644 index 000000000..58e4eb36b --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/simple_validation_in_http_endpoints.cs @@ -0,0 +1,92 @@ +using Alba; +using Shouldly; +using WolverineWebApi; + +namespace Wolverine.Http.Tests; + +public class simple_validation_in_http_endpoints : IntegrationContext +{ + public simple_validation_in_http_endpoints(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task happy_path_with_ienumerable_string_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpEnumerableMessage(3)).ToUrl("/simple-validation/ienumerable"); + }); + } + + [Fact] + public async Task sad_path_with_ienumerable_string_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpEnumerableMessage(20)).ToUrl("/simple-validation/ienumerable"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task happy_path_with_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpStringArrayMessage(3)).ToUrl("/simple-validation/string-array"); + }); + } + + [Fact] + public async Task sad_path_with_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpStringArrayMessage(20)).ToUrl("/simple-validation/string-array"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task happy_path_with_async_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpAsyncMessage(3)).ToUrl("/simple-validation/async"); + }); + } + + [Fact] + public async Task sad_path_with_async_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpAsyncMessage(20)).ToUrl("/simple-validation/async"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task happy_path_with_valuetask_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpValueTaskMessage(3)).ToUrl("/simple-validation/valuetask"); + }); + } + + [Fact] + public async Task sad_path_with_valuetask_string_array_validate() + { + await Scenario(x => + { + x.Post.Json(new SimpleValidateHttpValueTaskMessage(20)).ToUrl("/simple-validation/valuetask"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + } +} diff --git a/src/Http/Wolverine.Http/CodeGen/SimpleValidationHttpFrame.cs b/src/Http/Wolverine.Http/CodeGen/SimpleValidationHttpFrame.cs new file mode 100644 index 000000000..f5b1d6cdf --- /dev/null +++ b/src/Http/Wolverine.Http/CodeGen/SimpleValidationHttpFrame.cs @@ -0,0 +1,56 @@ +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. +/// Creates a ProblemDetails with status 400 and writes it to the response if validation messages exist. +/// +internal class SimpleValidationHttpFrame : AsyncFrame +{ + private static int _count; + private readonly Variable _variable; + private Variable? _context; + + public SimpleValidationHttpFrame(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 for any simple validation messages and abort with ProblemDetails if any exist"); + writer.Write( + $"var validationMessages{_count} = {_variable.Usage}.ToArray();"); + writer.Write( + $"BLOCK:if (validationMessages{_count}.Length > 0)"); + 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( + $"problemDetails{_count}.{nameof(ProblemDetails.Extensions)}[\"errors\"] = validationMessages{_count};"); + 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 681dfedf5..f485a3cd6 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -302,6 +302,12 @@ public override bool HasAttribute() return HasRequestType ? RequestType : ComplexQueryStringType; } + public override Frame? CreateSimpleValidationFrame(Variable variable) + { + Metadata.Produces(400, contentType: "application/problem+json"); + return new SimpleValidationHttpFrame(variable); + } + public override Frame[] AddStopConditionIfNull(Variable variable) { return [new SetStatusCodeAndReturnIfEntityIsNullFrame(variable)]; diff --git a/src/Http/WolverineWebApi/SimpleValidationUsage.cs b/src/Http/WolverineWebApi/SimpleValidationUsage.cs new file mode 100644 index 000000000..16faa8a6e --- /dev/null +++ b/src/Http/WolverineWebApi/SimpleValidationUsage.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using Wolverine.Http; + +namespace WolverineWebApi; + +#region sample_simple_validation_http_ienumerable + +public record SimpleValidateHttpEnumerableMessage(int Number); + +public static class SimpleValidationHttpEnumerableEndpoint +{ + public static IEnumerable Validate(SimpleValidateHttpEnumerableMessage message) + { + if (message.Number > 10) + { + yield return "Number must be 10 or less"; + } + } + + [WolverinePost("/simple-validation/ienumerable")] + public static string Post(SimpleValidateHttpEnumerableMessage message) => "Ok"; +} + +#endregion + +#region sample_simple_validation_http_string_array + +public record SimpleValidateHttpStringArrayMessage(int Number); + +public static class SimpleValidationHttpStringArrayEndpoint +{ + public static string[] Validate(SimpleValidateHttpStringArrayMessage message) + { + if (message.Number > 10) + { + return ["Number must be 10 or less"]; + } + + return []; + } + + [WolverinePost("/simple-validation/string-array")] + public static string Post(SimpleValidateHttpStringArrayMessage message) => "Ok"; +} + +#endregion + +#region sample_simple_validation_http_async + +public record SimpleValidateHttpAsyncMessage(int Number); + +public static class SimpleValidationHttpAsyncEndpoint +{ + public static Task ValidateAsync(SimpleValidateHttpAsyncMessage message) + { + if (message.Number > 10) + { + return Task.FromResult(new[] { "Number must be 10 or less" }); + } + + return Task.FromResult(Array.Empty()); + } + + [WolverinePost("/simple-validation/async")] + public static string Post(SimpleValidateHttpAsyncMessage message) => "Ok"; +} + +#endregion + +#region sample_simple_validation_http_valuetask + +public record SimpleValidateHttpValueTaskMessage(int Number); + +public static class SimpleValidationHttpValueTaskEndpoint +{ + public static ValueTask ValidateAsync(SimpleValidateHttpValueTaskMessage message) + { + if (message.Number > 10) + { + return new ValueTask(new[] { "Number must be 10 or less" }); + } + + return new ValueTask(Array.Empty()); + } + + [WolverinePost("/simple-validation/valuetask")] + public static string Post(SimpleValidateHttpValueTaskMessage message) => "Ok"; +} + +#endregion diff --git a/src/Testing/CoreTests/Acceptance/simple_validation_handlers.cs b/src/Testing/CoreTests/Acceptance/simple_validation_handlers.cs new file mode 100644 index 000000000..1cb2e8ecc --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/simple_validation_handlers.cs @@ -0,0 +1,227 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Wolverine.Tracking; +using Xunit; + +namespace CoreTests.Acceptance; + +public class simple_validation_handlers +{ + [Fact] + public async Task happy_path_with_ienumerable_string_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationEnumerableHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateEnumerableMessage(3)); + + SimpleValidationEnumerableHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_ienumerable_string_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationEnumerableHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateEnumerableMessage(20)); + + SimpleValidationEnumerableHandler.Handled.ShouldBeFalse(); + } + + [Fact] + public async Task happy_path_with_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationStringArrayHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateStringArrayMessage(3)); + + SimpleValidationStringArrayHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationStringArrayHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateStringArrayMessage(20)); + + SimpleValidationStringArrayHandler.Handled.ShouldBeFalse(); + } + + [Fact] + public async Task happy_path_with_async_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationAsyncHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateAsyncMessage(3)); + + SimpleValidationAsyncHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_async_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationAsyncHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateAsyncMessage(20)); + + SimpleValidationAsyncHandler.Handled.ShouldBeFalse(); + } + + [Fact] + public async Task happy_path_with_valuetask_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationValueTaskHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateValueTaskMessage(3)); + + SimpleValidationValueTaskHandler.Handled.ShouldBeTrue(); + } + + [Fact] + public async Task sad_path_with_valuetask_string_array_validate() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + SimpleValidationValueTaskHandler.Handled = false; + + await host.InvokeMessageAndWaitAsync(new SimpleValidateValueTaskMessage(20)); + + SimpleValidationValueTaskHandler.Handled.ShouldBeFalse(); + } +} + +#region sample_simple_validation_ienumerable + +public record SimpleValidateEnumerableMessage(int Number); + +public static class SimpleValidationEnumerableHandler +{ + public static IEnumerable Validate(SimpleValidateEnumerableMessage message) + { + if (message.Number > 10) + { + yield return "Number must be 10 or less"; + } + } + + public static void Handle(SimpleValidateEnumerableMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion + +#region sample_simple_validation_string_array + +public record SimpleValidateStringArrayMessage(int Number); + +public static class SimpleValidationStringArrayHandler +{ + public static string[] Validate(SimpleValidateStringArrayMessage message) + { + if (message.Number > 10) + { + return ["Number must be 10 or less"]; + } + + return []; + } + + public static void Handle(SimpleValidateStringArrayMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion + +#region sample_simple_validation_async + +public record SimpleValidateAsyncMessage(int Number); + +public static class SimpleValidationAsyncHandler +{ + public static Task ValidateAsync(SimpleValidateAsyncMessage message) + { + if (message.Number > 10) + { + return Task.FromResult(new[] { "Number must be 10 or less" }); + } + + return Task.FromResult(Array.Empty()); + } + + public static void Handle(SimpleValidateAsyncMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + public static bool Handled { get; set; } +} + +#endregion + +#region sample_simple_validation_valuetask + +public record SimpleValidateValueTaskMessage(int Number); + +public static class SimpleValidationValueTaskHandler +{ + public static ValueTask ValidateAsync(SimpleValidateValueTaskMessage message) + { + if (message.Number > 10) + { + return new ValueTask(new[] { "Number must be 10 or less" }); + } + + return new ValueTask(Array.Empty()); + } + + public static void Handle(SimpleValidateValueTaskMessage 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 ecebfeeff..920b4f535 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -45,6 +45,14 @@ public abstract class Chain : IChain public abstract bool TryInferMessageIdentity(out PropertyInfo? property); + /// + /// Default implementation for message handlers: log validation messages and return + /// + public virtual Frame? CreateSimpleValidationFrame(Variable variable) + { + return new SimpleValidationHandlerFrame(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 4b4c9b038..14af7c2ea 100644 --- a/src/Wolverine/Configuration/IChain.cs +++ b/src/Wolverine/Configuration/IChain.cs @@ -170,6 +170,14 @@ public interface IChain Frame[] AddStopConditionIfNull(Variable data, Variable? identity, IDataRequirement requirement); bool TryInferMessageIdentity(out PropertyInfo? property); + + /// + /// Create a Frame for simple validation based on a variable that contains + /// string validation messages (IEnumerable<string>, string[], etc.) + /// + /// 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); } #endregion \ No newline at end of file diff --git a/src/Wolverine/Middleware/ContinuationHandling.cs b/src/Wolverine/Middleware/ContinuationHandling.cs index 8bb63d875..606b94ee2 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()]; + return [new HandlerContinuationPolicy(), new SimpleValidationContinuationPolicy()]; } /// @@ -36,6 +36,7 @@ public static List ContinuationStrategies(this Generation list = [ new HandlerContinuationPolicy(), + new SimpleValidationContinuationPolicy(), new T() ]; rules.Properties[Continuations] = list; diff --git a/src/Wolverine/Middleware/SimpleValidationContinuationPolicy.cs b/src/Wolverine/Middleware/SimpleValidationContinuationPolicy.cs new file mode 100644 index 000000000..7d7dfa89e --- /dev/null +++ b/src/Wolverine/Middleware/SimpleValidationContinuationPolicy.cs @@ -0,0 +1,125 @@ +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 +/// IEnumerable<string>, string[], Task<string[]>, or ValueTask<string[]> +/// and generates appropriate validation handling code. +/// +public class SimpleValidationContinuationPolicy : IContinuationStrategy +{ + private static readonly Type[] ValidReturnTypes = + [ + typeof(IEnumerable), + typeof(string[]) + ]; + + /// + /// Helper used by generated code to log validation messages and return a boolean + /// indicating whether there are any validation failures. + /// + public static bool LogValidationMessages(ILogger logger, IEnumerable messages) + { + var hasMessages = false; + foreach (var message in messages) + { + hasMessages = true; + logger.LogWarning("Validation failure: {ValidationMessage}", message); + } + + return hasMessages; + } + + public bool TryFindContinuationHandler(IChain chain, MethodCall call, out Frame? frame) + { + // Only apply to methods named Validate or ValidateAsync + if (call.Method.Name != "Validate" && call.Method.Name != "ValidateAsync") + { + frame = null; + return false; + } + + var variable = FindStringEnumerableVariable(call); + if (variable == null) + { + frame = null; + return false; + } + + frame = chain.CreateSimpleValidationFrame(variable); + return frame != null; + } + + internal static Variable? FindStringEnumerableVariable(MethodCall call) + { + foreach (var variable in call.Creates) + { + if (IsStringEnumerable(variable.VariableType)) + { + return variable; + } + } + + return null; + } + + internal static bool IsStringEnumerable(Type type) + { + if (type == typeof(IEnumerable)) return true; + if (type == typeof(string[])) return true; + if (type == typeof(List)) return true; + + return false; + } +} + +/// +/// Frame that generates validation code for message handlers. +/// Logs validation messages and returns if any are found. +/// +internal class SimpleValidationHandlerFrame : SyncFrame +{ + private static int _count; + private readonly Variable _variable; + private Variable? _logger; + + public SimpleValidationHandlerFrame(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 for any simple validation messages and abort if any exist"); + writer.Write( + $"BLOCK:if ({typeof(SimpleValidationContinuationPolicy).FullNameInCode()}.{nameof(SimpleValidationContinuationPolicy.LogValidationMessages)}({_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); + } +}