Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/guide/handlers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,39 @@ For **HTTP endpoints**, the behavior is different — Wolverine will create a `P
<!-- snippet: sample_simple_validation_http_ienumerable -->
<!-- endSnippet -->

### 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:

<!-- snippet: sample_requirement_result_validation -->
<!-- endSnippet -->

Asynchronous validation:

<!-- snippet: sample_requirement_result_validation_async -->
<!-- endSnippet -->

Validation with empty messages (still stops processing):

<!-- snippet: sample_requirement_result_validation_empty_messages -->
<!-- endSnippet -->

For HTTP endpoints:

<!-- snippet: sample_requirement_result_http_validation -->
<!-- endSnippet -->

### Validation with HandlerContinuation

For more control, your `Validate` method can return a `HandlerContinuation` to explicitly signal whether processing should continue or stop:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Microsoft.AspNetCore.Mvc.ProblemDetails>();
json!.Detail.ShouldBe("Invalid Request");
}
}
62 changes: 62 additions & 0 deletions src/Http/Wolverine.Http/CodeGen/RequirementResultHttpFrame.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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".
/// </summary>
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<Variable> 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);
}
}
6 changes: 6 additions & 0 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ public override bool HasAttribute<T>()
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)];
Expand Down
70 changes: 70 additions & 0 deletions src/Http/WolverineWebApi/RequirementResultValidationUsage.cs
Original file line number Diff line number Diff line change
@@ -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<RequirementResult> 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
12 changes: 12 additions & 0 deletions src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Marten;
using Marten.Services.BatchQuerying;
using Microsoft.Extensions.Logging;

namespace Wolverine.Marten.Requirements;

public interface IDataRequirement
{
Task<RequirementResult> CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation);
Task<RequirementResult> CheckFromBatch(ILogger logger);
void RegisterInBatch(IBatchedQuery query);
}
Loading
Loading