9

I have strange behaviour of Web API, .Net 4.5.2. If optional string parameter is null, ModelState has no error. If it is not null and not empty, no errors again. But if it is just an empty string I have model state error.

Why do I get it and how to disable it?

Assuming app served on localhost:82 I have those results:

Url: http://localhost:82/
Response: "null"

Url: http://localhost:82/?q=1
Response: "1"

Url: http://localhost:82/?q=
Response: {
  "Message": "The request is invalid.",
  "ModelState": {
    "q.String": [
      "A value is required but was not present in the request."
    ]
  }
}

Test controller and config is below. This is reduced to bare minimum default "Asp.net web application" with "WebApi" in VS2013.

namespace Web.Api.Test.Controllers
{
    using System.Web.Http;

    [Route]
    public class HomeController : ApiController
    {
        [Route]
        [HttpGet]
        public IHttpActionResult Search(string q = default(string))
        {
            return this.ModelState.IsValid
                ? this.Ok(q ?? "null")
                : (IHttpActionResult)this.BadRequest(this.ModelState);
        }
    }
}

Startup.cs is:

using Microsoft.Owin;

using WebApplication1;

[assembly: OwinStartup(typeof(Startup))]

namespace WebApplication1
{
    using System.Web.Http;

    using Newtonsoft.Json;

    using Owin;

    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            GlobalConfiguration.Configure(config =>
            {
                config.MapHttpAttributeRoutes();
                config.Formatters.JsonFormatter.SerializerSettings.Formatting = Formatting.Indented;

                config.Formatters.Remove(config.Formatters.XmlFormatter);
            });
        }
    }
}

PS: This question has a workaround, but it does not answer the main question: why does this situation happen and what reasons are behind this design decision.

7
  • This behavior seems pretty reasonable to me actually. Commented Aug 3, 2015 at 15:14
  • 3
    Why is it reasonable? Commented Aug 3, 2015 at 15:15
  • Because why would you pass in a URL with a parameter declared but not set to anything when you can accomplish the same thing by not specifying the parameter at all? Also if you want to include multiple parameters, then it creates ambiguity. Commented Aug 3, 2015 at 15:17
  • 1
    Because auto-generated client, which is out of my control, does this. And no, it does not create any ambiguities. http://localhost/?q=&k= is perfect and valid url, afaik. Commented Aug 3, 2015 at 15:23
  • What auto-generated client are you using? Commented Aug 3, 2015 at 15:32

6 Answers 6

6

I have had the same issue, came up with the following eventually:

public class SimpleTypeParameterBindingFactory
{
    private readonly TypeConverterModelBinder converterModelBinder = new TypeConverterModelBinder();
    private readonly IEnumerable<ValueProviderFactory> factories;

    public SimpleTypeParameterBindingFactory(HttpConfiguration configuration)
    {
        factories = configuration.Services.GetValueProviderFactories();
    }

    public HttpParameterBinding BindOrNull(HttpParameterDescriptor descriptor)
    {
        return IsSimpleType(descriptor.ParameterType)
            ? new ModelBinderParameterBinding(descriptor, converterModelBinder, factories)
            : null;
    }

    private static bool IsSimpleType(Type type)
    {
        return TypeDescriptor.GetConverter(type).CanConvertFrom(typeof (string));
    }
}

public class Startup
{
    public void Configure(IAppBuilder appBuilder)
    {
        var configuration = new HttpConfiguration();
        configuration.ParameterBindingRules.Insert(0, new SimpleTypeParameterBindingFactory(configuration).BindOrNull);
        configuration.EnsureInitialized();
    }
}

The problem is rooted in some magic code in ModelValidationNode, which creates model errors for null models even if corresponding parameter has default value. The code above just replaces CompositeModelBinder (which calls ModelValidationNode) with TypeConverterModelBinder for simple type parameters.

Sign up to request clarification or add additional context in comments.

Comments

4

Why do I get it and how to disable it?

Don't know why you get it. This maybe how you disable it, but after reading I don't think you want to really as there are simpler solutions, e.g:

Use of a model class solves this in a cleaner way.

public class SearchModel
{
    public string Q { get; set; }
}

public IHttpActionResult Search([FromUri] SearchModel model)
{
    return ModelState.IsValid
        ? Ok(model.Q ?? "null")
        : (IHttpActionResult) BadRequest(ModelState);
}

2 Comments

The problem with this approach is when your url contains no query string params your model is null' :(
Well then just null check also: model != null && ModelState.IsValid
1

That's why:

This is a MVC feature which binds empty strings to nulls.

We have found the same behavior in our application, and deep dive debugging with source code

git clone https://github.com/ASP-NET-MVC/aspnetwebstack

makes sense to search in the right direction. Here the method which set whitespace strings to null, and here the error is added to model state:

if (parentNode == null && ModelMetadata.Model == null)
{
    string trueModelStateKey = ModelBindingHelper.CreatePropertyModelName(ModelStateKey, ModelMetadata.GetDisplayName());
    modelState.AddModelError(trueModelStateKey, SRResources.Validation_ValueNotFound);
    return;
}

IMHO it is a bug. But who cares. We used this workaround

Comments

0

Have you tried [DisplayFormat(ConvertEmptyStringToNull = false)]?

1 Comment

DisplayFormatAttribute is valid only on properties and fields, so I can't use it. But thanks.
0

We found another solution

public class EmptyStringToNullModelBinder : Attribute, IModelBinder
{
    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        ValueProviderResult valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        bindingContext.Model = string.IsNullOrWhiteSpace(valueResult?.RawValue?.ToString()) ? null : valueResult.RawValue;
        return true;
    }
}

and for your case it would be like this:

[Route]
[HttpGet]
public IHttpActionResult Search([FromUri(BinderType = typeof(EmptyStringToNullModelBinder))]string q = null)
{
    return this.ModelState.IsValid
        ? this.Ok(q ?? "null")
        : (IHttpActionResult)this.BadRequest(this.ModelState);
}

Comments

0

code below is adapted version of this answer

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

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

        var rawValue = val.RawValue as string;

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

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

        // Provided with a value : /test/5?something=1
        try
        {
            bindingContext.Model = (T)Convert.ChangeType(val.RawValue, typeof(T));
            return true;
        }
        catch
        {
            //
        }

        bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"Cannot convert value to {typeof(T).Name}");
        return false;
    }
}

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.