Thursday, April 19, 2012

MVC Enum and Nullable object JSON ModelBinder




Warning: This post is quite long with so many unusable code except the last one which is my solution. Stop reading or get your own a cup of tea first :)

- When posting Json data to an MVC action method, you could face a problem if you're using numeric values for enum field instead of string representations of the enum names. There are some posts on stackoverflow.com to solve this problem but I don't really like it. The problem with that approach is that it can not bind nullable enum, array of enum, etc. Indeed, when trying to solve a problem with Model Binders we could have following options:

  • Override the DefaultModelBinder, if the ModelType to bind is the type we want, do it, otherwise delegate the job the the base class.
  • Make a specific ModelBinder for your type.
  • Change the way JsonValueProviderFactory works.
  • Hack TypeDescripter.
  • ... potential other options

- The first approach is the one used in the stackoverflow post. That seems to be the good one because we could have many enums. The "One size fits all" approach supposes to be the good choice. However, let check out other options first.

- The second option is really good if you have a specific type that MVC does not know how to bind such as Mongo ObjectID. However, for enum types, this approach is not very convenient because you have to add following line for every single enum type you have in the project:
ModelBinders.Binders.Add(typeof(YourEnumType), new EnumModelBinder());

- I found the third option when I tried to find different ways to solve my problem without using the code in stackoverflow post. Well, by reading the MVC source code and it's quite interesting to see how MVC team make something virtual but it's not that easy to extend the behavior you need. In short, when you post something to server, the MVC engine will use all registered ValueProviderFactory to get an IValueProvider.

- Obviously, when you post Json, the content type is "application/json" and nothing but the built-in JsonValueProviderFactory will return a IValueProvider which is an object of type DictionaryValueProvider. There is an interesting point here, if you post an array in JSON, something like {selectedFields:[1,2,3]}, JsonValueProviderFactory will put following keys/values to the dictionary:
selectedFields[0] = 1
selectedFields[2] = 2
selectedFields[3] = 3

- That potentialy is an issue if lately you want to retrieve the value of that field because the model name here is selectedFields, not selectedFields[0] nor selectedFields[1]. Anyways, that's the way MVC works and I believe if MVC serialize array that way, it can get the values back properly.

- Continue digging into class DictionaryValueProvider, you will see that it has an "private readonly" Dictionay. ValueProviderResult is the one I said that has method virtual but there is no really simple way to extend behavior just by overriding methods. I realized that if I override method ConverTo, I can fix the Enum binding problem here. Here is the code:
/// <summary>
/// This class is created to fix the Enum parser 
/// </summary>
public class JsonEnumValueProviderResult : ValueProviderResult
{
    public JsonEnumValueProviderResult(object rawValue, string attemptedValue, CultureInfo culture)
        : base(rawValue, attemptedValue, culture)
    {
    }
    public override object ConvertTo(Type type, CultureInfo culture)
    {
        int rawValue;
        var underlyingType = Nullable.GetUnderlyingType(type);

        if ((type.IsEnum || (underlyingType != null && underlyingType.IsEnum)) && int.TryParse(RawValue == null ? "0" : RawValue.ToString(), out rawValue))
        {
            return Enum.ToObject(underlyingType ?? type, rawValue);
        }
        return base.ConvertTo(type, culture);
    }
}

As I said, thing is not that simple. I have to somehow make DictionaryValueProvider use my JsonEnumValueProviderResult as well. And I would have to create this class:
/// <summary>
/// Inherit DictionaryValueProvider, just to return JsonEnumValueProviderResult for method GetValue
/// </summary>
/// <typeparam name="TValue"></typeparam>
public class CustomDictionaryValueProvider<TValue> : DictionaryValueProvider<TValue>
{
    private readonly Dictionary<string, JsonEnumValueProviderResult> _values = new Dictionary<string, JsonEnumValueProviderResult>(StringComparer.OrdinalIgnoreCase);

    public CustomDictionaryValueProvider(IDictionary<string, TValue> dictionary, CultureInfo culture)
        : base(dictionary, culture)
    {
        foreach (var entry in dictionary)
        {
            object rawValue = entry.Value;
            string attemptedValue = Convert.ToString(rawValue, culture);
            _values[entry.Key] = new JsonEnumValueProviderResult(rawValue, attemptedValue, culture);
        }
    }

    public override ValueProviderResult GetValue(string key)
    {
        if (key == null)
        {
            throw new ArgumentNullException("key");
        }

        JsonEnumValueProviderResult vpResult;
        _values.TryGetValue(key, out vpResult);
        return vpResult;
    }

}

Well, that's still not enough. I have to make JsonValueProviderFactory to return my CustomDictionaryValueProvider, either overriding it which is impossible because MVC team sealed this class :( or completly replace it. So if I keen on this approach I have to copy its source code and change only 1 line:
/// <summary>
/// JsonValueProviderFactory is sealed. This class is copied from JsonValueProviderFactory and will try to use CustomDictionaryValueProvider
/// </summary>
public class CustomJsonValueProviderFactory : ValueProviderFactory
{
    private static void AddToBackingStore(Dictionary<string, object> 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[prefix] = value;
    }

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

    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);
        AddToBackingStore(backingStore, String.Empty, jsonData);
        return new CustomDictionaryValueProvider<object>(backingStore, CultureInfo.CurrentCulture);
    }

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

- So the final step is replace the default JsonValueProviderFactory:
var oldFactory = ValueProviderFactories.Factories.FirstOrDefault(x => x is JsonValueProviderFactory);
if (oldFactory != null)
{
    ValueProviderFactories.Factories.Remove(oldFactory);
}
ValueProviderFactories.Factories.Add(new CustomJsonValueProviderFactory());

- It's not fun anymore, I hate to copy source code and change 1 line because I cannot extend it. So let's talk about last option. Again if you look in MVC source code class ValueProviderResult, you'll see that in method ConvertSimpleType where it convert simple types like int, enum, etc, It use TypeDescriptor to get the converter. So I thought If I change that static class, I'll have what I want. And here's the code:
internal class JsonEnumTypeConverter<T> : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return (sourceType == typeof(int) || sourceType.IsEnum || sourceType == typeof(string)) && CanConvertTo(context, typeof(T));
    }

    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
    {
        if (destinationType.IsEnum)
        {
            return true;
        }

        var underlyingType = Nullable.GetUnderlyingType(destinationType);
        return underlyingType != null && underlyingType.IsEnum;
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value == null)
        {
            return null;
        }
        try
        {
            int numericValue;
            return int.TryParse(value.ToString(), out numericValue)
                ? Enum.ToObject(typeof(T), numericValue)
                : Enum.Parse(typeof(T), value.ToString());
        }
        catch (Exception)
        {
            return base.ConvertFrom(context, culture, value);
        }
    }
}

- But again, I would have to register every single enums in the project which I don't like. So I'll have to get back the the first option which suppose to be the best choice now. Indeed, it works just fine for most scenarios but list of enum, nullable of enum. Futhur more, MVC is not good in handling nullable type like float?, int?. You post {value: 1} or even {value:1.0}, you will get null in the float? field. Unless you post {value: '1.0'} :D. So I'm thinking of getting the underlying type of nullable type then get the binder for that type and bind the value to the underlying type is likely the solution. Here is the code:
public class FixedDefaultModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType.IsEnum)
        {
            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult == null)
            {
                return base.BindModel(controllerContext, bindingContext);
            }

            int numericValue;
            return int.TryParse(valueProviderResult.AttemptedValue, out numericValue)
                ? Enum.ToObject(bindingContext.ModelType, numericValue)
                : Enum.Parse(bindingContext.ModelType, valueProviderResult.AttemptedValue);
        }

        var underlineType = Nullable.GetUnderlyingType(bindingContext.ModelType);
        if (underlineType != null)
        {
            var newBindingContext = new ModelBindingContext(bindingContext)
            {
                ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => bindingContext.Model, underlineType),
                ModelName = bindingContext.ModelName
            };
            return ModelBinders.Binders.GetBinder(underlineType).BindModel(controllerContext, newBindingContext);
        }

        return base.BindModel(controllerContext, bindingContext);
    }
}

This is the work solution.




It works :). What you have to do last is setting it as the default model binder:
ModelBinders.Binders.DefaultBinder = new FixedDefaultModelBinder();

It's clean and simple, isn't it? Appologise for letting you read through such a long post with so many unusable code except the last one because I was so excited to find out as many ways to solve my project's problem as possible.



Cheers.

4 comments:

masko said...

Thanks for the post helped me alot!
I had to modify the code slightly to make it work in out system. (If it could not parse the enum then return null)

return int.TryParse(valueProviderResult.AttemptedValue, out numericValue)
? Enum.ToObject(bindingContext.ModelType, numericValue)
: null;

Unknown said...

Cheers mate

Anonymous said...

Thanks Van, this helped a LOT. I had the exact same concerns about nullable enums etc and this is exactly what I needed to get me started. I ended up adding the equivalent of what masko added, but in a different way. Hopefully they will get this sorted out in MVC4

bzinn said...

The JsonEnumValueProviderResult code you posted helped me to fix my problem making a Fluent nHibernate convention to map nullable enums, so Thanks!

Post a Comment