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

System.Text.Json JsonStringEnumConverter with custom fallback in case of deserialization failures


I have a .NET 6 program that needs to deserialize a JSON string value (returned by external API) into a .NET enum.

The issue is that there are over 100 possible enum values (and more could be added without my knowledge), but I'm only interested in a few of them. So I would like to define an enum type like this and deserialize all the values that I'm not interested in to MyEnum.Unknown:

public enum MyEnum
{
    Unknown = 0,
    Value1,
    Value2,
    Value3,
    // only values that I'm interested in will be defined
}

If I'm using Newtonsoft.Json, I can do this quite easily with a custom JSON converter:

public class DefaultFallbackStringEnumConverter : StringEnumConverter
{
    private readonly object _defaultValue;

    public DefaultFallbackStringEnumConverter() : this(0)
    {
    }

    public DefaultFallbackStringEnumConverter(object defaultValue)
    {
        _defaultValue = defaultValue;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        try
        {
            return base.ReadJson(reader, objectType, existingValue, serializer);
        }
        catch (JsonException)
        {
            return _defaultValue;
        }
    }
}

But with System.Text.Json, I can't figure out how this can be done easily, because the JsonStringEnumConverter in STJ is actually a JsonConverterFactory that doesn't do the serialization itself (it throws exceptions in all overrides of JsonConverter as you can see here). Instead the factory creates EnumConverter<T>s that actually do the work, but EnumConverter<T> is internal so I can't even reference or inherit from it in user code.

Any idea how this can be done easily with STJ or is it not possible at all? Thanks a lot for the help!


Solution

  • You could use the decorator pattern and wrap the JsonStringEnumConverter factory in a decorator whose CreateConverter() method wraps the returned EnumConverter<T> in some inner decorator that catches the exception and returns a default value.

    The following does that:

    public class DefaultFallbackStringEnumConverter : JsonConverterFactoryDecorator
    {
        public DefaultFallbackStringEnumConverter(JsonStringEnumConverter inner) : base(inner) { }
        public DefaultFallbackStringEnumConverter() : this(new JsonStringEnumConverter()) { }
    
        protected virtual T GetDefaultValue<T>() where T : struct, Enum => default(T);
    
        public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var inner = base.CreateConverter(typeToConvert, options);
            return (JsonConverter?)Activator.CreateInstance(typeof(EnumConverterDecorator<>).MakeGenericType(Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert), new object? [] { this, inner });
        }
    
        sealed class EnumConverterDecorator<T> : JsonConverter<T> where T : struct, Enum
        {
            readonly DefaultFallbackStringEnumConverter parent;
            readonly JsonConverter<T> inner;
            public EnumConverterDecorator(DefaultFallbackStringEnumConverter parent, JsonConverter inner) => 
                (this.parent, this.inner)= (parent ?? throw new ArgumentException(nameof(parent)), (inner as JsonConverter<T>) ?? throw new ArgumentException(nameof(inner)));
    
            public override bool CanConvert(Type typeToConvert) => inner.CanConvert(typeToConvert);
            
            public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                try
                {
                    return inner.Read(ref reader, typeToConvert, options);
                }
                catch (JsonException)
                {
                    return parent.GetDefaultValue<T>();
                }
            }
            public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => inner.Write(writer, value, options);
        }
    }
    
    public class JsonConverterFactoryDecorator : JsonConverterFactory
    {
        readonly JsonConverterFactory inner;
        public JsonConverterFactoryDecorator(JsonConverterFactory inner) => this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
        public override bool CanConvert(Type typeToConvert) => inner.CanConvert(typeToConvert);
        public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => inner.CreateConverter(typeToConvert, options);
    }
    

    Do note that, unlike Json.NET, System.Text.Json does not have an equivalent to Json.NET's ConverterParameters (see issue #54187 for confirmation) so if you need a different default value for a specific enum, you will need to subclass DefaultFallbackStringEnumConverter for that specific enum, e.g. like so:

    public enum MyEnum2
    {
        Unknown1 = 1,  // Use this value for unknown values
        Value2 = 2,
        Value3 = 3,
    }
    
    public class MyEnum2Converter : DefaultFallbackStringEnumConverter
    {
        protected override T GetDefaultValue<T>() => typeof(T) == typeof(MyEnum2) ? (T)(object)MyEnum2.Unknown1 : base.GetDefaultValue<T>();
        public override bool CanConvert(Type typeToConvert) => base.CanConvert(typeToConvert) && typeToConvert == typeof(MyEnum2);
    }
    

    Then if your model looks like e.g.:

    public record Model(MyEnum MyEnum, 
                        [property: JsonConverter(typeof(MyEnum2Converter))] MyEnum2? MyEnum2);
    

    And your JSON looks like:

    {"MyEnum" : "missing value", "MyEnum2" : "missing value" }
    

    You will be able to deserialize and re-serialize as follows:

    var options = new JsonSerializerOptions
    {
        Converters = { new DefaultFallbackStringEnumConverter() },
    };
    
    var model = JsonSerializer.Deserialize<Model>(json, options);
    
    var newJson = JsonSerializer.Serialize(model, options);
    

    Demo fiddle here.