diff --git a/Wolverine.Http.DataAnnotationsValidation.Tests/Usings.cs b/Wolverine.Http.DataAnnotationsValidation.Tests/Usings.cs deleted file mode 100644 index bd8299f6f..000000000 --- a/Wolverine.Http.DataAnnotationsValidation.Tests/Usings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Xunit; -global using Shouldly; \ No newline at end of file diff --git a/Wolverine.Http.DataAnnotationsValidation.Tests/Wolverine.Http.DataAnnotationsValidation.Tests.csproj b/Wolverine.Http.DataAnnotationsValidation.Tests/Wolverine.Http.DataAnnotationsValidation.Tests.csproj deleted file mode 100644 index d62d1454e..000000000 --- a/Wolverine.Http.DataAnnotationsValidation.Tests/Wolverine.Http.DataAnnotationsValidation.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - false - net9.0 - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - diff --git a/build/build.cs b/build/build.cs index 059ab0bfa..372c1f7cc 100644 --- a/build/build.cs +++ b/build/build.cs @@ -145,7 +145,7 @@ class Build : NukeBuild }); Target HttpTests => _ => _ - .DependsOn(CoreHttpTests, DataAnnotationsValidationHttpTests); + .DependsOn(CoreHttpTests); Target CoreHttpTests => _ => _ .DependsOn(Compile, DockerUp) @@ -160,20 +160,6 @@ 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); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f5147bd11..d2784c22e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -223,8 +223,8 @@ const config: UserConfig = { {text: 'Uploading Files', link: '/guide/http/files'}, {text: 'Integration with Sagas', link: '/guide/http/sagas'}, {text: 'Integration with Marten', link: '/guide/http/marten'}, + {text: 'Validation', link: '/guide/http/validation'}, {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, - {text: 'DataAnnotations Validation', link: '/guide/http/dataannotationsvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, {text: 'Caching', link: '/guide/http/caching'} ] diff --git a/docs/guide/http/dataannotationsvalidation.md b/docs/guide/http/dataannotationsvalidation.md deleted file mode 100644 index eeeaa8fe5..000000000 --- a/docs/guide/http/dataannotationsvalidation.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 - - \ No newline at end of file diff --git a/docs/guide/http/fluentvalidation.md b/docs/guide/http/fluentvalidation.md index b44874e3f..5ca6005c5 100644 --- a/docs/guide/http/fluentvalidation.md +++ b/docs/guide/http/fluentvalidation.md @@ -1,133 +1,3 @@ # Fluent Validation Middleware for HTTP -::: warning -If you need to use IoC services in a Fluent Validation `IValidator` that might force Wolverine to use a service locator -pattern in the generated code (basically from `AddScoped(s => build it at runtime)`), we recommend instead using a -more explicit `Validate` or `ValidateAsync()` method directly in your HTTP endpoint class for the data input. -::: - -Wolverine.Http has a separate package called `WolverineFx.Http.FluentValidation` that provides a simple middleware -for using [Fluent Validation](https://docs.fluentvalidation.net/en/latest/) in your HTTP endpoints. - -To get started, install that Nuget reference: - -```bash -dotnet add package WolverineFx.Http.FluentValidation -``` - -Next, let's assume that you have some Fluent Validation validators registered in your application container for the -request types of your HTTP endpoints -- and the [UseFluentValidation](/guide/handlers/fluent-validation) method from the -`WolverineFx.FluentValidation` package will help find these validators and register them in a way that optimizes this -middleware usage. - -Next, add this one single line of code to your Wolverine.Http bootstrapping: - -```csharp -opts.UseFluentValidationProblemDetailMiddleware(); -``` - -as shown in context below in an application shown below: - - - -```cs -app.MapWolverineEndpoints(opts => -{ - // This is strictly to test the endpoint policy - - opts.ConfigureEndpoints(httpChain => - { - // The HttpChain model is a configuration time - // model of how the HTTP endpoint handles requests - - // This adds metadata for OpenAPI - httpChain.WithMetadata(new CustomMetadata()); - }); - - // more configuration for HTTP... - - // Opting into the Fluent Validation middleware from - // Wolverine.Http.FluentValidation - opts.UseFluentValidationProblemDetailMiddleware(); -``` -snippet source | anchor - - -## AsParameters Binding - -The Fluent Validation middleware can also be used against the `[AsParameters]` input -of an HTTP endpoint: - - - -```cs -public static class ValidatedAsParametersEndpoint -{ - [WolverineGet("/asparameters/validated")] - public static string Get([AsParameters] ValidatedQuery query) - { - return $"{query.Name} is {query.Age}"; - } -} - -public class ValidatedQuery -{ - [FromQuery] - public string? Name { get; set; } - - public int Age { get; set; } - - public class ValidatedQueryValidator : AbstractValidator - { - public ValidatedQueryValidator() - { - RuleFor(x => x.Name).NotNull(); - } - } -} -``` -snippet source | anchor - - -## QueryString Binding - -Wolverine.HTTP can apply the Fluent Validation middleware to complex types that are bound by the `[FromQuery]` behavior: - - - -```cs -public record CreateCustomer -( - string FirstName, - string LastName, - string PostalCode -) -{ - public class CreateCustomerValidator : AbstractValidator - { - public CreateCustomerValidator() - { - RuleFor(x => x.FirstName).NotNull(); - RuleFor(x => x.LastName).NotNull(); - RuleFor(x => x.PostalCode).NotNull(); - } - } -} - -public static class CreateCustomerEndpoint -{ - [WolverinePost("/validate/customer")] - public static string Post(CreateCustomer customer) - { - return "Got a new customer"; - } - - [WolverinePost("/validate/customer2")] - public static string Post2([FromQuery] CreateCustomer customer) - { - return "Got a new customer"; - } -} -``` -snippet source | anchor - +See the [Validation Page](./validation). \ No newline at end of file diff --git a/docs/guide/http/validation.md b/docs/guide/http/validation.md new file mode 100644 index 000000000..555db6275 --- /dev/null +++ b/docs/guide/http/validation.md @@ -0,0 +1,401 @@ +# Validation within Wolverine.HTTP + +::: info +You can of course use completely custom Wolverine middleware for validation, and once again, returning the `ProblemDetails` +object or `WolverineContinue.NoProblems` to communicate validation errors is our main recommendation in that case. +::: + +Wolverine.HTTP has direct support for utilizing validation within HTTP endpoint that all revolve around the +[ProblemDetails](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails?view=aspnetcore-7.0) specification. + +1. Using one off `Validate()` or `ValidateAsync()` methods embedded directly in your endpoint types that return `ProblemDetails`. This is our recommendation for any + validation logic like data lookups that would require you to utilize IoC services or database calls. +2. Fluent Validation middleware through the separate `WolverineFx.Http.FluentValidation` Nuget +3. [Data Annotations](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations?view=net-10.0) middleware that is an option you have to explicitly configure within Wolverine.HTTP application + +::: tip +We **very strongly** recommend using the one off `ValidateAsync()` method for any validation that requires you to use an IoC' +service rather than trying to use the Fluent Validation `IValidator` interface. Especially if that validation logic +is specific to that HTTP endpoint. +::: + +## Using ProblemDetails + +Wolverine has some first class support for the [ProblemDetails](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails?view=aspnetcore-7.0) specification in its [HTTP middleware model](./middleware). +Wolverine also has a [Fluent Validation middleware package](./fluentvalidation) for HTTP endpoints, but it's frequently valuable to write one +off, explicit validation for certain endpoints. + +Consider this contrived sample endpoint with explicit validation being done in a "Before" middleware method: + + + +```cs +public class ProblemDetailsUsageEndpoint +{ + public ProblemDetails Before(NumberMessage message) + { + // If the number is greater than 5, fail with a + // validation message + if (message.Number > 5) + return new ProblemDetails + { + Detail = "Number is bigger than 5", + Status = 400 + }; + + // All good, keep on going! + return WolverineContinue.NoProblems; + } + + [WolverinePost("/problems")] + public static string Post(NumberMessage message) + { + return "Ok"; + } +} + +public record NumberMessage(int Number); +``` +snippet source | anchor + + +Wolverine.Http now (as of 1.2.0) has a convention that sees a return value of `ProblemDetails` and looks at that as a +"continuation" to tell the http handler code what to do next. One of two things will happen: + +1. If the `ProblemDetails` return value is the same instance as `WolverineContinue.NoProblems`, just keep going +2. Otherwise, write the `ProblemDetails` out to the HTTP response and exit the HTTP request handling + +To make that clearer, here's the generated code: + +```csharp + public class POST_problems : Wolverine.Http.HttpHandler + { + private readonly Wolverine.Http.WolverineHttpOptions _wolverineHttpOptions; + + public POST_problems(Wolverine.Http.WolverineHttpOptions wolverineHttpOptions) : base(wolverineHttpOptions) + { + _wolverineHttpOptions = wolverineHttpOptions; + } + + public override async System.Threading.Tasks.Task Handle(Microsoft.AspNetCore.Http.HttpContext httpContext) + { + var problemDetailsUsageEndpoint = new WolverineWebApi.ProblemDetailsUsageEndpoint(); + var (message, jsonContinue) = await ReadJsonAsync(httpContext); + if (jsonContinue == Wolverine.HandlerContinuation.Stop) return; + + var problemDetails = problemDetailsUsageEndpoint.Before(message); + if (!(ReferenceEquals(problemDetails, Wolverine.Http.WolverineContinue.NoProblems))) + { + await Microsoft.AspNetCore.Http.Results.Problem(problemDetails).ExecuteAsync(httpContext).ConfigureAwait(false); + return; + } + + var result_of_Post = WolverineWebApi.ProblemDetailsUsageEndpoint.Post(message); + await WriteString(httpContext, result_of_Post); + } + + } +``` + +And for more context, here's the matching "happy path" and "sad path" tests for the endpoint above: + + + +```cs +[Fact] +public async Task continue_happy_path() +{ + // Should be good + await Scenario(x => + { + x.Post.Json(new NumberMessage(3)).ToUrl("/problems"); + }); +} + +[Fact] +public async Task stop_with_problems_if_middleware_trips_off() +{ + // This is the "sad path" that should spawn a ProblemDetails + // object + var result = await Scenario(x => + { + x.Post.Json(new NumberMessage(10)).ToUrl("/problems"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); +} +``` +snippet source | anchor + + +Lastly, if Wolverine sees the existence of a `ProblemDetails` return value in any middleware, Wolverine will fill in OpenAPI +metadata for the "application/problem+json" content type and a status code of 400. This behavior can be easily overridden +with your own metadata if you need to use a different status code like this: + +```csharp + // Use 418 as the status code instead + [ProducesResponseType(typeof(ProblemDetails), 418)] +``` + +### Using ProblemDetails with Marten aggregates + +Of course, if you are using [Marten's aggregates within your Wolverine http handlers](./marten), you also want to be able to validation using the aggregate's details in your middleware and this is perfectly possible like this: + + + +```cs +[AggregateHandler] +public static ProblemDetails Before(IShipOrder command, Order order) +{ + if (order.IsShipped()) + { + return new ProblemDetails + { + Detail = "Order already shipped", + Status = 428 + }; + } + return WolverineContinue.NoProblems; +} +``` +snippet source | anchor + + +## ProblemDetails Within Message Handlers + +`ProblemDetails` can be used within message handlers as well with similar rules. See this example +from the tests: + + + +```cs +public static class NumberMessageHandler +{ + public static ProblemDetails Validate(NumberMessage message) + { + if (message.Number > 5) + { + return new ProblemDetails + { + Detail = "Number is bigger than 5", + Status = 400 + }; + } + + // All good, keep on going! + return WolverineContinue.NoProblems; + } + + // This "Before" method would only be utilized as + // an HTTP endpoint + [WolverineBefore(MiddlewareScoping.HttpEndpoints)] + public static void BeforeButOnlyOnHttp(HttpContext context) + { + Debug.WriteLine("Got an HTTP request for " + context.TraceIdentifier); + CalledBeforeOnlyOnHttpEndpoints = true; + } + + // This "Before" method would only be utilized as + // a message handler + [WolverineBefore(MiddlewareScoping.MessageHandlers)] + public static void BeforeButOnlyOnMessageHandlers() + { + CalledBeforeOnlyOnMessageHandlers = true; + } + + // Look at this! You can use this as an HTTP endpoint too! + [WolverinePost("/problems2")] + public static void Handle(NumberMessage message) + { + Debug.WriteLine("Handled " + message); + Handled = true; + } + + // These properties are just a cheap trick in Wolverine internal tests + public static bool Handled { get; set; } + public static bool CalledBeforeOnlyOnMessageHandlers { get; set; } + public static bool CalledBeforeOnlyOnHttpEndpoints { get; set; } +} +``` +snippet source | anchor + + +This functionality was added so that some handlers could be both an endpoint and message handler +without having to duplicate code or delegate to the handler through an endpoint. + + +## Data Annotations + +::: 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, add this one line of code to your Wolverine.HTTP configuration: + +```csharp +app.MapWolverineEndpoints(opts => +{ + // Use Data Annotations that are built + // into the Wolverine.HTTP library + opts.UseDataAnnotationsValidationProblemDetailMiddleware(); + +}); +``` + +This middleware will kick in for any HTTP endpoint where the request type has any property +decorated with a [`ValidationAttribute`](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.validationattribute?view=net-10.0) +or which implements the `IValidatableObject` interface. + +Any validation errors detected will cause the HTTP request to fail with a `ProblemDetails` response. + +For an example, consider this input model that will be a request type in your application: + +snippet: sample_validated_CreateAccount + +As long as the Data Annotations middleware is active, the `CreateAccount` model would be validated if used +as the request body like this: + +snippet: sample_posting_CreateAccount + +or even like this: + +snippet: sample_posting_create_account_as_query_string + +## Fluent Validation Middleware + +::: warning +If you need to use IoC services in a Fluent Validation `IValidator` that might force Wolverine to use a service locator +pattern in the generated code (basically from `AddScoped(s => build it at runtime)`), we recommend instead using a +more explicit `Validate` or `ValidateAsync()` method directly in your HTTP endpoint class for the data input. +::: + +Wolverine.Http has a separate package called `WolverineFx.Http.FluentValidation` that provides a simple middleware +for using [Fluent Validation](https://docs.fluentvalidation.net/en/latest/) in your HTTP endpoints. + +To get started, install that Nuget reference: + +```bash +dotnet add package WolverineFx.Http.FluentValidation +``` + +Next, let's assume that you have some Fluent Validation validators registered in your application container for the +request types of your HTTP endpoints -- and the [UseFluentValidation](/guide/handlers/fluent-validation) method from the +`WolverineFx.FluentValidation` package will help find these validators and register them in a way that optimizes this +middleware usage. + +Next, add this one single line of code to your Wolverine.Http bootstrapping: + +```csharp +opts.UseFluentValidationProblemDetailMiddleware(); +``` + +as shown in context below in an application shown below: + + + +```cs +app.MapWolverineEndpoints(opts => +{ + // This is strictly to test the endpoint policy + + opts.ConfigureEndpoints(httpChain => + { + // The HttpChain model is a configuration time + // model of how the HTTP endpoint handles requests + + // This adds metadata for OpenAPI + httpChain.WithMetadata(new CustomMetadata()); + }); + + // more configuration for HTTP... + + // Opting into the Fluent Validation middleware from + // Wolverine.Http.FluentValidation + opts.UseFluentValidationProblemDetailMiddleware(); +``` +snippet source | anchor + + +## AsParameters Binding + +The Fluent Validation middleware can also be used against the `[AsParameters]` input +of an HTTP endpoint: + + + +```cs +public static class ValidatedAsParametersEndpoint +{ + [WolverineGet("/asparameters/validated")] + public static string Get([AsParameters] ValidatedQuery query) + { + return $"{query.Name} is {query.Age}"; + } +} + +public class ValidatedQuery +{ + [FromQuery] + public string? Name { get; set; } + + public int Age { get; set; } + + public class ValidatedQueryValidator : AbstractValidator + { + public ValidatedQueryValidator() + { + RuleFor(x => x.Name).NotNull(); + } + } +} +``` +snippet source | anchor + + +## QueryString Binding + +Wolverine.HTTP can apply the Fluent Validation middleware to complex types that are bound by the `[FromQuery]` behavior: + + + +```cs +public record CreateCustomer +( + string FirstName, + string LastName, + string PostalCode +) +{ + public class CreateCustomerValidator : AbstractValidator + { + public CreateCustomerValidator() + { + RuleFor(x => x.FirstName).NotNull(); + RuleFor(x => x.LastName).NotNull(); + RuleFor(x => x.PostalCode).NotNull(); + } + } +} + +public static class CreateCustomerEndpoint +{ + [WolverinePost("/validate/customer")] + public static string Post(CreateCustomer customer) + { + return "Got a new customer"; + } + + [WolverinePost("/validate/customer2")] + public static string Post2([FromQuery] CreateCustomer customer) + { + return "Got a new customer"; + } +} +``` +snippet source | anchor + diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/DataAnnotationsValidationExtensions.cs b/src/Http/Wolverine.Http.DataAnnotationsValidation/DataAnnotationsValidationExtensions.cs deleted file mode 100644 index 45fd7ea25..000000000 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/DataAnnotationsValidationExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Wolverine.Attributes; -using Wolverine.Http.DataAnnotationsValidation; -using Wolverine.Http.DataAnnotationsValidation.Internals; - -[assembly: WolverineModule] - -namespace Wolverine.Http.DataAnnotationsValidation; - - -public class DataAnnotationsValidationExtension : IWolverineExtension -{ - public void Configure(WolverineOptions options) - { - options.Services.AddSingleton(typeof(IProblemDetailSource<>), typeof(ProblemDetailSource<>)); - } -} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/Wolverine.Http.DataAnnotationsValidation.csproj b/src/Http/Wolverine.Http.DataAnnotationsValidation/Wolverine.Http.DataAnnotationsValidation.csproj deleted file mode 100644 index 95d992062..000000000 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/Wolverine.Http.DataAnnotationsValidation.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - DataAnnotations Validation middleware for Wolverine HTTP Endpoints - WolverineFx.Http.DataAnnotationsValidation - false - false - false - false - false - - - - - - - diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/WolverineHttpOptionsExtensions.cs b/src/Http/Wolverine.Http.DataAnnotationsValidation/WolverineHttpOptionsExtensions.cs deleted file mode 100644 index dd0f55960..000000000 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/WolverineHttpOptionsExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Wolverine.Http.DataAnnotationsValidation.Internals; - -namespace Wolverine.Http.DataAnnotationsValidation; - -public static class WolverineHttpOptionsExtensions -{ - #region sample_usage_of_http_add_policy - - /// - /// Apply DataAnnotations Validation middleware to all Wolverine HTTP endpoints - /// - /// - public static void UseDataAnnotationsValidationProblemDetailMiddleware(this WolverineHttpOptions httpOptions) - { - httpOptions.AddPolicy(); - } - - #endregion -} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Tests/Internal/Generated/WolverineHandlers/POST_todo_create.cs b/src/Http/Wolverine.Http.Tests/Internal/Generated/WolverineHandlers/POST_todo_create.cs index 9fdae9fa5..8fbdbdf8c 100644 --- a/src/Http/Wolverine.Http.Tests/Internal/Generated/WolverineHandlers/POST_todo_create.cs +++ b/src/Http/Wolverine.Http.Tests/Internal/Generated/WolverineHandlers/POST_todo_create.cs @@ -40,11 +40,11 @@ public override async System.Threading.Tasks.Task Handle(Microsoft.AspNetCore.Ht return; } + var tenantIdentifier = new JasperFx.MultiTenancy.TenantId(tenantId); var messageContext = new Wolverine.Runtime.MessageContext(_wolverineRuntime); messageContext.TenantId = tenantId; // Building the Marten session using the detected tenant id await using var documentSession = _outboxedSessionFactory.OpenSession(messageContext, tenantId); - var tenantIdentifier = new JasperFx.MultiTenancy.TenantId(tenantId); // Reading the request body via JSON deserialization var (command, jsonContinue) = await ReadJsonAsync(httpContext); if (jsonContinue == Wolverine.HandlerContinuation.Stop) return; diff --git a/Wolverine.Http.DataAnnotationsValidation.Tests/dataannotations_validation_middleware.cs b/src/Http/Wolverine.Http.Tests/dataannotations_validation_middleware.cs similarity index 73% rename from Wolverine.Http.DataAnnotationsValidation.Tests/dataannotations_validation_middleware.cs rename to src/Http/Wolverine.Http.Tests/dataannotations_validation_middleware.cs index b30fa6fed..d878bd9e5 100644 --- a/Wolverine.Http.DataAnnotationsValidation.Tests/dataannotations_validation_middleware.cs +++ b/src/Http/Wolverine.Http.Tests/dataannotations_validation_middleware.cs @@ -4,17 +4,22 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using WolverineWebApi; +using WolverineWebApi.Validation; -namespace Wolverine.Http.DataAnnotationsValidation.Tests; +namespace Wolverine.Http.Tests; -public class dataannotations_validation_middleware : IAsyncLifetime +public class dataannotations_validation_middleware : IntegrationContext { - protected IAlbaHost theHost; + public dataannotations_validation_middleware(AppFixture fixture) : base(fixture) + { + } [Fact] public void adds_problem_validation_to_open_api_metadata() { - var endpoints = theHost.Services.GetServices().SelectMany(x => x.Endpoints).OfType() + var endpoints = Host.Services.GetServices().SelectMany(x => x.Endpoints).OfType() .ToList(); var endpoint = endpoints.Single(x => x.RoutePattern.RawText == "/validate/account"); @@ -30,7 +35,7 @@ public async Task one_validator_happy_path() var createCustomer = new CreateAccount("accountName", "12345678"); // Succeeds w/ a 200 - var result = await theHost.Scenario(x => + var result = await Host.Scenario(x => { x.Post.Json(createCustomer).ToUrl("/validate/account"); x.ContentTypeShouldBe("text/plain"); @@ -42,7 +47,7 @@ public async Task one_validator_sad_path() { var createCustomer = new CreateAccount(null, "123"); - var results = await theHost.Scenario(x => + var results = await Host.Scenario(x => { x.Post.Json(createCustomer).ToUrl("/validate/account"); x.ContentTypeShouldBe("application/problem+json"); @@ -58,7 +63,7 @@ public async Task one_validator_sad_path() public async Task one_validator_happy_path_on_complex_query_string_argument() { // Succeeds w/ a 200 - var result = await theHost.Scenario(x => + var result = await Host.Scenario(x => { x.Post.Url("/validate/account2") .QueryString(nameof(CreateAccount.AccountName), "name") @@ -70,7 +75,7 @@ public async Task one_validator_happy_path_on_complex_query_string_argument() [Fact] public async Task one_validator_sad_path_on_complex_query_string_argument() { - var results = await theHost.Scenario(x => + var results = await Host.Scenario(x => { x.Post.Url("/validate/account2") .QueryString(nameof(CreateAccount.Reference), "11111"); @@ -86,7 +91,7 @@ public async Task one_validator_sad_path_on_complex_query_string_argument() [Fact] public async Task when_using_compound_handler_validation_is_called_before_load() { - var results = await theHost.Scenario(x => + var results = await Host.Scenario(x => { x.Post.Url("/validate/account-compound"); x.ContentTypeShouldBe("application/problem+json"); @@ -96,24 +101,4 @@ public async Task when_using_compound_handler_validation_is_called_before_load() var problems = results.ReadAsJson(); } - 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(); - } } \ No newline at end of file diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/IProblemDetailSource.cs b/src/Http/Wolverine.Http/Validation/IProblemDetailSource.cs similarity index 90% rename from src/Http/Wolverine.Http.DataAnnotationsValidation/IProblemDetailSource.cs rename to src/Http/Wolverine.Http/Validation/IProblemDetailSource.cs index 282b5aab0..f1c40983b 100644 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/IProblemDetailSource.cs +++ b/src/Http/Wolverine.Http/Validation/IProblemDetailSource.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; -namespace Wolverine.Http.DataAnnotationsValidation; +namespace Wolverine.Http.Validation; /// /// What do you do with a validation failure? Generally assumed to throw an diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/DataAnnotationsHttpValidationExecutor.cs b/src/Http/Wolverine.Http/Validation/Internals/DataAnnotationsHttpValidationExecutor.cs similarity index 83% rename from src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/DataAnnotationsHttpValidationExecutor.cs rename to src/Http/Wolverine.Http/Validation/Internals/DataAnnotationsHttpValidationExecutor.cs index 53b59de65..a1ae3a659 100644 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/DataAnnotationsHttpValidationExecutor.cs +++ b/src/Http/Wolverine.Http/Validation/Internals/DataAnnotationsHttpValidationExecutor.cs @@ -1,8 +1,8 @@ -using Microsoft.AspNetCore.Http; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; -namespace Wolverine.Http.DataAnnotationsValidation.Internals; +namespace Wolverine.Http.Validation.Internals; public class DataAnnotationsHttpValidationExecutor { diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/HttpChainDataAnnotationsValidationPolicy.cs b/src/Http/Wolverine.Http/Validation/Internals/HttpChainDataAnnotationsValidationPolicy.cs similarity index 76% rename from src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/HttpChainDataAnnotationsValidationPolicy.cs rename to src/Http/Wolverine.Http/Validation/Internals/HttpChainDataAnnotationsValidationPolicy.cs index 10cac9c14..c98d90abf 100644 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/HttpChainDataAnnotationsValidationPolicy.cs +++ b/src/Http/Wolverine.Http/Validation/Internals/HttpChainDataAnnotationsValidationPolicy.cs @@ -1,10 +1,12 @@ +using System.ComponentModel.DataAnnotations; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; +using JasperFx.Core.Reflection; using Microsoft.AspNetCore.Http; using Wolverine.Http.CodeGen; -namespace Wolverine.Http.DataAnnotationsValidation.Internals; +namespace Wolverine.Http.Validation.Internals; internal class HttpChainDataAnnotationsValidationPolicy : IHttpPolicy { @@ -21,6 +23,12 @@ public void Apply(HttpChain chain, IServiceContainer container) var validatedType = chain.HasRequestType ? chain.RequestType : chain.ComplexQueryStringType; if (validatedType == null) return; + // ONLY apply if there are ValidationAttributes + if (!validatedType.GetProperties().Any(x => x.GetAllAttributes().Any()) && !validatedType.CanBeCastTo()) + { + return; + } + chain.Metadata.ProducesValidationProblem(); var method = diff --git a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/ProblemDetailSource.cs b/src/Http/Wolverine.Http/Validation/Internals/ProblemDetailSource.cs similarity index 91% rename from src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/ProblemDetailSource.cs rename to src/Http/Wolverine.Http/Validation/Internals/ProblemDetailSource.cs index fbe56cca3..487138782 100644 --- a/src/Http/Wolverine.Http.DataAnnotationsValidation/Internals/ProblemDetailSource.cs +++ b/src/Http/Wolverine.Http/Validation/Internals/ProblemDetailSource.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; -namespace Wolverine.Http.DataAnnotationsValidation.Internals; +namespace Wolverine.Http.Validation.Internals; public class ProblemDetailSource : IProblemDetailSource { diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index 133bb290d..93d4c12a8 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -12,6 +12,8 @@ using Wolverine.Configuration; using Wolverine.Http.CodeGen; using Wolverine.Http.Transport; +using Wolverine.Http.Validation; +using Wolverine.Http.Validation.Internals; using Wolverine.Middleware; using Wolverine.Runtime; using Wolverine.Runtime.Routing; @@ -158,6 +160,8 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(typeof(IProblemDetailSource<>), typeof(ProblemDetailSource<>)); services.ConfigureWolverine(opts => { diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index 5fcbb6dad..15a4152b5 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -11,6 +11,7 @@ using Wolverine.Http.Resources; using Wolverine.Http.Runtime; using Wolverine.Http.Runtime.MultiTenancy; +using Wolverine.Http.Validation.Internals; using Wolverine.Middleware; namespace Wolverine.Http; @@ -139,6 +140,15 @@ public WolverineHttpOptions() /// public ServiceProviderSource ServiceProviderSource { get; set; } = ServiceProviderSource.IsolatedAndScoped; + /// + /// Apply DataAnnotations Validation middleware to all Wolverine HTTP endpoints + /// + /// + public void UseDataAnnotationsValidationProblemDetailMiddleware() + { + AddPolicy(); + } + public async ValueTask TryDetectTenantId(HttpContext httpContext) { foreach (var strategy in TenantIdDetection.Strategies) diff --git a/src/Http/WolverineWebApi/ProblemDetailsUsage.cs b/src/Http/WolverineWebApi/ProblemDetailsUsage.cs index cae661d8c..b0f6a3704 100644 --- a/src/Http/WolverineWebApi/ProblemDetailsUsage.cs +++ b/src/Http/WolverineWebApi/ProblemDetailsUsage.cs @@ -9,7 +9,7 @@ namespace WolverineWebApi; public class ProblemDetailsUsageEndpoint { - public ProblemDetails Before(NumberMessage message) + public ProblemDetails Validate(NumberMessage message) { // If the number is greater than 5, fail with a // validation message diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 2ea74c15b..440c7e9e2 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -231,6 +231,10 @@ // Opting into the Fluent Validation middleware from // Wolverine.Http.FluentValidation opts.UseFluentValidationProblemDetailMiddleware(); + + // Or instead, you could use Data Annotations that are built + // into the Wolverine.HTTP library + opts.UseDataAnnotationsValidationProblemDetailMiddleware(); #endregion diff --git a/Wolverine.Http.DataAnnotationsValidation.Tests/DataAnnotationsValidationEndpoints.cs b/src/Http/WolverineWebApi/Validation/DataAnnotationsValidationEndpoints.cs similarity index 82% rename from Wolverine.Http.DataAnnotationsValidation.Tests/DataAnnotationsValidationEndpoints.cs rename to src/Http/WolverineWebApi/Validation/DataAnnotationsValidationEndpoints.cs index 2c86eda71..5cb39276f 100644 --- a/Wolverine.Http.DataAnnotationsValidation.Tests/DataAnnotationsValidationEndpoints.cs +++ b/src/Http/WolverineWebApi/Validation/DataAnnotationsValidationEndpoints.cs @@ -1,7 +1,8 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; -namespace Wolverine.Http.DataAnnotationsValidation.Tests; +namespace WolverineWebApi.Validation; #region sample_endpoint_with_dataannotations_validation @@ -14,6 +15,8 @@ public override bool IsValid(object? value) } } +#region sample_validated_CreateAccount + public record CreateAccount( // don't forget the property prefix on records [property: Required] string AccountName, @@ -29,19 +32,33 @@ public IEnumerable Validate(ValidationContext validationContex } } +#endregion + public static class CreateAccountEndpoint { + #region sample_posting_CreateAccount + [WolverinePost("/validate/account")] - public static string Post(CreateAccount account) + public static string Post( + + // In this case CreateAccount is being posted + // as JSON + CreateAccount account) { return "Got a new account"; } + #endregion + + #region sample_posting_create_account_as_query_string + [WolverinePost("/validate/account2")] public static string Post2([FromQuery] CreateAccount customer) { return "Got a new account"; } + + #endregion } #endregion diff --git a/wolverine.sln b/wolverine.sln index c98ac8bb0..55f3b6044 100644 --- a/wolverine.sln +++ b/wolverine.sln @@ -311,10 +311,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolverine.DataAnnotationsVa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolverine.DataAnnotationsValidation.Tests", "src\Extensions\Wolverine.DataAnnotationsValidation.Tests\Wolverine.DataAnnotationsValidation.Tests.csproj", "{0FD02607-BF12-4201-90F9-3FA88BFCDFBC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolverine.Http.DataAnnotationsValidation", "src\Http\Wolverine.Http.DataAnnotationsValidation\Wolverine.Http.DataAnnotationsValidation.csproj", "{5860A8F5-018D-4993-9849-EFCABF4BDB16}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wolverine.Http.DataAnnotationsValidation.Tests", "Wolverine.Http.DataAnnotationsValidation.Tests\Wolverine.Http.DataAnnotationsValidation.Tests.csproj", "{AE07303F-C6CD-44CB-987D-3B64568DE2E0}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1753,30 +1749,6 @@ Global {0FD02607-BF12-4201-90F9-3FA88BFCDFBC}.Release|x64.Build.0 = Release|Any CPU {0FD02607-BF12-4201-90F9-3FA88BFCDFBC}.Release|x86.ActiveCfg = Release|Any CPU {0FD02607-BF12-4201-90F9-3FA88BFCDFBC}.Release|x86.Build.0 = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|x64.ActiveCfg = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|x64.Build.0 = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|x86.ActiveCfg = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Debug|x86.Build.0 = Debug|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|Any CPU.Build.0 = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|x64.ActiveCfg = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|x64.Build.0 = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|x86.ActiveCfg = Release|Any CPU - {5860A8F5-018D-4993-9849-EFCABF4BDB16}.Release|x86.Build.0 = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|x64.ActiveCfg = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|x64.Build.0 = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|x86.ActiveCfg = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Debug|x86.Build.0 = Debug|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|Any CPU.Build.0 = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|x64.ActiveCfg = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|x64.Build.0 = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|x86.ActiveCfg = Release|Any CPU - {AE07303F-C6CD-44CB-987D-3B64568DE2E0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1921,8 +1893,6 @@ Global {CA5FB523-D71D-4EF3-97B8-00CCDC05C00D} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {38F1B1E4-87B9-401C-9401-7C9318DFAF55} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} {0FD02607-BF12-4201-90F9-3FA88BFCDFBC} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} - {5860A8F5-018D-4993-9849-EFCABF4BDB16} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} - {AE07303F-C6CD-44CB-987D-3B64568DE2E0} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {30422362-0D90-4DBE-8C97-DD2B5B962768}