TLDR; source code available on github

We came across a situation where we needed some additional data to perform validation on the payload being sent via an HTTP post. Essentially it involved calling up some setup rules from a database and comparing the payload passed in against those rules to ensure the payload was valid. We had the following requirements:

  1. Retrieve data for use by validation logic using async/await api
  2. Leverage existing validation pipeline. i.e. no custom validation logic in controller
  3. Remain transparent to users of the validation attributes

The problem

Out of the box the System.Component model validation attribute provides a way to access a service locator using the validationContext.GetService(typeof()) api however this is not an async api. Executing an async operation here would cause us to wait synchronously which was a show stopper.

A great post by Andrew Lock got us most of the way there however this method does not allow for asynchronous operations and prevented us meeting the first requirement.

We invesitgated using Filters however the filter pipeline is executed after model binding and validation takes place which prevented us from meeting requirement 2 and 3.

We dug into the AspNetCore code base and found that intercepting the model binding step was possible and would give us what we needed.

Custom Model Binders

We found that we could intercept the model binding by implementing a custom IModelBinder. In AspNetCore, model binding happens asynchronoulsy so this gave us the async hook to go and fetch the additional data required for validation.

Writing a full model binder for complex objects is non-trivial and there was no way I was going to take that on. Instead I figured we could intercept and proxy the call to the original model binder. This will require some additional logic to wire it all up.

Custom Model Binding

IModelBinder

The custom model binder implementation is straight forward.

public class CustomValidationModelBinder : IModelBinder
{
  private readonly IModelBinder _underlyingModelBinder;

  public CustomValidationModelBinder(IModelBinder underlyingModelBinder)
  {
    _underlyingModelBinder = underlyingModelBinder;
  }

  public async Task BindModelAsync(ModelBindingContext bindingContext)
  {
    // Perform model binding using original model binder
    await _underlyingModelBinder.BindModelAsync(bindingContext).ConfigureAwait(false);

    // If model binding failed don't continue
    if (bindingContext.Result.Model == null)
    {
        return;
    }

    // Perform some additional work after model binding occurs but before validation is executed.
    // i.e. fetch some additional data to be used by validation
  }
}

IModelBinderProvider

We need to tell the Mvc framework how to create an instance of our custom model binder. To do this we need to implement an IModelBinderProvider. This too is straight forward:

public class CustomValidationModelBinderProvider : IModelBinderProvider
{
  private readonly IModelBinderProvider _underlyingModelBinderProvider;

  public CustomValidationModelBinderProvider(IModelBinderProvider underlyingModelBinderProvider)
  {
    _underlyingModelBinderProvider = underlyingModelBinderProvider;
  }

  public IModelBinder GetBinder(ModelBinderProviderContext context)
  {
    var underlyingModelBinderProvider = _underlyingModelBinderProvider.GetBinder(context);
    return new CustomValidationModelBinder(underlyingModelBinderProvider);
  }
}

Hooking it up

To hook this up to the Mvc framework we can create an extension method to be called by the Startup.cs class.

public static void UseRiskDataModelBindingProvider(this MvcOptions opts)
{
  var underlyingModelBinder = opts.ModelBinderProviders.FirstOrDefault(x => x.GetType() == typeof(BodyModelBinderProvider));

  if (underlyingModelBinder == null)
  {
      return;
  }

  var index = opts.ModelBinderProviders.IndexOf(underlyingModelBinder);
  opts.ModelBinderProviders.Insert(index, new CustomValidationModelBinderProvider(underlyingModelBinder));
}

This is called in Startup.cs.

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddMvc(opts => opts.UseRiskDataModelBindingProvider()).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
  ...
}

Providing context

We still need to get the additional data to the validation attributes. To achieve this we make use of the ability to resolve services within the attribute using validationContext.GetService. The key is to preload the data (or valiadtion context) asynchrously and provide a way for the attribute to get hold of the validation context synchronously. We can create a provider mechanism to achieve this.

For this example, when given a model which contains a name and a list of items, we want to be sure that those values are contained within some pre-defined data stored away in a database or service.

Then given a model which looks like this

public class ValuesModel
{
  [Required]
  [IsValidName]
  public string Name { get; set; }

  [Required]
  [ContainsValidItems]
  public List<string> Items { get; set; }
}

We will need data which represents the valid choices for name and items. The context may appear as follows:

public class CustomValidationContext
{
    public CustomValidationContext(ICollection<string> validNames, 
        ICollection<string> validItems)
    {
        ValidNames = validNames;
        ValidItems = validItems;
    }

    public ICollection<string> ValidNames { get; }

    public ICollection<string> ValidItems { get; }
}

Validation Context Provider

The provider is simply a class which provides acess to the validation context instance. It could be implemented as follows.

public class CustomValidationContextProvider
{
  private CustomValidationContext _context;

  public CustomValidationContext Current
  {
    get
    {
      if (_context == null)
      {
          throw new InvalidOperationException("The custom validation context has not been initialized. Ensure that the CustomValidationModelBinder is being used.");
      }

      return _context;
    }
  }

  internal void Set(CustomValidationContext context)
  {
    if (_context != null)
    {
      throw new InvalidOperationException("Custom validation context has already been set.");
    }

    _context = context;
  }
}

Fetching the data

We need a way to fetch the data for our custom validation. This can be done using a DbContext or a service call. For this example we have created a simple validation context factory for brevity. This does nothing but return some sample data using async/await immediately fulfilled task.

public class CustomValidationContextFactory
{
  public Task<CustomValidationContext> Create()
  {
    return Task.FromResult(new CustomValidationContext(new [] { "a", "b", "c" }, new[] {"1", "2", "3"}));
  }
}

Register the services

We need to make these services available to the IoC container. We can do this as follows.

public static class ServiceCollectionExtensions
{
  public static void AddCustomValidation(this IServiceCollection services)
  {
    services.Add(new ServiceDescriptor(typeof(CustomValidationContextFactory), typeof(CustomValidationContextFactory), ServiceLifetime.Scoped));
    services.Add(new ServiceDescriptor(typeof(CustomValidationContextProvider), typeof(CustomValidationContextProvider), ServiceLifetime.Scoped));
  }
}

This is called in Startup.cs

public void ConfigureServices(IServiceCollection services)
{
  ...
  services.AddCustomValidation();
  ...
}

Wire up validation logic

So to make the data avaible to the custom validators we need to complete the IModelBinder implementation. Using service location we can get hold of our custom context factory and provider to create an instance of CustomValidationContext and register it with the provider.

Since we have access to the original HttpRequest we could use parameters from that request when creating the CustomValidationContext which can be usefull!

The implementation appears as follows:

public class CustomValidationModelBinder : IModelBinder
{
  private readonly IModelBinder _underlyingModelBinder;

  public CustomValidationModelBinder(IModelBinder underlyingModelBinder)
  {
    _underlyingModelBinder = underlyingModelBinder;
  }

  public async Task BindModelAsync(ModelBindingContext bindingContext)
  {
    await _underlyingModelBinder.BindModelAsync(bindingContext).ConfigureAwait(false);

    // If model binding failed don't continue
    if (bindingContext.Result.Model == null)
    {
        return;
    }

    // Wire up the validation context using async methods
    var customValidationContextFactory = (CustomValidationContextFactory)bindingContext.HttpContext.RequestServices.GetService(typeof(CustomValidationContextFactory));
    var customValidationContextProvider = (CustomValidationContextProvider)bindingContext.HttpContext.RequestServices.GetService(typeof(CustomValidationContextProvider));
    var customValidationContext = await customValidationContextFactory.Create();
    customValidationContextProvider.Set(customValidationContext);
  }
}

Custom Validation Attributes

For convenience we can create a base class which is responsible for fishing out the CustomValidationContextProvider to get hold of the CustomValidationContext instance and make it available within the IsValid method of the validation attribute.

public abstract class CustomValidationBaseAttribute : ValidationAttribute
{
  protected sealed override ValidationResult IsValid(object value, ValidationContext validationContext)
  {
    var customValidationContextProvider = (CustomValidationContextProvider)validationContext.GetService(typeof(CustomValidationContextProvider));

    if (customValidationContextProvider == null)
    {
        throw new InvalidOperationException("The custom validation context provider has not been registered");
    }

    return IsValid(value, customValidationContextProvider.Current, validationContext);
  }

  protected abstract ValidationResult IsValid(object value, CustomValidationContext customValidationContext, ValidationContext validationContext);
}

Custom Validators

And finally… we are able to implement our custom validation logic using the additonal validation context to do so.

Given a model and action as follows:

public class ValuesModel
{
  [Required]
  [IsValidName]
  public string Name { get; set; }

  [Required]
  [ContainsValidItems]
  public List<string> Items { get; set; }
}
[HttpPost]
public Task<IActionResult> Post(ValuesModel value)
{
  // Do some stuff with your valid instance of value
}

We can implement the custom validation attributes [IsValidName] and [ContainsValidItems] respectively.

public class IsValidNameAttribute : CustomValidationBaseAttribute
{
  protected override ValidationResult IsValid(object value, CustomValidationContext customValidationContext,
      ValidationContext validationContext)
  {
    var name = value as string;

    if (!string.IsNullOrEmpty(name) && !customValidationContext.ValidNames.Contains(name))
    {
        return new ValidationResult($"{name} is an invalid value. It must be one of {string.Join(", ", customValidationContext.ValidNames)}");
    }

    return ValidationResult.Success;
  }
}
public class ContainsValidItemsAttribute : CustomValidationBaseAttribute
{
  protected override ValidationResult IsValid(object value, CustomValidationContext customValidationContext,
    ValidationContext validationContext)
  {
    if (value is ICollection<string> items && items.Any(item => !customValidationContext.ValidItems.Contains(item)))
    {
        return new ValidationResult($"Items contains invalid values. It must be any of {string.Join(", ", customValidationContext.ValidItems)}");
    }

    return ValidationResult.Success;
  }
}

Conclusion

We had to jump through a number of hoops to get this right and it feels like it should have been easier. Being required to create the validation context asynchronously is what threw the spanner in the works for us. If you know of a simpler solution to this problem I would love to hear from you!

Here is a gift for reading all the way to the end.

Leave a comment


Implementing Versioning & Audit Trails with SQL Server Temporal Tables and .Net Core

Inevitably when building enterprise software you will be required to implement audit trail and/or versioning functionality. At its core, ...… Continue reading

Resource Scheduling Apps with RavenDB 4.0

Published on March 03, 2018