Skip to content

Commit f1877fb

Browse files
LodewijkSioenjeremydmiller
authored andcommitted
Add DataAnnotations Validation middlewares
1 parent 9f6e8a5 commit f1877fb

29 files changed

+1068
-12
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace Wolverine.Http.DataAnnotationsValidation.Tests;
5+
6+
7+
#region sample_endpoint_with_dataannotations_validation
8+
9+
public class ReferenceAttribute : ValidationAttribute
10+
{
11+
public override bool IsValid(object? value)
12+
{
13+
return value is string { Length: 8 };
14+
}
15+
}
16+
17+
public record CreateAccount(
18+
// don't forget the property prefix on records
19+
[property: Required] string AccountName,
20+
[property: Reference] string Reference
21+
) : IValidatableObject
22+
{
23+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
24+
{
25+
if (AccountName.Equals("invalid", StringComparison.InvariantCultureIgnoreCase))
26+
{
27+
yield return new("AccountName is invalid", [nameof(AccountName)]);
28+
}
29+
}
30+
}
31+
32+
public static class CreateAccountEndpoint
33+
{
34+
[WolverinePost("/validate/account")]
35+
public static string Post(CreateAccount account)
36+
{
37+
return "Got a new account";
38+
}
39+
40+
[WolverinePost("/validate/account2")]
41+
public static string Post2([FromQuery] CreateAccount customer)
42+
{
43+
return "Got a new account";
44+
}
45+
}
46+
47+
#endregion
48+
49+
public static class CreateAccountCompoundEndpoint
50+
{
51+
public record Account(string Name);
52+
53+
public static Account Load(CreateAccount cmd)
54+
{
55+
if (cmd.AccountName == null)
56+
throw new ApplicationException("Something went wrong. Fluent validation should stop execution before load");
57+
58+
return new Account(cmd.AccountName);
59+
}
60+
61+
public static ProblemDetails Validate(Account account)
62+
{
63+
if (account == null)
64+
throw new ApplicationException("Something went wrong. Fluent validation should stop execution before load");
65+
66+
return WolverineContinue.NoProblems;
67+
}
68+
69+
[WolverinePost("/validate/account-compound")]
70+
public static string Post(CreateAccount cmd)
71+
{
72+
return "Got a new account";
73+
}
74+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
global using Xunit;
2+
global using Shouldly;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<IsPackable>false</IsPackable>
5+
<TargetFrameworks>net9.0</TargetFrameworks>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="Alba" Version="8.2.0" />
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
11+
<PackageReference Include="xunit" Version="2.9.0" />
12+
<PackageReference Include="Shouldly" Version="4.3.0" />
13+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
14+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
15+
<PrivateAssets>all</PrivateAssets>
16+
</PackageReference>
17+
<PackageReference Include="coverlet.collector" Version="6.0.4">
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
19+
<PrivateAssets>all</PrivateAssets>
20+
</PackageReference>
21+
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\src\Http\Wolverine.Http.DataAnnotationsValidation\Wolverine.Http.DataAnnotationsValidation.csproj" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Alba;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Http.Metadata;
5+
using Microsoft.AspNetCore.Routing;
6+
using Microsoft.Extensions.DependencyInjection;
7+
8+
namespace Wolverine.Http.DataAnnotationsValidation.Tests;
9+
10+
public class dataannotations_validation_middleware : IAsyncLifetime
11+
{
12+
protected IAlbaHost theHost;
13+
14+
[Fact]
15+
public void adds_problem_validation_to_open_api_metadata()
16+
{
17+
var endpoints = theHost.Services.GetServices<EndpointDataSource>().SelectMany(x => x.Endpoints).OfType<RouteEndpoint>()
18+
.ToList();
19+
20+
var endpoint = endpoints.Single(x => x.RoutePattern.RawText == "/validate/account");
21+
22+
var produces = endpoint.Metadata.OfType<IProducesResponseTypeMetadata>().Single(x => x.Type == typeof(HttpValidationProblemDetails));
23+
produces.StatusCode.ShouldBe(400);
24+
produces.ContentTypes.Single().ShouldBe("application/problem+json");
25+
}
26+
27+
[Fact]
28+
public async Task one_validator_happy_path()
29+
{
30+
var createCustomer = new CreateAccount("accountName", "12345678");
31+
32+
// Succeeds w/ a 200
33+
var result = await theHost.Scenario(x =>
34+
{
35+
x.Post.Json(createCustomer).ToUrl("/validate/account");
36+
x.ContentTypeShouldBe("text/plain");
37+
});
38+
}
39+
40+
[Fact]
41+
public async Task one_validator_sad_path()
42+
{
43+
var createCustomer = new CreateAccount(null, "123");
44+
45+
var results = await theHost.Scenario(x =>
46+
{
47+
x.Post.Json(createCustomer).ToUrl("/validate/account");
48+
x.ContentTypeShouldBe("application/problem+json");
49+
x.StatusCodeShouldBe(400);
50+
});
51+
52+
// Just proving that we have HttpValidationProblemDetails content
53+
// in the request
54+
var problems = results.ReadAsJson<HttpValidationProblemDetails>();
55+
}
56+
57+
[Fact]
58+
public async Task one_validator_happy_path_on_complex_query_string_argument()
59+
{
60+
// Succeeds w/ a 200
61+
var result = await theHost.Scenario(x =>
62+
{
63+
x.Post.Url("/validate/account2")
64+
.QueryString(nameof(CreateAccount.AccountName), "name")
65+
.QueryString(nameof(CreateAccount.Reference), "12345678");
66+
x.ContentTypeShouldBe("text/plain");
67+
});
68+
}
69+
70+
[Fact]
71+
public async Task one_validator_sad_path_on_complex_query_string_argument()
72+
{
73+
var results = await theHost.Scenario(x =>
74+
{
75+
x.Post.Url("/validate/account2")
76+
.QueryString(nameof(CreateAccount.Reference), "11111");
77+
x.ContentTypeShouldBe("application/problem+json");
78+
x.StatusCodeShouldBe(400);
79+
});
80+
81+
// Just proving that we have HttpValidationProblemDetails content
82+
// in the request
83+
var problems = results.ReadAsJson<HttpValidationProblemDetails>();
84+
}
85+
86+
[Fact]
87+
public async Task when_using_compound_handler_validation_is_called_before_load()
88+
{
89+
var results = await theHost.Scenario(x =>
90+
{
91+
x.Post.Url("/validate/account-compound");
92+
x.ContentTypeShouldBe("application/problem+json");
93+
x.StatusCodeShouldBe(400);
94+
});
95+
96+
var problems = results.ReadAsJson<HttpValidationProblemDetails>();
97+
}
98+
99+
public async Task InitializeAsync()
100+
{
101+
var builder = WebApplication.CreateBuilder([]);
102+
103+
builder.Host.UseWolverine();
104+
builder.Services.AddWolverineHttp();
105+
106+
theHost = await AlbaHost.For(builder, app =>
107+
{
108+
app.MapWolverineEndpoints(opts =>
109+
{
110+
opts.UseDataAnnotationsValidationProblemDetailMiddleware();
111+
});
112+
});
113+
}
114+
115+
public Task DisposeAsync()
116+
{
117+
return theHost.StopAsync();
118+
}
119+
}

build/build.cs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class Build : NukeBuild
9090
});
9191

9292
Target TestExtensions => _ => _
93-
.DependsOn(FluentValidationTests, MemoryPackTests, MessagePackTests);
93+
.DependsOn(FluentValidationTests, DataAnnotationsValidationTests, MemoryPackTests, MessagePackTests);
9494

9595
Target FluentValidationTests => _ => _
9696
.DependsOn(Compile)
@@ -104,7 +104,20 @@ class Build : NukeBuild
104104
.EnableNoRestore()
105105
.SetFramework(Framework));
106106
});
107-
107+
108+
Target DataAnnotationsValidationTests => _ => _
109+
.DependsOn(Compile)
110+
.ProceedAfterFailure()
111+
.Executes(() =>
112+
{
113+
DotNetTest(c => c
114+
.SetProjectFile(Solution.Extensions.Wolverine_DataAnnotationsValidation_Tests)
115+
.SetConfiguration(Configuration)
116+
.EnableNoBuild()
117+
.EnableNoRestore()
118+
.SetFramework(Framework));
119+
});
120+
108121
Target MemoryPackTests => _ => _
109122
.DependsOn(Compile)
110123
.ProceedAfterFailure()
@@ -130,8 +143,11 @@ class Build : NukeBuild
130143
.EnableNoRestore()
131144
.SetFramework(Framework));
132145
});
133-
146+
134147
Target HttpTests => _ => _
148+
.DependsOn(CoreHttpTests, DataAnnotationsValidationHttpTests);
149+
150+
Target CoreHttpTests => _ => _
135151
.DependsOn(Compile, DockerUp)
136152
.ProceedAfterFailure()
137153
.Executes(() =>
@@ -144,7 +160,20 @@ class Build : NukeBuild
144160
.SetFramework(Framework));
145161
});
146162

147-
163+
Target DataAnnotationsValidationHttpTests => _ => _
164+
.DependsOn(Compile)
165+
.ProceedAfterFailure()
166+
.Executes(() =>
167+
{
168+
DotNetTest(c => c
169+
.SetProjectFile(Solution.Http.Wolverine_Http_DataAnnotationsValidation_Tests)
170+
.SetConfiguration(Configuration)
171+
.EnableNoBuild()
172+
.EnableNoRestore()
173+
.SetFramework(Framework));
174+
});
175+
176+
148177
Target Commands => _ => _
149178
.DependsOn(HelpCommand, DescribeCommand, CodegenPreviewCommand);
150179

docs/.vitepress/config.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const config: UserConfig<DefaultTheme.Config> = {
120120
{text: 'Multi-Tenancy', link: '/guide/handlers/multi-tenancy'},
121121
{text: 'Execution Timeouts', link: '/guide/handlers/timeout'},
122122
{text: 'Fluent Validation Middleware', link: '/guide/handlers/fluent-validation'},
123+
{text: 'DataAnnotations Validation Middleware', link: '/guide/handlers/dataannotations-validation'},
123124
{text: 'Sticky Handler to Endpoint Assignments', link: '/guide/handlers/sticky'},
124125
{text: 'Message Batching', link: '/guide/handlers/batching'},
125126
{text: 'Persistence Helpers', link: '/guide/handlers/persistence'}
@@ -223,6 +224,7 @@ const config: UserConfig<DefaultTheme.Config> = {
223224
{text: 'Integration with Sagas', link: '/guide/http/sagas'},
224225
{text: 'Integration with Marten', link: '/guide/http/marten'},
225226
{text: 'Fluent Validation', link: '/guide/http/fluentvalidation'},
227+
{text: 'DataAnnotations Validation', link: '/guide/http/dataannotationsvalidation'},
226228
{text: 'Problem Details', link: '/guide/http/problemdetails'},
227229
{text: 'Caching', link: '/guide/http/caching'}
228230
]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# DataAnnotations Validation Middleware
2+
3+
4+
::: tip
5+
There is also an HTTP specific middleware for WolverineFx.Http that uses the `ProblemDetails` specification. See
6+
[DataAnnotations Validation Middleware for HTTP](/guide/http/dataannotationsvalidation) for more information.
7+
:::
8+
9+
::: warning
10+
While it is possible to access the IoC Services via `ValidationContext`, we recommend instead using a
11+
more explicit `Validate` or `ValidateAsync()` method directly in your message handler class for the data input.
12+
:::
13+
14+
For simple input validation of your messages, the [Data Annotation Attributes](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations?view=net-10.0)
15+
are a good choice. The `WolverineFx.DataAnnotationsValidation` nuget package will add support
16+
for the built-in and custom attributes via middleware that will stop invalid messages from
17+
reaching the message handlers.
18+
19+
To get started, add the nuget package and configure your Wolverine Application:
20+
21+
<!-- snippet: sample_bootstrap_with_dataannotations_validation -->
22+
23+
Now you can decorate your messages with the built-in or custom `ValidationAttributes`:
24+
25+
<!-- snippet: dataannotations_usage -->
26+
27+
In the case above, the Validation check will happen at runtime *before* the call to the handler methods. If
28+
the validation fails, the middleware will throw a `ValidationException` and stop all processing.
29+
30+
Some notes about the middleware:
31+
32+
* The middleware is applied to all message handler types as there is no easy way of knowing if a message
33+
has some sort of validation attribute defined.
34+
* The registration also adds an error handling policy to discard messages when a `ValidationException` is thrown
35+
36+
## Customizing the Validation Failure Behavior
37+
38+
Out of the box, the Fluent Validation middleware will throw a `DataAnnotationsValidation.ValidationException`
39+
with all the validation failures if the validation fails. To customize that behavior, you can plug
40+
in a custom implementation of the `IFailureAction<T>` interface. This behaves exactly the same as
41+
the [Fluent Validation Customisation](/guide/handlers/fluent-validation).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# DataAnnotations Validation Middleware for HTTP
2+
3+
::: tip
4+
The Http package for DataAnnotations Validation is completely separate from the [non-HTTP](/guide/handlers/dataannotations-validation)
5+
package. If you have a hybrid application supporting both http-endpoint and other message handlers,
6+
you will need to install both packages.
7+
:::
8+
9+
::: warning
10+
While it is possible to access the IoC Services via `ValidationContext`, we recommend instead using a
11+
more explicit `Validate` or `ValidateAsync()` method directly in your message handler class for the data input.
12+
:::
13+
14+
Wolverine.Http has a separate package called `WolverineFx.Http.DataAnnotationsValidation` that provides a simple middleware
15+
to use [Data Annotation Attributes](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations?view=net-10.0)
16+
in your endpoints.
17+
18+
To get started, install the Nuget reference:
19+
20+
```bash
21+
dotnet add package WolverineFx.Http.DataAnnotationsValidation
22+
```
23+
24+
Next, add this one single line of code to your Wolverine.Http bootstrapping:
25+
26+
```csharp
27+
opts.UseFluentValidationProblemDetailMiddleware();
28+
```
29+
30+
Using the validators is pretty much the same as the regular DataAnnotations package
31+
32+
<!-- snippet: sample_endpoint_with_dataannotations_validation -->

0 commit comments

Comments
 (0)