Tuesday, October 22, 2019

Deserializing generic interfaces with System.Text.Json

.Net Core 3.0 introduces new JSON (de)serialization classes in the System.Text.Json Namespace.

These are high-performance classes for working with JSON. If you really don't want to use JSON.Net or need to get the most from your serialization performance you may want to consider them.

As they're new, documentation isn't a thorough as other JSON solutions and some things work a bit differently.

One thing that's particularly non-obvious is how to deserialize to a generic interface of interfaces.
e.g. an `IList<ISomething>`.

This is a problem I was recently challenged with.

It's easy to use a converter to deserialize a single interface to a concrete type with an attribute on the property.


   [JsonConverter(typeof(InterfaceConverter<FormattedDate, IFormattedDate>))]
   public IFormattedDate Opened { get; set; }


   ...

   class FormattedDate : IFormattedDate
   {
       public string DateValue { get; set; }
   }

   interface IFormattedDate
   {
       string DateValue { get; set; }
   }

   public class InterfaceConverter<M, I> : JsonConverter<I> where M : class, I
   {
        public override I Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            return JsonSerializer.Deserialize<M>(ref reader, options);
        }

        public override void Write(Utf8JsonWriter writer, I value, JsonSerializerOptions options) { }
   }

However, this doesn't work with generic interfaces.

For this, we need to use a converter factory. Actually, we need two, but first, what is a converter factory?

A converter factory takes a type (class, property, etc.) and can return a converter to use to serialize (write) or deserialize (read) instances of that type.

Rather than use attributes to tell when to use the converter, the factories are passed to the serialization methods.

    var serializerOptions = new JsonSerializerOptions
    {
        Converters = {
            new InterfaceConverterFactory(typeof(FormattedDate), typeof(IFormattedDate)),
            new IListInterfaceConverterFactory(typeof(IFormattedDate)),
            }
    };

    var goodObj = JsonSerializer.Deserialize<GoodObject>(json, serializerOptions);

Above, you can see two converter factories and this is what we need for our particular problem.
During the [de]serialization of a type, the serializer will ask the factory if there is a converter for the type.

    class GoodObject
    {
        [JsonConverter(typeof(Converters.InterfaceConverter<FormattedDate, IFormattedDate>))]
        public IFormattedDate Opened { get; set; }

        public IList<IFormattedDate> ImportantEvents { get; set; }
    }

The handling of types during [de]serialization happens at the broadest level first, so to deserialize the `ImportantEvents` in the above class the serializer will look for a converter for `GoodObject`, `IList`, and `IFormattedDate` in that order, assuming that the type has not already been handled by an earlier converter. This knowledge is enough to solve the problem.

Adding a converter to deserialize an `IList<I>` into a `List<I>` is the first step.

    public class ListConverter<M> : JsonConverter<IList<M>>
    {
        public override IList<M> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            return JsonSerializer.Deserialize<List<M>>(ref reader, options);
        }

        public override void Write(Utf8JsonWriter writer, IList<M> value, JsonSerializerOptions options)
        {
            throw new NotImplementedException();
        }
    }

Then we need an appropriate factory to create this converter.

    public class IListInterfaceConverterFactory : JsonConverterFactory
    {
        public IListInterfaceConverterFactory(Type interfaceType)
        {
            this.InterfaceType = interfaceType;
        }

        public Type InterfaceType { get; }

        public override bool CanConvert(Type typeToConvert)
        {
            if (typeToConvert.Equals(typeof(IList<>).MakeGenericType(this.InterfaceType))
             && typeToConvert.GenericTypeArguments[0].Equals(this.InterfaceType))
            {
                return true;
            }

            return false;
        }

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            return (JsonConverter)Activator.CreateInstance(
                typeof(ListConverter<>).MakeGenericType(this.InterfaceType));
        }
    }

Because this will return a concrete list of interfaces and the serializer doesn't know how to handle those interfaces, it will ask the registered factories if they can provide a converter. We can use the converter we originally used in an attribute, we just need to add a factory to create it.

    public class InterfaceConverterFactory : JsonConverterFactory
    {
        public InterfaceConverterFactory(Type concrete, Type interfaceType)
        {
            this.ConcreteType = concrete;
            this.InterfaceType = interfaceType;
        }

        public Type ConcreteType { get; }
        public Type InterfaceType { get; }

        public override bool CanConvert(Type typeToConvert)
        {
            return typeToConvert == this.InterfaceType;
        }

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var converterType = typeof(InterfaceConverter<,>).MakeGenericType(this.ConcreteType, this.InterfaceType);

            return (JsonConverter)Activator.CreateInstance(converterType);
        }
    }

Now we can deserialize a provided string.

    var json = "{\"Opened\":{\"DateValue\":\"2019-10-21T13:35\"}, \"ImportantEvents\":[{\"DateValue\":\"2019-10-21T13:36\"},{\"DateValue\":\"2019-10-21T13:37\"}]}";

    var serializerOptions = new JsonSerializerOptions
    {
        Converters = {
            new InterfaceConverterFactory(typeof(FormattedDate), typeof(IFormattedDate)),
            new IListInterfaceConverterFactory(typeof(IFormattedDate)),
            }
    };

    var goodObj = JsonSerializer.Deserialize<GoodObject>(json, serializerOptions);

Yay!






2 comments:

  1. That's a nice gotcha, thanks for sharing!

    ReplyDelete
  2. Thank you Matt, this is so helpful!

    ReplyDelete

I get a lot of comment spam :( - moderation may take a while.