FluentValidation with Blazor static server-side rendering (static SSR) on HTTP GET

Pier-Luc Bonneville
Pier-Luc Bonneville
Being technical is important at the leadership level in a world where IT is eating the world.
Dec 10, 2023 7 min read
thumbnail for this post
Photo by Joshua Harris

Let’s see how to use FluentValidation with Blazor static server-side rendering (SSR) on HTTP GET requests.

Traditionally, we use FluentValidation (or DataAnnotations) to validate a form on HTTP POST requests. However, we can also use it to validate a form on HTTP GET requests. This is useful when we want to validate a model created by elements provided from the query string.

Prerequisites

Add the FluentValidation NuGet package to the Blazor project.

dotnet add package FluentValidation

The Model

Once we’ve set up FluentValidation, the next step is to create a simple model with three properties: Name and DoB and DoD.

public record Person
{
    public string? Name { get; set; }
    public string? DoB { get; set; }
    public string? DoD { get; set; }

    public DateOnly DoBAsDateOnly => DateOnly.TryParse(DoB, out var date) ? date : default;
    public DateOnly DoDAsDateOnly => DateOnly.TryParse(DoD, out var date) ? date : default;
}

Notice that we have two additional properties: DoBAsDateOnly and DoDAsDateOnly. These properties are used to convert the DoB and DoD properties to DateOnly type. I wanted to have complete control over data validation and not get any errors from the type bindings.

The Validator

Then, let’s create a simple validator for the Person model. We will validate the Name, DoB, and DoD properties.

public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator()
    {
        // ===================================================================
        // Name
        // ===================================================================
        RuleFor(x => x.Name).NotEmpty();

        // ===================================================================
        // Date of birth
        // ===================================================================
        RuleFor(x => x.DoB)
            .NotEmpty().WithName("Date of birth");

        RuleFor(x => x.DoB)
            .Must(x => DateOnly.TryParse(x, out _))
            .WithMessage("Invalid date format for 'Date of birth'.")
            .When(x => !string.IsNullOrWhiteSpace(x.DoB));

        RuleFor(x => x.DoB)
            .Must(x =>
            {
                _ = DateOnly.TryParse(x, out var date);

                return date.DayNumber < DateOnly.FromDateTime(DateTime.Now).DayNumber;
            })
            .WithMessage("The 'Date of birth' date can't be in the future.")
            .When(x => !string.IsNullOrWhiteSpace(x.DoB))
            .When(x => DateOnly.TryParse(x.DoB, out _));

        // ===================================================================
        // Date of death
        // ===================================================================
        RuleFor(x => x.DoD)
            .NotEmpty().WithName("Date of death");

        RuleFor(x => x.DoD)
            .Must(x => DateOnly.TryParse(x, out _))
            .WithMessage("Invalid date format for 'Date of death'.")
            .When(x => !string.IsNullOrWhiteSpace(x.DoD));

        RuleFor(x => x.DoD)
            .Must(x =>
            {
                _ = DateOnly.TryParse(x, out var date);

                return date.DayNumber < DateOnly.FromDateTime(DateTime.Now).DayNumber;
            })
            .WithMessage("The 'Date of death' can't be in the future.")
            .When(x => !string.IsNullOrWhiteSpace(x.DoD))
            .When(x => DateOnly.TryParse(x.DoD, out _));

        RuleFor(x => x.DoD)
            .Must((x, _) => x.DoDAsDateOnly.DayNumber > x.DoDAsDateOnly.DayNumber)
            .WithMessage("The 'Date of death' needs to be greater than the 'Date of birth'.")
            .When(x => DateOnly.TryParse(x.DoB, out _) && DateOnly.TryParse(x.DoD, out _));
    }
}

The Razor Component

Now for the simple form with our three input fields: Name, DoB, and DoD.

We’ll use the Enhance attribute on the EditForm component to enable enhance navigation for our form POST requests saving us a full page reload.

We’ll also need to supply the EditContext to the EditForm component. We will use the EnableFluentValidations extension method to enable FluentValidation’s validation for our form in the OnInitialized method.

The <ValidationSummary /> component will display the validation errors for the form and the <ValidationMessage /> components will display the validation errors for each input field.

@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<EditForm OnValidSubmit="Submit" FormName="PersonForm" EditContext="_editContext" Enhance>
    <ValidationSummary />

    <div class="mb-3">
        <label for="@($"{nameof(Person)}.{nameof(Person.Name)}")" class="required form-label">Name <strong class="required">(required)</strong></label>
        <ValidationMessage For="@(() => Person.Name)" />
        <InputText @bind-Value="Person.Name" id="@($"{nameof(Person)}.{nameof(Person.Name)}")" class="form-control" />
    </div>

    <div class="mb-3">
        <label for="@($"{nameof(Person)}.{nameof(Person.DoB)}")" class="required form-label">Date of birth <strong class="required">(required)</strong></label>
        <ValidationMessage For="@(() => Person.DoB)" />
        <InputText type="date" @bind-Value="Person.DoB" id="@($"{nameof(Person)}.{nameof(Person.DoB)}")" class="form-control" placeholder="yyyy-mm-dd" />
    </div>

    <div class="mb-3">
        <label for="@($"{nameof(Person)}.{nameof(Person.DoD)}")" class="required form-label">Date of death <strong class="required">(required)</strong></label>
        <ValidationMessage For="@(() => Person.DoD)" />
        <InputText type="date" @bind-Value="Person.DoD" id="@($"{nameof(Person)}.{nameof(Person.DoD)}")" class="form-control" placeholder="yyyy-mm-dd" />
    </div>

    <button type="submit" class="btn btn-primary">Search</button>
</EditForm>

The Code-Behind

  1. In the code behind, we’ll use the SupplyParameterFromForm attribute on the Person property to supply the Person model from the form.
  2. We will use the SupplyParameterFromQuery attribute on the N, B, and D properties to supply the query string values to the Person model in the OnParametersSet method.
  3. We will use the EnableFluentValidations extension method to enable FluentValidation’s validation for our form in the OnInitialized method.
  4. On HTTP GET requests, we will validate and assign the query string values to the Person model in the OnParametersSet method and then, if any of the fields are not empty, validate the form using the EditContext.Validate(); method.
public partial class Home
{
    private EditContext? _editContext;

    [SupplyParameterFromForm]
    public Person Person { get; set; } = new();

    [SupplyParameterFromQuery(Name = "n")]
    public string? Name { get; set; }

    [SupplyParameterFromQuery(Name = "b")]
    public string? DoB { get; set; }

    [SupplyParameterFromQuery(Name = "d")]
    public string? DoD { get; set; }

    [Inject]
    public IServiceProvider ServiceProvider { get; set; } = null!;

    [Inject]
    public ILogger<Home> Logger { get; set; } = null!;

    [Inject]
    public NavigationManager NavigationManager { get; set; } = null!;

    [Inject]
    public IHttpContextAccessor HttpContextAccessor { get; set; } = null!;

    protected override void OnInitialized()
    {
        _editContext = new(Person);
        _editContext.EnableFluentValidations<PersonValidator>(ServiceProvider);

        base.OnInitialized();
    }

    protected override void OnParametersSet()
    {
        if (HttpContextAccessor.HttpContext!.Request.Method != "GET")
        {
            return;
        }

        // Assign the query string values to the Person model
        Person.Name = Name;
        Person.DoB = DoB;
        Person.DoD = DoD;

        // If any of the fields are not empty, validate the form
        if (new[] { Name, DoB, DoD }.Any(x => !string.IsNullOrWhiteSpace(x)))
        {
            _editContext!.Validate();
        }
    }

    private void Submit()
    {
        Logger.LogInformation("Submit: {Name}, {DoB} {DoD}", Person.Name, Person.DoB, Person.DoD);

        var qs = QueryString.Create(new Dictionary<string, string?>
        {
            ["n"] = Person.Name,
            ["b"] = Person.DoB,
            ["d"] = Person.DoD
        });

        // Redirect to the same page with the query string (PRG pattern)
        NavigationManager.NavigateTo($"/{qs}");
    }
}

The FluentValidationsEventSubscriptions class

Here is the class called FluentValidationsEventSubscriptions that will handle the OnFieldChanged and OnValidationRequested events for the EditContext class.

public sealed class FluentValidationsEventSubscriptions<TValidator> : IDisposable
    where TValidator : IValidator
{
    private readonly EditContext _editContext;
    private readonly IServiceProvider? _serviceProvider;
    private readonly ValidationMessageStore _messages;
    private readonly IValidator _validator;

    public FluentValidationsEventSubscriptions(EditContext editContext, IServiceProvider serviceProvider)
    {
        _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
        _messages = new ValidationMessageStore(_editContext);

        _editContext.OnFieldChanged += OnFieldChanged;
        _editContext.OnValidationRequested += OnValidationRequested;

        _validator = _serviceProvider.GetRequiredService<TValidator>();
    }

    private void OnFieldChanged(object? sender, FieldChangedEventArgs args)
    {
        var fieldIdentifier = args.FieldIdentifier;
        _messages.Clear(fieldIdentifier);

        var propertiesToValidate = new string[] { fieldIdentifier.FieldName };
        var fluentValidationContext =
            new ValidationContext<object>(
                instanceToValidate: fieldIdentifier.Model,
                propertyChain: new FluentValidation.Internal.PropertyChain(),
                validatorSelector: new FluentValidation.Internal.MemberNameValidatorSelector(propertiesToValidate)
            );

        var validationResult = _validator.Validate(fluentValidationContext);

        AddValidationResult(fieldIdentifier.Model, validationResult);
    }

    private void OnValidationRequested(object? sender, ValidationRequestedEventArgs args)
    {
        _messages.Clear();

        var validationContext = new ValidationContext<object>(_editContext.Model);
        var validationResult = _validator.Validate(validationContext);

        AddValidationResult(_editContext.Model, validationResult);
    }

    public void Dispose()
    {
        _messages.Clear();

        _editContext.OnFieldChanged -= OnFieldChanged;
        _editContext.OnValidationRequested -= OnValidationRequested;
        _editContext.NotifyValidationStateChanged();
    }

    private void AddValidationResult(object model, ValidationResult validationResult)
    {
        foreach (ValidationFailure error in validationResult.Errors)
        {
            var fieldIdentifier = new FieldIdentifier(model, error.PropertyName);
            _messages.Add(fieldIdentifier, error.ErrorMessage);
        }

        _editContext.NotifyValidationStateChanged();
    }
}

The EnableFluentValidations extension method

Let’s create an extension method called EnableFluentValidationsValidation that will enable FluentValidation validation on our form.

public static class EditContextFluentValidationsExtensions
{
    /// <summary>
    /// Enables FluentValidation validation support for the <see cref="EditContext"/>.
    /// </summary>
    /// <typeparam name="TValidator">The validator type that implements <see cref="IValidator"/>.</typeparam>
    /// <param name="editContext">The <see cref="EditContext"/>.</param>
    /// <param name="serviceProvider">The <see cref="IServiceProvider"/> to be used in the <see cref="ValidationContext"/>.</param>
    /// <returns>A disposable object whose disposal will remove FluentValidations validation support from the <see cref="EditContext"/>.</returns>
    public static IDisposable EnableFluentValidations<TValidator>(this EditContext editContext, IServiceProvider serviceProvider)
        where TValidator : IValidator
    {
        ArgumentNullException.ThrowIfNull(serviceProvider);
        return new FluentValidationsEventSubscriptions<TValidator>(editContext, serviceProvider);
    }
}

Remark that we’ll need to pass the extension method an instance of IServiceProvider so that it can resolve the IValidator instance from the generic type parameter.

_editContext.EnableFluentValidations<PersonValidator>(ServiceProvider);

DI Services Registration

Register the IHttpContextAccessor service and any of your validators in the Program class.

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PersonValidator>();

That’s it! We can now use FluentValidation with Blazor static server-side rendering (SSR) on HTTP GET and HTTP POST requests.

Conclusion

In conclusion, using FluentValidation with Blazor static server-side rendering on HTTP GET requests offers a significant shift in how we traditionally handle form validation, normally only on HTTP POST requests. It can provides a more flexible way to validate a model created from query string elements. This technique is particularly useful in scenarios where you need to validate data before it’s passed into the Blazor component render method.

As we’ve seen, setting up FluentValidation for this purpose is a straightforward process that can be easily integrated into your existing Blazor projects. It’s another tool in your .NET toolbox that can help make your applications more robust and user-friendly.