├── test └── AspNet.FluentValidation.Tests │ ├── Usings.cs │ ├── AspNet.FluentValidation.Tests.csproj │ └── ValidationFilterTests.cs ├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ └── publish.yml ├── src └── AspNet.FluentValidation │ ├── assets │ └── icon.png │ ├── ValidateAttribute.cs │ ├── EndpointValidationMetadata.cs │ ├── AspNet.FluentValidation.csproj │ ├── ValidationFilterOptions.cs │ ├── ValidationStrategies.cs │ └── ValidationExtensions.cs ├── .config └── dotnet-tools.json ├── .gitignore ├── Directory.Build.props ├── LICENSE ├── .vscode ├── launch.json └── tasks.json ├── o9d-aspnet.sln └── README.md /test/AspNet.FluentValidation.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using Shouldly; -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 2 | * @benfoster -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benfoster/o9d-aspnet/HEAD/src/AspNet.FluentValidation/assets/icon.png -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "cake.tool": { 6 | "version": "3.0.0", 7 | "commands": [ 8 | "dotnet-cake" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/ValidateAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace O9d.AspNet.FluentValidation; 2 | 3 | /// 4 | /// Attribute used to indicate an input parameter or class should be validated 5 | /// 6 | [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = true)] 7 | public sealed class ValidateAttribute : Attribute 8 | { 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.swp 3 | *.*~ 4 | project.lock.json 5 | .DS_Store 6 | *.pyc 7 | nupkg/ 8 | 9 | # User-specific files 10 | *.suo 11 | *.user 12 | *.userosscache 13 | *.sln.docstates 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | build/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Oo]ut/ 27 | msbuild.log 28 | msbuild.err 29 | msbuild.wrn 30 | 31 | # Cake - Uncomment if you are using it 32 | tools/** 33 | !tools/packages.config 34 | 35 | artifacts/ 36 | BenchmarkDotNet.Artifacts/ -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/EndpointValidationMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace O9d.AspNet.FluentValidation; 2 | 3 | /// 4 | /// Marker metadata used to indicate that the endpoint input parameter should be validated 5 | /// 6 | public sealed class EndpointValidationMetadata 7 | { 8 | public EndpointValidationMetadata(Type[] typesToValidate) 9 | { 10 | if (typesToValidate is null) 11 | { 12 | throw new ArgumentNullException(nameof(typesToValidate)); 13 | } 14 | 15 | TypesToValidate = typesToValidate; 16 | } 17 | 18 | public Type[] TypesToValidate { get; } 19 | } -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | latest 4 | 5 | enable 6 | nullable; 7 | true 8 | 9 | 10 | @benfoster 11 | o9d 12 | https://github.com/benfoster/o9d-aspnet 13 | https://github.com/benfoster/o9d-aspnet.git 14 | git 15 | 16 | 17 | normal 18 | 19 | -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/AspNet.FluentValidation.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0 4 | enable 5 | enable 6 | O9d.AspNet.FluentValidation 7 | O9d.AspNet.FluentValidation 8 | O9d.AspNet.FluentValidation 9 | Fluent Validation extensions for ASP.NET Core Minimal APIs 10 | icon.png 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Java 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 11.0.x 19 | - name: Setup .NET 7.0 20 | uses: actions/setup-dotnet@v1 21 | with: 22 | dotnet-version: 7.0.x 23 | - name: Restore tools 24 | run: dotnet tool restore 25 | - name: Run the build script 26 | uses: cake-build/cake-action@v1 27 | env: 28 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 29 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | target: CI 33 | #verbosity: Diagnostic 34 | - name: Upload pre-release packages 35 | uses: actions/upload-artifact@v2 36 | with: 37 | name: packages 38 | path: artifacts/*.nupkg 39 | -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/ValidationFilterOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using FluentValidation.Results; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace O9d.AspNet.FluentValidation; 6 | 7 | /// 8 | /// Options used to configure the Validation Filter 9 | /// 10 | public sealed class ValidationFilterOptions 11 | { 12 | /// 13 | /// Gets or sets the delegate used to determine whether the endpoint parameter is validateable 14 | /// 15 | public ValidationStrategy ShouldValidate { get; set; } = ValidationStrategies.HasValidateAttribute; 16 | 17 | /// 18 | /// Gets or sets the factory used to create a HTTP result when validation fails. Defaults to a HTTP 422 Validation Problem. 19 | /// 20 | public Func InvalidResultFactory { get; set; } = CreateValidationProblemResult; 21 | 22 | private static IResult CreateValidationProblemResult(ValidationResult validationResult) 23 | => Results.ValidationProblem(validationResult.ToDictionary(), statusCode: (int)HttpStatusCode.UnprocessableEntity); 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben Foster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | 8 | jobs: 9 | publish: 10 | name: Build and publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Java 17 | uses: actions/setup-java@v1 18 | with: 19 | java-version: 11.0.x 20 | - name: Setup .NET 7.0 21 | uses: actions/setup-dotnet@v1 22 | with: 23 | dotnet-version: 7.0.x 24 | - name: Restore tools 25 | run: dotnet tool restore 26 | - name: Run the build script 27 | uses: cake-build/cake-action@v1 28 | env: 29 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 30 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 31 | NUGET_API_URL: https://api.nuget.org/v3/index.json 32 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NUGET_PRE_API_KEY: ${{ secrets.GITHUB_TOKEN }} 35 | NUGET_PRE_API_URL: https://nuget.pkg.github.com/benfoster/index.json 36 | with: 37 | target: Publish 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/test/AspNet.FluentValidation.Tests/bin/Debug/net7.0/AspNet.FluentValidation.Tests.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/test/AspNet.FluentValidation.Tests", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /test/AspNet.FluentValidation.Tests/AspNet.FluentValidation.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0 4 | enable 5 | enable 6 | false 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/test/AspNet.FluentValidation.Tests/AspNet.FluentValidation.Tests.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/test/AspNet.FluentValidation.Tests/AspNet.FluentValidation.Tests.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/test/AspNet.FluentValidation.Tests/AspNet.FluentValidation.Tests.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/ValidationStrategies.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace O9d.AspNet.FluentValidation; 4 | 5 | /// 6 | /// Represents a strategy used to determine whether the provided endpoint details are validateable. 7 | /// 8 | /// The endpoint parameter 9 | /// The endpoint metadata 10 | /// True if the parameter is validateable, otherwise False 11 | public delegate bool ValidationStrategy(ParameterInfo parameterInfo, IList endpointMetadata); 12 | 13 | /// 14 | /// Built-in strategies for determining if an endpoint parameter is validateable 15 | /// 16 | public static class ValidationStrategies 17 | { 18 | /// 19 | /// Validation strategy that checks for a presence of the 20 | /// 21 | public static readonly ValidationStrategy HasValidateAttribute = (pi, _) 22 | => pi.GetCustomAttribute(true) is not null 23 | || pi.ParameterType.GetCustomAttribute(true) is not null; 24 | 25 | /// 26 | /// Validation strategy that checks for the presence of metadata on the endpoint. 27 | /// 28 | public static readonly ValidationStrategy HasValidationMetadata = (pi, endpointMetadata) => 29 | { 30 | foreach (var metadata in endpointMetadata) 31 | { 32 | if (metadata is EndpointValidationMetadata endpointValidationMetadata) 33 | { 34 | return endpointValidationMetadata.TypesToValidate.Contains(pi.ParameterType); 35 | } 36 | } 37 | 38 | return false; 39 | }; 40 | 41 | /// 42 | /// Creates a validation strategy that checks if the parameter type implements or derives the specified type 43 | /// 44 | /// The type to check 45 | public static ValidationStrategy TypeImplements() 46 | => (pi, _) => pi.ParameterType.IsAssignableTo(typeof(T)); 47 | } -------------------------------------------------------------------------------- /o9d-aspnet.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6E5085C8-F9C4-4A1A-9FF0-F2EB5AB19018}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.FluentValidation", "src\AspNet.FluentValidation\AspNet.FluentValidation.csproj", "{F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{6E7FB354-6B93-4CE5-BE1F-1322A374FD68}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.FluentValidation.Tests", "test\AspNet.FluentValidation.Tests\AspNet.FluentValidation.Tests.csproj", "{6CC35956-249E-4F12-970E-40815DC90D18}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {6CC35956-249E-4F12-970E-40815DC90D18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {6CC35956-249E-4F12-970E-40815DC90D18}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {6CC35956-249E-4F12-970E-40815DC90D18}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {6CC35956-249E-4F12-970E-40815DC90D18}.Release|Any CPU.Build.0 = Release|Any CPU 31 | EndGlobalSection 32 | GlobalSection(NestedProjects) = preSolution 33 | {F7ADFC81-AD04-47BE-8E76-E4B5E2ECF892} = {6E5085C8-F9C4-4A1A-9FF0-F2EB5AB19018} 34 | {6CC35956-249E-4F12-970E-40815DC90D18} = {6E7FB354-6B93-4CE5-BE1F-1322A374FD68} 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.NET Extensions 2 | 3 | Opinionated extensions for ASP.NET Core. 4 | 5 | ## O9d.AspNet.FluentValidation 6 | 7 | This package includes a validation filter that can be used with ASP.NET Minimal APIs to automatically validate incoming requests using [FluentValidation](https://github.com/FluentValidation/FluentValidation). 8 | 9 | For more information on the motivation behind this filter, see [this blog post](https://benfoster.io/blog/minimal-api-validation-endpoint-filters/). 10 | 11 | ### Installation 12 | 13 | Install from [Nuget](https://www.nuget.org/packages/O9d.AspNet.FluentValidation). 14 | 15 | ``` 16 | dotnet add package O9d.AspNet.FluentValidation 17 | ``` 18 | 19 | ### Usage 20 | 21 | Rather than attaching the filter to every endpoint, create a group under which your endpoints are created and add the validation filter: 22 | 23 | ```c# 24 | var group = app.MapGroup("/") 25 | .WithValidationFilter(); 26 | ``` 27 | 28 | Automatic validation is opt-in and defaults to a strategy that uses the provided `[Validate]` attribute: 29 | 30 | ```c# 31 | group.MapPost("/things", ([Validate] DoSomething _) => Results.Ok()); 32 | 33 | public class DoSomething 34 | { 35 | public string? Name { get; set; } 36 | 37 | public class Validator : AbstractValidator 38 | { 39 | public Validator() 40 | { 41 | RuleFor(x => x.Name).NotEmpty(); 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | If the request parameter is not valid, by default, a `ValidationProblem` is returned with the HTTP status code `422 - Unprocessable Entity` 48 | 49 | ```json 50 | { 51 | "type": "https://tools.ietf.org/html/rfc4918#section-11.2", 52 | "title": "One or more validation errors occurred.", 53 | "status": 422, 54 | "errors": { 55 | "Name": ["'Name' must not be empty."] 56 | } 57 | } 58 | ``` 59 | 60 | ### Validation Strategies 61 | 62 | A validation strategy determines how whether a parameter type is validateable. The default behaviour is to use the `[Validate]` attribute which can be applied to the parameter (as above) or directly to the class. The library also includes support for the following: 63 | 64 | #### Metadata-based strategy 65 | 66 | You can choose to decorate the endpoints you wish to validate explicitly using metadata: 67 | 68 | ```c# 69 | var group = app.MapGroup("/") 70 | .WithValidationFilter(options => options.ShouldValidate = ValidationStrategies.HasValidationMetadata) 71 | .RequireValidation(typeof(DoSomething)); 72 | 73 | group.MapPost("/things", (DoSomething _) => Results.Ok()); 74 | ``` 75 | 76 | You need to specify the types that should be enlisted for validation so it generally only makes sense to do this at a group level. 77 | 78 | #### Type convention driven strategy 79 | 80 | My preferred approach is to define a marker interface e.g. `IValidateable` and then decorate my input parameters with this: 81 | 82 | ```c# 83 | var group = app.MapGroup("/") 84 | .WithValidationFilter(options => options.ShouldValidate = ValidationStrategies.TypeImplements()); 85 | 86 | group.MapPost("/things", (DoSomething _) => Results.Ok()); 87 | ``` 88 | 89 | #### Custom strategy 90 | 91 | If none of the built-in strategies work for you, you can create your own implementation of the `ValidationStrategy` delegate: 92 | 93 | ```c# 94 | public delegate bool ValidationStrategy(ParameterInfo parameterInfo, IList endpointMetadata); 95 | ``` 96 | 97 | ### Overriding the validation result 98 | 99 | You can override the default validation result by providing your own factory, which takes the FluentValidation `ValidationResult` as an input: 100 | 101 | ```c# 102 | var group = app.MapGroup("/") 103 | .WithValidationFilter(options => options.InvalidResultFactory = validationResult => Results.BadRequest()); 104 | 105 | group.MapPost("/things", ([Validate] DoSomething _) => Results.Ok()); 106 | ``` 107 | -------------------------------------------------------------------------------- /src/AspNet.FluentValidation/ValidationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using FluentValidation; 3 | using Microsoft.AspNetCore.Http; 4 | using O9d.AspNet.FluentValidation; 5 | 6 | namespace Microsoft.AspNetCore.Builder; 7 | 8 | public static class ValidationExtensions 9 | { 10 | /// 11 | /// Indicates that endpoints created by the builder should be enlisted for validation. 12 | /// 13 | /// The Microsoft.AspNetCore.Builder.IEndpointConventionBuilder. 14 | /// The parameter types to validate. 15 | public static TBuilder RequireValidation(this TBuilder builder, params Type[] typesToValidate) where TBuilder : IEndpointConventionBuilder 16 | => builder.WithMetadata(new EndpointValidationMetadata(typesToValidate)); 17 | 18 | /// 19 | /// Adds a filter to validateable endpoints that uses Fluent Validation to validate input parameters. 20 | /// 21 | /// The Microsoft.AspNetCore.Builder.IEndpointConventionBuilder. 22 | /// A configuration expression to apply to the validation filter options. 23 | public static TBuilder WithValidationFilter(this TBuilder builder, Action? configureOptions = null) where TBuilder : IEndpointConventionBuilder 24 | { 25 | var options = new ValidationFilterOptions(); 26 | configureOptions?.Invoke(options); 27 | 28 | // Use a convention so we can capture the endpoint's metadata and provide this to the validation strategy 29 | builder.Add(eb => eb.FilterFactories.Add( 30 | (ctx, next) => CreateValidationFilterFactory(options, eb.Metadata, ctx, next)) 31 | ); 32 | 33 | return builder; 34 | } 35 | 36 | private static EndpointFilterDelegate CreateValidationFilterFactory( 37 | ValidationFilterOptions options, 38 | IList metadata, 39 | EndpointFilterFactoryContext context, 40 | EndpointFilterDelegate next) 41 | { 42 | IEnumerable validateableParameters 43 | = GetValidateableParameters(options, context.MethodInfo, metadata); 44 | 45 | if (validateableParameters.Any()) 46 | { 47 | // Caches the parameters to avoid reflecting each time 48 | return invocationContext => CreateValidationFilter(options, validateableParameters, invocationContext, next); 49 | } 50 | 51 | // pass-thru 52 | return invocationContext => next(invocationContext); 53 | } 54 | 55 | private static async ValueTask CreateValidationFilter( 56 | ValidationFilterOptions options, 57 | IEnumerable validationDescriptors, 58 | EndpointFilterInvocationContext invocationContext, 59 | EndpointFilterDelegate next) 60 | { 61 | foreach (ValidateableParameterDescriptor descriptor in validationDescriptors) 62 | { 63 | var argument = invocationContext.Arguments[descriptor.ArgumentIndex]; 64 | 65 | // Resolve the validator 66 | IValidator? validator 67 | = invocationContext.HttpContext.RequestServices.GetService(descriptor.ValidatorType) as IValidator; 68 | 69 | // TODO consider whether we mutate the descriptor to skip if no validator is registered 70 | 71 | if (argument is not null && validator is not null) 72 | { 73 | var validationResult = await validator.ValidateAsync( 74 | new ValidationContext(argument) 75 | ); 76 | 77 | if (!validationResult.IsValid) 78 | { 79 | return options.InvalidResultFactory(validationResult); 80 | } 81 | } 82 | } 83 | 84 | return await next.Invoke(invocationContext); 85 | } 86 | 87 | private static IEnumerable GetValidateableParameters( 88 | ValidationFilterOptions options, 89 | MethodInfo methodInfo, 90 | IList metadata) 91 | { 92 | ParameterInfo[] parameters = methodInfo.GetParameters(); 93 | 94 | for (int i = 0; i < parameters.Length; i++) 95 | { 96 | ParameterInfo parameter = parameters[i]; 97 | 98 | if (options.ShouldValidate(parameter, metadata)) 99 | { 100 | Type validatorType = typeof(IValidator<>).MakeGenericType(parameter.ParameterType); 101 | 102 | yield return new ValidateableParameterDescriptor 103 | { 104 | ArgumentIndex = i, 105 | ArgumentType = parameter.ParameterType, 106 | ValidatorType = validatorType 107 | }; 108 | } 109 | } 110 | } 111 | 112 | private class ValidateableParameterDescriptor 113 | { 114 | public required int ArgumentIndex { get; init; } 115 | public required Type ArgumentType { get; init; } 116 | public required Type ValidatorType { get; init; } 117 | } 118 | } -------------------------------------------------------------------------------- /test/AspNet.FluentValidation.Tests/ValidationFilterTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using FluentValidation; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.TestHost; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace O9d.AspNet.FluentValidation.Tests; 10 | 11 | public class ValidationFilterTests 12 | { 13 | [Fact] 14 | public async void Skips_validation_without_filter() 15 | { 16 | using var app = await CreateApplication(app 17 | => app.MapPost("/things", (DoSomething _) => Results.Ok())); 18 | 19 | using var httpResponse 20 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 21 | 22 | Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); 23 | } 24 | 25 | [Fact] 26 | public async void Validates_using_attribute_strategy() 27 | { 28 | using var app = await CreateApplication( 29 | app => 30 | { 31 | var group = app.MapGroup("/") 32 | .WithValidationFilter(); // Uses attribute strategy by default 33 | 34 | group.MapPost("/things", ([Validate] DoSomething _) => Results.Ok()); 35 | }, 36 | services => services.AddScoped, DoSomething.Validator>() 37 | ); 38 | 39 | using var httpResponse 40 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 41 | 42 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); 43 | } 44 | 45 | [Fact] 46 | public async Task Skips_validation_if_validator_not_registered() 47 | { 48 | using var app = await CreateApplication( 49 | app => 50 | { 51 | var group = app.MapGroup("/") 52 | .WithValidationFilter(); 53 | 54 | group.MapPost("/things", ([Validate] DoSomething _) => Results.Ok()); 55 | } 56 | ); 57 | 58 | using var httpResponse 59 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 60 | 61 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.OK); 62 | } 63 | 64 | [Fact] 65 | public async Task Validates_with_metadata_strategy() 66 | { 67 | using var app = await CreateApplication( 68 | app => 69 | { 70 | var group = app.MapGroup("/") 71 | .WithValidationFilter(options => options.ShouldValidate = ValidationStrategies.HasValidationMetadata) 72 | .RequireValidation(typeof(DoSomething)); 73 | 74 | group.MapPost("/things", (DoSomething _) => Results.Ok()); 75 | }, 76 | services => services.AddScoped, DoSomething.Validator>() 77 | ); 78 | 79 | using var httpResponse 80 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 81 | 82 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); 83 | } 84 | 85 | [Fact] 86 | public async Task Skips_metadata_validation_when_type_not_registered() 87 | { 88 | using var app = await CreateApplication( 89 | app => 90 | { 91 | var group = app.MapGroup("/") 92 | .WithValidationFilter(options => options.ShouldValidate = ValidationStrategies.HasValidationMetadata) 93 | .RequireValidation(Array.Empty()); 94 | 95 | group.MapPost("/things", (DoSomething _) => Results.Ok()); 96 | }, 97 | services => services.AddScoped, DoSomething.Validator>() 98 | ); 99 | 100 | using var httpResponse 101 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 102 | 103 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.OK); 104 | } 105 | 106 | [Fact] 107 | public async Task Validates_with_type_strategy() 108 | { 109 | using var app = await CreateApplication( 110 | app => 111 | { 112 | var group = app.MapGroup("/") 113 | .WithValidationFilter(options => options.ShouldValidate = ValidationStrategies.TypeImplements()); 114 | 115 | group.MapPost("/things", (DoSomething _) => Results.Ok()); 116 | }, 117 | services => services.AddScoped, DoSomething.Validator>() 118 | ); 119 | 120 | using var httpResponse 121 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 122 | 123 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); 124 | } 125 | 126 | [Fact] 127 | public async Task Can_override_validation_result() 128 | { 129 | using var app = await CreateApplication( 130 | app => 131 | { 132 | var group = app.MapGroup("/") 133 | .WithValidationFilter(options => options.InvalidResultFactory = _ => Results.BadRequest()); 134 | 135 | group.MapPost("/things", ([Validate] DoSomething _) => Results.Ok()); 136 | }, 137 | services => services.AddScoped, DoSomething.Validator>() 138 | ); 139 | 140 | using var httpResponse 141 | = await app.GetTestClient().PostAsJsonAsync("/things", new DoSomething()); 142 | 143 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 144 | } 145 | 146 | [Fact] 147 | public async void Validates_when_using_class_attribute() 148 | { 149 | using var app = await CreateApplication( 150 | app => 151 | { 152 | var group = app.MapGroup("/") 153 | .WithValidationFilter(); 154 | 155 | group.MapPost("/things", (AttributedThing _) => Results.Ok()); 156 | }, 157 | services => services.AddScoped, AttributedThing.Validator>() 158 | ); 159 | 160 | using var httpResponse 161 | = await app.GetTestClient().PostAsJsonAsync("/things", new AttributedThing()); 162 | 163 | httpResponse.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); 164 | } 165 | 166 | private static async Task CreateApplication(Action configureApplication, Action? configureServices = null) 167 | { 168 | var builder = WebApplication.CreateBuilder(); 169 | configureServices?.Invoke(builder.Services); 170 | builder.WebHost.UseTestServer(); 171 | 172 | var app = builder.Build(); 173 | 174 | configureApplication(app); 175 | 176 | await app.StartAsync(); 177 | return app; 178 | } 179 | 180 | public class DoSomething : IValidateable 181 | { 182 | public string? Name { get; set; } 183 | 184 | public class Validator : AbstractValidator 185 | { 186 | public Validator() 187 | { 188 | RuleFor(x => x.Name).NotEmpty(); 189 | } 190 | } 191 | } 192 | 193 | [Validate] 194 | public class AttributedThing : IValidateable 195 | { 196 | public string? Name { get; set; } 197 | 198 | public class Validator : AbstractValidator 199 | { 200 | public Validator() 201 | { 202 | RuleFor(x => x.Name).NotEmpty(); 203 | } 204 | } 205 | } 206 | 207 | public interface IValidateable { } 208 | } --------------------------------------------------------------------------------