Serialize objects implementing interface with System.Text.Json
Asked Answered
F

8

15

I have a master class which contains a generic collection. Elements in the collection are of diffetent types, and each implements an interface.

Master class:

public class MasterClass
{
    public ICollection<IElement> ElementCollection { get; set; }
}

Contract for the elements:

public interface IElement
{
    string Key { get; set; }
}

Two samples for the elements:

public class ElementA : IElement
{
    public string Key { get; set; }

    public string AValue { get; set; }
}

public class ElementB : IElement
{
    public string Key { get; set; }

    public string BValue { get; set; }
}

I need to serialize an instance of MasterClass object using the new System.Text.Json library in Json. Using the following code,

public string Serialize(MasterClass masterClass)
{
    var options = new JsonSerializerOptions
    {
        WriteIndented = true,
    };
    return JsonSerializer.Serialize(masterClass, options);
}

I get the follwing JSON:

{
    "ElementCollection":
    [
        {
            "Key": "myElementAKey1"
        },
        {
            "Key": "myElementAKey2"
        },
        {
            "Key": "myElementBKey1"
        }
    ]
}

instead of:

{
    "ElementCollection":
    [
        {
            "Key": "myElementAKey1",
            "AValue": "MyValueA-1"
        },
        {
            "Key": "myElementAKey2",
            "AValue": "MyValueA-2"
        },
        {
            "Key": "myElementBKey1",
            "AValue": "MyValueB-1"
        }
    ]
}

Which class (converter, writer, ...)should I implement to obtain the complete JSON ?

Thanks in advance for your help.

Furunculosis answered 14/10, 2019 at 9:32 Comment(0)
F
4

The solution is to implement a generic converter (System.Text.Json.Serialization.JsonConverter) :

public class ElementConverter : JsonConverter<IElement>
{
    public override IElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, IElement value, JsonSerializerOptions options)
    {
        if (value is ElementA)
            JsonSerializer.Serialize(writer, value as ElementA, typeof(ElementA), options);
        else if (value is ElementB)
            JsonSerializer.Serialize(writer, value as ElementB, typeof(ElementB), options);
        else
            throw new ArgumentOutOfRangeException(nameof(value), $"Unknown implementation of the interface {nameof(IElement)} for the parameter {nameof(value)}. Unknown implementation: {value?.GetType().Name}");
    }
}

This just needs some more work for the Read method.

Furunculosis answered 21/10, 2019 at 8:18 Comment(2)
Not working for me. Self-cycling inside Write method. ElementA is an IElementHere
@Here Checkout my answere here: #58374415 Maybe it will help you.Groundnut
R
13

This works for me:

public class TypeMappingConverter<TType, TImplementation> : JsonConverter<TType>
  where TImplementation : TType
{
  [return: MaybeNull]
  public override TType Read(
    ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
      JsonSerializer.Deserialize<TImplementation>(ref reader, options);

  public override void Write(
    Utf8JsonWriter writer, TType value, JsonSerializerOptions options) =>
      JsonSerializer.Serialize(writer, (TImplementation)value!, options);
}

Usage:

var options =
   new JsonSerializerOptions 
   {
     Converters = 
     {
       new TypeMappingConverter<BaseType, ImplementationType>() 
     }
   };

JsonSerializer.Deserialize<Wrapper>(value, options);

Tests:

[Fact]
public void Should_serialize_references()
{
  // arrange
  var inputEntity = new Entity
  {
    References =
    {
      new Reference
      {
        MyProperty = "abcd"
      },
      new Reference
      {
        MyProperty = "abcd"
      }
    }
  };

  var options = new JsonSerializerOptions
  {
    WriteIndented = true,
    Converters =
    {
      new TypeMappingConverter<IReference, Reference>()
    }
  };

      var expectedOutput =
@"{
  ""References"": [
    {
      ""MyProperty"": ""abcd""
    },
    {
      ""MyProperty"": ""abcd""
    }
  ]
}";

  // act
  var actualOutput = JsonSerializer.Serialize(inputEntity, options);

  // assert
  Assert.Equal(expectedOutput, actualOutput);
}

[Fact]
public void Should_deserialize_references()
{
  // arrange

  var inputJson =
@"{
  ""References"": [
    {
      ""MyProperty"": ""abcd""
    },
    {
      ""MyProperty"": ""abcd""
    }
  ]
}";

  var expectedOutput = new Entity
  {
    References =
    {
      new Reference
      {
        MyProperty = "abcd"
      },
      new Reference
      {
        MyProperty = "abcd"
      }
    }
  };

  var options = new JsonSerializerOptions
  {
    WriteIndented = true
  };

  options.Converters.AddTypeMapping<IReference, Reference>();

  // act
  var actualOutput = JsonSerializer.Deserialize<Entity>(inputJson, options);

  // assert
  actualOutput
      .Should()
      .BeEquivalentTo(expectedOutput);
}


public class Entity
{
  HashSet<IReference>? _References;
  public ICollection<IReference> References
  {
    get => _References ??= new HashSet<IReference>();
    set => _References = value?.ToHashSet();
  }
}

public interface IReference
{
  public string? MyProperty { get; set; }
}

public class Reference : IReference
{
  public string? MyProperty { get; set; }
}
Reynold answered 1/11, 2020 at 19:29 Comment(6)
Your solution worked for me but is it possible to use mappings that were already put in place while configuring ioc instead of duplicate them ?Murine
@Murine You can register those converters with the IoC controlled JSON settings.Reynold
You could be more clear ? For example I already registered like this private void ConfigureServices(ServiceCollection services) { services.AddScoped<IParentObject, ParentObject>(); services.AddScoped<IChildObject, ChildObject>(); } avoiding duplication with same Converters = { new TypeMappingConverter<IParentObject, ParentObject>(), new TypeMappingConverter<IChildObject, ChildObject>(), }Murine
I have this kind of error popping with .NET5 and your Deserialize implementation. System.InvalidOperationException: 'Each parameter in constructor 'Void .ctor(Lib.IChildA,Lib.IChildB)' on type 'Lib.Parent' must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object. The match can be case-insensitive.'Murine
In your example you have obe implemention type, but in the question there are two types, ElementA and ElementB - How can we create a Converter that determins the implementation based on the JSON it is trying to convert?Incontinent
Check my answere here: https://mcmap.net/q/35894/-serialize-objects-implementing-interface-with-system-text-jsonGroundnut
G
9

What you're looking for is called polymorphic serialization.

Here's Microsoft documentation article

Here's another question about it

According to documentation you just need to cast your interface to an object. For example:

public class TreeRow
{
    [JsonIgnore]
    public ICell[] Groups { get; set; } = new ICell[0];

    [JsonIgnore]
    public ICell[] Aggregates { get; set; } = new ICell[0];

    [JsonPropertyName("Groups")]
    public object[] JsonGroups => Groups;

    [JsonPropertyName("Aggregates")]
    public object[] JsonAggregates => Aggregates;


    public TreeRow[] Children { get; set; } = new TreeRow[0];
}
Gramps answered 11/11, 2020 at 18:13 Comment(2)
Works for serialization. Can it be used for deserialization as well?Backbreaker
@Backbreaker #58074804Gramps
F
4

The solution is to implement a generic converter (System.Text.Json.Serialization.JsonConverter) :

public class ElementConverter : JsonConverter<IElement>
{
    public override IElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, IElement value, JsonSerializerOptions options)
    {
        if (value is ElementA)
            JsonSerializer.Serialize(writer, value as ElementA, typeof(ElementA), options);
        else if (value is ElementB)
            JsonSerializer.Serialize(writer, value as ElementB, typeof(ElementB), options);
        else
            throw new ArgumentOutOfRangeException(nameof(value), $"Unknown implementation of the interface {nameof(IElement)} for the parameter {nameof(value)}. Unknown implementation: {value?.GetType().Name}");
    }
}

This just needs some more work for the Read method.

Furunculosis answered 21/10, 2019 at 8:18 Comment(2)
Not working for me. Self-cycling inside Write method. ElementA is an IElementHere
@Here Checkout my answere here: #58374415 Maybe it will help you.Groundnut
G
4

I have currently been infront of the same problem in Blazor app, so I was not been able to switch to Newtonsoft.Json easily. I found two ways. One is in reality hack. You can create custom converter, where you use Newtonsoft.Json in Read/Write methods, instead of System.Text.Json. But that was not what I was looking for. So I make some custom interface converter. I have some working solution, that not have been tested widely, but it's working for what I need.

Situation

I have a List<TInterface> with objects implementing TInterface. But there is a lot of different implementations. I need to serialize data on server, and deserialize on client WASM app, with all the data. For JavaScript deserialization, the implementation with custom Write method mentioned later is enough. For deserialization in C#, I need to know the exact types of objects serialized for each item in the list.

First, I need JsonConverterAttribute on interface. So I was following this article: https://khalidabuhakmeh.com/serialize-interface-instances-system-text-json. There is some implementation of Writer, that will handle interface type. But there is not Read implementation. So I had to make my own.

How

  • modify Write method to write type of object as first property to JSON object. Using JsonDocument to get all properties from original object.
  • when reading the JSON, use clonned reader (as suggested in Microsoft docs for custom json converters) to find first property named $type with type information. Than create instance of that type and use type to deserialize data from original reader.

Code

Interface and classes:

[JsonInterfaceConverter(typeof(InterfaceConverter<ITest>))]
public interface ITest
{
    int Id { get; set; }
    string Name { get; set; }
}

public class ImageTest : ITest
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Image { get; set; } = string.Empty;
}

public class TextTest : ITest
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Text { get; set; } = string.Empty;
    public bool IsEnabled { get; set; }
}

Interface converter attribute:

// Source: https://khalidabuhakmeh.com/serialize-interface-instances-system-text-json
[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)]
public class JsonInterfaceConverterAttribute : JsonConverterAttribute
{
    public JsonInterfaceConverterAttribute(Type converterType)
        : base(converterType)
    {
    }
}

Converter:

public class InterfaceConverter<T> : JsonConverter<T>
    where T : class
{
    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        Utf8JsonReader readerClone = reader;
        if (readerClone.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.PropertyName)
        {
            throw new JsonException();
        }

        string propertyName = readerClone.GetString();
        if (propertyName != "$type")
        {
            throw new JsonException();
        }

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.String)
        {
            throw new JsonException();
        }

        string typeValue = readerClone.GetString();
        var instance = Activator.CreateInstance(Assembly.GetExecutingAssembly().FullName, typeValue).Unwrap();
        var entityType = instance.GetType();

        var deserialized = JsonSerializer.Deserialize(ref reader, entityType, options);
        return (T)deserialized;
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        switch (value)
        {
            case null:
                JsonSerializer.Serialize(writer, (T)null, options);
                break;
            default:
                {
                    var type = value.GetType();
                    using var jsonDocument = JsonDocument.Parse(JsonSerializer.Serialize(value, type, options));
                    writer.WriteStartObject();
                    writer.WriteString("$type", type.FullName);

                    foreach (var element in jsonDocument.RootElement.EnumerateObject())
                    {
                        element.WriteTo(writer);
                    }

                    writer.WriteEndObject();
                    break;
                }
        }
    }
}

Usage:

    var list = new List<ITest>
    {
        new ImageTest { Id = 1, Name = "Image test", Image = "some.url.here" },
        new TextTest { Id = 2, Name = "Text test", Text = "kasdglaskhdgl aksjdgl asd gasdg", IsEnabled = true },
        new TextTest { Id = 3, Name = "Text test 2", Text = "asd gasdg", IsEnabled = false },
        new ImageTest { Id = 4, Name = "Second image", Image = "diff.url.here" }
    };

    var json = JsonSerializer.Serialize(list);
    var data = JsonSerializer.Deserialize<List<ITest>>(json);

    // JSON data
    // [
    //   {
    //      "$type":"ConsoleApp1.ImageTest",
    //      "Id":1,
    //      "Name":"Image test",
    //      "Image":"some.url.here"
    //   },
    //   {
    //      "$type":"ConsoleApp1.TextTest",
    //      "Id":2,
    //      "Name":"Text test",
    //      "Text":"kasdglaskhdgl aksjdgl asd gasdg",
    //      "IsEnabled":true
    //   },
    //   {
    //      "$type":"ConsoleApp1.TextTest",
    //      "Id":3,
    //      "Name":"Text test 2",
    //      "Text":"asd gasdg",
    //      "IsEnabled":false
    //   },
    //   {
    //      "$type":"ConsoleApp1.ImageTest",
    //      "Id":4,
    //      "Name":"Second image",
    //      "Image":"diff.url.here"
    //   }
    // ]

Edit: I made a NuGet package with this logic. You can download it here: InterfaceConverter.SystemTextJson

Edit 26.3.2022: The NuGet package version has implemented more logic, eg. looking for type in all referenced assemblies.

Groundnut answered 26/12, 2021 at 18:10 Comment(2)
Your solution is nice but what if concrete type in an other assembly?Andris
@MuhammadWaqasAziz that is already improved in the NuGet package.Groundnut
B
3

Working further on the idea of Rom Eh. In my case, I don't always know the implementation objects. But it turns out you can just cast to an object and all of its properties will be serialized.

(System.Text.Json.Serialization.JsonConverter) :

public class ElementConverter : JsonConverter<IElement>
{
    public override IElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, IElement value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, value as object, options);
    }
}

This just needs some more work for the Read method.

Backman answered 11/4, 2023 at 6:23 Comment(0)
W
1

I have had the same issue, but my problem may not have been related to yours. It turns out that each object to which the incomming JSON data must be serialized requires a constructor with no arguments. All my objects had constructors with all arguments (to make it easier to create and populate them from a database).

Workingwoman answered 5/3, 2021 at 20:0 Comment(1)
Have you tried to add another constructor whitout argument ?Furunculosis
A
0

Improved On @t00thy Solution

Your solution is nice but what if concrete type in an other assembly?

Converter Class

public class InterfaceConverter<T> : JsonConverter<T> where T : class
{
    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        Utf8JsonReader readerClone = reader;
        if (readerClone.TokenType != JsonTokenType.StartObject)
            throw new JsonException("Problem in Start object! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.PropertyName)
            throw new JsonException("Token Type not equal to property name! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        string? propertyName = readerClone.GetString();
        if (string.IsNullOrWhiteSpace(propertyName) || propertyName != "$type")
            throw new JsonException("Unable to get $type! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.String)
            throw new JsonException("Token Type is not JsonTokenString! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        string? typeValue = readerClone.GetString();
        if(string.IsNullOrWhiteSpace(typeValue))
            throw new JsonException("typeValue is null or empty string! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        string? asmbFullName = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(ass => !string.IsNullOrEmpty(ass.GetName().Name) && ass.GetName().Name.Equals(typeValue.Split(" ")[1]))?.FullName;

        if (string.IsNullOrWhiteSpace(asmbFullName))
            throw new JsonException("Assembly name is null or empty string! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        ObjectHandle? instance = Activator.CreateInstance(asmbFullName, typeValue.Split(" ")[0]);
        if(instance == null)
            throw new JsonException("Unable to create object handler! Handler is null! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        object? unwrapedInstance = instance.Unwrap();
        if(unwrapedInstance == null)
            throw new JsonException("Unable to unwrap instance! Or instance is null! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        Type? entityType = unwrapedInstance.GetType();
        if(entityType == null)
            throw new JsonException("Instance type is null! Or instance is null! method: " + nameof(Read) + " class :" + nameof(InterfaceConverter<T>));

        object? deserialized = JsonSerializer.Deserialize(ref reader, entityType, options);
        if(deserialized == null)
            throw new JsonException("De-Serialized object is null here!");

        return (T)deserialized;
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        switch (value)
        {
            case null:
                JsonSerializer.Serialize(writer, typeof(T) ,options);
                break;
            default:
                {
                    var type = value.GetType();
                    using var jsonDocument = JsonDocument.Parse(JsonSerializer.Serialize(value, type, options));
                    writer.WriteStartObject();
                    writer.WriteString("$type", type.FullName + " " + type.Assembly.GetName().Name);

                    foreach (var element in jsonDocument.RootElement.EnumerateObject())
                    {
                        element.WriteTo(writer);
                    }

                    writer.WriteEndObject();
                    break;
                }
        }
    }
}

Converter Attribute

[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)]
public class JsonInterfaceConverterAttribute : JsonConverterAttribute
{
    public JsonInterfaceConverterAttribute(Type converterType)
        : base(converterType)
    {
    }
}

Interfaces and Classes

[JsonInterfaceConverter(typeof(InterfaceConverter<IUser>))]
public interface IUser
{
    int Id { get; set; }
    string Name { get; set; }
    IEnumerable<IRight> Rights { get; set; }
}

[JsonInterfaceConverter(typeof(InterfaceConverter<IRight>))]
public interface IRight
{
    int Id { get; set; }
    bool HasRight { get; set; }
}

public class User : IUser
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public IEnumerable<IRight> Rights { get; set; } = Enumerable.Empty<IRight>();
}

public class Right : IRight
{
    public int Id { get; set; }
    public bool HasRight { get; set; }
}

Usage:

        //           your dependency injector
        IUser user = IServiceProvider.GetRequiredService<IUser>();
        user.Id = 1;
        user.Name = "Xyz";

        List<IRight> rights = new ();
        //           your dependency injector
        IRight right1 = IServiceProvider.GetRequiredService<IRight>();
        right1.Id = 1;
        right1.HasRight = true;
        rights.Add(right1);
        //           your dependency injector
        IRight right2 = IServiceProvider.GetRequiredService<IRight>();
        right2.Id = 2;
        right2.HasRight = true;
        rights.Add(right2);
        //           your dependency injector
        IRight right3 = IServiceProvider.GetRequiredService<IRight>();
        right3.Id = 1;
        right3.HasRight = true;
        rights.Add(right2);

        var serializedRights = JsonSerializer.Serialize(rights);

        user.Rights = rights;

        // Serialization is simple
        var serilizedUser = JsonSerializer.Serialize(user);

        //But for DeSerialization of single object you need to use it some thing like this
        //                                                    Ask your dependency injector to resolve and get type of object
        IUser usr = JsonSerializer.Deserialize(serilizedUser, IServiceProvider.GetRequiredService<IUser>().GetType());

        //DeSerialization of list or enumerable is simple
        IEnumerable<IRight>? rits = JsonSerializer.Deserialize<IEnumerable<IRight>>(serializedRights);
Andris answered 22/2, 2022 at 4:37 Comment(1)
Hi. You can make a PR here. github.com/vpekarek/InterfaceConverter.SystemTextJson I think the nuget package already solve this problem, but did not update the comment here.Groundnut
S
0

Contract for the elements:

public interface IElement
{
    string Key { get; set; }
}

Two samples for the elements:

public class ElementA : IElement
{
    public string Key { get; set; }

    public string AValue { get; set; }
}

public class ElementB : IElement
{
    public string Key { get; set; }

    public string BValue { get; set; }
}

Solution:

public string Serilize(IElement element) 
{
   return JsonSerializer.Serialize(element, element.GetType())
}
Satrap answered 16/3 at 23:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.