It's been a while since I've had a programming issue that warrants a blog post, but here's an interesting one.
First, let me set up the situation. I have an MVC project (MVC version 5.0) that communicates to RESTful services using WebAPI (version 5.1). In one particular view, I write out an object in JSON with the intent that the client can make changes to it and POST it back to an MVC action to get an HTML table back. Here's the model:
public class PaymentScheduleRequestModel {
[JsonProperty(PropertyName = "effDt", NullValueHandling = NullValueHandling.Ignore)]
public DateTime EffectiveDate { get; set; }
[JsonProperty(PropertyName = "termId", NullValueHandling = NullValueHandling.Ignore)]
public Nullable<int> ContractTermId { get; set; }
[JsonProperty(PropertyName = "billDay", NullValueHandling = NullValueHandling.Ignore)]
public int BillingDayOfMonth { get; set; }
[JsonProperty(PropertyName = "freq", NullValueHandling = NullValueHandling.Ignore)]
public PaymentScheduleFrequencyId PaymentScheduleFrequencyId { get; set; }
[JsonProperty(PropertyName = "prem", NullValueHandling = NullValueHandling.Ignore)]
public decimal TotalPremium { get; set; }
}
(Note that the JsonProperty decorators exist because the same model is used to communicate to the WebAPI service -- we are using shorter property names to lighten the payload.)
And the MVC action that is set up to process it looks like this:
[HttpPost] public async Task<ActionResult> NewPaymentSchedule(PaymentScheduleRequestModel request) { … }
To put it in JavaScript, the view has this block of code:
window.ScriptModel = @Html.Raw(Json.Encode(new {
PaymentScheduleUrl = Url.Action("NewPaymentSchedule", "Contract"),
PaymentScheduleRequestModel = new PaymentScheduleRequestModel {
ContractTermId = Model.ContractTermId,
EffectiveDate = Model.StartDate ?? DateTime.Now,
TotalPremium = 200m
}
}));
Unfortunately, Json.Encode doesn't work well with dates. This is the output that the browser sees (with line breaks added for legibility):
window.ScriptModel={
"PaymentScheduleUrl":"/Contract/NewPaymentSchedule",
"PaymentScheduleRequestModel":{
"EffectiveDate":"\/Date(1078729200000)\/",
"ContractTermId":null,
"BillingDayOfMonth":0,
"PaymentScheduleFrequencyId":0,
"TotalPremium":200
}
};
Notice two things about System.Web.Helpers.Json.Encode's output:
- The property names are the .Net property names. The same code could not be used to post what should be the same model to MVC or to WebAPI.
- The EffectiveDate field has been converted to a Date function, enclosed in a string. Even if the Date function would reveal the correct value, the fact that it's in a string means JavaScript will not see it as a Date.
If I don't do anything with this object and just post it back to the MVC app, the resulting object does not have a valid date — all other values carry over, but the EffectiveDate property is 01/01/0001. So not only does it look odd, not only is it inconvenient in that JavaScript can't use it as-is (without picking apart the string), but it doesn't even work for round-tripping data to and from the client.
In doing some research on this topic, I came across Scott Hanselman's blog post describing the problem, and stating that the release of WebAPI won't have the issue since it will use JSON.Net (a.k.a. Newtonsoft.Json). Since I'm using the same model in an WebAPI call further downstream, I can verify that it does work as intended. It uses the JsonProperty decorators to rename the properties, and it serializes and deserializes like magic.
To solve this problem in MVC, you have to alter how it deals with JSON on the way out and on the way in.
On the way out is easy in my case, since I am manually spitting out JSON into the HTML. I just exchanged Json.Encode with Newtonsoft's serializer:
window.ScriptModel = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(new {
PaymentScheduleUrl = Url.Action("NewPaymentSchedule", "Contract"),
PaymentScheduleRequestModel = new PaymentScheduleRequestModel {
ContractTermId = Model.ContractTermId,
EffectiveDate = Model.StartDate ?? DateTime.Now,
TotalPremium = 200m
}
}));
And the browser sees:
window.ScriptModel={
"PaymentScheduleUrl":"/Contract/NewPaymentSchedule",
"PaymentScheduleRequestModel":{
"effDt":"2004-03-08T00:00:00",
"billDay":0,
"freq":0,
"prem":200
}
};
This is better. Of course, the controller action doesn't understand this. It's still looking for the .Net property names, and, since they don't exist on the incoming object, all values come back empty. (In this simple example, not only is EffectiveDate 01/01/0001, but TotalPremium is 0.0.)
The trick here is to override MVC's default model binder, so that it, too, uses the Newtonsoft.Json library. It is also consistent to have MVC use a value provider factory that also uses JSON.Net.
Fortunately, people smarter than I figured out these two steps. I found a value provider factory on this blog: http://www.dalsoft.co.uk/blog/index.php/2012/01/10/asp-net-mvc-3-improved-jsonvalueproviderfactory-using-json-net/
and the important piece, the model binder, that will translate the JSON property names to their real .Net names, is detailed here: http://stackoverflow.com/questions/4164114/posting-json-data-to-asp-net-mvc
My implementation looks like this:
And this code gets called from Application_Start (I actually added it to my WebApiConfig class (which, for some reason, exists in my MVC app even though it's obviously not the same as WebAPI), since other configuration-type things were being done here):
ValueProviderFactories.Factories.Remove(ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().FirstOrDefault());
ValueProviderFactories.Factories.Add(new JsonDotNetValueProviderFactory());
ModelBinders.Binders.DefaultBinder = new JsonDotNetDefaultModelBinder();
All this because, although Microsoft updated one of their web interfaces (WebAPI) to use the JSON.Net library that works, another one of their interfaces (MVC) uses their own, broken, JSON serialization library.
1 comment:
Note that I found an issue with parameterized routes. When posting JSON to a controller action where the route includes parameters, the model binder is called on each parameter, and this model binder attempts to bind the posted JSON to the parameters.
Example:
[HttpPost][Route("Customer/{customerId:int}/Vehicle/{vehicleId:int}/Policy/Create"]
public async Task Create(int customerId, int vehicleId, PolicyRequest policyRequest)
An error is thrown on the first (of three) calls to BindModel, as it tries to bind the JSON to customerId and throws the error "Error reading integer. Unexpected token: StartObject. Path '', line 1, position 1."
FromUri/FromBody attributes don't work here, probably because they're WebAPI attributes that MVC doesn't process. (Or maybe it does process them, but we end up in BindModel anyway -- either way, the problem remains.)
I've added some code to the top of BindModel that checks to see if the ValueProvider has assigned a value to the current ModelName (which it will when it resolves the route), and skips it instead of trying to deserialize the JSON.
Post a Comment