RequiredIf - Extend the validation in Blazor

15/02/2022

As often I am writing about topics I encounter in my daily life. So just imagine you have a blog... like this one you are reading right now. This has some properties. Simplified your model could look like this:

public class BlogPostModel
{
    [Required]
    public string Title { get; set; }

    [Required]
    public string ShortDescription { get; set; }

    [Required]
    public string Content { get; set; }

    [Required]
    public string PreviewImageUrl { get; set; }

    [Required]
    public bool IsPublished { get; set; } = true;
}

That looks very nice. Basically the user (in this case: me) has to fill up all the properties otherwise the form will not get submitted. But what if the blog post gets not published IsPublished == false. Do we still have to provide all the information? Unfortunately yes. Blazor does not offer us any possibility to give the Required attribute any context. If Blazor doesn't, then we do!

RequiredIf

The goal is that we want to express:

Content is only mandatory, if we publish the blog post!

So let us start with the notation. I want something like this:

public class BlogPostModel
{
    [Required]
    public string Title { get; set; }
    
    [RequiredIf(nameof(IsPublished), true)]
    public string Content { get; set; }
    
    [Required]
    public bool IsPublished { get; set; }
}

Now here a few things:

  • Title is still mandatory. I don't want to change that
  • Content should be only required if IsPublished is true

Why the nameof notation. Well attributes are nice and cool but they lag some key features. For example generics. An attribute is not allowed to have generics. So we have to workaround that. To be able to be used for validation we have to at least extend ValidationAttribute. Have a look at the documentation here. You can see that there are a lot of derived attributes.

So let's go, the constructor is pretty much done:

public class RequiredIfAttribute : ValidationAttribute
{
    private readonly string _propertyName;
    private readonly object? _isValue;

    public RequiredIfAttribute(string propertyName, object? isValue)
    {
        _propertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
        _isValue = isValue;
    }

Well that was easy.. the heavy lifting will follow. But first let us override the error message. We want to indicate in the error message as well, that there is some dependencies. We can do so by overriding FormatErrorMessage

public override string FormatErrorMessage(string name)
{
    var errorMessage = $"Property {name} is required when {_propertyName} is {_isValue}";
    return ErrorMessage ?? errorMessage;
}

In our case with the blog post, we want to say: Property content is required when IsPublished is not True.

Now the central function: ValidationResult? IsValid(object? value, ValidationContext validationContext). (Here for more information) This one gets called for every property with a RequiredAttribute. So this is our point to jump in and do the heavy work.

The first thing we want to do is: Check if we have every information. That also includes, does our propertyName which we got via nameof(IsPublished) really exist?

protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
    ArgumentNullException.ThrowIfNull(validationContext);
    var property = validationContext.ObjectType.GetProperty(_propertyName);

    if (property == null)
    {
        throw new NotSupportedException($"Can't find {_propertyName} on searched type: {validationContext.ObjectType.Name}");
    }

I guess the only line which needs a bit more explanation is the following: var property = validationContext.ObjectType.GetProperty(_propertyName);

The ValidationContext.ObjectType holds our model (BlogPostModel). That is important because we need to resolve the dependency to our property. Therefore we need the type and also the concrete instance to get the actual value of IsPublished. So this snippet gives us the type information.

var requiredIfTypeActualValue = property.GetValue(validationContext.ObjectInstance);

if (requiredIfTypeActualValue == null && _isValue != null)
{
    return ValidationResult.Success;
}

var requiredIfNotTypeActualValue = property.GetValue(validationContext.ObjectInstance); does exactly what I wrote before. We want to get the real value of our RequiredIf "pointer". Again in our case we want to have IsPublished.

Afterwards we make a quick check the actual value is null and the expected is not null. The both would be null, we would consider the property as required. If not we can go out here and can say: "Everything fine!". And now exactly that is left: What if the property is required? Well then we just have to check if it is set or not:

if (requiredIfTypeActualValue == null || requiredIfTypeActualValue.Equals(_isValue))
{
    return value == null
        ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName))
        : ValidationResult.Success;
}

return ValidationResult.Success;

The whole if does nothing more than checking, if our IsPublished is either null or the required value. If so, we check if value is null. If so that would be a violation and leads to an error result. Why did I use requiredIfTypeActualValue.Equals(_isValue) instead of requiredIfTypeActualValue == _isValue. If you check the types, they are all objects. That means == would check for ReferenceEquals. Not so great if we use bool.

Now if you just want to use this and more: I am currently building a library which houses such helper functionality. I am also planning to add dependencies outside of the model.

If you want to see the code plus tests: Here you go on github. And here directly the nuget package.

The whole Thing

using System.ComponentModel.DataAnnotations;

namespace LinkDotNet.ValidationExtensions;

public class RequiredIfAttribute : ValidationAttribute
{
    private readonly string _propertyName;
    private readonly object? _isValue;

    public RequiredIfAttribute(string propertyName, object? isValue)
    {
        _propertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
        _isValue = isValue;
    }

    public override string FormatErrorMessage(string name)
    {
        var errorMessage = $"Property {name} is required when {_propertyName} is {_isValue}";
        return ErrorMessage ?? errorMessage;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        ArgumentNullException.ThrowIfNull(validationContext);
        var property = validationContext.ObjectType.GetProperty(_propertyName);

        if (property == null)
        {
            throw new NotSupportedException($"Can't find {_propertyName} on searched type: {validationContext.ObjectType.Name}");
        }

        var requiredIfTypeActualValue = property.GetValue(validationContext.ObjectInstance);

        if (requiredIfTypeActualValue == null && _isValue != null)
        {
            return ValidationResult.Success;
        }

        if (requiredIfTypeActualValue == null || requiredIfTypeActualValue.Equals(_isValue))
        {
            return value == null
                ? new ValidationResult(FormatErrorMessage(validationContext.DisplayName))
                : ValidationResult.Success;
        }

        return ValidationResult.Success;
    }
}
1
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x