12

I made a test website to debug an issue I'm having, and it appears that either I'm passing in the JSON data wrong or MVC just can't bind nullable longs. I'm using the latest MVC 3 release, of course.

public class GetDataModel
{
    public string TestString { get; set; }
    public long? TestLong { get; set; }
    public int? TestInt { get; set; }
}

[HttpPost]
public ActionResult GetData(GetDataModel model)
{
    // Do stuff
}

I'm posting a JSON string with the correct JSON content type:

{ "TestString":"test", "TestLong":12345, "TestInt":123 }

The long isn't bound, it's always null. It works if I put the value in quotes, but I shouldn't have to do that, should I? Do I need to have a custom model binder for that value?

Edgar
  • 4,348
  • 4
  • 40
  • 59

5 Answers5

4

I created a testproject just to test this. I put your code into my HomeController and added this to index.cshtml:

<script type="text/javascript">
    $(function () {
        $.post('Home/GetData', { "TestString": "test", "TestLong": 12345, "TestInt": 123 });
    });
</script>

I put a breakpoint in the GetData method, and the values were binded to the model like they should:

enter image description here

So I think there's something wrong with the way you send the values. Are you sure the "TestLong" value is actually sent over the wire? You can check this using Fiddler.

fretje
  • 8,322
  • 2
  • 49
  • 61
  • I was making the request with Fiddler already. They are being sent. And I can see it arrive if I read the request input stream on the server. This is annoying me. Is there anything else I can debug? – Edgar May 20 '11 at 07:12
  • Wait, your code doesn't post JSON, which is what I was doing. That's probably why it works. – Edgar May 20 '11 at 08:01
  • Looks like there's an issue with the MVC model binder for JSON. If I make the long number bigger than an int can handle, it gets bound correctly. I guess if it's small enough for an int, it guesses it's an int and tries to cast it to a long?, but can't. Need a custom converter? – Edgar May 20 '11 at 08:34
  • @Ed: You're right, I didn't realize the json object gets automatically converted to a querystring... strange when I try with `$.post('Home/GetData', { TestString: 'test', TestLong: 12345, TestInt: 123 }, function (data) { }, 'json');` the data still gets converted to a querystring... what am I doing wrong? – fretje May 20 '11 at 08:51
  • Ok, I found that I have to `JSON.stringify` the javascript object, and then it gets sent like it should: `{"TestString":"test","TestLong":12345,"TestInt":123}`. Only now all the properties are null where my breakpoint gets hit... I'm still doing something wrong... – fretje May 20 '11 at 08:59
  • `$.ajax({ url: 'Home/GetData', type: 'POST', contentType: 'application/json', data: JSON.stringify({ "TestString": "test", "TestLong": 12345, "TestInt": 123 }), dataType: 'json' });` – Edgar May 20 '11 at 09:14
  • @Ed, ok got it... on to debugging the source code now... but will be for a bit later... have to go now. – fretje May 20 '11 at 09:17
3

If you don't want to go with Regex and you only care about fixing long?, the following will also fix the problem:

public class JsonModelBinder : DefaultModelBinder {     
  public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor, IModelBinder propertyBinder)  
  {
        var propertyType = propertyDescriptor.PropertyType;
        if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            var provider = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (provider != null 
                && provider.RawValue != null 
                && Type.GetTypeCode(provider.RawValue.GetType()) == TypeCode.Int32) 
            {
                var value = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize(provider.AttemptedValue, bindingContext.ModelMetadata.ModelType);
                return value;
            }
        } 

        return base.GetPropertyValue(controllerContext, bindingContext, propertyDescriptor, propertyBinder);
  }
}
Daniel
  • 920
  • 1
  • 11
  • 22
  • Thanks. I came across this problem in an older MVC3 project and your solution worked. The method that is being overridden needs to be `GetPropertyValue` instead of `BindModel` – Bill Oct 24 '13 at 07:36
2

You can use this model binder class

public class LongModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (string.IsNullOrEmpty(valueResult.AttemptedValue))
        {
            return (long?)null;
        }
        var modelState = new ModelState { Value = valueResult };
        object actualValue = null;
        try
        {
            actualValue = Convert.ToInt64(
                valueResult.AttemptedValue,
                CultureInfo.InvariantCulture
            );
        }
        catch (FormatException e)
        {
            modelState.Errors.Add(e);
        }
        bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
        return actualValue;
    }
}

In Global.asax Application_Start add these lines

ModelBinders.Binders.Add(typeof(long), new LongModelBinder());
ModelBinders.Binders.Add(typeof(long?), new LongModelBinder());
user1080381
  • 1,597
  • 1
  • 16
  • 22
2

My colleague came up with a workaround for this. The solution is to take the input stream and use a Regex to wrap all numeric variables in quotes to trick the JavaScriptSerializer into deserialising the longs properly. It's not a perfect solution, but it takes care of the issue.

This is done in a custom model binder. I used Posting JSON Data to ASP.NET MVC as an example. You have to take care, though, if the input stream is accessed anywhere else.

public class JsonModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (!IsJSONRequest(controllerContext))
            return base.BindModel(controllerContext, bindingContext);

        // Get the JSON data that's been posted
        var jsonStringData = new StreamReader(controllerContext.HttpContext.Request.InputStream).ReadToEnd();

        // Wrap numerics
        jsonStringData = Regex.Replace(jsonStringData, @"(?<=:)\s{0,4}(?<num>[\d\.]+)\s{0,4}(?=[,|\]|\}]+)", "\"${num}\"");

        // Use the built-in serializer to do the work for us
        return new JavaScriptSerializer().Deserialize(jsonStringData, bindingContext.ModelMetadata.ModelType);
    }

    private static bool IsJSONRequest(ControllerContext controllerContext)
    {
        var contentType = controllerContext.HttpContext.Request.ContentType;
        return contentType.Contains("application/json");
    }
}

Then put this in the Global:

ModelBinders.Binders.DefaultBinder = new JsonModelBinder();

Now the long gets bound successfully. I would call this a bug in the JavaScriptSerializer. Also note that arrays of longs or nullable longs get bound just fine without the quotes.

Community
  • 1
  • 1
Edgar
  • 4,348
  • 4
  • 40
  • 59
  • 1
    It also appears that the JavaScriptSerializer also doesn't know what to do with a byte[], so it makes it an int[]. – ericb Oct 06 '11 at 21:17
  • 3
    The problem with this approach is that you loose many features of the DefaultModelBinder - such as model validation – Maksymilian Majer Nov 03 '11 at 10:53
1

I wanted to incorporate the solution presented by Edgar but still have the features of the DefaultModelBinder. So instead of creating a new model binder I went with a different approach and replaced the JsonValueProviderFactory with a custom one. There's only a minor change in the code from the original MVC3 source code:

public sealed class NumericJsonValueProviderFactory : ValueProviderFactory
{

    private static void AddToBackingStore(Dictionary<string, object> backingStore, string prefix, object value)
    {
        IDictionary<string, object> d = value as IDictionary<string, object>;
        if (d != null)
        {
            foreach (KeyValuePair<string, object> entry in d)
            {
                AddToBackingStore(backingStore, MakePropertyKey(prefix, entry.Key), entry.Value);
            }
            return;
        }

        IList l = value as IList;
        if (l != null)
        {
            for (int i = 0; i < l.Count; i++)
            {
                AddToBackingStore(backingStore, MakeArrayKey(prefix, i), l[i]);
            }
            return;
        }

        // primitive
        backingStore[prefix] = value;
    }

    private static object GetDeserializedObject(ControllerContext controllerContext)
    {
        if (!controllerContext.HttpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
        {
            // not JSON request
            return null;
        }

        StreamReader reader = new StreamReader(controllerContext.HttpContext.Request.InputStream);
        string bodyText = reader.ReadToEnd();
        if (String.IsNullOrEmpty(bodyText))
        {
            // no JSON data
            return null;
        }

        JavaScriptSerializer serializer = new JavaScriptSerializer();

        // below is the code that Edgar proposed and the only change to original source code
        bodyText = Regex.Replace(bodyText, @"(?<=:)\s{0,4}(?<num>[\d\.]+)\s{0,4}(?=[,|\]|\}]+)", "\"${num}\""); 

        object jsonData = serializer.DeserializeObject(bodyText);
        return jsonData;
    }

    public override IValueProvider GetValueProvider(ControllerContext controllerContext)
    {
        if (controllerContext == null)
        {
            throw new ArgumentNullException("controllerContext");
        }

        object jsonData = GetDeserializedObject(controllerContext);
        if (jsonData == null)
        {
            return null;
        }

        Dictionary<string, object> backingStore = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
        AddToBackingStore(backingStore, String.Empty, jsonData);
        return new DictionaryValueProvider<object>(backingStore, CultureInfo.CurrentCulture);
    }

    private static string MakeArrayKey(string prefix, int index)
    {
        return prefix + "[" + index.ToString(CultureInfo.InvariantCulture) + "]";
    }

    private static string MakePropertyKey(string prefix, string propertyName)
    {
        return (String.IsNullOrEmpty(prefix)) ? propertyName : prefix + "." + propertyName;
    }
}

Then to register the new value provider you need to add the following lines to your Global.asax:

ValueProviderFactories.Factories.Remove(ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().FirstOrDefault());
ValueProviderFactories.Factories.Add(new NumericJsonValueProviderFactory());
Maksymilian Majer
  • 2,956
  • 2
  • 29
  • 42