DEV Community

Angius
Angius

Posted on

Fluent Validator for File Size With Client-side Validation

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}.";
}
Enter fullscreen mode Exit fullscreen mode

Let's quickly go over it:

  1. 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.
  2. Constructor, that much is obvious
  3. Override of the method where we will check the validity
  4. Override of the property that gives us the validator name
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

And is used, as promised, in a very easy way:

RuleFor(r => r.Avatar)
    .FileSmallerThan(100 * 1024); // 100 KB
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The search led me to CodePlex, and I wasn't even on the 2nd page of Google
  2. I asked twice in total in four different Discord servers, to no avail
  3. My posts on r/csharp and r/dotnet remain unanswered to this day
  4. My Stack Overflow question didn't get an answer either
  5. The only lead I had was this comment from February 2020
  6. 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)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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>();
})
Enter fullscreen mode Exit fullscreen mode

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
    });
})
Enter fullscreen mode Exit fullscreen mode
  1. We need to configure the client-side validators, duh
  2. Here's where the non-generic interface steps in. Using FileSizeValidator<> will not work, the interface is necessary.
  3. 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.

Discussion (0)