Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

System.Text.Json - Use custom JsonConverter conditionally depending on field attribute

I have a custom attribute [Foo] implemented as follows:

public class FooAttribute
  : Attribute
{
}

Now I want to use the System.Text.Json.JsonSerializer to step into each field that has that attribute, in order to manipulate how is serialized and deserialized.

For example, if I have the following class

class SampleInt
{
    [Foo] 
    public int Number { get; init; }

    public int StandardNumber { get; init; }

    public string Text { get; init; }
}

when I serialize an instance of this class, I want a custom int JsonConverter to apply only for that field.

public class IntJsonConverter
    : JsonConverter<int>
{
    public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // do whatever before reading if the text starts with "potato". But this should be triggered only if destination type has the Foo attribute. How?
        return reader.GetInt32();
    }

    public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
    {
        writer.WriteStringValue("potato" + value.ToString());
    }
}

so that the serialization for

var sample =
  new SampleInt
  {
    Number = 123,
    StandardNumber = 456
    Text = "bar"
};

like this

var serializeOptions = new JsonSerializerOptions();
var serializeOptions.Converters.Add(new IntJsonConverter());
var resultJson = JsonSerializer.Serialize(sample, serializeOptions);

results on the following json

{
  "number": "potato123",
  "standardNumber": 456,
  "text": "bar"
}

and not in

{
  "number": "potato123",
  "standardNumber": "potato456",
  "text": "bar"
}

In a similar manner, I want the deserialization to be conditional, and only use the custom converter if the destination field has the [Foo] attribute.

With Newtonsoft, this is possible using Contract Resolvers and overriding CreateProperties method like this.

public class SerializationContractResolver
    : DefaultContractResolver
{
    private readonly ICryptoTransform _encryptor;
    private readonly FieldEncryptionDecryption _fieldEncryptionDecryption;

    public SerializationContractResolver(
        ICryptoTransform encryptor,
        FieldEncryptionDecryption fieldEncryptionDecryption)
    {
        _encryptor = encryptor;
        _fieldEncryptionDecryption = fieldEncryptionDecryption;
        NamingStrategy = new CamelCaseNamingStrategy();
    }

    protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
    {
        var properties = base.CreateProperties(type, memberSerialization);
        foreach (var jsonProperty in properties)
        {
            var hasAttribute = HasAttribute(type, jsonProperty);
            if (hasAttribute)
            {
                var serializationJsonConverter = new MyJsonConverter();
                jsonProperty.Converter = serializationJsonConverter;
            }
        }
        return properties;
    }
    
    private bool HasAttribute(Type type, JsonProperty jsonProperty)
    {
        var propertyInfo = type.GetProperty(jsonProperty.UnderlyingName);
        if (propertyInfo is null)
        {
            return false;
        }
        var hasAttribute =
            propertyInfo.CustomAttributes
                .Any(x => x.AttributeType == typeof(FooAttribute));
        var propertyType = propertyInfo.PropertyType;
        var isSimpleValue = propertyType.IsValueType || propertyType == typeof(string);
        var isSupportedField = isSimpleValue && hasPersonalDataAttribute;
        return isSupportedField;
    }
}

But I don't want to use Newtonsoft. I want to use the new dotnet System.Text.Json serializer. Is it possible to use it in a similar granular way?

like image 399
diegosasw Avatar asked Sep 19 '25 23:09

diegosasw


1 Answers

As of .NET 7 and later you can use a typeInfo modifier to customize your type's serialization contract and apply a converter to properties or fields marked with some attribute.

To do this, first define the following generic modifier that sets JsonPropertyInfo.CustomConverter:

public static partial class JsonExtensions
{
    public static Action<JsonTypeInfo> WithPropertyConverterFor<TAttribute>(JsonConverter? converter, bool checkCanConvert = false) 
        where TAttribute : System.Attribute => typeInfo =>
    {
        if (typeInfo.Kind != JsonTypeInfoKind.Object)
            return;
        foreach (var property in typeInfo.Properties)
            if (property.AttributeProvider?.GetCustomAttributes(typeof(TAttribute), true).Any() == true 
                && (!checkCanConvert || converter == null || converter.CanConvert(property.PropertyType)))
                property.CustomConverter = converter;
    };
}

Then, when setting up your JsonSerializerOptions, add the modifier as follows:

JsonSerializerOptions options = new()
{   // Add whatever standard options you need here, e.g.
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};

options.TypeInfoResolver = 
    (options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver())
    .WithAddedModifier(JsonExtensions.WithPropertyConverterFor<FooAttribute>(
        new IntJsonConverter()));

Then when you serialize a SampleInt e.g. as follows:

var sample = new SampleInt { Number = 123, StandardNumber = 456, Text = "bar" };
var json = JsonSerializer.Serialize(sample, options);

The [Foo] properties will be serialized using the converter:

{
  "number": "potato123",
  "standardNumber": 456,
  "text": "bar"
}

Notes:

  • Pass null for converter to remove an existing converter.

  • Setting a converter for which CanConvert returns false for the property's declared type will cause an exception to be thrown, so if [Foo] might be applied to properties of type other than int, pass checkCanConvert : true to the modifier.

    In practice you might want to do this if you have multiple converters for different property types. E.g. if you also have LongJsonConverter you could do:

    options.TypeInfoResolver = 
        (options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver())
        .WithAddedModifier(JsonExtensions.WithPropertyConverterFor<FooAttribute>(
            new IntJsonConverter(), checkCanConvert : true))
        .WithAddedModifier(JsonExtensions.WithPropertyConverterFor<FooAttribute>(
            new LongJsonConverter(), checkCanConvert : true));
    
  • If have properties of many different types marked with [Foo], you could use the factory pattern e.g. like so:

    public class FooDataConverterFactory : JsonConverterFactory
    {
        static readonly Dictionary<Type, JsonConverter> wellKnownConverters = new ()
        {
            [typeof(int)] = new IntJsonConverter(),
            [typeof(long)] = new LongJsonConverter(),
            [typeof(string)] = new StringJsonConverter(),
            // Add others as required or use Type.MakeGenericType() to manufacture them.
        };
    
        public override bool CanConvert(Type typeToConvert) => wellKnownConverters.ContainsKey(typeToConvert);
        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => wellKnownConverters[typeToConvert];
    };
    
    options.TypeInfoResolver = 
        (options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver())
        .WithAddedModifier(JsonExtensions.WithPropertyConverterFor<FooAttribute>(
            new FooDataConverterFactory()));
    
  • This wasn't relevant back when the question was asked, but if you are now using source generation, then as of .NET 9, it should be populating JsonPropertyInfo.AttributeProvider (see this issue for confirmation) so the above code should work with native aot in .NET 9 and later. Neverless I haven't tested.

Demo .NET 8 fiddle here.

like image 151
dbc Avatar answered Sep 21 '25 15:09

dbc