Let's assume we have the following simple ajax-call:
$.ajax({
url: "/somecontroller/someaction",
data: JSON.stringify({
someString1: "",
someString2: null,
someArray1: [],
someArray2: null
}),
method: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8"
})
.done(function (response) {
console.log(response);
});
The ajax call targets an action of an asp.net controller. The asp.net website has default ("factory") settings when it comes to the handling json-serialization with the only tweak being that Newtonsoft.Json.dll is installed via nuget and thus the web.config contains the following section:
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" />
</dependentAssembly>
The configuration sections for both webapi and mvc inside global.asax.cs have remained as they where. Having said all this, I noticed that if the controller 'somecontroller' is a webapi controller:
public class FooController : ApiController
{
public class Some
{
public string SomeString1 { get; set; }
public string SomeString2 { get; set; }
public long[] SomeArray1 { get; set; }
public long[] SomeArray2 { get; set; }
}
[HttpPost]
public IHttpActionResult Bar([FromBody] Some entity)
{
return Ok(new {ping1 = (string) null, ping2 = "", ping3 = new long[0]});
}
}
then the data received in the c# world inside the 'someaction' method are like so:
entity.someString1: "",
entity.someString2: null,
entity.someArray1: [],
entity.someArray2: null
However, if the controller is an mvc controller (mvc4 to be precise):
public class FooController : System.Web.Mvc.Controller
{
public class Some
{
public string SomeString1 { get; set; }
public string SomeString2 { get; set; }
public long[] SomeArray1 { get; set; }
public long[] SomeArray2 { get; set; }
}
[HttpPost]
public System.Web.Mvc.JsonResult Bar([FromBody] Some entity)
{
return Json(new { ping1 = (string)null, ping2 = "", ping3 = new long[0] });
}
}
then the data received in the csharp world inside the method look like so:
entity.someString1: null,
entity.someString2: null,
entity.someArray1: null,
entity.someArray2: null
It's apparent that there is a deviation between webapi and mvc controllers in terms of how deserialization of parameters works both when it comes to empty arrays and empty strings. I have managed to work around the quirks of the MVC controller so as to enforce the "webapi" behaviour both for empty strings and empty arrays (I will post my solution at the end for completeness).
My question is this:
Why does this deviation in regards to deserialization exist in the first place?
I can't come to terms that it was done merely for the sake of "convenience" given how much room the default mvc-settings leave for bugs that are just nerve-racking to discern and fix clearly and consistently at the action/dto-level.
Addendum: For anyone interested here's how I forced the mvc controller to behave the "webapi" way when it comes to deserializing parameters before feeding them into the action-methods:
//inside Application_Start
ModelBinders.Binders.DefaultBinder = new CustomModelBinder_Mvc();
ValueProviderFactories.Factories.Remove(
ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().FirstOrDefault()
);
ValueProviderFactories.Factories.Add(new JsonNetValueProviderFactory_Mvc());
Utility classes:
using System.Web.Mvc;
namespace Project.Utilities
{
public sealed class CustomModelBinder_Mvc : DefaultModelBinder //0
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
bindingContext.ModelMetadata.ConvertEmptyStringToNull = false;
Binders = new ModelBinderDictionary { DefaultBinder = this };
return base.BindModel(controllerContext, bindingContext);
}
}
//0 respect empty ajaxstrings aka "{ foo: '' }" gets converted to foo="" instead of null http://stackoverflow.com/a/12734370/863651
}
And
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using System.Globalization;
using System.IO;
using System.Web.Mvc;
using IValueProvider = System.Web.Mvc.IValueProvider;
// ReSharper disable RedundantCast
namespace Project.Utilities
{
public sealed class JsonNetValueProviderFactory_Mvc : ValueProviderFactory //parameter deserializer
{
public override IValueProvider GetValueProvider(ControllerContext controllerContext)
{
if (controllerContext == null)
throw new ArgumentNullException(nameof(controllerContext));
if (!controllerContext.HttpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
return null;
var jsonReader = new JsonTextReader(new StreamReader(controllerContext.HttpContext.Request.InputStream));
if (!jsonReader.Read())
return null;
var jsonObject = jsonReader.TokenType == JsonToken.StartArray //0
? (object)JsonSerializer.Deserialize<List<ExpandoObject>>(jsonReader)
: (object)JsonSerializer.Deserialize<ExpandoObject>(jsonReader);
return new DictionaryValueProvider<object>(AddToBackingStore(jsonObject), InvariantCulture); //1
}
private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture;
private static readonly JsonSerializer JsonSerializer = new JsonSerializer //newtonsoft
{
Converters =
{
new ExpandoObjectConverter(),
new IsoDateTimeConverter {Culture = InvariantCulture}
}
};
//0 use jsonnet to deserialize object to a dynamic expando object if we start with a [ treat this as an array
//1 return the object in a dictionary value provider which mvc can understand
private static IDictionary<string, object> AddToBackingStore(object value, string prefix = "", IDictionary<string, object> backingStore = null)
{
backingStore = backingStore ?? new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
var d = value as IDictionary<string, object>;
if (d != null)
{
foreach (var entry in d)
{
AddToBackingStore(entry.Value, MakePropertyKey(prefix, entry.Key), backingStore);
}
return backingStore;
}
var l = value as IList;
if (l != null)
{
if (l.Count == 0) //0 here be dragons
{
backingStore[prefix] = new object[0]; //0 here be dragons
}
else
{
for (var i = 0; i < l.Count; i++)
{
AddToBackingStore(l[i], MakeArrayKey(prefix, i), backingStore);
}
}
return backingStore;
}
backingStore[prefix] = value;
return backingStore;
}
private static string MakeArrayKey(string prefix, int index) => $"{prefix}[{index.ToString(CultureInfo.InvariantCulture)}]";
private static string MakePropertyKey(string prefix, string propertyName) => string.IsNullOrEmpty(prefix) ? propertyName : $"{prefix}.{propertyName}";
}
//0 here be dragons its vital to deserialize empty jsarrays "{ foo: [] }" to empty csharp array aka new object[0]
//0 here be dragons without this tweak we would get null which is completely wrong
}