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
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

namespace Wolverine.Http.DataAnnotationsValidation.Tests;


#region sample_endpoint_with_dataannotations_validation

public class ReferenceAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
return value is string { Length: 8 };
}
}

public record CreateAccount(
// don't forget the property prefix on records
[property: Required] string AccountName,
[property: Reference] string Reference
) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AccountName.Equals("invalid", StringComparison.InvariantCultureIgnoreCase))
{
yield return new("AccountName is invalid", [nameof(AccountName)]);
}
}
}

public static class CreateAccountEndpoint
{
[WolverinePost("/validate/account")]
public static string Post(CreateAccount account)
{
return "Got a new account";
}

[WolverinePost("/validate/account2")]
public static string Post2([FromQuery] CreateAccount customer)
{
return "Got a new account";
}
}

#endregion

public static class CreateAccountCompoundEndpoint
{
public record Account(string Name);

public static Account Load(CreateAccount cmd)
{
if (cmd.AccountName == null)
throw new ApplicationException("Something went wrong. Fluent validation should stop execution before load");

return new Account(cmd.AccountName);
}

public static ProblemDetails Validate(Account account)
{
if (account == null)
throw new ApplicationException("Something went wrong. Fluent validation should stop execution before load");

return WolverineContinue.NoProblems;
}

[WolverinePost("/validate/account-compound")]
public static string Post(CreateAccount cmd)
{
return "Got a new account";
}
}
2 changes: 2 additions & 0 deletions Wolverine.Http.DataAnnotationsValidation.Tests/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Xunit;
global using Shouldly;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
<TargetFrameworks>net9.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Alba" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\Http\Wolverine.Http.DataAnnotationsValidation\Wolverine.Http.DataAnnotationsValidation.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Alba;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace Wolverine.Http.DataAnnotationsValidation.Tests;

public class dataannotations_validation_middleware : IAsyncLifetime
{
protected IAlbaHost theHost;

[Fact]
public void adds_problem_validation_to_open_api_metadata()
{
var endpoints = theHost.Services.GetServices<EndpointDataSource>().SelectMany(x => x.Endpoints).OfType<RouteEndpoint>()
.ToList();

var endpoint = endpoints.Single(x => x.RoutePattern.RawText == "/validate/account");

var produces = endpoint.Metadata.OfType<IProducesResponseTypeMetadata>().Single(x => x.Type == typeof(HttpValidationProblemDetails));
produces.StatusCode.ShouldBe(400);
produces.ContentTypes.Single().ShouldBe("application/problem+json");
}

[Fact]
public async Task one_validator_happy_path()
{
var createCustomer = new CreateAccount("accountName", "12345678");

// Succeeds w/ a 200
var result = await theHost.Scenario(x =>
{
x.Post.Json(createCustomer).ToUrl("/validate/account");
x.ContentTypeShouldBe("text/plain");
});
}

[Fact]
public async Task one_validator_sad_path()
{
var createCustomer = new CreateAccount(null, "123");

var results = await theHost.Scenario(x =>
{
x.Post.Json(createCustomer).ToUrl("/validate/account");
x.ContentTypeShouldBe("application/problem+json");
x.StatusCodeShouldBe(400);
});

// Just proving that we have HttpValidationProblemDetails content
// in the request
var problems = results.ReadAsJson<HttpValidationProblemDetails>();
}

[Fact]
public async Task one_validator_happy_path_on_complex_query_string_argument()
{
// Succeeds w/ a 200
var result = await theHost.Scenario(x =>
{
x.Post.Url("/validate/account2")
.QueryString(nameof(CreateAccount.AccountName), "name")
.QueryString(nameof(CreateAccount.Reference), "12345678");
x.ContentTypeShouldBe("text/plain");
});
}

[Fact]
public async Task one_validator_sad_path_on_complex_query_string_argument()
{
var results = await theHost.Scenario(x =>
{
x.Post.Url("/validate/account2")
.QueryString(nameof(CreateAccount.Reference), "11111");
x.ContentTypeShouldBe("application/problem+json");
x.StatusCodeShouldBe(400);
});

// Just proving that we have HttpValidationProblemDetails content
// in the request
var problems = results.ReadAsJson<HttpValidationProblemDetails>();
}

[Fact]
public async Task when_using_compound_handler_validation_is_called_before_load()
{
var results = await theHost.Scenario(x =>
{
x.Post.Url("/validate/account-compound");
x.ContentTypeShouldBe("application/problem+json");
x.StatusCodeShouldBe(400);
});

var problems = results.ReadAsJson<HttpValidationProblemDetails>();
}

public async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder([]);

builder.Host.UseWolverine();
builder.Services.AddWolverineHttp();

theHost = await AlbaHost.For(builder, app =>
{
app.MapWolverineEndpoints(opts =>
{
opts.UseDataAnnotationsValidationProblemDetailMiddleware();
});
});
}

public Task DisposeAsync()
{
return theHost.StopAsync();
}
}
37 changes: 33 additions & 4 deletions build/build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class Build : NukeBuild
});

Target TestExtensions => _ => _
.DependsOn(FluentValidationTests, MemoryPackTests, MessagePackTests);
.DependsOn(FluentValidationTests, DataAnnotationsValidationTests, MemoryPackTests, MessagePackTests);

Target FluentValidationTests => _ => _
.DependsOn(Compile)
Expand All @@ -104,7 +104,20 @@ class Build : NukeBuild
.EnableNoRestore()
.SetFramework(Framework));
});


Target DataAnnotationsValidationTests => _ => _
.DependsOn(Compile)
.ProceedAfterFailure()
.Executes(() =>
{
DotNetTest(c => c
.SetProjectFile(Solution.Extensions.Wolverine_DataAnnotationsValidation_Tests)
.SetConfiguration(Configuration)
.EnableNoBuild()
.EnableNoRestore()
.SetFramework(Framework));
});

Target MemoryPackTests => _ => _
.DependsOn(Compile)
.ProceedAfterFailure()
Expand All @@ -130,8 +143,11 @@ class Build : NukeBuild
.EnableNoRestore()
.SetFramework(Framework));
});

Target HttpTests => _ => _
.DependsOn(CoreHttpTests, DataAnnotationsValidationHttpTests);

Target CoreHttpTests => _ => _
.DependsOn(Compile, DockerUp)
.ProceedAfterFailure()
.Executes(() =>
Expand All @@ -144,7 +160,20 @@ class Build : NukeBuild
.SetFramework(Framework));
});


Target DataAnnotationsValidationHttpTests => _ => _
.DependsOn(Compile)
.ProceedAfterFailure()
.Executes(() =>
{
DotNetTest(c => c
.SetProjectFile(Solution.Http.Wolverine_Http_DataAnnotationsValidation_Tests)
.SetConfiguration(Configuration)
.EnableNoBuild()
.EnableNoRestore()
.SetFramework(Framework));
});


Target Commands => _ => _
.DependsOn(HelpCommand, DescribeCommand, CodegenPreviewCommand);

Expand Down
2 changes: 2 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{text: 'Multi-Tenancy', link: '/guide/handlers/multi-tenancy'},
{text: 'Execution Timeouts', link: '/guide/handlers/timeout'},
{text: 'Fluent Validation Middleware', link: '/guide/handlers/fluent-validation'},
{text: 'DataAnnotations Validation Middleware', link: '/guide/handlers/dataannotations-validation'},
{text: 'Sticky Handler to Endpoint Assignments', link: '/guide/handlers/sticky'},
{text: 'Message Batching', link: '/guide/handlers/batching'},
{text: 'Persistence Helpers', link: '/guide/handlers/persistence'}
Expand Down Expand Up @@ -223,6 +224,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{text: 'Integration with Sagas', link: '/guide/http/sagas'},
{text: 'Integration with Marten', link: '/guide/http/marten'},
{text: 'Fluent Validation', link: '/guide/http/fluentvalidation'},
{text: 'DataAnnotations Validation', link: '/guide/http/dataannotationsvalidation'},
{text: 'Problem Details', link: '/guide/http/problemdetails'},
]
},
Expand Down
41 changes: 41 additions & 0 deletions docs/guide/handlers/dataannotations-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# DataAnnotations Validation Middleware


::: tip
There is also an HTTP specific middleware for WolverineFx.Http that uses the `ProblemDetails` specification. See
[DataAnnotations Validation Middleware for HTTP](/guide/http/dataannotationsvalidation) for more information.
:::

::: warning
While it is possible to access the IoC Services via `ValidationContext`, we recommend instead using a
more explicit `Validate` or `ValidateAsync()` method directly in your message handler class for the data input.
:::

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)
are a good choice. The `WolverineFx.DataAnnotationsValidation` nuget package will add support
for the built-in and custom attributes via middleware that will stop invalid messages from
reaching the message handlers.

To get started, add the nuget package and configure your Wolverine Application:

<!-- snippet: sample_bootstrap_with_dataannotations_validation -->

Now you can decorate your messages with the built-in or custom `ValidationAttributes`:

<!-- snippet: dataannotations_usage -->

In the case above, the Validation check will happen at runtime *before* the call to the handler methods. If
the validation fails, the middleware will throw a `ValidationException` and stop all processing.

Some notes about the middleware:

* The middleware is applied to all message handler types as there is no easy way of knowing if a message
has some sort of validation attribute defined.
* The registration also adds an error handling policy to discard messages when a `ValidationException` is thrown

## Customizing the Validation Failure Behavior

Out of the box, the Fluent Validation middleware will throw a `DataAnnotationsValidation.ValidationException`
with all the validation failures if the validation fails. To customize that behavior, you can plug
in a custom implementation of the `IFailureAction<T>` interface. This behaves exactly the same as
the [Fluent Validation Customisation](/guide/handlers/fluent-validation).
32 changes: 32 additions & 0 deletions docs/guide/http/dataannotationsvalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# DataAnnotations Validation Middleware for HTTP

::: tip
The Http package for DataAnnotations Validation is completely separate from the [non-HTTP](/guide/handlers/dataannotations-validation)
package. If you have a hybrid application supporting both http-endpoint and other message handlers,
you will need to install both packages.
:::

::: warning
While it is possible to access the IoC Services via `ValidationContext`, we recommend instead using a
more explicit `Validate` or `ValidateAsync()` method directly in your message handler class for the data input.
:::

Wolverine.Http has a separate package called `WolverineFx.Http.DataAnnotationsValidation` that provides a simple middleware
to use [Data Annotation Attributes](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations?view=net-10.0)
in your endpoints.

To get started, install the Nuget reference:

```bash
dotnet add package WolverineFx.Http.DataAnnotationsValidation
```

Next, add this one single line of code to your Wolverine.Http bootstrapping:

```csharp
opts.UseFluentValidationProblemDetailMiddleware();
```

Using the validators is pretty much the same as the regular DataAnnotations package

<!-- snippet: sample_endpoint_with_dataannotations_validation -->
Loading
Loading