Originally posted on my blog
Intro
Fluent Validation is a great package for handling, well, validation.
It ties in well with ASP.NET as well, providing client-side hits in the form of data-val
attributes on your form inputs that can be used by whatever means of client-side validation you use, the jQuery thingamajig by default.
There's but one problem, one thing that good ol' attribute-based validators have over this package: the ease of creating custom client-side validators. Server-side? No issue at all, the docs guide you cleanly through the process.
Client-side? That's where issues begin.
In this post, however, we will make a file size validator that also works client-side. So sit back and enjoy.
Server-side validator
First things first, we need a server-side validator. That much is easy, all we really need is to inherit from the generic PropertyValidator<T, TProperty>
class and override a couple of methods. Our class could look something like this in its barebones form:
public class FileSizeValidator<T> : PropertyValidator<T, IFormFile>
{
public uint Max { get; } // 1
public FileSizeValidator(uint max) => Max = max; // 2
public override bool IsValid(ValidationContext<T> context, IFormFile value) // 3
{
}
public override string Name => "FileSizeValidator"; // 4
protected override string GetDefaultMessageTemplate(string errorCode) // 5
=> "Maximum file size is {MaxFilesize}.";
}
Let's quickly go over it:
- Property to store out maximum file size in. It could be a private field as well, but you will soon see that no, it has to be a property.
- Constructor, that much is obvious
- Override of the method where we will check the validity
- Override of the property that gives us the validator name
- Override of the method that gives us the error message to display later
This won't do much quite yet, there's barely any code there, and the IsValid()
method doesn't return anything either. So let's implement it:
public override bool IsValid(ValidationContext<T> context, IFormFile value)
{
if (value is null) return true;
if (value.Length <= Max) return true;
context.MessageFormatter
.AppendArgument("MaxFilesize", Max.Bytes());
return false;
}
Very simple implementation, really. If the file sent is null
, treat it as valid, since the user might not want to submit an image with their form. That's my use case, at least, and if you do want the file to be required consider adding a boolean that'd control it, or hardcoding return false
instead.
If the size of the file is less or equal to the desired maximum, it's all well and good, we can safely return true
as well.
In any other case, we construct the error message using the provided MessageFormatter
and return false
. The Bytes()
extension method comes from Humanizer, another great package, and it ensures the number of bytes is displayed in a human-friendly format, so as kilobytes, megabytes, or whatever will handle the size best.
Helper
Adding a bare validator like this can be a bit cumbersome, so we can create a helper extension method of RuleBuilder<T, out TProperty>
. That way, we will be able to just chain that method onto our validator. It's extremely simple:
public static class FileSizeValidatorExtension
{
public static IRuleBuilderOptions<T, IFormFile> FileSmallerThan<T>(this IRuleBuilder<T, IFormFile> ruleBuilder, uint max)
=> ruleBuilder.SetValidator(new FileSizeValidator<T>(max));
}
And is used, as promised, in a very easy way:
RuleFor(r => r.Avatar)
.FileSmallerThan(100 * 1024); // 100 KB
Preparation for client-side validation
Remember how I mentioned that Max
being a property will be relevant? That's because we need an interface now. The client-side validator will require us to cast a validator to our validator, and won't give us the necessary types to cast it to our generic FileSizeValidator<T>
. That's why we need a non-generic interface that our validator will implement:
public interface IFileSizeValidator : IPropertyValidator
{
public uint Max { get; }
}
It just contains the property and itself inherits another interface, that's already implemented by the PropertyValidator<T, TProperty>
class our FileSizeValidator<T>
already inherits from... Yes, it's a bit of a spaghetti mess of inheritances and implementations but trust me on that.
All that's left now is to implement this interface on our class:
public class FileSizeValidator<T> : PropertyValidator<T, IFormFile>, IFileSizeValidator
And now we can finally get to...
Client-side validator
This one was a doozy.
To give you an idea of how hard it was to figure out what needs to be done (including the need for a non-generic interface) here's some quick facts about my search:
- The search led me to CodePlex, and I wasn't even on the 2nd page of Google
- I asked twice in total in four different Discord servers, to no avail
- My posts on r/csharp and r/dotnet remain unanswered to this day
- My Stack Overflow question didn't get an answer either
- The only lead I had was this comment from February 2020
- Help arrived 9 days after I raised an issue
And I'm very much grateful for that, because the answer to that issue was what directed me at the non-generic interface.
With that out of the way, let's get to it. First, we need to create a class that inherits from ClientValidatorBase
and, again, override a method:
public class FileSizeClientValidator : ClientValidatorBase
{
public FileSizeClientValidator(IValidationRule rule, IRuleComponent component) : base(rule, component)
{ }
public override void AddValidation(ClientModelValidationContext context)
{
}
}
Nothing groundbreaking so far. And nothing will really be groundbreaking here. We can just go for
public class FileSizeClientValidator : ClientValidatorBase
{
public FileSizeClientValidator(IValidationRule rule, IRuleComponent component) : base(rule, component)
{ }
public override void AddValidation(ClientModelValidationContext context)
{
var validator = (IFileSizeValidator)Validator; // 1
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-filesize", $"File can't be larger than ${validator.Max.Bytes()}"); // 2
MergeAttribute(context.Attributes, "data-val-filesize-max", validator.Max.ToString()); // 3
}
}
and be done with it. That's... it. When I wrote this code and looked back at it, I was half-relieved and half-disappointed, to be perfectly honest. All my issues were resolved by the cast to non-generic interface [1], as that allowed me to pull the Max
property out of the validator [2].
But, let's do one more thing. The validation message is contained within our server-side validator, after all, so let's try to pull it out of there instead of creating a new error message of our own:
private string GetErrorMessage(IFileSizeValidator lengthVal, ModelValidationContextBase context) {
var cfg = context.ActionContext.HttpContext.RequestServices.GetRequiredService<ValidatorConfiguration>(); // 1
var formatter = cfg.MessageFormatterFactory() // 2
.AppendPropertyName(Rule.GetDisplayName(null))
.AppendArgument("MaxFilesize", lengthVal.Max.Bytes());
string message;
try {
message = Component.GetUnformattedErrorMessage();
}
catch (NullReferenceException) {
message = "Maximum file size is {MaxFilesize}."; // 3
}
message = formatter.BuildMessage(message);
return message;
}
All in all, it grabs the validation config [1] to provide you with a formatter [2], the rest is constructing the message. It looks like boilerplate cruft, but remember that Fluent Validations have i18n support, so it's needed to decide what language to use, for example.
Then, of course, a fallback message [3] and we can return it and edit one of the attributes in our validator to
MergeAttribute(context.Attributes, "data-val-filesize", GetErrorMessage(validator, context));
Registering it
Client-side validators need to be explicitly registered and tied to their server-side counterparts. That is also where the non-generic interface comes in clutch. Let's head over to Startup.ConfigureServices()
. You probably have a bit of code something like this in there:
services.AddFluentValidation(options =>
{
options.RegisterValidatorsFromAssemblyContaining<Startup>();
})
so that you can register the validators. We need to modify it a bit to register the client-side validator as well:
services.AddFluentValidation(options =>
{
options.RegisterValidatorsFromAssemblyContaining<Startup>();
options.ConfigureClientsideValidation(clientside => // 1
{
clientside.ClientValidatorFactories[typeof(IFileSizeValidator)] = (_, rule, component) => // 2
new FileSizeClientValidator(rule, component); // 3
});
})
- We need to configure the client-side validators, duh
- Here's where the non-generic interface steps in. Using
FileSizeValidator<>
will not work, the interface is necessary. - And here's our brand new client-side validator.
And we are finally done!
Afterword
It was a journey. Journey through archived websites, through source code, through flames, dust, and despair. But, in the end, it turns out that yet again it's the smallest things. A single interface with barely a property inside was all that was needed to complete the task.
Top comments (0)