4

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
    }
XDS
  • 3,786
  • 2
  • 36
  • 56
  • 1
    Related question: http://stackoverflow.com/questions/3641723/why-do-i-get-null-instead-of-empty-string-when-receiving-post-request-in-from-ra. Not finding any detailed rationale, just a few bits about the default values for reference types in a new instance of a view model being null during model binding. Doesn't really make sense to me either why it was chosen. – Thomas Langston Apr 07 '17 at 15:54
  • Indeed. I was studying such posts where people would beat around the bush with workarounds without hitting the nail on its head by addressing the core issue (aka data corruption going on). I feel as if there's an elephant in the room here. – XDS Apr 11 '17 at 09:20

1 Answers1

3

Why does this deviation in regards to deserialization exist in the first place?

History.

When ASP.NET MVC was first created in 2009, it used the native .NET JavaScriptSerializer class to handle JSON serialization. When Web API came along three years later, the authors decided to switch to using the increasingly popular Json.Net serializer because it was much more robust and full-featured than the older JavaScriptSerializer. However, they apparently felt that they could not change MVC to match for backward compatibility reasons-- existing projects which relied on specific JavaScriptSerializer behaviors would break unexpectedly when upgraded. So, that decision created the discrepancy between MVC and Web API.

In ASP.NET MVC Core, the internals of MVC and Web API have been unified and use Json.Net.

Brian Rogers
  • 125,747
  • 31
  • 299
  • 300
  • I take it that this particular discrepancy persisted through to Core. I'm baffled by the fact that for one this issue is not underlined and highlighted and for another that there is not a single well-documented, official way to enforce the WebAPI behaviour on MVC controllers (think an on-off switch). This would preserve backwards compatibility and would make the lives of developers a hell lot easier. I'm disappointed by the fact that original implementation had such an issue. Microsoft should have known better than knowingly inducing data-corruption in empty arrays and strings (2009 or not). – XDS Apr 07 '17 at 19:13
  • 1
    Yes, MVC and Web API have used different serializers for all non-Core versions to date. However, that discrepancy was eliminated in MVC Core, as I said. (It is my understanding that the `JavaScriptSerializer` class was never even ported over, so there's no chance that MVC uses it.) – Brian Rogers Apr 07 '17 at 20:12