Search code examples
c#json.net-coreserializationsystem.text.json

How to implement custom JsonConverter based on property names?


I have a converter preparing my doubles like so.

class DoubleConverter : JsonConverter<double>
{
  public override double Read(...) { ... }
  public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
  {
    writer.WriteStringValue(value + " double from...?");
  }
}

I'd like to be able to specify the name of the property it's been converted from. (In reality it's about controlling the number of digits but also other stuff may be needed).

Didn't really found any good approach (just followed the docs) or info in the overriden method. I have two ideas, non of which appeals to me.

  1. Assign the custom converter in the DTO class per each property individually using an attribute. I'm not even sure if it would work and even if so, it's waaaay to tedious.
  2. Implement the converter on JsonConverter<MyDto>. That would mean a lot of needless custom implementation of the obvious. And also, there's the tedious part.

Are those the only options or is there a way to scootch around it?


Solution

  • It is not possible to get the the current path or property name from Utf8JsonWriter for the simple reason that it does not track the path. The only thing it tracks is a BitStack indicating whether the current container is an array or object. [1]

    Instead, in .NET 7 and later you can use a typeInfo modifier to customize your type's contract to add a converter to each double-valued member whose constructor is passed its MemberInfo.

    First define the following modifier:

    public static partial class JsonExtensions
    {
        public static Action<JsonTypeInfo> AddMemberAwareDoubleConverters { get; } = static typeInfo => 
        {
            if (typeInfo.Kind != JsonTypeInfoKind.Object)
                return;
            foreach (var property in typeInfo.Properties)
                if (property.CustomConverter == null && property.GetMemberInfo() is {} memberInfo)
                    if (property.PropertyType == typeof(double)) 
                        property.CustomConverter = new MemberAwareDoubleConverter(memberInfo);
                    else if (property.PropertyType == typeof(double?))
                        property.CustomConverter = new MemberAwareNullableDoubleConverter(memberInfo);
        };
    
        public static MemberInfo? GetMemberInfo(this JsonPropertyInfo property) => (property.AttributeProvider as MemberInfo);
    }
    
    class MemberAwareDoubleConverter : JsonConverter<double>
    {
        MemberInfo MemberInfo { get; }
        
        public MemberAwareDoubleConverter(MemberInfo memberInfo) => this.MemberInfo = memberInfo ?? throw new ArgumentNullException(nameof(memberInfo));
    
        public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) =>
            writer.WriteRawValue($"{JsonSerializer.Serialize(value)} /* double from {MemberInfo.Name} */", true);
        
        public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 
            // TODO: Handle "NaN", "Infinity", "-Infinity"
            reader.GetDouble();
    }
    
    class MemberAwareNullableDoubleConverter : JsonConverter<double?>
    {
        public override bool HandleNull => true;
        MemberInfo MemberInfo { get; }
        
        public MemberAwareNullableDoubleConverter(MemberInfo memberInfo) => this.MemberInfo = memberInfo ?? throw new ArgumentNullException(nameof(memberInfo));
    
        public override void Write(Utf8JsonWriter writer, double? value, JsonSerializerOptions options) =>
            writer.WriteRawValue($"{JsonSerializer.Serialize(value)} /* double? from {MemberInfo.Name} */", true);
        
        public override double? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 
            // TODO: Handle "NaN", "Infinity", "-Infinity"
            reader.TokenType switch
            {
                JsonTokenType.Number => reader.GetDouble(),
                JsonTokenType.Null => null,
                _ => throw new JsonException(),
            };
    }
    

    Now if your model looks like, e.g.:

    public class Root
    {
        public double RootDoubleValue { get; set; }
        public double? RootNullableValue { get; set; }
        public List<Item> Items { get; set; } = new ();
    }
    
    public record Item(double ItemDoubleValue, double? ItemNullableValue);
    

    And you serialize using the modifier as follows:

    var options = new JsonSerializerOptions
    {
        TypeInfoResolver = new DefaultJsonTypeInfoResolver
        {
            Modifiers = { JsonExtensions.AddMemberAwareDoubleConverters },
        },
        // Others as required
        WriteIndented = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
    };
    var json = JsonSerializer.Serialize(root, options);
    

    You will see that the JSON generated includes comments showing the property names:

    {
      "RootDoubleValue": 101.01 /* double from RootDoubleValue */,
      "RootNullableValue": 202.02 /* double? from RootNullableValue */,
      "Items": [
        {
          "ItemDoubleValue": 2101.01 /* double from ItemDoubleValue */,
          "ItemNullableValue": null /* double? from ItemNullableValue */
        }
      ]
    }
    

    Notes:

    • Honestly I don't recommend this approach. Customizing the logic of the converter based on the property name has a whiff of God object code smell because the converter is required to know all the formatting rules for all double-valued properties in your application.

      Instead, consider adding custom attributes to your models indicating the required formatting, then in your typeInfo modifier, pass that information on to an appropriate converter.

      Or if you simply need to control the number of digits, apply an appropriate RoundingJsonConverter from this answer to Convert JsonConverter to System.Text.Json to support multiple primitive types and nullable.

    Demo fiddle here.


    [1] You also cannot get the path from Utf8JsonReader. See How do I get the property path from Utf8JsonReader? for details.