Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

JsonConverter with Interface

I have an object which comes from the client and get deserialized from the Web Api 2 automatically.

Now I have a problem with one property of my model. This property "CurrentField" is of Type IField and there are 2 different Implementations of this interface.

This is my model (just a dummy)

public class MyTest
{
    public IField CurrentField {get;set;}
}

public interface IField{
    string Name {get;set;}
}

public Field1 : IField{
    public string Name {get;set;}
    public int MyValue {get;set;}
}

public Field2 : IField{
    public string Name {get;set;}
    public string MyStringValue {get;set;}
}

I tried to create a custom JsonConverter to find out of what type my object from the client is (Field1 or Field2) but I just don't know how.

My Converter gets called and I can see the object when I call var obj = JObject.load(reader);

but how can I find out what type it is? I can't do something like

if(obj is Field1) ...

this is the method where I should check this right?

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
like image 379
Tobias Koller Avatar asked Oct 24 '15 18:10

Tobias Koller


1 Answers

How to automatically select a concrete type when deserializing an interface using Json.NET

The easiest way to solve your problem is to serialize and deserialize your JSON (on both the client and server sides) with TypeNameHandling = TypeNameHandling.Auto. If you do, your JSON will include the actual type serialized for an IFIeld property, like so:

{
  "CurrentField": {
    "$type": "MyNamespace.Field2, MyAssembly",
    "Name": "name",
    "MyStringValue": "my string value"
  }
}

However, note this caution from the Newtonsoft docs:

TypeNameHandling should be used with caution when your application deserializes JSON from an external source. Incoming types should be validated with a custom SerializationBinder when deserializing with a value other than None.

For a discussion of why this may be necessary, see TypeNameHandling caution in Newtonsoft Json, How to configure Json.NET to create a vulnerable web API, and Alvaro Muñoz & Oleksandr Mirosh's blackhat paper https://www.blackhat.com/docs/us-17/thursday/us-17-Munoz-Friday-The-13th-JSON-Attacks-wp.pdf

If for whatever reason you cannot change what the server outputs, you can create a JsonConverter that loads the JSON into a JObject and checks to see what fields are actually present, then searches through possible concrete types to find one with the same properties:

public class JsonDerivedTypeConverer<T> : JsonConverter
{
    public JsonDerivedTypeConverer() { }

    public JsonDerivedTypeConverer(params Type[] types)
    {
        this.DerivedTypes = types;
    }

    readonly HashSet<Type> derivedTypes = new HashSet<Type>();

    public IEnumerable<Type> DerivedTypes
    {
        get
        {
            return derivedTypes.ToArray(); 
        }
        set
        {
            if (value == null)
                throw new ArgumentNullException();
            derivedTypes.Clear();
            if (value != null)
                derivedTypes.UnionWith(value);
        }
    }

    JsonObjectContract FindContract(JObject obj, JsonSerializer serializer)
    {
        List<JsonObjectContract> bestContracts = new List<JsonObjectContract>();
        foreach (var type in derivedTypes)
        {
            if (type.IsAbstract)
                continue;
            var contract = serializer.ContractResolver.ResolveContract(type) as JsonObjectContract;
            if (contract == null)
                continue;
            if (obj.Properties().Select(p => p.Name).Any(n => contract.Properties.GetClosestMatchProperty(n) == null))
                continue;
            if (bestContracts.Count == 0 || bestContracts[0].Properties.Count > contract.Properties.Count)
            {
                bestContracts.Clear();
                bestContracts.Add(contract);
            }
            else if (contract.Properties.Count == bestContracts[0].Properties.Count)
            {
                bestContracts.Add(contract);
            }
        }
        return bestContracts.Single();
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(T);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
            return null;
        var obj = JObject.Load(reader); // Throws an exception if the current token is not an object.
        var contract = FindContract(obj, serializer);
        if (contract == null)
            throw new JsonSerializationException("no contract found for " + obj.ToString());
        if (existingValue == null || !contract.UnderlyingType.IsAssignableFrom(existingValue.GetType()))
            existingValue = contract.DefaultCreator();
        using (var sr = obj.CreateReader())
        {
            serializer.Populate(sr, existingValue);
        }
        return existingValue;
    }

    public override bool CanWrite { get { return false; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
}

Then you can apply that as a converter to IField:

[JsonConverter(typeof(JsonDerivedTypeConverer<IField>), new object [] { new Type [] { typeof(Field1), typeof(Field2) } })]
public interface IField
{
    string Name { get; set; }
}

Note that this solution is a little fragile. If the server omits the MyStringValue or MyValue fields (because they have default value and DefaultValueHandling = DefaultValueHandling.Ignore, for example) then the converter won't know which type to create and will throw an exception. Similarly, if two concrete types implementing IField have the same property names, differing only in type, the converter will throw an exception. Using TypeNameHandling.Auto avoids these potential problems.

Update

The following version checks to see if the "$type" parameter is present, and if TypeNameHandling != TypeNameHandling.None, falls back on default serialization. It has to do a couple of tricks to prevent infinite recursion when falling back:

public class JsonDerivedTypeConverer<T> : JsonConverter
{
    public JsonDerivedTypeConverer() { }

    public JsonDerivedTypeConverer(params Type[] types)
    {
        this.DerivedTypes = types;
    }

    readonly HashSet<Type> derivedTypes = new HashSet<Type>();

    public IEnumerable<Type> DerivedTypes
    {
        get
        {
            return derivedTypes.ToArray(); 
        }
        set
        {
            derivedTypes.Clear();
            if (value != null)
                derivedTypes.UnionWith(value);
        }
    }

    JsonObjectContract FindContract(JObject obj, JsonSerializer serializer)
    {
        List<JsonObjectContract> bestContracts = new List<JsonObjectContract>();
        foreach (var type in derivedTypes)
        {
            if (type.IsAbstract)
                continue;
            var contract = serializer.ContractResolver.ResolveContract(type) as JsonObjectContract;
            if (contract == null)
                continue;
            if (obj.Properties().Select(p => p.Name).Where(n => n != "$type").Any(n => contract.Properties.GetClosestMatchProperty(n) == null))
                continue;
            if (bestContracts.Count == 0 || bestContracts[0].Properties.Count > contract.Properties.Count)
            {
                bestContracts.Clear();
                bestContracts.Add(contract);
            }
            else if (contract.Properties.Count == bestContracts[0].Properties.Count)
            {
                bestContracts.Add(contract);
            }
        }
        return bestContracts.Single();
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(T);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
            return null;
        var obj = JObject.Load(reader); // Throws an exception if the current token is not an object.
        if (obj["$type"] != null && serializer.TypeNameHandling != TypeNameHandling.None)
        {
            // Prevent infinite recursion when using an explicit converter in the list.
            var removed = serializer.Converters.Remove(this);
            try
            {
                // Kludge to prevent infinite recursion when using JsonConverterAttribute on the type: deserialize to object.
                return obj.ToObject(typeof(object), serializer);
            }
            finally
            {
                if (removed)
                    serializer.Converters.Add(this);
            }
        }
        else
        {
            var contract = FindContract(obj, serializer);
            if (contract == null)
                throw new JsonSerializationException("no contract found for " + obj.ToString());
            if (existingValue == null || !contract.UnderlyingType.IsAssignableFrom(existingValue.GetType()))
                existingValue = contract.DefaultCreator();
            using (var sr = obj.CreateReader())
            {
                serializer.Populate(sr, existingValue);
            }
            return existingValue;
        }
    }

    public override bool CanWrite { get { return false; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
}
like image 155
dbc Avatar answered Sep 23 '22 10:09

dbc