I'm seeing a JSON deserialization problem which I can not explain or fix.
public class Model
{
public List<ItemModel> items { get; set; }
}
public class ItemModel
{
public int sid { get; set; }
public string name { get; set; }
public DataModel data { get; set; }
public List<ItemModel> items { get; set; }
}
public class DataModel
{
public double? d1 { get; set; }
public double? d2 { get; set; }
public double? d3 { get; set; }
}
public ActionResult Save(int id, Model model) {
}
{'items':[{'sid':3157,'name':'a name','items':[{'sid':3158,'name':'child name','data':{'d1':2,'d2':null,'d3':2}}]}]}
var jss = new JavaScriptSerializer();
var m = jss.Deserialize<Model>(json);
Assert.Equal(2, m.items.First().items.First().data.d1);
the same JSON string, when sent to the Save
action, doesn't get deserialized the same way, specially the D1, D2, and D3 values are all set to NULL. Always.
What's going on here, and how can I fix it?
Model binding is a simplistic way to correlate C# code with an HTTP request. The model binding applies to transforming the HTTP request data in the query's form string and form collection of the action method parameters. We can consider these parameters to be primitive type or complex type.
MVC doesn't use data bindings like old web api. You have to use model bindings in a MVC or MVVM approach.
Bind AttributeThe [Bind] attribute will let you specify the exact properties of a model should include or exclude in binding. In the following example, the Edit() action method will only bind StudentId and StudentName properties of the Student model class. Example: Binding Parameters.
It might sound counter-intuitive, but you should send those doubles as strings in the json:
'data':{'d1':'2','d2':null,'d3':'2'}
Here is my complete test code that invokes this controller action using AJAX, and allows binding to every value of the model:
$.ajax({
url: '@Url.Action("save", new { id = 123 })',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
items: [
{
sid: 3157,
name: 'a name',
items: [
{
sid: 3158,
name: 'child name',
data: {
d1: "2",
d2: null,
d3: "2"
}
}
]
}
]
}),
success: function (result) {
// ...
}
});
And just to illustrate the extent of the problem of trying to deserialize numeric types from JSON, let's take a few examples:
public double? Foo { get; set; }
{ foo: 2 }
=> Foo = null{ foo: 2.0 }
=> Foo = null{ foo: 2.5 }
=> Foo = null{ foo: '2.5' }
=> Foo = 2.5public float? Foo { get; set; }
{ foo: 2 }
=> Foo = null{ foo: 2.0 }
=> Foo = null{ foo: 2.5 }
=> Foo = null{ foo: '2.5' }
=> Foo = 2.5public decimal? Foo { get; set; }
{ foo: 2 }
=> Foo = null{ foo: 2.0 }
=> Foo = null{ foo: 2.5 }
=> Foo = 2.5{ foo: '2.5' }
=> Foo = 2.5Now let's do the same with non-nullable types:
public double Foo { get; set; }
{ foo: 2 }
=> Foo = 2.0{ foo: 2.0 }
=> Foo = 2.0{ foo: 2.5 }
=> Foo = 2.5{ foo: '2.5' }
=> Foo = 2.5public float Foo { get; set; }
{ foo: 2 }
=> Foo = 2.0{ foo: 2.0 }
=> Foo = 2.0{ foo: 2.5 }
=> Foo = 2.5{ foo: '2.5' }
=> Foo = 2.5public decimal Foo { get; set; }
{ foo: 2 }
=> Foo = 0{ foo: 2.0 }
=> Foo = 0{ foo: 2.5 }
=> Foo = 2.5{ foo: '2.5' }
=> Foo = 2.5Conclusion: deserializing numeric types from JSON is one big hell-of-a mess. Use strings in the JSON. And of course, when you use strings, be careful with the decimal separator as it is culture dependent.
I have been asked in the comments section why this passes unit tests, but doesn't work in ASP.NET MVC. The answer is simple: It's because ASP.NET MVC does many more things than a simple call to a JavaScriptSerializer.Deserialize
, which is what the unit test does. So you are basically comparing apples to oranges.
Let's dive deeper into what happens. In ASP.NET MVC 3 there's a built-in JsonValueProviderFactory
which internally uses the JavaScriptDeserializer
class to deserialize the JSON. This works, as you have already seen, in the unit test. But there's much more to it in ASP.NET MVC, as it also uses a default model binder that is responsible for instantiating your action parameters.
And if you look at the source code of ASP.NET MVC 3, and more specifically the DefaultModelBinder.cs class, you will notice the following method which is invoked for each property that will have a value to be set:
public class DefaultModelBinder : IModelBinder {
...............
[SuppressMessage("Microsoft.Globalization", "CA1304:SpecifyCultureInfo", MessageId = "System.Web.Mvc.ValueProviderResult.ConvertTo(System.Type)", Justification = "The target object should make the correct culture determination, not this method.")]
[SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We're recording this exception so that we can act on it later.")]
private static object ConvertProviderResult(ModelStateDictionary modelState, string modelStateKey, ValueProviderResult valueProviderResult, Type destinationType) {
try {
object convertedValue = valueProviderResult.ConvertTo(destinationType);
return convertedValue;
}
catch (Exception ex) {
modelState.AddModelError(modelStateKey, ex);
return null;
}
}
...............
}
Let's focus more specifically on the following line:
object convertedValue = valueProviderResult.ConvertTo(destinationType);
If we suppose that you had a property of type Nullable<double>
, here's what this would look like when you debug your application:
destinationType = typeof(double?);
No surprises here. Our destination type is double?
because that's what we used in our view model.
Then take a look at the valueProviderResult
:
See this RawValue
property out there? Can you guess its type?
So this method simply throws an exception because it obviously cannot convert the decimal
value of 2.5
to a double?
.
Do you notice what value is returned in this case? That's why you end up with null
in your model.
That's very easy to verify. Simply inspect the ModelState.IsValid
property inside your controller action and you will notice that it is false
. And when you inspect the model error that was added to the model state you will see this:
The parameter conversion from type 'System.Decimal' to type 'System.Nullable`1[[System.Double, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]' failed because no type converter can convert between these types.
You may now ask, "But why is the RawValue property inside the ValueProviderResult of type decimal?". Once again the answer lies inside the ASP.NET MVC 3 source code (yeah, you should have downloaded it by now). Let's take a look at the JsonValueProviderFactory.cs
file, and more specifically the GetDeserializedObject
method:
public sealed class JsonValueProviderFactory : ValueProviderFactory {
............
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();
object jsonData = serializer.DeserializeObject(bodyText);
return jsonData;
}
............
}
Do you notice the following line:
JavaScriptSerializer serializer = new JavaScriptSerializer();
object jsonData = serializer.DeserializeObject(bodyText);
Can you guess what the following snippet will print on your console?
var serializer = new JavaScriptSerializer();
var jsonData = (IDictionary<string, object>)serializer
.DeserializeObject("{\"foo\":2.5}");
Console.WriteLine(jsonData["foo"].GetType());
Yep, you guessed it right, it's a decimal
.
You may now ask, "But why did they use the serializer.DeserializeObject method instead of serializer.Deserialize as in my unit test?" It's because the ASP.NET MVC team made the design decision to implement JSON request binding using a ValueProviderFactory
, which doesn't know the type of your model.
See now how your unit test is completely different than what really happens under the covers of ASP.NET MVC 3? Which normally should explain why it passes, and why the controller action doesn't get a correct model value?
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With