Search code examples
c#validationasp.net-core

ASP.NET Core [Require] non-nullable types


Here, the question was posed how to validate non-nullable required types.

The provided solution to make the field nullable like the following is not desirable in my case.

[Required]
public int? Data { get; set; }

How can you change the behavior to instead make the following fail validation in the cases where the field is omitted from the request.

[Required]
public int Data { get; set; }

I have tried a custom validator, but these do not have information about the raw value and only see the default 0 value. I have also tried a custom model binder but it seems to work at the level of the entire request model instead of the integer fields which a want. My binder experiment looks like this:

public class RequiredIntBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(int))
            throw new InvalidOperationException($"{nameof(RequiredIntBinder)} can only be applied to integer properties");

        var value = bindingContext.ValueProvider.GetValue(bindingContext.BinderModelName);
        if (value == ValueProviderResult.None)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        return new SimpleTypeModelBinder(bindingContext.ModelType).BindModelAsync(bindingContext);
    }
}

public class RequiredIntBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.ModelType == typeof(int))
        {
            return new BinderTypeModelBinder(typeof(RequiredIntBinder));
        }

        return null;
    }
}

and is registered with mvc like this

options.ModelBinderProviders.Insert(0, new RequiredIntBinderProvider());

but the model binder is never used. I feel like I might be close but cannot connect the last dots.


Solution

  • Solution working with json requests

    You cannot validate an already created model instance, because a non-nullable property has always a value (no matter whether it was assigned from json or is a default value). The solution is to report the missing value already during deserialization.

    Create a contract resolver

    public class RequiredPropertiesContractResolver : DefaultContractResolver
    {
        protected override JsonObjectContract CreateObjectContract(Type objectType)
        {
            var contract = base.CreateObjectContract(objectType);
    
            foreach (var contractProperty in contract.Properties)
            {
                if (contractProperty.PropertyType.IsValueType
                    && contractProperty.AttributeProvider.GetAttributes(typeof(RequiredAttribute), inherit: true).Any())
                {
                    contractProperty.Required = Required.Always;
                }
            }
    
            return contract;
        }
    }
    

    and then assign it to SerializerSettings:

    services.AddMvc()
            .AddJsonOptions(jsonOptions =>
            {
                jsonOptions.SerializerSettings.ContractResolver = new RequiredPropertiesContractResolver();
            });
    

    The ModelState is then invalid for non-nullable properties with the [Required] attribute if the value is missing from json.


    Example

    Json body

    var jsonBody = @"{ Data2=123 }"
    

    is invalid for model

    class Model
    {
        [Required]
        public int Data { get; set; }
    
        public int Data2 { get; set; }
    }