Whit Waldo
Whit Waldo

Reputation: 5217

Serializing a list of generic type via custom JsonConverter

To start, I'm trying to use System.Text.Json to resolve this. I'm aware that this Just Works with Newtonsoft.Json, but for various other reasons not specified here, I'm trying not to use Newtonsoft.Json for my JSON serialization needs.

I'm attempting to use a custom JsonConverter to pick up the [EnumMember] attributes and use their respective Value properties as the value for my serialized JSON objects instead of System.Text.Json using their various numerical values instead. This isn't supported out of the box for various reasons detailed in this very lengthy thread.

I'm using a converter that's worked well enough for my needs from that thread that looks as follows:

public sealed class JsonStringEnumConverter<TEnum> : JsonConverter<TEnum> where TEnum : struct, Enum
{
    private readonly Dictionary<TEnum, string> _enumToString = new();
    private readonly Dictionary<string, TEnum> _stringToEnum = new();

    /// <inheritdoc />
    public JsonStringEnumConverter()
    {
        var type = typeof(TEnum);
        var values = Enum.GetValues<TEnum>();

        foreach (var value in values)
        {
            var enumMember = type.GetMember(value.ToString())[0];
            var attr = enumMember.GetCustomAttributes(typeof(EnumMemberAttribute), false)
                .Cast<EnumMemberAttribute>()
                .FirstOrDefault();

            _stringToEnum.Add(value.ToString(), value);

            if (attr?.Value != null)
            {
                _enumToString.Add(value, attr.Value);
                _stringToEnum.Add(attr.Value, value);
            }
            else
            {
                _enumToString.Add(value, value.ToString());
            }
        }
    }

    /// <summary>Reads and converts the JSON to type <typeparamref name="T" />.</summary>
    /// <param name="reader">The reader.</param>
    /// <param name="typeToConvert">The type to convert.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    /// <returns>The converted value.</returns>
    public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var stringValue = reader.GetString();

        if (stringValue != null && _stringToEnum.TryGetValue(stringValue, out var enumValue))
        {
            return enumValue;
        }

        return default;
    }

    /// <summary>Writes a specified value as JSON.</summary>
    /// <param name="writer">The writer to write to.</param>
    /// <param name="value">The value to convert to JSON.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(_enumToString[value]);
    }
}

In the following unit test, this works precisely as expected:

[TestClass]
public sealed class JsonStringEnumConverterTests
{
    [TestMethod]
    public void ShouldSerializeWithEnumMemberValue() //Works without issue
    {
        var obj = new MyTestObj(Colors.Red);
        var sut = JsonSerializer.Serialize(obj);

        Assert.AreEqual(@"{""color"":""r""}", sut);
    }

    private record MyTestObj([property:JsonPropertyName("color"),JsonConverter(typeof(JsonStringEnumConverter<Colors>))]Colors Color);

    private enum Colors
    {
        [EnumMember(Value="r")]
        Red,
        [EnumMember(Value="b")]
        Blue,
        [EnumMember(Value="g")]
        Green
    }
}

However, when trying to do the same to serialize a list of enums as in the following, I receive an exception (shared under the unit test):

[TestClass]
public sealed class JsonStringEnumConverterTests
{
    [TestMethod]
    public void ShouldSerializeListWithEnumMemberValues() //Throws exception below
    {
        var obj = new ManyTestObj(new List<Colors> {Colors.Red, Colors.Blue});
        var sut = JsonSerializer.Serialize(obj);

        Assert.AreEqual(@"{""color"":[""r"",""b""]}", sut);
    }

    private record ManyTestObj(
        [property: JsonPropertyName("color"), JsonConverter(typeof(JsonStringEnumConverter<Colors>))]
        List<Colors> Colors);

    private enum Colors
    {
        [EnumMember(Value="r")]
        Red,
        [EnumMember(Value="b")]
        Blue,
        [EnumMember(Value="g")]
        Green
    }
}

This yields the following exception:

System.InvalidOperationException: The converter specified on 'xyz.Tests.Serialization.Json.Converters.JsonStringEnumConverterTests+ManyTestObj.Colors' is not compatible with the type 'System.Collections.Generic.List1[xyz.Tests.Serialization.Json.Converters.JsonStringEnumConverterTests+Colors]'. at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_SerializationConverterOnAttributeNotCompatible(Type classTypeAttributeIsOn, MemberInfo memberInfo, Type typeToConvert) at System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver.GetConverterFromAttribute(JsonConverterAttribute converterAttribute, Type typeToConvert, MemberInfo memberInfo, JsonSerializerOptions options) at System.Text.Json.Serialization.Metadata.ReflectionJsonTypeInfo1.CreateProperty(Type typeToConvert, MemberInfo memberInfo, JsonSerializerOptions options, Boolean shouldCheckForRequiredKeyword) at System.Text.Json.Serialization.Metadata.ReflectionJsonTypeInfo`1.LateAddProperties() at System.Text.Json.Serialization.Metadata.JsonTypeInfo.InitializePropertyCache() at System.Text.Json.Serialization.Metadata.JsonTypeInfo.Configure() at System.Text.Json.Serialization.Metadata.JsonTypeInfo.g__ConfigureLocked|143_0() at System.Text.Json.JsonSerializerOptions.GetTypeInfoInternal(Type type, Boolean ensureConfigured, Boolean resolveIfMutable) at System.Text.Json.JsonSerializer.GetTypeInfo(JsonSerializerOptions options, Type inputType) at System.Text.Json.JsonSerializer.GetTypeInfo[T](JsonSerializerOptions options) at System.Text.Json.JsonSerializer.Serialize[TValue](TValue value, JsonSerializerOptions options) at xyz.Tests.Serialization.Json.Converters.JsonStringEnumConverterTests.ShouldSerializeListWithEnumMemberValues() in xyz.Tests\Serialization\Json\Converters\JsonStringEnumConverterTests.cs:line 24 at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) at System.Reflection.MethodInvoker.Invoke(Object obj, IntPtr* args, BindingFlags invokeAttr)

Now, Newtonsoft.Json's version of a JsonConverter is smart enough to figure out when an IEnumerable is presented and the type it passes for conversion is looped automatically so the converter need only be written against a single instance and it works without a hitch.

I just wrote a separate converter that can be used just fine with List as follows:

public sealed class JsonStringEnumListConverter<TEnum> : JsonConverter<List<TEnum>> where TEnum : struct, Enum
{
    private readonly Dictionary<TEnum, string> _enumToString = new();
    private readonly Dictionary<string, TEnum> _stringToEnum = new();

    /// <inheritdoc />
    public JsonStringEnumListConverter()
    {
        var type = typeof(TEnum);
        var values = Enum.GetValues<TEnum>();

        foreach (var value in values)
        {
            var enumMember = type.GetMember(value.ToString())[0];
            var attr = enumMember.GetCustomAttributes(typeof(EnumMemberAttribute), false)
                .Cast<EnumMemberAttribute>()
                .FirstOrDefault();

            _stringToEnum.Add(value.ToString(), value);

            if (attr?.Value != null)
            {
                _enumToString.Add(value, attr.Value);
                _stringToEnum.Add(attr.Value, value);
            }
            else
            {
                _enumToString.Add(value, value.ToString());
            }
        }
    }

    /// <summary>Reads and converts the JSON to type <typeparamref name="T" />.</summary>
    /// <param name="reader">The reader.</param>
    /// <param name="typeToConvert">The type to convert.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    /// <returns>The converted value.</returns>
    public override List<TEnum>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartArray)
        {
            throw new JsonException();
        }

        reader.Read();

        var elements = new List<TEnum>();
        while (reader.TokenType != JsonTokenType.EndArray)
        {
            var stringValue = reader.GetString();
            if (stringValue != null && _stringToEnum.TryGetValue(stringValue, out var enumValue))
            {
                elements.Add(enumValue);
            }
            reader.Read();
        }

        return elements;
    }
    
    /// <summary>Writes a specified value as JSON.</summary>
    /// <param name="writer">The writer to write to.</param>
    /// <param name="value">The value to convert to JSON.</param>
    /// <param name="options">An object that specifies serialization options to use.</param>
    public override void Write(Utf8JsonWriter writer, List<TEnum> value, JsonSerializerOptions options)
    {
        writer.WriteStartArray();
        foreach (var item in value)
        {
            writer.WriteStringValue(_enumToString[item]);
        }

        writer.WriteEndArray();
    }
}

Tweaking the second unit test above shows that this works fine:

[TestClass]
public sealed class JsonStringEnumConverterTests
{
    [TestMethod]
    public void ShouldSerializeListWithEnumMemberValues() //Works fine
    {
        var obj = new ManyTestObj(new List<Colors> {Colors.Red, Colors.Blue});
        var sut = JsonSerializer.Serialize(obj);

        Assert.AreEqual(@"{""color"":[""r"",""b""]}", sut);
    }

    private record ManyTestObj(
        [property: JsonPropertyName("color"), JsonConverter(typeof(JsonStringEnumListConverter<Colors>))]
        List<Colors> Colors);

    private enum Colors
    {
        [EnumMember(Value="r")]
        Red,
        [EnumMember(Value="b")]
        Blue,
        [EnumMember(Value="g")]
        Green
    }
}

But now this means that I'm going to have to remember to use a different converter just because I'm using a List. Further, if I wanted to apply it to yet another collection type, say, IEnumerable, I'd have to create yet a third converter to avoid getting the same exception as before (since apparently the types must line up just so or it throws).

Apart from just using the second converter typed for List and hoping I don't need to serialize any other collection types, is there any way I might accommodate this requirement with the .NET 7 version of System.Text.Json (e.g. some bizarre configuration flag that's not obvious)? Thank you.

Edit:

In an effort to limit the need to remember all the concrete implementations of JsonConverters I may have to create and explicitly assigning each to the affected properties, I've created a custom JsonConverterAttribute that can be applied itself to all the properties and hide the mapping to the concrete JsonConverter in its implementation.

[AttributeUsage(AttributeTargets.Property)]
public class JsonStringEnumAttribute<TEnum> : JsonConverterAttribute where TEnum : struct, Enum
{
    /// <summary>
    /// Creates a JsonConverter based on the type provided.
    /// </summary>
    /// <param name="typeToConvert">The type to convert.</param>
    /// <returns></returns>
    public override JsonConverter? CreateConverter(Type typeToConvert)
    {
        if (typeToConvert == typeof(TEnum))
        {
            return new JsonStringEnumConverter<TEnum>();
        }

        if (typeToConvert == typeof(IList<TEnum>) || typeToConvert == typeof(List<TEnum>))
        {
            return new JsonStringEnumListConverter<TEnum>();
        }

        throw new ArgumentException(
            $"This converter only works with enum and List<enum> types and it was provided {typeToConvert}");
    }
}

And, of course, the unit tests demonstrating that this works:

[TestClass]
public sealed class JsonStringEnumConverterTests
{
    [TestMethod]
    public void SingleEntityAttributeTest()
    {
        var obj = new SingleAttributeTestObj(Colors.Red);
        var sut = JsonSerializer.Serialize(obj);

        Assert.AreEqual(@"{""color"":""r""}", sut);
    }

    [TestMethod]
    public void ListEntityAttributeTest()
    {
        var obj = new MultipleAttributeTestObject(new List<Colors> {Colors.Red, Colors.Blue});
        var sut = JsonSerializer.Serialize(obj);

        Assert.AreEqual(@"{""color"":[""r"",""b""]}", sut);
    }

    private record SingleAttributeTestObj([property: JsonPropertyName("color"), JsonStringEnum<Colors>]
        Colors color);

    private record MultipleAttributeTestObject([property: JsonPropertyName("color"), JsonStringEnum<Colors>]
        List<Colors> color);

    private enum Colors
    {
        [EnumMember(Value="r")]
        Red,
        [EnumMember(Value="b")]
        Blue,
        [EnumMember(Value="g")]
        Green
    }
}

I've also filed an issue with the dotnet/runtime repo requesting that this be implemented to transparently support the collections with a custom JsonConverter that they apparently support with a built-in JsonConverter.

Upvotes: 2

Views: 1950

Answers (1)

Guru Stron
Guru Stron

Reputation: 143083

In short - no. You need either write custom converter, for example ItemConverterDecorator from another answer, or mark the enum itself:

[JsonConverter(typeof(JsonStringEnumConverter<Colors>))]
enum Colors
{
    [EnumMember(Value="r")]
    Red,
    [EnumMember(Value="b")]
    Blue,
    [EnumMember(Value="g")]
    Green
}

Or pass the converter in the options:

var sut = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter<Colors>() }
});

Upvotes: 1

Related Questions