JSON.NET as a WebAPI 2 OData serializer vs ODataMediaTypeFormatter

I'm trying to use JSON.NET as a default serializer in WebAPI 2 stack. I've implemented JsonMediaTypeFormatter, in which I've used JSON.NET serializer for serialize/deserialize data and created JsonContentNegotiator for using this media type formatter. All works fine except OData querying - if I add [Queryable] metadata ot action method, then response object doesn't contains any metadata information, only list of entities.

Small example. My action method:

[Queryable]
public async Task<PageResult<RuleType>> GetRuleType(ODataQueryOptions<RuleType> options)
{
    var ret = await _service.ListRuleTypesAsync(options);
    return new PageResult<RuleType>(
        ret,
        Request.GetNextPageLink(),
        Request.GetInlineCount());
}

If I use default OData serialize and call some query by Rule type (for example - .../odata/RuleType?$inlinecount=allpages&$skip=0&$top=1), I receive classic OData response with metadata info and count property:

odata.metadata ".../odata/$metadata#RuleType" 
odata.count    "2" 
value
        0    {
                 Id: 1
             Name: "General"
             Code: "General"
             Notes: null
             }

(some fields skipped, but I have Notes property with null value) But if I add my JsonContentNegotiator with JsonMediaTypeFormatter as a serializer - I receive only list of entities:

[
  {
    "Id": 1,
    "Name": "General",
    "Code": "General"
  }
]

(no Notes field here because of NullValueHandling.Ignore) Even more. If I remove [Queryable] attribute in action method - I receive another result:

{
  "Items": [
    {
      "Id": 1,
      "Name": "General",
      "Code": "General"
    }
  ],
  "Count": 2
}

In this case I've received Count, but still no metadata here. And also odata response property names completely differs from default.

My mind is blowing up. I just want to use JSON.NET as my serializer in any part of my web app (because of some strong restrictions). How can I do this?


Solution 1:

I've already figured out my problem and found the solution. OData uses separate media type formatters, inherited from ODataMediaTypeFormatter. Also OData uses different formatters for serialization and deserialization. For replacing this behavior we have to implement descendants of ODataDeserializerProvider and/or ODataSerializerProvider classes and add those classes to the HttpConfiguration.Formatters collections by

var odataFormatters = ODataMediaTypeFormatters
    .Create(new MyODataSerializerProvider(), new MuODataDeserializerProvider());
config.Formatters.AddRange(odataFormatters);

Small deserialization provider example:

public class JsonODataDeserializerProvider : ODataDeserializerProvider
{
    public override ODataEdmTypeDeserializer GetEdmTypeDeserializer(IEdmTypeReference edmType)
    {
        var kind = GetODataPayloadKind(edmType);

        return new JsonODataEdmTypeDeserializer(kind, this);
    }

    private static ODataPayloadKind GetODataPayloadKind(IEdmTypeReference edmType)
    {
        switch (edmType.TypeKind())
        {
            case EdmTypeKind.Entity:
                return ODataPayloadKind.Entry;
            case EdmTypeKind.Primitive:
            case EdmTypeKind.Complex:
                return ODataPayloadKind.Property;
            case EdmTypeKind.Collection:
                IEdmCollectionTypeReference collectionType = edmType.AsCollection();
                return collectionType.ElementType().IsEntity() ? ODataPayloadKind.Feed : ODataPayloadKind.Collection;
            default:
                return ODataPayloadKind.Entry;
        }
    }

    public override ODataDeserializer GetODataDeserializer(IEdmModel model, Type type, HttpRequestMessage request)
    {
        var edmType = model.GetEdmTypeReference(type);

        return edmType == null ? null : GetEdmTypeDeserializer(edmType);
    }
}

ODataDeserializer:

public class JsonODataEdmTypeDeserializer : ODataEdmTypeDeserializer
{
    public JsonODataEdmTypeDeserializer(ODataPayloadKind payloadKind) : base(payloadKind)
    {
    }

    public JsonODataEdmTypeDeserializer(ODataPayloadKind payloadKind, ODataDeserializerProvider deserializerProvider) : base(payloadKind, deserializerProvider)
    {
    }

    public override object Read(ODataMessageReader messageReader, Type type, ODataDeserializerContext readContext)
    {
        var data = readContext.Request.Content.ReadAsStringAsync().Result;

        return JsonConvert.DeserializeObject(data, type);
    }
}

And I also have added EdmLibsHelper class from WebAPI OData source code in my project with GetEdmTypeReference() and GetEdmType() methods because this class is internal.

Solution 2:

If it helps anyone else, here is how I re-used my Json.NET custom serializer in OData.

In Startup, insert your custom serializer provider:

    var odataFormatters = ODataMediaTypeFormatters.Create(new MyODataSerializerProvider(), new DefaultODataDeserializerProvider());
    config.Formatters.InsertRange(0, odataFormatters);

Here is my MyODataSerializerProvider.cs:

    public class MyODataSerializerProvider : DefaultODataSerializerProvider
    {
        public override ODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference edmType)
        {
            switch (edmType.TypeKind())
            {
                case EdmTypeKind.Enum:
                    ODataEdmTypeSerializer enumSerializer = base.GetEdmTypeSerializer(edmType);
                    return enumSerializer;

                case EdmTypeKind.Primitive:
                    ODataEdmTypeSerializer primitiveSerializer = base.GetEdmTypeSerializer(edmType);
                    return primitiveSerializer;

                case EdmTypeKind.Collection:
                    IEdmCollectionTypeReference collectionType = edmType.AsCollection();
                    if (collectionType.ElementType().IsEntity())
                    {
                        ODataEdmTypeSerializer feedSerializer = base.GetEdmTypeSerializer(edmType);
                        return feedSerializer;
                    }
                    else
                    {
                        ODataEdmTypeSerializer collectionSerializer = base.GetEdmTypeSerializer(edmType);
                        return collectionSerializer;
                    }

                case EdmTypeKind.Complex:
                    ODataEdmTypeSerializer complexTypeSerializer = base.GetEdmTypeSerializer(edmType);
                    return complexTypeSerializer;

                case EdmTypeKind.Entity:
                    ODataEdmTypeSerializer entityTypeSerializer = new MyODataEntityTypeSerializer(this);
                    return entityTypeSerializer;                    

                default:
                    return null;
            }
        }
    }

This then calls into MyODataEntityTypeSerializer.cs:

public class MyODataEntityTypeSerializer : ODataEntityTypeSerializer
{
    private static Logger logger = LogManager.GetCurrentClassLogger();

    public DocsODataEntityTypeSerializer(ODataSerializerProvider serializerProvider)
        : base(serializerProvider)
    {
    }

    public override ODataEntry CreateEntry(SelectExpandNode selectExpandNode, EntityInstanceContext entityInstanceContext)
    {
        ODataEntry entry = base.CreateEntry(selectExpandNode, entityInstanceContext);
        if(entry.TypeName == typeof(YourObject).FullName)
        {
            YourObjectEntryConverter converter = new YourObjectEntryConverter(entry);
            entry = converter.Convert();
        }
        return entry;
    }
}

Note that YourObject is your custom class that has a Json.NET serializer attached, via attribute or config.

Here's the converter class:

public class YourObjectEntryConverter
{
    private ODataEntry _entry;
    private string[] _suppressed_properties = {
                                                "YourProperty1", "YourProperty2"
                                              };

    public YourObjectEntryConverter(ODataEntry entry)
    {
        _entry = entry;
    }

    public ODataEntry Convert()
    {
        // 1st pass: create a poco from odata
        YourObject yours = new YourObject();
        PropertyInfo[] properties = typeof(YourObject).GetProperties();           
        foreach (PropertyInfo property in properties)
        {
            foreach (ODataProperty odata_property in _entry.Properties)
            {
                if (property.Name == odata_property.Name)
                {
                    if (odata_property.Value is ODataCollectionValue)
                    {
                        // my json de/serialization populates these; ymmv
                    }
                    else if (odata_property.Value is DateTimeOffset)
                    {
                        DateTimeOffset? dto = odata_property.Value as DateTimeOffset?;
                        property.SetValue(yours, dto.Value.DateTime);
                    }
                    else if (odata_property.Value == null)
                    {
                        property.SetValue(yours, odata_property.Value);
                    }
                    else if (ODataUtils.IsPrimitiveType(odata_property.Value.GetType()))
                    {
                        property.SetValue(yours, odata_property.Value);
                    }
                    // todo complex types
                    break;
                }
            }
        }

        // 2nd pass: use json serializer in the business layer to add markup
        // this call fires the "decorators" in YourObjectSerializer.cs via Json.NET
        string json = JsonConvert.SerializeObject(yours);
        // suck the newly added info back in
        YourObject serialized = JsonConvert.DeserializeObject<YourObject>(json);

        // 3rd pass: scrape the json poco and shovel it back into odata
        foreach (PropertyInfo property in properties)
        {
            foreach (ODataProperty odata_property in _entry.Properties)
            {
                if (property.Name == odata_property.Name)
                {
                    if (odata_property.Value is ODataCollectionValue)
                    {
                        var collection = odata_property.Value as ODataCollectionValue;
                        var collection_typename = property.PropertyType.ToString();
                        if (collection_typename.Contains("List") && collection_typename.Contains("YourSubObject"))
                        {
                            IList<YourSubObject> subobjects = property.GetValue(serialized) as IList<YourSubObject>;
                            List<ODataComplexValue> subobjects_list = new List<ODataComplexValue>();
                            foreach(YourSubObject subobject in subobjects)
                            {
                                subobjects_list.Add(ODataUtils.CreateComplexValue(typeof(YourSubObject), subobject));
                            }
                            collection.Items = subobjects_list.AsEnumerable();
                        }
                    }
                    else if (odata_property.Value is DateTimeOffset)
                    {
                        DateTimeOffset? dto = odata_property.Value as DateTimeOffset?;
                        property.SetValue(yours, dto.Value.DateTime);
                    }
                    else
                    {
                        object new_value = property.GetValue(serialized);
                        object old_value = property.GetValue(yours);
                        if (null == old_value && null != new_value)
                        {
                            Type t = new_value.GetType();
                            if (!ODataUtils.IsPrimitiveType(t))
                            {
                                odata_property.Value = ODataUtils.CreateComplexValue(t, new_value);
                            }
                            else
                            {
                                odata_property.Value = new_value;
                            }                                
                        }
                        else if (odata_property.Value is Guid)
                        {
                            Guid? new_guid = new_value as Guid?;
                            Guid? old_guid = old_value as Guid?;
                            if (Guid.Empty == old_guid.Value && Guid.Empty != new_guid.Value)
                            {
                                odata_property.Value = new_value;
                            }
                        }
                    }
                    break;
                }
            }
        }

        // 4th pass: add stuff that json added to the entry
        List<ODataProperty> new_properties = new List<ODataProperty>();
        foreach (PropertyInfo property in properties)
        {
            object value = property.GetValue(serialized);
            if (null != value)
            {
                bool lost_property = true; // couldn't resist
                foreach (ODataProperty odata_property in _entry.Properties)
                {
                    if (property.Name == odata_property.Name)
                    {
                        lost_property = false;
                        break;
                    }
                }
                if (lost_property)
                {
                    ODataProperty new_property = ODataUtils.CreateProperty(property.Name, value);
                    new_properties.Add(new_property);
                }
            }
        }


        // 5th pass: strip odata properties we don't want to expose externally
        List<ODataProperty> unsuppressed_properties = new List<ODataProperty>();
        foreach (ODataProperty odata_property in _entry.Properties)
        {
            if (!_suppressed_properties.Contains(odata_property.Name))
            {
                unsuppressed_properties.Add(odata_property);
            }
        }
        unsuppressed_properties.AddRange(new_properties); // from 4th pass
        _entry.Properties = unsuppressed_properties.AsEnumerable();

        return _entry;

    }
}

Lastly, here's my utils class:

public class ODataUtils
{
    public static bool IsPrimitiveType(Type t)
    {
        if (!t.IsPrimitive && t != typeof(Decimal) && t != typeof(String) && t != typeof(Guid) && t != typeof(DateTime)) // todo
        {
            return false;
        }
        return true;
    }

    public static ODataProperty CreateProperty(string name, object value)
    {
        object property_value = value;
        if(value != null)
        {
            Type t = value.GetType();
            if (!IsPrimitiveType(t))
            {
                property_value = CreateComplexValue(t, value);
            }
            else if (t == typeof(DateTime) || t == typeof(DateTime?))
            {
                DateTime dt = (DateTime)value;
                dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
                DateTimeOffset dto = dt;
                property_value = dto;
            }
        }
        ODataProperty new_property = new ODataProperty()
        {
            Name = name,
            Value = property_value
        };
        return new_property;
}

    public static ODataComplexValue CreateComplexValue(Type type, object value)
    {
        ODataComplexValue complex_value = new ODataComplexValue();
        complex_value.TypeName = type.ToString();
        PropertyInfo[] complex_properties = type.GetProperties();
        List<ODataProperty> child_properties = new List<ODataProperty>();
        foreach (PropertyInfo property in complex_properties)
        {
            ODataProperty child_property = CreateProperty(property.Name, property.GetValue(value));
            child_properties.Add(child_property);
        }
        complex_value.Properties = child_properties.AsEnumerable();
        return complex_value;
    }
}

Its all a horrible hack, but if you have a bunch of special Json.NET serialization code for your objects that you want to re-use in OData, this worked for me.