14

I reproduced the issue I am having in a brand new MVC Web API project.

This is the default code with a slight modification.

public string Get(int? id, int? something = null)
{
    var isValid = ModelState.IsValid;
    return "value";
}

If you go to http://localhost/api/values/5?something=123 then this works fine, and isValid is true.

If you go to http://localhost/api/values/5?something= then isValid is false.

The issue I am having is that if you provide a null or omitted value for an item that is nullable, the ModelState.IsValid flags a validation error saying "A value is required but was not present in the request."

The ModelState dictionary also looks like this:

enter image description here

with two entries for something, one nullable, which I am not sure if it is significant or not.

Any idea how I can fix this so that the model is valid when nullable parameters are omitted or provided as null? I am using model validation within my web api and it breaks it if every method with a nullable parameter generates model errors.

NibblyPig
  • 51,118
  • 72
  • 200
  • 356
  • Potentially useful info here: http://stackoverflow.com/a/11922978/3130094 - presumably you are seeing this situation because rather than leaving out the `something` parameter altogether you are adding it and assigning nothing – Jamie Dunstan Sep 30 '15 at 08:51
  • I did try messing about the json serializer in my original project, I am not sure how to set it to ignore in this project though as I am not making json requests but using the browser (which I believe is different but could be wrong?) – NibblyPig Sep 30 '15 at 08:58
  • asp.net 4.5.2 if that helps. I added `config.Formatters.JsonFormatter.SerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore };` to tell it to ignore the serialization and deserialization of null values but this didn't work. I don't know if `something=` is treated as `null` or `"" (empty string)` though, or if this serializer is even used for my test operations. – NibblyPig Sep 30 '15 at 09:03
  • 1
    Looking at the errors, there's 2 params, but errors against **three** params. If you pass the url as : "/test/5?something.Nullable`1=" - then it passes validation. Looks like you'll need to add a custom binding provider. – freedomn-m Sep 30 '15 at 09:05
  • @SLC Does the JsonSerializer do anything here, the parameter is provided as url parameter and not via post as json. so this setting does nothing on your problem – Jehof Sep 30 '15 at 09:09
  • If you drill into the kvps, only the nullable something has an error associated with it, not that that really helps. I think you're right and custom binding may be the only way, which is unfortunate but should be simple: any nullable parameters that have a null value just remove the model error – NibblyPig Sep 30 '15 at 09:10
  • @freedomn-m I can confirm the behavior – Jehof Sep 30 '15 at 09:11
  • @SLC, `JsonFormatter` has nothing to do here. We're talking about parameter binding of `GET` requests. – haim770 Sep 30 '15 at 12:02
  • I got a similar problem. I found out that it was cause by routes so that can be a case for few people. – yogihosting Feb 24 '17 at 20:25

4 Answers4

8

It appears that the default binding model doesn't fully understand nullable types. As seen in the question, it gives three parameter errors rather than the expected two.

You can get around this with a custom nullable model binder:

Model Binder

public class NullableIntModelBinder : IModelBinder
{
    public bool BindModel(System.Web.Http.Controllers.HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(int?))
        {
            return false;
        }

        ValueProviderResult val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (val == null)
        {
            return false;
        }

        string rawvalue = val.RawValue as string;

        // Not supplied : /test/5
        if (rawvalue == null)
        {
            bindingContext.Model = null;
            return true;
        }

        // Provided but with no value : /test/5?something=
        if (rawvalue == string.Empty)
        {
            bindingContext.Model = null;
            return true;
        }

        // Provided with a value : /test/5?something=1
        int result;
        if (int.TryParse(rawvalue, out result))
        {
            bindingContext.Model = result;
            return true;
        }

        bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Cannot convert value to int");
        return false;
    }
}

Usage

public ModelStateDictionary Get(
    int? id, 
    [ModelBinder(typeof(NullableIntModelBinder))]int? something = null)
{
    var isValid = ModelState.IsValid;

    return ModelState;
}

Adapted from the asp.net page: http://www.asp.net/web-api/overview/formats-and-model-binding/parameter-binding-in-aspnet-web-api for further reading and an alternative method to set it at the class(controller) level rather than per parameter.

This handles the 3 valid scenarios:

/test/5
/test/5?something=
/test/5?something=2

this first give "something" as null. Anything else (eg ?something=x) gives an error.

If you change the signature to

int? somthing

(ie remove = null) then you must explicitly provide the parameter, ie /test/5 will not be a valid route unless you tweak your routes as well.

freedomn-m
  • 27,664
  • 8
  • 35
  • 57
2

You'll have to register a custom model-binder for nullable types as the default binder is calling the validator for nullable parameters as well, and the latter considers those empty values as invalid.

The Model Binder:

public class NullableModelBinder<T> : System.Web.Http.ModelBinding.IModelBinder where T : struct
{
    private static readonly TypeConverter converter = TypeDescriptor.GetConverter( typeof( T ) );

    public bool BindModel( HttpActionContext actionContext, System.Web.Http.ModelBinding.ModelBindingContext bindingContext )
    {
        var val = bindingContext.ValueProvider.GetValue( bindingContext.ModelName );

        // Cast value to string but when it fails we must not suppress the validation
        if ( !( val?.RawValue is string rawVal ) ) return false;

        // If the string contains a valid value we can convert it and complete the binding
        if ( converter.IsValid( rawVal ) )
        {
            bindingContext.Model = converter.ConvertFromString( rawVal );
            return true;
        }

        // If the string does contain data it cannot be nullable T and we must not suppress this error
        if ( !string.IsNullOrWhiteSpace( rawVal ) ) return false;

        // String is empty and allowed due to it being a nullable type
        bindingContext.ValidationNode.SuppressValidation = true;
        return false;
    }
}

Registration:

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // ...

        var provider = new SimpleModelBinderProvider(typeof(int?), new NullableModelBinder<int>());
        config.Services.Insert(typeof(ModelBinderProvider), 0, provider);

        // ...
    }
}
Sc0tTy
  • 636
  • 5
  • 12
haim770
  • 48,394
  • 7
  • 105
  • 133
0

Remove the default null value from the second parameter. The model binder will set it to null if it's something other than int.

Kim
  • 829
  • 7
  • 12
  • This doesn't affect it, it was just something I tried to see if it would fix the problem that I left in – NibblyPig Sep 30 '15 at 08:46
  • What if you don't specify the parameter at all in the GET? As in: GET /api/values/5 – Kim Sep 30 '15 at 08:49
  • Very interesting, isValid is `true` when omitted. If I remove the `=null` default though, it doesn't match the route. This explains a fair bit. – NibblyPig Sep 30 '15 at 08:56
  • When you have an empty URL value with a prefix, it is treated as a null. – Stuart Sep 30 '15 at 08:58
0

I've found a working workaround for me (just exclude null values from data being sent - as opposed to sending values as nulls).

See https://stackoverflow.com/a/66712465/908608

infografnet
  • 3,749
  • 1
  • 35
  • 35