24

I am writing a custom System.Text.Json.JsonConverter<T> to upgrade an old data model to a new version. I have overridden Read() and implemented the necessary postprocessing. However, I don't need to do anything custom at all in the Write() method. How can I automatically generate the default serialization that I would get if I did not have a converter at all? Obviously I could just use different JsonSerializerOptions for deserialization and serialization, however my framework doesn't provide different options for each straightforwardly.

A simplified example follows. Say I formerly had the following data model:

public record Person(string Name);

Which I have upgraded to

public record Person(string FirstName, string LastName);

I have written a converter as follows:

public sealed class PersonConverter : JsonConverter<Person>
{
    record PersonDTO(string FirstName, string LastName, string Name); // A DTO with both the old and new properties.

    public override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var dto = JsonSerializer.Deserialize<PersonDTO>(ref reader, options);
        var oldNames = dto?.Name?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty<string>();
        return new Person(dto.FirstName ?? oldNames.FirstOrDefault(), dto.LastName ?? oldNames.LastOrDefault());
    }

    public override void Write(Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        => // What do I do here? I want to preserve other options such as options.PropertyNamingPolicy, which are lost by the following call
        JsonSerializer.Serialize(writer, person);
}

And round-trip with

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Converters = { new PersonConverter() },
};
var person = JsonSerializer.Deserialize<Person>(json, options);
var json2 = JsonSerializer.Serialize(person, options);

Then the result is {"FirstName":"FirstName","LastName":"LastName"} -- i.e. the camel casing during serialization is lost. But if I pass in options while writing by recursively calling

    public override void Write(Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        => // What do I do here? I want to preserve other options such as options.PropertyNamingPolicy, which are lost by the following call
        JsonSerializer.Serialize(writer, person, options);

Then serialization fails with a stack overflow.

How can I get an exact default serialization that ignores the custom converter? There is no equivalent to Json.NET's JsonConverter.CanWrite property.

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340

2 Answers2

16

As explained in the docs, converters are chosen with the following precedence:

  • [JsonConverter] applied to a property.
  • A converter added to the Converters collection.
  • [JsonConverter] applied to a custom value type or POCO.

And in addition there is another case:

  • a JsonConverter<T> returned by some JsonConverterFactory when the factory is applied through any of the three methods above.

Each case needs to be dealt with separately.

  1. If you have [JsonConverter] applied to a property., then simply calling JsonSerializer.Serialize(writer, person, options); will generate a default serialization.

  2. If you have A converter added to the Converters collection., then inside the Write() (or Read()) method, you can copy the incoming options using the JsonSerializerOptions copy constructor, remove the converter from the copy's Converters list, and pass the modified copy into JsonSerializer.Serialize<T>(Utf8JsonWriter, T, JsonSerializerOptions);

    This can't be done as easily in .NET Core 3.x because the copy constructor does not exist in that version. Temporarily modifying the Converters collection of the incoming options to remove the converter would not be not thread safe and so is not recommended. Instead one would need create new options and manually copy each property as well as the Converters collection, skipping converts of type converterType.

    Do note that this will cause problems with serialization of recursive types such as trees, because nested objects of the same type will not be serialized initially using the converter.

  3. If you have [JsonConverter] applied to a custom value type or POCO. there does not appear to be a way to generate a default serialization.

  4. The case of a JsonConverter<T> returned by a JsonConverterFactory in the converters list is not addressed by this answer, as it would be necessary to disable the factory, rather than just the converter. In such a situation it's not clear whether to disable the factory completely, or just for the specific concrete type T.

Since, in the question, the converter is added to the Converters list, the following modified version correctly generates a default serialization:

public sealed class PersonConverter : DefaultConverterFactory<Person>
{
    record PersonDTO(string FirstName, string LastName, string Name); // A DTO with both the old and new properties.

    protected override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions modifiedOptions)
    {
        var dto = JsonSerializer.Deserialize<PersonDTO>(ref reader, modifiedOptions);
        var oldNames = dto?.Name?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty<string>();
        return new Person(dto.FirstName ?? oldNames.FirstOrDefault(), dto.LastName ?? oldNames.LastOrDefault());
    }
}

public abstract class DefaultConverterFactory<T> : JsonConverterFactory
{
    class DefaultConverter : JsonConverter<T>
    {
        readonly JsonSerializerOptions modifiedOptions;
        readonly DefaultConverterFactory<T> factory;

        public DefaultConverter(JsonSerializerOptions options, DefaultConverterFactory<T> factory)
        {
            this.factory = factory;
            this.modifiedOptions = options.CopyAndRemoveConverter(factory.GetType());
        }

        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => factory.Write(writer, value, modifiedOptions);

        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => factory.Read(ref reader, typeToConvert, modifiedOptions);
    }

    protected virtual T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions modifiedOptions)
        => (T)JsonSerializer.Deserialize(ref reader, typeToConvert, modifiedOptions);

    protected virtual void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions modifiedOptions) 
        => JsonSerializer.Serialize(writer, value, modifiedOptions);

    public override bool CanConvert(Type typeToConvert) => typeof(T) == typeToConvert;

    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new DefaultConverter(options, this);
}

public static class JsonSerializerExtensions
{
    public static JsonSerializerOptions CopyAndRemoveConverter(this JsonSerializerOptions options, Type converterType)
    {
        var copy = new JsonSerializerOptions(options);
        for (var i = copy.Converters.Count - 1; i >= 0; i--)
            if (copy.Converters[i].GetType() == converterType)
                copy.Converters.RemoveAt(i);
        return copy;
    }
}

Notes:

  • I used a converter factory rather than a converter as the base class for PersonConverter because it allowed me to conveniently cache the copied options inside the manufactured converter.

  • If you try to apply a DefaultConverterFactory<T> to a custom value type or POCO, e.g.

    [JsonConverter(typeof(PersonConverter))] public record Person(string FirstName, string LastName);
    

    A nasty stack overflow will occur.

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 1
    An [earlier version](https://stackoverflow.com/revisions/65430421/3) of this answer cached the `JsonConverter defaultConverter` in the constructor for `DefaultConverter`; I removed that because it caused problems with polymorphic serialization of values declared as `object`, support for `NumberHandling`, and reported problems with compile-time code generation reported problems [here](https://stackoverflow.com/q/70252828/3744182). – dbc Dec 17 '21 at 16:08
  • 1
    I think your solution to the second scenario needs more explanation. If the converter has been created by a factory inside the `Converters` collection, removing the converter has no effect. – Pharaz Fadaei Jun 29 '22 at 01:29
  • 2
    @PharazFadaei - when a converter is manufactured by a converter factory, it isn't added to `JsonSerializerOptions.Converters`. Instead it's cached in the metadata information `JsonSerializerOptions._classes`. You can see it at this traceback here: https://dotnetfiddle.net/hy94kw... – dbc Jun 29 '22 at 16:11
  • 1
    @PharazFadaei ... But `_classes` is intentionally not copied by the JsonSerializerOptions copy constructor, see https://github.com/dotnet/runtime/blob/v6.0.6/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerOptions.cs#L105, and in any event I copy the options in the constructor for the inner converter, before it could been added anywhere. – dbc Jun 29 '22 at 16:11
  • 2
    And this is THE problem. You can remove a generated converter from the collection (it's not there) but the factory is still there and will provide the same generated converter again. I think by 'A converter added to the Converters collection.' Microsoft means the converters generetad by a factory in the `Converters` collection as well. So in this case simply removing the converter as suggested by your second point is not enough. – Pharaz Fadaei Jun 29 '22 at 17:00
  • 1
    I don't remove the generated converter from the collection. I remove the factory from the copied option's collection: `options.CopyAndRemoveConverter(factory.GetType())`. Then I pass the copied and modified options down into the recursive serialization call. Anyway, if something is not working and you can share a [mcve], ideally via a fiddle, I'd be glad to take a look. – dbc Jun 29 '22 at 17:03
  • 1
    I'm not arguing about the `DefaultConverterFactory` class. My point is that your second suggestion ('If you have A converter added to the Converters collection...') does not cover converter factories. How would you serialize using the default converters inside the `Write` method of a `SomeGenericConverter : JsonConverter>` generated by a `SomeGenericConverterFactory`? – Pharaz Fadaei Jun 29 '22 at 17:43
  • 1
    So, just to be clear, your scenario is that you have some outer factory that manufactures an inner converter factory that inherits from `DefaultConverterFactory` which in turn manufactures an innermost converter? – dbc Jun 29 '22 at 18:26
  • 2
    @PharazFadaei - replying after a long time, I've clarified the answer applies only to simple converters, and not factories. – dbc May 09 '23 at 22:32
1

I really wanted to use the JsonConverterAttribute on the class and ended up with a solution using an inherited private dummy-class as suggested by this answer. This also avoids any problems when there is a property of the same type, such as in a tree structure.

The following example doesn't use the Person-example in the question, but the idea is appliable to that usage.

Let's say you have SomeDto that you want to do some special handling during serialization or deserialization:

[JsonConverter(typeof(SomeDtoJsonConverter))]
internal class SomeDto
{
    public string? P1 { get; set; }
    public int P2 { get; set; }
    public SomeDto? P3 { get; set; }
    // ...
}

Then you can create a JsonConverter that bypasses referencing itself by copying your object into a Dummy private class that doesn't have a custom JsonConverter, and calling the "default" JsonSerializer with that dummy type/object:

internal class SomeDtoJsonConverter : JsonConverter<SomeDto>
{
    public override SomeDto? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // ...

        // Call the default deserializer using the private type Dummy to avoid infinite recursion
        Dummy? dummy = JsonSerializer.Deserialize<Dummy?>(ref reader, options);
        SomeDto? someDto = MapFrom<Dummy, SomeDto>(dummy);

        // ...
        return someDto;
    }

    public override void Write(Utf8JsonWriter writer, SomeDto someDto, JsonSerializerOptions options)
    {
        // ...
        Dummy? dummy = MapFrom<SomeDto, Dummy>(someDto);
        // ...

        // Call the default serializer using the private type Dummy to avoid infinite recursion
        JsonSerializer.Serialize<Dummy?>(writer, dummy, options);
    }

    private class Dummy : SomeDto { }

    // Copy properties from source object to new instance of target object, for instance using reflection.
    private static TTarget? MapFrom<TSource, TTarget>(TSource? sourceObject) where TTarget : class, new()
    {
        if (sourceObject is null)
            return null;

        IEnumerable<PropertyInfo> sourceProperties = typeof(TSource).GetProperties().Where(prop => prop.CanRead);
        PropertyInfo[] targetProperties = typeof(TTarget).GetProperties().Where(prop => prop.CanWrite).ToArray();

        TTarget target = new TTarget();
        foreach (PropertyInfo sourceProperty in sourceProperties)
        {
            PropertyInfo? targetProperty = targetProperties.FirstOrDefault(prop => prop.Name == sourceProperty.Name);
            targetProperty?.SetValue(target, sourceProperty.GetValue(sourceObject));
        }
        return target;
    }
}

Example of usage:

public class SomeDtoJsonConverterTest
{
    [Fact]
    public void Write_SerializesUsingDefaultSerializer()
    {
        SomeDto someDto = new SomeDto { P1 = "Hello", P2 = 42 };
        string json = JsonSerializer.Serialize(someDto);
        Assert.Contains("\"P1\":\"Hello\"", json);
        Assert.Contains("\"P2\":42", json);
        Assert.Contains("\"P3\":null", json);
    }

    [Fact]
    public void Read_DeserializesUsingDefaultDeserializer()
    {
        string json = "{ \"P1\":\"Hello\", \"P2\":42 }";
        SomeDto someDto = JsonSerializer.Deserialize<SomeDto>(json)!;
        Assert.Equal("Hello", someDto.P1);
        Assert.Equal(42, someDto.P2);
        Assert.Null(someDto.P3);
    }

    [Fact]
    public void Write_SerializesTypeWithRecursiveProperty()
    {
        SomeDto someDto = new SomeDto { P1 = "Hello", P2 = 42 };
        someDto.P3 = new SomeDto { P1 = "World", P2 = 17 };
        string json = JsonSerializer.Serialize(someDto);
        Assert.Contains("\"P1\":\"Hello\"", json);
        Assert.Contains("\"P2\":42", json);
        Assert.Contains("\"P3\":{", json);
        Assert.Contains("\"P1\":\"World\"", json);
        Assert.Contains("\"P2\":17", json);
    }

    [Fact]
    public void Write_DeserializesTypeWithRecursiveProperty()
    {
        string json = "{ \"P1\":\"Hello\", \"P2\":42, \"P3\":{ \"P1\":\"World\", \"P2\":17 } }";
        SomeDto someDto = JsonSerializer.Deserialize<SomeDto>(json)!;
        Assert.Equal("Hello", someDto.P1);
        Assert.Equal(42, someDto.P2);
        Assert.Equal("World", someDto.P3!.P1);
        Assert.Equal(17, someDto.P3!.P2);
    }
}
Ulf Åkerstedt
  • 3,086
  • 4
  • 26
  • 28