Search code examples
c#enumsjson.netasp.net-core-6.0system.text.json

Custom enum serialisation with System.Text.Json


I am working with an API that handles requests that submit large amounts of data in JSON format (e.g. 10MB+). To date, we have been using Newtonsoft.Json, but recently we have been experiencing performance issues, mostly related to the amount of time and/or memory required to deserialise the request data into the appropriate types, so we have been looking at switching to System.Text.Json, which according to our internal benchmarking is significantly better in both metrics.

Where I am having problems in modifying the existing code is that we have some custom deserialisation logic written around processing enums, in the form of a JSON.NET custom converter. For historical reasons, we have some clients of the API who use a different set of enum values to the values that the API expects! So, our enum might look something like this:

[AttributeUsage(AttributeTargets.Field)]
public class AlternativeValueAttribute : Attribute
{
    public AlternativeValueAttribute(string code) {
        Code = code;
    }
        
    public string Code { get; }
}

public enum Allowances
{
  [AlternativeValue("CD")] Car = 0,
  [AlternativeValue("AD")] Transport = 1,
  [AlternativeValue("LD")] Laundry = 2
}

public class AllowanceRequest
{
  public Allowances Type { get; set; }
  public Allowances? AdditionalType { get; set; }
  public decimal Value { get; set; }
}

so client A will submit their data as:

{ "type": "Car", "value": 25.75 }

and client B will submit their data as:

{ "type": 0, "value": 25.75 }

and client C will submit their data as:

{ "type": "CD", "value": 25.75 }

(I've given a single example here, but there are many enums that make use of this custom attribute, so having an enum-specific converter is not really a valid approach - the converter needs to be fairly generic).

I am having difficulties understanding exactly how System.Text.Json handles custom conversion of enums, as that seems to be a special case and handled differently to a regular class, requiring the use of a JsonConverterFactory instead of an implementation of JsonConverter<>.

public class AlternativeValueJsonStringEnumConverter : JsonConverterFactory {
    public AlternativeValueJsonStringEnumConverter() {}

    public override bool CanConvert(Type typeToConvert) {
        var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
        return enumType.IsEnum;
    }

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
        return new CustomStringEnumConverter(options);
    }

    private class CustomStringEnumConverter : JsonConverter<Enum?>
    {
        public CustomStringEnumConverter(JsonSerializerOptions options) { }

        public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
            var isNullable = Nullable.GetUnderlyingType(typeToConvert) != null;
            var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;

            switch (reader.TokenType) {
                case JsonTokenType.Null when !isNullable:
                    throw new JsonException("Cannot deserialise null value to non-nullable field");
                case JsonTokenType.String:
                    var result = ReadStringValue(reader, enumType);
                    return (Enum?) result;
                case JsonTokenType.Number:
                    return ReadNumberValue(reader, enumType);
                default:
                    return null;
            }
        }

        public override void Write(Utf8JsonWriter writer, Enum? value, JsonSerializerOptions options) {
            if (value == null) {
                writer.WriteNullValue();
            } else {
                var description = value.ToString();
                writer.WriteStringValue(description);
            }
        }

        public override bool CanConvert(Type typeToConvert) {
            var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
            return enumType.IsEnum;
        }

        private static string GetDescription(Enum source) {
            var fieldInfo = source.GetType().GetField(source.ToString());
            if (fieldInfo == null) {
                return source.ToString();
            }
            
            var attributes = (System.ComponentModel.DescriptionAttribute[])fieldInfo.GetCustomAttributes(typeof(System.ComponentModel.DescriptionAttribute), false);
            return attributes != null && attributes.Length > 0
                ? attributes[0].Description
                : source.ToString();
        }

        private static object? ReadStringValue(Utf8JsonReader reader, Type enumType) {
            var parsedValue = reader.GetString()!;

            foreach (var item in Enum.GetValues(enumType))
            {
                var attribute = item.GetType().GetTypeInfo().GetRuntimeField(item.ToString()).GetCustomAttribute<AlternativeValueAttribute>();
                if (attribute == null && Enum.TryParse(enumType, parsedValue, true, out var result)) {
                    return result;
                }

                if (attribute != null && attribute.Code == parsedValue &&
                    Enum.TryParse(enumType, item.ToString(), true, out var attributedResult)) {
                    return attributedResult;
                }

                if (parsedValue == item.ToString() && Enum.TryParse(enumType, parsedValue, true, out var parsedResult)) {
                    return parsedResult;
                }
            }

            return null;
        }

        private static Enum? ReadNumberValue(Utf8JsonReader reader, Type enumType) {
            var result = int.Parse(reader.GetString()!);
            var castResult = Enum.ToObject(enumType, result);
            foreach (var item in Enum.GetValues(enumType)) {
                if (castResult.Equals(item)) {
                    return (Enum?)Convert.ChangeType(castResult, enumType);
                }
            }

            throw new JsonException($"Could not convert '{result}' to enum of type '{enumType.Name}'.");
        }
    }
}

I then run the same tests that I wrote for the JSON.NET implementation, to make sure nothing has broken e.g.:

public class WhenUsingCustomSerialiser
{
    [Fact]
    public void ItShouldDeserialiseWhenValueIsDecorated()
    {
        var settings = new JsonSerializerOptions { WriteIndented = false };
        settings.Converters.Add(new AlternativeValueJsonStringEnumConverter());

        var output = JsonSerializer.Deserialize<AllowanceRequest>("{ Type: 'CD', Value: 25.75 }", settings);
        output.Should().BeEquivalentTo(new { Type = Allowances.Car, Value = 25.75M });
    }
}

But that fails with the following exception:

System.InvalidOperationException
The converter 'AlternativeValueJsonStringEnumConverter+CustomStringEnumConverter' is not compatible with the type 'System.Nullable`1[TestEnum]'.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_SerializationConverterNotCompatible(Type converterType, Type type)
   at System.Text.Json.JsonSerializerOptions.GetConverterInternal(Type typeToConvert)
   at System.Text.Json.JsonSerializerOptions.DetermineConverter(Type parentClassType, Type runtimePropertyType, MemberInfo memberInfo)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.GetConverter(Type type, Type parentClassType, MemberInfo memberInfo, Type& runtimeType, JsonSerializerOptions options)
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo.AddProperty(MemberInfo memberInfo, Type memberType, Type parentClassType, Boolean isVirtual, Nullable`1 parentTypeNumberHandling, JsonSerializerOptions options)

Does anyone have any experience in writing their own custom enum serialization code?


Solution

  • The exception is thrown because your converter factory is manufacturing an inner converter of type JsonConverter<Enum?>, and Enum? is not assignable from or to the nullable type Allowances?. To confirm that nullability is the problem, change AdditionalType to public Allowances AdditionalType { get; set; } and your code will work, see fiddle #1 for a demo. [1]

    Beyond this, you have some additional issues with your converter:

    1. If you write a converter for some struct type T, as of .NET 5 it is no longer necessary to write a converter for Nullable<T>. System.Text.Json will assign null for null JSON values, and invoke your converter for non-null values.

      For confirmation see Honor converters for underlying types of Nullable specified with JsonConverterAttribute #32006.

    2. Generally the factory converter pattern should be used when you have some type hierarchy and want to manufacture a specific converter JsonConverter<TConcrete> for each concrete type.

      For instance, the built-in JsonStringEnumConverter currently manufactures a converter of type EnumConverter<T> for each concrete enum type T.

      If you are going to use a factory, it would make sense for you do do the same. And if you do, it will fix the converter compatibility exception.

    3. Your code does not handle [Flags] enums correctly. Your method ReadNumberValue() also does not handle enums with underlying type long or ulong.

    4. Your code is not parsing numeric enum values in the invariant locale.

    5. You are reading and writing numeric values as strings. You need to read and write them as integers.

    6. Your code is also not formatting unknown (enum) enum values in the invariant locale. value.ToString() may return a string with a localized negative sign in certain locales. MSFT's EnumConverter<T> does handle this correctly.

    7. Your code allows unknown enum values when serializing, but throws when deserializing. It should be consistent.

    Putting all the above together, I'd suggest an alternate approach: fall back to the framework's default converter if there are no alternative values applied, and unless the incoming string value happens to match one of the codes. The following does just that:

    public class AlternativeValueJsonStringEnumConverter : JsonConverterFactory {
        public AlternativeValueJsonStringEnumConverter() {}
    
        static readonly JsonConverterFactory defaultConverter = new JsonStringEnumConverter(namingPolicy : null, allowIntegerValues : true);
    
        public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum;
    
        public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
            Debug.Assert(typeToConvert.IsEnum);
            // TODO: handle [Flags] or disallow
            var innerDefaultConverter = defaultConverter.CreateConverter(typeToConvert, options);
            var values = Enum.GetValues(typeToConvert).Cast<Enum>()
                .Select(item => (Item : item, Code : item.GetType().GetTypeInfo().GetRuntimeField(item.ToString())?.GetCustomAttribute<AlternativeValueAttribute>()?.Code))
                .Where(p => p.Code != null)
                .ToList();
            if (values.Count > 0)
                return (JsonConverter?)Activator.CreateInstance(typeof(CustomStringEnumConverter<>).MakeGenericType(typeToConvert), new object? [] { options, values, innerDefaultConverter });
            else
                return innerDefaultConverter;
        }
    
        private class CustomStringEnumConverter<TEnum> : JsonConverter<TEnum> where TEnum : struct, Enum
        {
            readonly Dictionary<string, TEnum> codes;
            readonly JsonConverter<TEnum> innerDefaultConverter;
        
            public CustomStringEnumConverter(JsonSerializerOptions options, List<(Enum Item, string Code)> values, JsonConverter innerDefaultConverter)
            {
                this.codes = values.ToDictionary(p => p.Code, p => (TEnum)p.Item);
                this.innerDefaultConverter = (JsonConverter<TEnum>)innerDefaultConverter ?? throw new ArgumentNullException(nameof(innerDefaultConverter));
            }
            
            public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
                TryReadAlternateValue(ref reader, out var value) ? value : innerDefaultConverter.Read(ref reader, typeToConvert, options);
    
            public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) => 
                innerDefaultConverter.Write(writer, value, options);
    
            public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum;
    
            private bool TryReadAlternateValue(ref Utf8JsonReader reader, out TEnum value)
            {
                if (reader.TokenType == JsonTokenType.String && codes.TryGetValue(reader.GetString()!, out value))
                    return true;
                value = default;
                return false;
            }
        }
    }
    

    You will still need to handle [Flags] enums with alternate values, which System.Text.Json serializes as comma-separated flag names like so:

    "FlagOne, FlagThirtyTwo, FlagSixtyTwo"
    

    Demo fiddle #2 here.


    [1] The exception is thrown specifically from DefaultJsonTypeInfoResolver.GetConverterForType(Type typeToConvert, options, resolveJsonConverterAttribute):

    // Expand if factory converter & validate.
    converter = options.ExpandConverterFactory(converter, typeToConvert);
    if (!converter.TypeToConvert.IsInSubtypeRelationshipWith(typeToConvert))
    {
         ThrowHelper.ThrowInvalidOperationException_SerializationConverterNotCompatible(converter.GetType(), typeToConvert);
    }
    

    Since Nullable<AdditionalType> is neither a subtype nor a supertype of System.Enum, the exception is thrown.

    The error message is, admittedly, useless.