What you ask for has several solutions, none of them is perfect i guess.
The most simple is to use SetValueMustBeANumberAccessor and I think it's the cleanest.
However if you need more flexibility, you can build around fluent validation and ASP.NET validation pipeline.
First of all, i want to mention that FluentValidation.AspNetCore package is deprecated and building around it might be not best decision as in terms of maintainability.
Having said all that, here's what you can do also to make your approach more centralized around Fluent Validation.
The most important thing here to understand is that ASP.NET has it's own validation built in. If the content fails to be parsed to the model, ASP.NET short-circuits the request, meaning it does not invoke neither the action (method for endpoint) neither none of its filters. So first step is to disable this behavior
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
This might be breaking change, as now it won't short circuit. We get one advantage and one disatvantage:
- now we can define filter and handle model binding errors manually (so could leverage fluent validation in the process)
- but now it also reaches the method itself, where we need to check for
ModelState.IsValid
i.e. if (!ModelState.IsValid) return BadRequest(ModelState);
So, now we can use following logic in filter that will be executed before the action:
- check if model state is valid
- if not, check if there is "validatable" model to be checked, if so, validate the object
- if validation initially failed, we will have
null instead of model, so we create empty model and validate it
- now we compare default validation and replace any errors that are coming out of our Fluent Validation.
Below is implementation of above logic and it contains inline comments for clarity:
public class CustomModelBindingErrorFilter : IActionFilter
{
private readonly IServiceProvider _serviceProvider;
public CustomModelBindingErrorFilter(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void OnActionExecuting(ActionExecutingContext context)
{
// Only proceed if ModelState is invalid (binding failed)
if (context.ModelState.IsValid)
return;
// Go through each action argument
foreach (var kvp in context.ActionDescriptor.Parameters)
{
var paramName = kvp.Name;
var paramType = kvp.ParameterType;
// Try to get the actual bound object (might be null)
context.ActionArguments.TryGetValue(paramName, out var modelInstance);
// Try to resolve validator dynamically from DI
var validatorType = typeof(IValidator<>).MakeGenericType(paramType);
var validator = _serviceProvider.GetService(validatorType) as IValidator;
if (validator == null)
continue; // No validator registered, skip
// Create an empty instance if binding failed (null)
if (modelInstance == null)
modelInstance = Activator.CreateInstance(paramType);
// Run FluentValidation manually
var validationContext = new ValidationContext<object>(modelInstance);
ValidationResult result = validator.Validate(validationContext);
// For each existing property with an error, replace its messages
foreach (var stateEntry in context.ModelState)
{
string propertyName = stateEntry.Key;
if (stateEntry.Value?.Errors?.Count > 0)
{
// See if validator has a rule for this property
var failure = result.Errors.FirstOrDefault(e =>
string.Equals($"$.{e.PropertyName}", propertyName, StringComparison.OrdinalIgnoreCase));
if (failure != null)
{
// Replace binding errors with FluentValidation message
stateEntry.Value.Errors.Clear();
stateEntry.Value.Errors.Add(failure.ErrorMessage);
}
}
}
}
}
public void OnActionExecuted(ActionExecutedContext context) { }
}
And of course, to use that filter you need to register it:
builder.Services.AddControllers(options =>
{
options.Filters.Add<CustomModelBindingErrorFilter>();
});