Skip to content

Unexplained custom model binding / validation behaviour #46960

@stevendarby

Description

@stevendarby

Hello, we've seen an unusual thing happen in production which we've never seen before, and I would appreciate any help trying to work it out.

We have some code which inserts a model binder to essentially allow async validation, the results of which are checked later, at the 'true' validation stage. I have tried to create a minimal reproduction of all the key bits - see below.

Given this code, I would not expect ValidationInfo.Results to ever throw the InvalidOperationException when calling the Test controller/action, when it reaches the attribute IsValid method. We have seen this happen once with the production code, among hundreds of thousands of other successful calls at the same endpoint.

I'm wondering if anyone can spot a bug in the code below - perhaps there is something around model binding / validation that is not as deterministic as believed - model binders in a different order, things processed in a different order etc.? Could an error during model binding be swallowed and it continue to the validation stage without setting ValidationInfo.Results?

Sorry if this lacking more detail, we don't have a lot to go on ourselves. We're using .NET 6.

Code
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddScoped<ValidationInfo>()
    .AddControllers(o =>
    {
        var defaultModelBinder = o.ModelBinderProviders.OfType<BodyModelBinderProvider>().FirstOrDefault();
        if (defaultModelBinder == null)
        {
            return;
        }

        o.ModelBinderProviders.Insert(
            o.ModelBinderProviders.IndexOf(defaultModelBinder),
            new CustomModelBinderProvider(defaultModelBinder));
    });


var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

public class CustomModelBinderProvider : IModelBinderProvider
{
    private readonly IModelBinderProvider _underlyingModelBinderProvider;

    public CustomModelBinderProvider(IModelBinderProvider underlyingModelBinderProvider)
        => _underlyingModelBinderProvider = underlyingModelBinderProvider;

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        var isDtoBase = typeof(DtoBase).IsAssignableFrom(context.Metadata.ModelType);

        return isDtoBase
            ? new CustomModelBinder(_underlyingModelBinderProvider.GetBinder(context)!)
            : null;
    }
}

public class CustomModelBinder : IModelBinder
{
    private readonly IModelBinder _underlyingModelBinder;

    public CustomModelBinder(IModelBinder underlyingModelBinder)
        => _underlyingModelBinder = underlyingModelBinder;

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

        if (bindingContext.Result.Model is DtoBase model)
        {
            var validationInfo = bindingContext.HttpContext.RequestServices.GetRequiredService<ValidationInfo>();

            validationInfo.Results = await Task.WhenAll(GetValidValues(model));
        }
    }

    private static IEnumerable<Task<(string Type, ValidationInfoResult Result)>> GetValidValues(DtoBase model)
        => model.GetType()
            .GetProperties()
            .Select(p => new { Property = p, p.GetCustomAttribute<LookupAttribute>()?.Type })
            .Where(x => x.Type != null)
            .Select(x => new { x.Type, Value = x.Property.GetValue(model)?.ToString() })
            .Where(x => x.Value != null)
            .Select(async x => (x.Type!, Result: await ValidateAsync(x.Type!, x.Value!)));

    private static async Task<ValidationInfoResult> ValidateAsync(string type, string value)
    {
        await Task.Delay(Random.Shared.Next(10, 100)); // request to another service
        return new ValidationInfoResult { Value = value, IsValid = Random.Shared.Next(0, 2) == 1 };
    }
}

public class LookupAttribute : ValidationAttribute
{
    public string Type { get; }
    public override bool RequiresValidationContext => true;

    public LookupAttribute(string type)
        => Type = type;

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        var validationInfo = validationContext.GetRequiredService<ValidationInfo>();

        return value is null || validationInfo.Results.Any(x => x.Type == Type && x.Result.Value == value.ToString() && x.Result.IsValid)
            ? ValidationResult.Success
            : new ValidationResult("Invalid value");
    }
}

public class ValidationInfoResult
{
    public string Value { get; init; }
    public bool IsValid { get; init; }
}

public class ValidationInfo
{
    private (string Type, ValidationInfoResult Result)[]? _results;

    public (string Type, ValidationInfoResult Result)[] Results
    {
        get => _results ?? throw new InvalidOperationException("Results has not been set!");
        set => _results = value;
    }
}

public class DtoBase
{
}

public class TestDto : DtoBase
{
    [Lookup("Title")] public string? Title { get; set; }
    [Lookup("Country")] public string? Country { get; set; }
    [Lookup("Status")] public string? Status { get; set; }
}

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpPut]
    public TestDto Test(TestDto dto)
        => dto;
}

Send in anything with values like below. You should get a mix of 200 and 400 based on the random validation but never a 500.

{
    "title": "Ms",
    "country": "UK",
    "status": "OK"
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-mvcIncludes: MVC, Actions and Controllers, Localization, CORS, most templatesfeature-model-binding

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions