Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I get a null value instead of a serialization error when deserializing an enum by string conversion?

A few of my API endpoints have models that include enums. FluentValidation is being used to verify that the values sent across meet their respective requirements.

To aid in usability and document generation, enums are allowed to be sent as strings rather than integers. Validation that the value sent across is in the correct range works fine if an invalid integer is sent, but serialization will fail if an invalid string is sent across.

public enum Foo 
{
    A = 1,
    B = 2
}

public class Bar 
{
    public Foo? Foo {get;set;}
}

void Main()
{
    var options = new JsonSerializerOptions();
    options.Converters.Add(new JsonStringEnumConverter());
    options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

    var jsonString = "{\"foo\": \"C\"}";
    var jsonSpan = (ReadOnlySpan<byte>)Encoding.UTF8.GetBytes(jsonString);

    try
    {
        var result = JsonSerializer.Deserialize<Bar>(jsonSpan, options);
        Console.WriteLine(result.Foo == null);
    }
    catch(Exception ex) 
    {
        Console.WriteLine("Serialization Failed");
    }
}

My desired outcome would be to simply deserialize the enum property to null when the string does not match any of the enum's fields so that the model can be passed through to the validator to create a friendly message.

How can I achieve this? This is using net-core 3 preview 8 with the System.Text.Json API.

like image 871
Jonathon Chase Avatar asked Sep 11 '19 16:09

Jonathon Chase


2 Answers

As far I have tried, I have 2 solutions, one using System.Text.Json and the other one is Newtonsoft.

System.Text.Json

Your create a custom class using JsonConverter

You introduce Unknown enum in Foo.

in stead of using JsonStringEnumConverter

options.Converters.Add(new JsonStringEnumConverter());

Use your customized class CustomEnumConverter

options.Converters.Add(new CustomEnumConverter());

So lets put thing together:

public enum Foo
{
    A = 1,
    B = 2,
    // what ever name and enum number that fits your logic
    Unknown = 99
}

public class Bar
{
    public Foo? Foo { get; set; }
}   

public static void Main()
{
    var options = new JsonSerializerOptions();
    options.Converters.Add(new CustomEnumConverter());
    options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

    var jsonString = "{\"foo\": \"C\"}";
    var jsonSpan = (ReadOnlySpan<byte>)Encoding.UTF8.GetBytes(jsonString);

    try
    {
        var result = JsonSerializer.Deserialize<Bar>(jsonSpan, options);

        if (result.Foo == Foo.Unknown)
            result.Foo = null;

        Console.WriteLine(result.Foo == null);
    }
    catch (Exception ex)
    {
        Console.WriteLine("Serialization Failed" + ex.Message);
    }
}

Here is the code CustomEnumConverter

internal sealed class CustomEnumConverter : JsonConverter<Foo>
{
    public override Foo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.String:
                var isNullable = IsNullableType(typeToConvert);
                var enumType = isNullable ? Nullable.GetUnderlyingType(typeToConvert) : typeToConvert;
                var names = Enum.GetNames(enumType ?? throw new InvalidOperationException());
                if (reader.TokenType != JsonTokenType.String) return Foo.Unknown;
                var enumText = System.Text.Encoding.UTF8.GetString(reader.ValueSpan);
                if (string.IsNullOrEmpty(enumText)) return Foo.Unknown;
                var match = names.FirstOrDefault(e => string.Equals(e, enumText, StringComparison.OrdinalIgnoreCase));
                return (Foo) (match != null ? Enum.Parse(enumType, match) : Foo.Unknown);
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public override void Write(Utf8JsonWriter writer, Foo value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }

    private static bool IsNullableType(Type t)
    {
        return (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>));
    }
}

Running this code should return True with out exception.

For this solution I got some inspiration from here.

The other way is a bit similar but using Newtonsoft.

Note: Remember what I did here is just example to demonstrate stuff, please validate every thing, test it before going production.

Newtonsoft (Original Answer)

Another way to solve this using Newtonsoft with custom JsonConverter.

What you do is added attribute of your custom JsonConverter to your Foo class [JsonConverter(typeof(CustomEnumConverter))].

Then make your class method to return null if the enum is not recognized.

You can of course customize almost any type and have different customization classes.

Ok install Newtonsoft.Json nuget package via Nuget Manager.

We start with you code modification:

//add the attribute here
[JsonConverter(typeof(CustomEnumConverter))]
public enum Foo
{
    A = 1,
    B = 2
}

public class Bar
{
    public Foo? Foo { get; set; }
}

public static void Main()
{
    var jsonString = "{\"foo\": \"C\"}";

    try
    {
        // use newtonsoft json converter
        var result = JsonConvert.DeserializeObject<Bar>(jsonString);
        Console.WriteLine(result.Foo == null);
    }
    catch (Exception ex)
    {
        Console.WriteLine("Serialization Failed" + ex.Message);
    }
}

And now for your customization class:

public class CustomEnumConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        var type = IsNullableType(objectType) ? Nullable.GetUnderlyingType(objectType) : objectType;
        return type != null && type.IsEnum;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var isNullable = IsNullableType(objectType);
        var enumType = isNullable ? Nullable.GetUnderlyingType(objectType) : objectType;
        var names = Enum.GetNames(enumType ?? throw new InvalidOperationException());

        if (reader.TokenType != JsonToken.String) return null;
        var enumText = reader.Value.ToString();

        if (string.IsNullOrEmpty(enumText)) return null;
        var match = names.FirstOrDefault(e => string.Equals(e, enumText, StringComparison.OrdinalIgnoreCase));

        return match != null ? Enum.Parse(enumType, match) : null;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        writer.WriteValue(value.ToString());
    }

    public override bool CanWrite => true;

    private static bool IsNullableType(Type t)
    {
        return (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>));
    }
}

Now it is test time.

When we fire the program with out [JsonConverter(typeof(CustomEnumConverter))] we get error as shown here: enter image description here

But when we added [JsonConverter(typeof(CustomEnumConverter))] and run the program again it works: enter image description here

Links:

  • https://www.newtonsoft.com/json
  • I got inspiration from this answer:
    How can I ignore unknown enum values during json deserialization?
  • https://bytefish.de/blog/enums_json_net/
like image 104
Maytham Avatar answered Oct 10 '22 04:10

Maytham


You can deserialize into a string and TryParse

public class Bar
{
    public string Foo { get; set; }
    public Foo? FooEnum { get; set; }
}

...
var result = JsonSerializer.Deserialize<Bar>(jsonSpan, options);
Enum.TryParse<Foo>(result, out Bar.FooEnum);
like image 24
Andrew Colombi Avatar answered Oct 10 '22 02:10

Andrew Colombi