Skip to content

Support for custom converters and OnXXX callbacks #29177

Closed
dotnet/corefx
#38864
@steveharter

Description

@steveharter

Primary scenarios for converters:

  • To support a custom data type (e.g. a PhoneNumber struct stored as a JSON string). Data types are typically immutable structs that have a constructor taking the input and no property setters.
    • Since a converter instantiates the value or object, the converter has flexibility to call constructors with parameters. The converter may also set public fields or use reflection to set non-public state.
    • Data types can either be JSON primitives (number, string, boolean) or JSON objects.
  • To support non-trivial POCO scenarios that have semantics based on state:
    • Property dependencies and ordering. For example, in order to deserialize a given property, it may need state from another property.
    • Adding or removing properties dynamically.
    • Polymorphic deserialization. Today the deserializer doesn't support polymorphism except deserializing into a JsonElement if the corresponding property is object.
  • To override built-in converters default (de)serialization.
    • Common cases include DateTime, specific Enums, and supporting JSON strings for integers or floating point types.

Update: currently OnXXX callback are out of scope for 3.0. See the comments section for a workaround using a converter.
OnXXX callbacks are methods that are invoked on the POCO object during (de)serialization. They are specified by adding an attribute to each such method. Scenarios for OnXXX callbacks mostly relate to defaulting and validation scenarios:
- OnDeserializing: initialize getter-only collections before deserialization occurs.
- OnDeserialized: initialize unassigned properties; set calculated properties in preparation for consumption; throw exception if missing or invalid state.
- OnSerializing: assign default values; throw exception if missing or invalid state; set up any temporary variables used for serialization workarounds.
- OnSerialized: clear any temporary variables.

To create a new converter:

  1. Create a class that derives from JsonValueConverter<T> which closes the <T> to the type to convert.
  2. Override the Read and Write methods.
  3. Have the user register the converter by registering through JsonSerializerOptions or by placing [JsonConverter] on a property. Optionally, if the converter is for a custom data type, the data type itself can have a [JsonConverter] which will self-register.

API

// Runtime registration
namespace System.Text.Json
{
   public class JsonSerializationOptions // existing class
   {
. . .
        /// <summary>
        /// The list of custom converters.
        /// </summary>
        /// <remarks>
        /// Once serialization or deserialization occurs, the list cannot be modified.
        /// </remarks>
        public IList<JsonConverter> Converters { get; }

        /// <summary>
        /// Returns the converter for the specified type.
        /// </summary>
        /// <param name="typeToConvert">The type to return a converter for.</param>
        /// <returns>
        /// The first converter that supports the given type, or null if there is no converter.
        /// </returns>
        public JsonConverter GetConverter(Type typeToConvert)
. . .
    }

   public class JsonException : Exception // existing class
   {
. . .
        public JsonException() { } 
        public JsonException(string message) { } 
        public JsonException(string message, System.Exception innerException) { } 
. . . 
    }
}

// Custom converter support
namespace System.Text.Json.Serialization
{
    /// <summary>
    /// When placed on a property or type, specifies the converter type to use.
    /// </summary>
    /// <remarks>
    /// The specified converter type must derive from <see cref="JsonConverter"/>.
    /// When placed on a property, the specified converter will always be used.
    /// When placed on a type, the specified converter will be used unless a compatible converter is added to
    /// <see cref="JsonSerializerOptions.Converters"/> or there is another <see cref="JsonConverterAttribute"/> on a property
    /// of the same type.
    /// </remarks>
    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
    public class JsonConverterAttribute : JsonAttribute
    {
        /// <summary>
        /// Initializes a new instance of <see cref="JsonConverterAttribute"/> with the specified converter type.
        /// </summary>
        /// <param name="converterType">The type of the converter.</param>
        public JsonConverterAttribute(Type converterType);

        /// <summary>
        /// Initializes a new instance of <see cref="JsonConverterAttribute"/>.
        /// </summary>
        protected JsonConverterAttribute() { } 

        /// <summary>
        /// The type of the converter.
        /// </summary>
        /// <remarks>
        /// An instance of this type will be created automatically unless <see cref="CreateConverter"/> returns a
        /// non-null value.
        /// </remarks>
        public Type ConverterType { get; }
        
        /// <summary>
        /// If overriden, allows a custom attribute to create the converter in order to pass additional state.
        /// </summary>
        /// <returns>
        /// The custom converter, or null if the serializer should create the custom converter.
        /// </returns>
        protected virtual JsonConverter CreateConverter(Type typeToConvert);
    }

    // Non-generic base class; not derived from directly.
    // TDB: better naming for JsonConverter?: JsonBaseConverter

    /// <summary>
    /// Converts an object or value to or from JSON.
    /// </summary>
    public abstract class JsonConverter
    {
        private internal JsonConverter();

        /// <summary>
        /// Determines whether the type can be converted.
        /// </summary>
        /// <param name="typeToConvert">The type is checked as to whether it can be converted.</param>
        /// <returns>True if the type can be converted, false otherwise.</returns>
        public abstract bool CanConvert(Type typeToConvert);
    }

    // TBD: better naming for JsonConverterFactory?: JsonLateBoundConverter, JsonDynamicConverter, JsonSurrogateConverter?

    /// <summary>
    /// Supports converting several types by using a factory pattern.
    /// </summary>
    /// <remarks>
    /// This is useful for converters supporting generics, such as a converter for <see cref="System.Collections.Generic.List{T}"/>.
    /// </remarks>
    public abstract class JsonConverterFactory : JsonConverter
    {
        /// <summary>
        /// When overidden, constructs a new <see cref="JsonConverterFactory"/> instance.
        /// </summary>
        protected internal JsonConverterFactory();

        /// <summary>
        /// Create a converter for the provided <see cref="Type"/>.
        /// </summary>
        /// <param name="typeToConvert">The type to convert.</param>
        /// <returns>
        /// An instance of a <see cref="JsonConverter{T}"/> where T is compatible with <paramref name="typeToConvert"/>.
        /// </returns>        
        protected virtual JsonConverter CreateConverter(Type typeToConvert);
    }

    // JsonConverter<T> is the base class used at run-time for the actual conversions.
    // It is possible to have other base classes like this in the future; for example not exposing reader\writer.

    /// <summary>
    /// Converts an object or value to or from JSON.
    /// </summary>
    /// <typeparam name="T">The <see cref="Type"/> to convert.</typeparam>
    public abstract class JsonConverter<T> : JsonConverter
    {
        /// <summary>
        /// When overidden, constructs a new <see cref="JsonConverter{T}"/> instance.
        /// </summary>
        protected internal JsonConverter();

        /// <summary>
        /// Determines whether the type can be converted.
        /// </summary>
        /// <remarks>
        /// The default implementation is to return True when <paramref name="typeToConvert"/> equals typeof(T).
        /// </remarks>
        /// <param name="typeToConvert"></param>
        /// <returns>True if the type can be converted, False otherwise.</returns>
        public override bool CanConvert(Type typeToConvert);
        
        /// <summary>
        /// Read and convert the JSON to T.
        /// </summary>
        /// <param name="reader">The <see cref="Utf8JsonReader"/> to read from.</param>
        /// <param name="typeToConvert">The <see cref="Type"/> being converted.</param>
        /// <param name="options">The <see cref="JsonSerializerOptions"/> being used.</param>
        /// <returns>The value that was converted.</returns>
        public abstract T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options);

        /// <summary>
        /// Write the value as JSON.
        /// </summary>
        /// <param name="writer">The <see cref="Utf8JsonWriter"/> to write to.</param>
        /// <param name="value">The value to convert.</param>
        /// <param name="options">The <see cref="JsonSerializerOptions"/> being used.</param>
        public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);
    }
}

Samples

Custom Enum converter

public enum MyBoolEnum
{
    True = 1,   // JSON is "TRUE"
    False = 2,  // JSON is "FALSE"
    Unknown = 3 // JSON is "?"
}

// A converter used to change Enum value names.
public class MyBoolEnumConverter : JsonConverter<MyBoolEnum>
{
    // CanConvert does not need to be implemented here since we only convert MyBoolEnum.

// A converter for a specific Enum.
private class MyBoolEnumConverter : JsonConverter<MyBoolEnum>
{
    // CanConvert does not need to be implemented here since we only convert MyBoolEnum.

    public override MyBoolEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        string enumValue = reader.GetString();
        if (enumValue == "TRUE")
        {
            return MyBoolEnum.True;
        }

        if (enumValue == "FALSE")
        {
            return MyBoolEnum.False;
        }

        if (enumValue == "?")
        {
            return MyBoolEnum.Unknown;
        }

        throw new JsonException();
    }

    public override void Write(Utf8JsonWriter writer, MyBoolEnum value, JsonSerializerOptions options)
    {
        if (value is MyBoolEnum.True)
        {
            writer.WriteStringValue("TRUE");
        }
        else if (value is MyBoolEnum.False)
        {
            writer.WriteStringValue("FALSE");
        }
        else
        {
            writer.WriteStringValue("?");
        }
    }
}

[Fact]
public static void CustomEnumConverter()
{
    var options = new JsonSerializerOptions();
    options.Converters.Add(new MyBoolEnumConverter());

    {
        MyBoolEnum value = JsonSerializer.Parse<MyBoolEnum>(@"""TRUE""", options);
        Assert.Equal(MyBoolEnum.True, value);
        Assert.Equal(@"""TRUE""", JsonSerializer.ToString(value, options));
    }

    {
        MyBoolEnum value = JsonSerializer.Parse<MyBoolEnum>(@"""FALSE""", options);
        Assert.Equal(MyBoolEnum.False, value);
        Assert.Equal(@"""FALSE""", JsonSerializer.ToString(value, options));
    }

    {
        MyBoolEnum value = JsonSerializer.Parse<MyBoolEnum>(@"""?""", options);
        Assert.Equal(MyBoolEnum.Unknown, value);
        Assert.Equal(@"""?""", JsonSerializer.ToString(value, options));
    }
}

Polymorphic POCO converter

public class Person
{
    public string Name { get; set; }
}

public class Customer : Person
{
    public string OfficeNumber { get; set; }
}

public class Employee : Person
{
    public decimal CreditLimit { get; set; }
}

// A polymorphic POCO converter using a type discriminator.
private class PersonConverter : JsonConverter<Person>
{
    enum TypeDiscriminator
    {
        Customer = 1,
        Employee = 2
    }

    public override bool CanConvert(Type typeToConvert)
    {
        return typeof(Person).IsAssignableFrom(typeToConvert);
    }

    public override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        reader.Read();
        if (reader.TokenType != JsonTokenType.PropertyName)
        {
            throw new JsonException();
        }

        string propertyName = reader.GetString();
        if (propertyName != "TypeDiscriminator")
        {
            throw new JsonException();
        }

        reader.Read();
        if (reader.TokenType != JsonTokenType.Number)
        {
            throw new JsonException();
        }

        Person value;
        TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
        switch (typeDiscriminator)
        {
            case TypeDiscriminator.Customer:
                value = new Customer();
                break;

            case TypeDiscriminator.Employee:
                value = new Employee();
                break;

            default:
                throw new JsonException();
        }

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                return value;
            }

            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                propertyName = reader.GetString();
                reader.Read();
                switch (propertyName)
                {
                    case "CreditLimit":
                        decimal creditLimit = reader.GetDecimal();
                        ((Customer)value).CreditLimit = creditLimit;
                        break;
                    case "OfficeNumber":
                        string officeNumber = reader.GetString();
                        ((Employee)value).OfficeNumber = officeNumber;
                        break;
                    case "Name":
                        string name = reader.GetString();
                        value.Name = name;
                        break;
                }
            }
        }

        throw new JsonException();
    }

    public override void Write(Utf8JsonWriter writer, Person value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        if (value is Customer)
        {
            writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Customer);
            writer.WriteNumber("CreditLimit", ((Customer)value).CreditLimit);
        }
        else if (value is Employee)
        {
            writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.Employee);
            writer.WriteString("OfficeNumber", ((Employee)value).OfficeNumber);
        }

        writer.WriteString("Name", value.Name);

        writer.WriteEndObject();
    }
}

[Fact]
public static void PersonConverterPolymorphic()
{
    const string customerJson = @"{""TypeDiscriminator"":1,""CreditLimit"":100.00,""Name"":""C""}";
    const string employeeJson = @"{""TypeDiscriminator"":2,""OfficeNumber"":""77a"",""Name"":""E""}";

    var options = new JsonSerializerOptions();
    options.Converters.Add(new PersonConverter());

    {
        Person person = JsonSerializer.Parse<Person>(customerJson, options);
        Assert.IsType<Customer>(person);
        Assert.Equal(100, ((Customer)person).CreditLimit);
        Assert.Equal("C", person.Name);

        string json = JsonSerializer.ToString(person, options);
        Assert.Equal(customerJson, json);
    }

    {
        Person person = JsonSerializer.Parse<Person>(employeeJson, options);
        Assert.IsType<Employee>(person);
        Assert.Equal("77a", ((Employee)person).OfficeNumber);
        Assert.Equal("E", person.Name);

        string json = JsonSerializer.ToString(person, options);
        Assert.Equal(employeeJson, json);
    }
}

Passing additional state to converter using an attribute

// A custom data type representing a point where JSON is "XValue,YValue".
public struct Point
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get;}
    public int Y { get;}
}

// Converter for a custom data type that has additional state (coordinateOffset).
private class PointConverter : JsonConverter<Point>
{
    private int _coordinateOffset;

    public PointConverter() { }

    public PointConverter(int coordinateOffset = 0)
    {
        _coordinateOffset = coordinateOffset;
    }

    public override Point Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.String)
        {
            throw new JsonException();
        }

        string[] stringValues = reader.GetString().Split(',');
        if (stringValues.Length != 2)
        {
            throw new JsonException();
        }

        if (!int.TryParse(stringValues[0], out int x) || !int.TryParse(stringValues[1], out int y))
        {
            throw new JsonException();
        }

        var value = new Point(x + _coordinateOffset, y + _coordinateOffset);
        return value;
    }

    public override void Write(Utf8JsonWriter writer, Point value, JsonSerializerOptions options)
    {
        string stringValue = $"{value.X - _coordinateOffset},{value.Y - _coordinateOffset}";
        writer.WriteStringValue(stringValue);
    }
}


[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class PointConverterAttribute : JsonConverterAttribute
{
    public PointConverterAttribute(int coordinateOffset = 0) : base()
    {
        CoordinateOffset = coordinateOffset;
    }

    public int CoordinateOffset { get; private set; }

    /// <summary>
    /// If overriden, allows a custom attribute to create the converter in order to pass additional state.
    /// </summary>
    /// <returns>The custom converter, or null if the serializer should create the custom converter.</returns>
    public override JsonConverter CreateConverter(Type typeToConvert)
    {
        return new PointConverter(CoordinateOffset);
    }
}

public class ClassWithPointConverterAttribute
{
    [PointConverter(10)]
    public Point Point1 { get; set; }
}

[Fact]
public static void CustomAttributeExtraInformation()
{
    const string json = @"{""Point1"":""1,2""}";

    ClassWithPointConverterAttribute obj = JsonSerializer.Parse<ClassWithPointConverterAttribute>(json);
    Assert.Equal(11, obj.Point1.X);
    Assert.Equal(12, obj.Point1.Y);

    string jsonSerialized = JsonSerializer.ToString(obj);
    Assert.Equal(@"{""Point1"":""11,12""}", jsonSerialized);
}

List converter with additional state

// A List{T} converter that used CreateConverter().
private class ListConverter : JsonConverterFactory
{
    int _offset;

    public ListConverter(int offset)
    {
        _offset = offset;
    }

    public override bool CanConvert(Type typeToConvert)
    {
        if (!typeToConvert.IsGenericType)
            return false;

        Type generic = typeToConvert.GetGenericTypeDefinition();
        if (generic != typeof(List<>))
            return false;

        Type arg = typeToConvert.GetGenericArguments()[0];
        return arg == typeof(int) ||
            arg == typeof(long);
    }

    protected override JsonConverter CreateConverter(Type type)
    {
        Type elementType = type.GetGenericArguments()[0];

        JsonConverter converter = (JsonConverter)Activator.CreateInstance(
            typeof(ListConverter<>).MakeGenericType(elementType),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            new object[] { _offset },
            culture: null);

        return converter;
    }
}

// Demonstrates List<T>; Adds offset to each integer or long to verify converter ran.
private class ListConverter<T> : JsonConverter<List<T>>
{
    private int _offset;
    public ListConverter(int offset)
    {
        _offset = offset;
    }

    public override List<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartArray)
        {
            throw new FormatException();
        }

        var value = new List<T>();

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndArray)
            {
                return value;
            }

            if (reader.TokenType != JsonTokenType.Number)
            {
                throw new FormatException();
            }

            if (typeof(T) == typeof(int))
            {
                int element = reader.GetInt32();
                IList list = value;
                list.Add(element + _offset);
            }
            else if (typeof(T) == typeof(long))
            {
                long element = reader.GetInt64();
                IList list = value;
                list.Add(element + _offset);
            }
        }

        throw new FormatException();
    }

    public override void Write(Utf8JsonWriter writer, List<T> value, JsonSerializerOptions options)
    {
        writer.WriteStartArray();

        foreach (T item in value)
        {
            if (item is int)
            {
                writer.WriteNumberValue((int)(object)item - _offset);
            }
            else if (item is long)
            {
                writer.WriteNumberValue((long)(object)item - _offset);
            }
            else
            {
                Assert.True(false);
            }
        }

        writer.WriteEndArray();
    }
}

[Fact]
public static void ListConverterOpenGeneric()
{
    const string json = "[1,2,3]";

    var options = new JsonSerializerOptions();
    options.Converters.Add(new ListConverter(10));

    {
        List<int> list = JsonSerializer.Parse<List<int>>(json, options);
        Assert.Equal(11, list[0]);
        Assert.Equal(12, list[1]);
        Assert.Equal(13, list[2]);

        string jsonSerialized = JsonSerializer.ToString(list, options);
        Assert.Equal(json, jsonSerialized);
    }

    {
        List<long> list = JsonSerializer.Parse<List<long>>(json, options);
        Assert.Equal(11, list[0]);
        Assert.Equal(12, list[1]);
        Assert.Equal(13, list[2]);

        string jsonSerialized = JsonSerializer.ToString(list, options);
        Assert.Equal(json, jsonSerialized);
    }
}

Requirements

  • Support any type of converter (primitive, POCO, arrays, IEnumerable, etc).
  • The reader will be placed on the first token in the TryRead().
  • Override internal converters. For example, a new Int32converter could be created to automatically convert a json string to an Int32 (not supported in the default Int32 converter).
  • If multiple converters are specified for a type, the first converter that returns true to CanConvert is used.
  • Automatically handle null and Nullable<T> (the converter is not called)
  • Allow a converter to be passed additional state if created directly or through a [JsonConverter]-derived attribute.
  • Allow the Read() and Write() methods to throw exceptions (TBD) caught and re-thrown as JsonException which will be displayed as a nice JsonException ("unable to convert" with JsonPath) with Path set.
  • Detect whether the Read() and Write() methods read or wrote too much or too little and if so, throw an exception.
  • Prevent the Converters collection on the options class from being changed once (de)serialization has occurred.
  • Throw InvalidOperationException if there is more than one converter attribute on a given Type or property.
  • For Async Stream scenarios (that support streaming through multiple buffers), a "read-ahead" feature ensures the reader will not run out of buffer within a converter.

Design for open generics (typically collection classes)

Consider a custom List<T> converter:

public class MyListConverter<T> : JsonConverter<List<T>> {...}

The read method:

public override bool TryRead(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options, out List<T> value)

The approach taken here is have a separate type (JsonConverterFactory) with factory method (CreateConverter())

Then the list converter would have both a non-generic MyListConverter class (that derives from JsonConverterFactory) and a generic MyListConverter<T> (that derives from JsonConverter<List<T>>).

Only the non-generic MyListConverter class is added to the options.Converters property. Internally there is another list\dictionary that caches all created converters for a specific T, meaning List<int> is a different entry than List<long>.

Priority of converter registration

Converters can be registered at design-time vs run-time and at a property-level vs. class-level. The basic rules for priority over which is selected:

  • Run-time has precedence over design-time
  • Property-level has precedence over class-level.
  • User-specified has precedence over built-in.

Thus the priority from highest to lowest:

  • runtime+property: Future feature: options.Converters.Add(converter, property)
  • designtime+property: [JsonConverter] applied to a property.
  • runtime+class: options.Converters.Add(converter)
  • designtime+class: [JsonConverter] applied to a custom data type or POCO.
  • Built-in converters (primitive types, JsonElement, arrays, etc).

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions