.NET Core 3 System.Text.Json EnumMemberAttribute serialization

Part of the series: Fun with System.Text.Json

TL;DR: If you are looking for a quick solution, check out JsonStringEnumMemberConverter in Macross.Json.Extensions.

.NET Core 3 released on 9/23/2019 and contains a brand new, lighting-fast, serialization engine in the System.Text.Json namespace.

I started playing with the engine in Preview 9 and almost immediately noticed there’s some feature gaps with Json.NET (and even some bugs). One of these feature gaps is that while the serialization engine does have support for converting Enum values to strings (via the JsonStringEnumConverter type), it doesn’t give us a way to customize the values as they are mapped in/out of the JSON. Json.NET used EumMemberAttribute from the System.Runtime.Serialization namespace to do precisely that.

Here’s an example of how you could previously use Json.NET & EnumMemberAttribute on Enums to control which value will be read/written in the JSON:

    using Newtonsoft.Json;
    using Newtonsoft.Json.Converters;

    [JsonConverter(typeof(StringEnumConverter))]
    [Flags]
    public enum FlagDefinitions
    {
        None = 0x00,

        [EnumMember(Value = "all values")]
        All = One | Two | Three | Four,

        [EnumMember(Value = "one value")]
        One = 0x01,
        [EnumMember(Value = "two value")]
        Two = 0x02,
        [EnumMember(Value = "three value")]
        Three = 0x04,
        [EnumMember(Value = "four value")]
        Four = 0x08,
    }

In that example if our JSON document contained “one value” we would parse as FlagDefinitions.One and vice versa.

What can we do?

I have a PR out there on the corefx repo to add support into the built-in JsonStringEnumConverter type for EnumMemberAttribute. But while the team chews on that, we can actually roll our own converter with the support we need. Here’s a stand-alone port of the code on that PR, you guys can use this in your own projects if you want to get EnumMemberAttribute up and running with System.Text.Json just like we did with Json.NET:

using System;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.Generic;

namespace Code
{
    internal static class Program
    {
        public static void Main(string[] args)
        {
            string json = JsonSerializer.Serialize(FlagDefinitions.One | FlagDefinitions.Two);
            FlagDefinitions flags = JsonSerializer.Deserialize<FlagDefinitions>(json);
        }
    }

    [JsonConverter(typeof(JsonStringEnumMemberConverter))]
    [Flags]
    public enum FlagDefinitions
    {
        None = 0x00,

        [EnumMember(Value = "all values")]
        All = One | Two | Three | Four,

        [EnumMember(Value = "one value")]
        One = 0x01,
        [EnumMember(Value = "two value")]
        Two = 0x02,
        [EnumMember(Value = "three value")]
        Three = 0x04,
        [EnumMember(Value = "four value")]
        Four = 0x08,
    }

    public class JsonStringEnumMemberConverter : JsonConverterFactory
    {
        private readonly JsonNamingPolicy _namingPolicy;
        private readonly bool _allowIntegerValues;

        public JsonStringEnumMemberConverter()
            : this(namingPolicy: null, allowIntegerValues: true)
        {
        }

        public JsonStringEnumMemberConverter(JsonNamingPolicy namingPolicy = null, bool allowIntegerValues = true)
        {
            _namingPolicy = namingPolicy;
            _allowIntegerValues = allowIntegerValues;
        }

        public override bool CanConvert(Type typeToConvert)
        {
            return typeToConvert.IsEnum;
        }

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            return (JsonConverter)Activator.CreateInstance(
                typeof(Converter<>).MakeGenericType(typeToConvert),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: new object[] { _namingPolicy, _allowIntegerValues },
                culture: null);
        }

        private class Converter<T> : JsonConverter<T>
            where T : struct, Enum
        {
            private class EnumInfo
            {
                public string Name;
                public T EnumValue;
                public ulong RawValue;
            }

            private static readonly Type s_enumType = typeof(T);
            private static readonly TypeCode s_enumTypeCode = Type.GetTypeCode(s_enumType);

            private static ulong GetEnumValue(object value)
            {
                switch (s_enumTypeCode)
                {
                    case TypeCode.Int32:
                        return (ulong)(int)value;
                    case TypeCode.UInt32:
                        return (uint)value;
                    case TypeCode.UInt64:
                        return (ulong)value;
                    case TypeCode.Int64:
                        return (ulong)(long)value;

                    case TypeCode.SByte:
                        return (ulong)(sbyte)value;
                    case TypeCode.Byte:
                        return (byte)value;
                    case TypeCode.Int16:
                        return (ulong)(short)value;
                    case TypeCode.UInt16:
                        return (ushort)value;
                }

                throw new NotSupportedException();
            }

            private readonly bool _allowIntegerValues;
            private readonly bool _isFlags;
            private readonly Dictionary<ulong, EnumInfo> _rawToTransformed;
            private readonly Dictionary<string, EnumInfo> _transformedToRaw;

            public Converter(JsonNamingPolicy namingPolicy = null, bool allowIntegerValues = true)
            {
                _allowIntegerValues = allowIntegerValues;

                _isFlags = s_enumType.IsDefined(typeof(FlagsAttribute), true);

                string[] builtInNames = s_enumType.GetEnumNames();
                Array builtInValues = s_enumType.GetEnumValues();

                _rawToTransformed = new Dictionary<ulong, EnumInfo>();
                _transformedToRaw = new Dictionary<string, EnumInfo>();

                for (int i = 0; i < builtInNames.Length; i++)
                {
                    T enumValue = (T)builtInValues.GetValue(i);
                    ulong rawValue = GetEnumValue(enumValue);

                    string name = builtInNames[i];

                    string transformedName;
                    if (namingPolicy == null)
                    {
                        FieldInfo field = s_enumType.GetField(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)!;
                        EnumMemberAttribute enumMemberAttribute = field.GetCustomAttribute<EnumMemberAttribute>(true);
                        transformedName = enumMemberAttribute?.Value ?? name;
                    }
                    else
                    {
                        transformedName = namingPolicy.ConvertName(name) ?? name;
                    }

                    _rawToTransformed[rawValue] = new EnumInfo
                    {
                        Name = transformedName,
                        EnumValue = enumValue,
                        RawValue = rawValue
                    };
                    _transformedToRaw[transformedName] = new EnumInfo
                    {
                        Name = name,
                        EnumValue = enumValue,
                        RawValue = rawValue
                    };
                }
            }

            public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                JsonTokenType token = reader.TokenType;

                if (token == JsonTokenType.String)
                {
                    string enumString = reader.GetString();

                    // Case sensitive search attempted first.
                    if (_transformedToRaw.TryGetValue(enumString, out EnumInfo enumInfo))
                    {
                        return (T)Enum.ToObject(s_enumType, enumInfo.RawValue);
                    }

                    if (_isFlags)
                    {
                        ulong calculatedValue = 0;

                        string[] flagValues = enumString.Split(", ");
                        foreach (string flagValue in flagValues)
                        {
                            // Case sensitive search attempted first.
                            if (_transformedToRaw.TryGetValue(flagValue, out enumInfo))
                            {
                                calculatedValue |= enumInfo.RawValue;
                            }
                            else
                            {
                                // Case insensitive search attempted second.

                                bool matched = false;
                                foreach (KeyValuePair<string, EnumInfo> enumItem in _transformedToRaw)
                                {
                                    if (string.Equals(enumItem.Key, flagValue, StringComparison.OrdinalIgnoreCase))
                                    {
                                        calculatedValue |= enumItem.Value.RawValue;
                                        matched = true;
                                        break;
                                    }
                                }

                                if (!matched)
                                {
                                    throw new NotSupportedException();
                                }
                            }
                        }

                        return (T)Enum.ToObject(s_enumType, calculatedValue);
                    }
                    else
                    {
                        // Case insensitive search attempted second.
                        foreach (KeyValuePair<string, EnumInfo> enumItem in _transformedToRaw)
                        {
                            if (string.Equals(enumItem.Key, enumString, StringComparison.OrdinalIgnoreCase))
                            {
                                return (T)Enum.ToObject(s_enumType, enumItem.Value.RawValue);
                            }
                        }
                    }

                    throw new NotSupportedException();
                }

                if (token != JsonTokenType.Number || !_allowIntegerValues)
                {
                    throw new NotSupportedException();
                }

                switch (s_enumTypeCode)
                {
                    // Switch cases ordered by expected frequency

                    case TypeCode.Int32:
                        if (reader.TryGetInt32(out int int32))
                        {
                            return (T)Enum.ToObject(s_enumType, int32);
                        }
                        break;
                    case TypeCode.UInt32:
                        if (reader.TryGetUInt32(out uint uint32))
                        {
                            return (T)Enum.ToObject(s_enumType, uint32);
                        }
                        break;
                    case TypeCode.UInt64:
                        if (reader.TryGetUInt64(out ulong uint64))
                        {
                            return (T)Enum.ToObject(s_enumType, uint64);
                        }
                        break;
                    case TypeCode.Int64:
                        if (reader.TryGetInt64(out long int64))
                        {
                            return (T)Enum.ToObject(s_enumType, int64);
                        }
                        break;

                    case TypeCode.SByte:
                        if (reader.TryGetSByte(out sbyte byte8))
                        {
                            return (T)Enum.ToObject(s_enumType, byte8);
                        }
                        break;
                    case TypeCode.Byte:
                        if (reader.TryGetByte(out byte ubyte8))
                        {
                            return (T)Enum.ToObject(s_enumType, ubyte8);
                        }
                        break;
                    case TypeCode.Int16:
                        if (reader.TryGetInt16(out short int16))
                        {
                            return (T)Enum.ToObject(s_enumType, int16);
                        }
                        break;
                    case TypeCode.UInt16:
                        if (reader.TryGetUInt16(out ushort uint16))
                        {
                            return (T)Enum.ToObject(s_enumType, uint16);
                        }
                        break;
                }

                throw new NotSupportedException();
            }

            public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
            {
                ulong rawValue = GetEnumValue(value);

                if (_rawToTransformed.TryGetValue(rawValue, out EnumInfo enumInfo))
                {
                    writer.WriteStringValue(enumInfo.Name);
                    return;
                }

                if (_isFlags)
                {
                    ulong calculatedValue = 0;

                    StringBuilder Builder = new StringBuilder();
                    foreach (KeyValuePair<ulong, EnumInfo> enumItem in _rawToTransformed)
                    {
                        enumInfo = enumItem.Value;
                        if (!value.HasFlag(enumInfo.EnumValue)
                            || enumInfo.RawValue == 0) // Definitions with 'None' should hit the cache case.
                        {
                            continue;
                        }

                        // Track the value to make sure all bits are represented.
                        calculatedValue |= enumInfo.RawValue;

                        if (Builder.Length > 0)
                            Builder.Append(", ");
                        Builder.Append(enumInfo.Name);
                    }
                    if (calculatedValue == rawValue)
                    {
                        writer.WriteStringValue(Builder.ToString());
                        return;
                    }
                }

                if (!_allowIntegerValues)
                {
                    throw new NotSupportedException();
                }

                switch (s_enumTypeCode)
                {
                    case TypeCode.Int32:
                        writer.WriteNumberValue((int)rawValue);
                        break;
                    case TypeCode.UInt32:
                        writer.WriteNumberValue((uint)rawValue);
                        break;
                    case TypeCode.UInt64:
                        writer.WriteNumberValue(rawValue);
                        break;
                    case TypeCode.Int64:
                        writer.WriteNumberValue((long)rawValue);
                        break;
                    case TypeCode.Int16:
                        writer.WriteNumberValue((short)rawValue);
                        break;
                    case TypeCode.UInt16:
                        writer.WriteNumberValue((ushort)rawValue);
                        break;
                    case TypeCode.Byte:
                        writer.WriteNumberValue((byte)rawValue);
                        break;
                    case TypeCode.SByte:
                        writer.WriteNumberValue((sbyte)rawValue);
                        break;
                    default:
                        throw new NotSupportedException();
                }
            }
        }
    }
}

There is also code included in there to fix any generally non-trivial JsonNamingPolicy not working for deserialization. This dotnet/runtime issue is tracking that problem.

Update: The dotnet/corefx repo was moved to the dotnet/runtime repo. I don’t think they took PRs or issues over, so I’ll have to recreate that stuff. I tried a few days ago and the tests wouldn’t run so it was hard to get anything done. I’ll try again later. In the meantime, the code below has been rolled into a NuGet package called Macross.Json.Extensions (with another feature), and put up on GitHub.

Update (2): It looks like in .NET 6 we’ll get support for System.Runtime.Serialization in System.Text.Json which should also work for controlling the values of Enums being read or written out to JSON.

Update (3): The code in the NuGet has continued to evolve. You can now use JsonPropertyName to control the name of enum values (if you are using the .NET 5+ version of Sytem.Text.Json). And you can now specify a default value to use in the case of deserialization failures.

2 thoughts on “.NET Core 3 System.Text.Json EnumMemberAttribute serialization”

    • @Stef Heyenrath you are my first commenter! Thanks!

      I was just looking through the source of your extension. Nice job bud! FYI it suffers from the same bug as stock System.Text.Json in that if you use a naming policy that does anything other than swap case, deserialization will fail. I was going to contribute a unit test into your repo showing the failure, but no tests?! Tisk tisk!

      Also worth noting: The stock converter and the extension don’t cache for deserialization. The one I have above does, which I think will make it faster ultimately. I don’t have benchmarks to back that up. What I was trying to do with mine was fix that bug and then avoid paying a penalty to Enum.TryParse x2 + EnumMember attribute lookup.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.