Skip to content

Instantly share code, notes, and snippets.

@stamminator
Last active February 4, 2022 17:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stamminator/770747a889ff9848d02fb8a138d29885 to your computer and use it in GitHub Desktop.
Save stamminator/770747a889ff9848d02fb8a138d29885 to your computer and use it in GitHub Desktop.
Using System.Text.Json serialization in ASP.NET MVC 5

This gist shows the various pieces needed to implement System.Text.Json instead of System.Web.Script.Serialization.JavaScriptSerializer or Newsonsoft.Json in ASP.NET MVC 5. We need to make the replacement in two places: the model binding for deserializing requests, and JsonResult for serializing responses.

For the model binding, implement a custom Value Provider to replace the default JsonValueProviderFactory.

CustomJsonValueProviderFactory.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.Globalization;
using System.Text.Json;
using System.Web.Mvc;

/// <summary>
/// Adaptation of ASP.NET's default JsonValueProviderFactory (<see href="https://github.com/aspnet/AspNetWebStack/blob/main/src/System.Web.Mvc/JsonValueProviderFactory.cs"/>) 
/// that uses <c>System.Text.Json</c> instead of <c>System.Web.Script.Serialization.JavaScriptSerializer</c>.
/// </summary>
public class CustomJsonValueProviderFactory : ValueProviderFactory
{
    private static JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions();

    static CustomJsonValueProviderFactory()
    {
        _jsonSerializerOptions.Converters.Add(new DictionaryStringObjectJsonConverter());
    }

    private static void AddToBackingStore(EntryLimitedDictionary 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.Add(prefix, value);
    }

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

        Dictionary<string, object> result = null;

        if (controllerContext.HttpContext.Request.InputStream.Length > 0)
        {
            result = JsonSerializer.Deserialize<Dictionary<string, object>>
            (
                controllerContext.HttpContext.Request.InputStream,
                _jsonSerializerOptions
            );
        }

        return result;
    }

    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);
        EntryLimitedDictionary backingStoreWrapper = new EntryLimitedDictionary(backingStore);
        AddToBackingStore(backingStoreWrapper, String.Empty, jsonData);
        var dicVPro = new DictionaryValueProvider<object>(backingStore, CultureInfo.CurrentCulture);
        return dicVPro;
    }

    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;
    }

    private class EntryLimitedDictionary
    {
        private static int _maximumDepth = GetMaximumDepth();
        private readonly IDictionary<string, object> _innerDictionary;
        private int _itemCount = 0;

        public EntryLimitedDictionary(IDictionary<string, object> innerDictionary)
        {
            _innerDictionary = innerDictionary;
        }

        public void Add(string key, object value)
        {
            if (++_itemCount > _maximumDepth)
            {
                throw new InvalidOperationException("The JSON request was too large to be deserialized.");
            }

            _innerDictionary.Add(key, value);
        }

        private static int GetMaximumDepth()
        {
            NameValueCollection appSettings = ConfigurationManager.AppSettings;
            if (appSettings != null)
            {
                string[] valueArray = appSettings.GetValues("aspnet:MaxJsonDeserializerMembers");
                if (valueArray != null && valueArray.Length > 0)
                {
                    int result;
                    if (Int32.TryParse(valueArray[0], out result))
                    {
                        return result;
                    }
                }
            }

            return 1000; // Fallback default
        }
    }
}

The static constructor in the above code sets a custom convertor to be used by JsonSerializer which prevents its default behavior of wrapping the deserialized values in a JsonElement. This default behavior breaks the AddToBackingStore implementation, which expects each value to directly be the type inferred by the deserializer. For full compatibility, rather than reengineering this, we'll make JsonSerializer mimic System.Web.Script.Serialization.JavaScriptSerializer's behavior. The issue and fixed implementation are described in Josef Ottossen's blog post.

DictionaryStringObjectJsonConverter.cs

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Overrides System.Text.Json's default deserialization behavior for the value of JSON key/values so that JsonElement is not used,
/// but rather is deserialized directly to the inferred type.
/// Source: <see href="https://josef.codes/custom-dictionary-string-object-jsonconverter-for-system-text-json"/>
/// </summary>
public class DictionaryStringObjectJsonConverter : JsonConverter<Dictionary<string, object>>
{
    public override Dictionary<string, object> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException($"JsonTokenType was of type {reader.TokenType}, only objects are supported");
        }

        var dictionary = new Dictionary<string, object>();
        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                return dictionary;
            }

            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException("JsonTokenType was not PropertyName");
            }

            var propertyName = reader.GetString();

            if (string.IsNullOrWhiteSpace(propertyName))
            {
                throw new JsonException("Failed to get property name");
            }

            reader.Read();

            dictionary.Add(propertyName, ExtractValue(ref reader, options));
        }

        return dictionary;
    }

    public override void Write(Utf8JsonWriter writer, Dictionary<string, object> value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, value, options);
    }

    private object ExtractValue(ref Utf8JsonReader reader, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.String:
                if (reader.TryGetDateTime(out var date))
                {
                    return date;
                }
                return reader.GetString();
            case JsonTokenType.False:
                return false;
            case JsonTokenType.True:
                return true;
            case JsonTokenType.Null:
                return null;
            case JsonTokenType.Number:
                if (reader.TryGetInt64(out var result))
                {
                    return result;
                }
                return reader.GetDecimal();
            case JsonTokenType.StartObject:
                return Read(ref reader, null, options);
            case JsonTokenType.StartArray:
                var list = new List<object>();
                while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
                {
                    list.Add(ExtractValue(ref reader, options));
                }
                return list;
            default:
                throw new JsonException($"'{reader.TokenType}' is not supported");
        }
    }
}

Finally, in your application's Application_Start method, we need to replace the default model binder with our own.

public void Application_Start(object sender, EventArgs e)
{
    ...
    ValueProviderFactories.Factories.Remove(ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().FirstOrDefault());
    ValueProviderFactories.Factories.Add(new CustomJsonValueProviderFactory());
    ...
}

For serializing our responses, we need to implement our own JsonResult which uses S.T.J.JsonSerializer.

JsonResultCustom.cs

using System;
using System.Web;
using System.Web.Mvc;
using System.Text.Json;

/// <summary>
/// A <see cref="JsonResult"/> implementation that uses System.Text.Json to perform the serialization.
/// </summary>
public class JsonResultCustom : JsonResult
{
    public JsonResultCustom()
    {
        JsonRequestBehavior = JsonRequestBehavior.DenyGet;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }

        if (JsonRequestBehavior == JsonRequestBehavior.DenyGet
            && String.Equals(context.HttpContext.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException("This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet.");
        }

        HttpResponseBase response = context.HttpContext.Response;

        response.ContentType = "application/json";

        if (ContentEncoding != null)
            response.ContentEncoding = ContentEncoding;

        if (Data != null)
        {
            response.Write(JsonSerializer.Serialize(this.Data));
        }
    }
}

Now we will override the two Json() method overloads in System.Web.Mvc.Controller to return JsonResultCustom. We will implement a base controller which all of your other controllers should inherit.

BaseController.cs

public abstract class BaseController : Controller
{
    ...
    
    protected override JsonResult Json(object data, string contentType, System.Text.Encoding contentEncoding)
    {
        return new JsonResultCustom
        {
            ContentType = contentType,
            ContentEncoding = contentEncoding,
            Data = data
        };
    }

    protected override JsonResult Json(object data, string contentType, System.Text.Encoding contentEncoding, JsonRequestBehavior behavior)
    {
        return new JsonResultCustom
        {
            ContentType = contentType,
            ContentEncoding = contentEncoding,
            Data = data,
            JsonRequestBehavior = behavior
        };
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment