mcintyre321 / OneOf

Easy to use F#-like ~discriminated~ unions for C# with exhaustive compile time matching
MIT License
3.47k stars 162 forks source link

JsonConverter of the OneOf class for serialization #118

Open ling921 opened 2 years ago

ling921 commented 2 years ago

Example of OneOf<T0, T1>, here is the converter class

using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

public class OnOfTwoValueConverter : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(OneOf<,>);
    }

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var _typeOfT0 = typeToConvert.GetGenericArguments()[0];
        var _typeOfT1 = typeToConvert.GetGenericArguments()[1];

        return (JsonConverter)Activator.CreateInstance(
            typeof(OneOfJsonConverter<,>).MakeGenericType(new Type[] { _typeOfT0, _typeOfT1 }),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: new object[] { options },
            culture: null)!;
    }

    private class OneOfJsonConverter<T0, T1> : JsonConverter<OneOf<T0, T1>>
    {
        private readonly Type _typeOfT0;
        private readonly Type _typeOfT1;
        private readonly JsonConverter<T0> _converterOfT0;
        private readonly JsonConverter<T1> _converterOfT1;

        public OneOfJsonConverter(JsonSerializerOptions options)
        {
            _typeOfT0 = typeof(T0);
            _typeOfT1 = typeof(T1);
            _converterOfT0 = (JsonConverter<T0>)options.GetConverter(_typeOfT0);
            _converterOfT1 = (JsonConverter<T1>)options.GetConverter(_typeOfT1);
        }

        public override OneOf<T0, T1> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            throw new JsonException("Cannot be deserialized.");
        }

        public override void Write(Utf8JsonWriter writer, OneOf<T0, T1> value, JsonSerializerOptions options)
        {
            if (value.IsT0)
            {
                if (_converterOfT0 is not null)
                    _converterOfT0.Write(writer, value.AsT0, options);
                else
                    JsonSerializer.Serialize(writer, value.AsT0, options);
            }
            else if (value.IsT1)
            {
                if (_converterOfT1 is not null)
                    _converterOfT1.Write(writer, value.AsT1, options);
                else
                    JsonSerializer.Serialize(writer, value.AsT1, options);
            }
            else
            {
                writer.WriteNullValue();
            }
        }
    }
}

Then use it on OneOf class

[JsonConverter(typeof(OnOfTwoValueConverter))]
public class OneOf<T0, T1>

In the WebApi project, we can use like this

public OneOf<T0, T1> Get()
{
    if (...)
        return T0;
    else
        return T1;
}
romfir commented 2 years ago

I think the converter could be simplified to only use properties from IOneOf interface https://github.com/mcintyre321/OneOf/blob/014d68f723e1b772987f50c82016e6bfc09ee3f5/OneOf/IOneOf.cs#L3-L7 and it could be used for both serialization and deserialization

zspitz commented 2 years ago

I've been recently trying to write a general System.Json.Text converter for OneOf/OneOfBase. It's out of scope for what I want to do, but I'm putting some thoughts down here.

OneOf could have a converter factory, which would create the converter based on the generic parameters of the OneOf; as described here.

A OneOfBase-inheriting class might have additional properties. How would a converter be constructed which would handle those potentially added properties?

Write/serialization is trivial. For any OneOf/OneOfBase, the implementation would look like this, suitably extended:

public override void Write(Utf8JsonWriter writer, Uri value, JsonSerializerOptions options) {
    if (value.IsT0) {
        JsonSerializer.Serialize(writer, value.AsT0, options);
    } else { // value.IsT1 {
        JsonSerializer.Serialize(writer, value.AsT1, options);
    }
}

Read/deserialization is the real trouble. A OneOf/OneOfBase has multiple subtypes, generally unique. (If say string is used as a subtype multiple times, how could a JSON string be resolved to one string over the other? We could arbitrarily choose the first match.)

This means the converter would have to read the first token, and choose an appropriate subtype based on that token or on the entire value. Some possibilities are obvious:

But what happens for JsonTokenType.StartObject? If you have 5 different subtypes each with an ID property, how do you resolve which object type to create, before wrapping it in OneOf or OneOfBase? Even worse, what do you do if multiple subtypes have the same property name/type?

And for JsonTokenType.StartArray, you have to resolve the type of the array elements, and then figure out to which collection subtype to match it to.

I've written something similar for Newtonsoft.Json, which sort of worked for my needs.

ling921 commented 2 years ago

I found a solution for deserialization, see https://github.com/dotnet/runtime/issues/30083#issuecomment-1002914110

This converter factory works fine, but is not as efficient as the native one

Here is the full code:

public class OnOfTwoValueConverter : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(OneOf<,>);
    }

    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var _typeOfT0 = typeToConvert.GetGenericArguments()[0];
        var _typeOfT1 = typeToConvert.GetGenericArguments()[1];

        return (JsonConverter)Activator.CreateInstance(
            typeof(OneOfJsonConverter<,>).MakeGenericType(new Type[] { _typeOfT0, _typeOfT1 }),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: new object[] { options },
            culture: null)!;
    }

    private class OneOfJsonConverter<T0, T1> : JsonConverter<OneOf<T0, T1>>
    {
        private const string INDEX_KEY = "$index";

        private readonly Type _typeOfT0;
        private readonly Type _typeOfT1;

        public OneOfJsonConverter(JsonSerializerOptions options)
        {
            _typeOfT0 = typeof(T0);
            _typeOfT1 = typeof(T1);
        }

        public override OneOf<T0, T1> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            using var doc = JsonDocument.ParseValue(ref reader);
            if (!doc.RootElement.TryGetProperty(INDEX_KEY, out var indexElement)
                || !indexElement.TryGetInt32(out var index)
                || index < 0
                || index > 1)
            {
                throw new JsonException("Cannot not find type index or type index is not a valid number.");
            }

            if (index == 0)
            {
                return doc.Deserialize<T0>(options);
            }
            else
            {
                return doc.Deserialize<T1>(options);
            }
        }

        public override void Write(Utf8JsonWriter writer, OneOf<T0, T1> value, JsonSerializerOptions options)
        {
            writer.WriteStartObject();

            writer.WritePropertyName(INDEX_KEY);
            writer.WriteNumberValue(value.Index);

            using var doc = value.Match(
                t0 => JsonSerializer.SerializeToDocument(t0, _typeOfT0, options),
                t1 => JsonSerializer.SerializeToDocument(t1, _typeOfT1, options));

            foreach (var prop in doc.RootElement.EnumerateObject())
            {
                prop.WriteTo(writer);
            }

            writer.WriteEndObject();
        }
    }
}
zspitz commented 2 years ago

@ling921 There are a number of issues.

Firstly, the Write override can be far simpler, as I noted above.

But more importantly, your converter works only for OneOf<T0, T1>; it doesn't work for OneOf<T0, T1, T2> or any OneOfBase<T0, T1>-inheriting variant. Granted the only solution I see would be to have a separate converter factory for each OneOf variant.

ling921 commented 2 years ago

@zspitz Simply write a source generator like this https://github.com/mcintyre321/OneOf/blob/master/Generator/Program.cs

In the Write method, you should write a metadata to specify the type of the data, and then the Read method can read the type metadata and deserialize it to the specified type.

In the above example, I use $index as the metadata key to store the index corresponding to the data.

agross commented 1 year ago

Might be interesting to you how the F# people did it: https://github.com/Tarmil/FSharp.SystemTextJson/blob/master/docs/Customizing.md#unwrap-union-cases-with-a-record-field

agross commented 1 year ago

Here's a variant for System.Text.Json that (only!) supports OneOfBase-based DUs

```cs using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using OneOf; namespace Infrastructure.Serialization; public class OneOfConverterFactory : JsonConverterFactory { public override bool CanConvert(Type typeToConvert) => typeof(IOneOf).IsAssignableFrom(typeToConvert); public override JsonConverter CreateConverter(Type? typeToConvert, JsonSerializerOptions options) { var (oneOfGenericType, converterType) = GetTypes(typeToConvert); if (oneOfGenericType is null || converterType is null) { throw new NotSupportedException($"Cannot convert {typeToConvert}"); } var jsonConverter = (JsonConverter) Activator.CreateInstance( converterType.MakeGenericType(oneOfGenericType.GenericTypeArguments), BindingFlags.Instance | BindingFlags.Public, null, new object[] { options }, null)!; return jsonConverter; } static (Type? oneOfGenericType, Type? converterType) GetTypes(Type? type) { while (type is not null) { if (type.IsGenericType) { var genericTypeDefinition = type.GetGenericTypeDefinition(); if (genericTypeDefinition == typeof(OneOfBase<,>) || genericTypeDefinition == typeof(OneOf<,>)) { return (type, typeof(OneOf2JsonConverter<,>)); } if (genericTypeDefinition == typeof(OneOfBase<,,>) || genericTypeDefinition == typeof(OneOf<,,>)) { return (type, typeof(OneOf3JsonConverter<,,>)); } // TODO: Not supported (yet). // if (genericTypeDefinition == typeof(OneOfBase<,,,>) || // genericTypeDefinition == typeof(OneOf<,,,>)) // { // return (type, typeof(OneOfJson<,,,>)); // } // // if (genericTypeDefinition == typeof(OneOfBase<,,,,>) || // genericTypeDefinition == typeof(OneOf<,,,,>)) // { // return (type, typeof(OneOfJson<,,,,>)); // } // // if (genericTypeDefinition == typeof(OneOfBase<,,,,,>) || // genericTypeDefinition == typeof(OneOf<,,,,,>)) // { // return (type, typeof(OneOfJson<,,,,,>)); // } // // if (genericTypeDefinition == typeof(OneOfBase<,,,,,,>) || // genericTypeDefinition == typeof(OneOf<,,,,,,>)) // { // return (type, typeof(OneOfJson<,,,,,,>)); // } // // if (genericTypeDefinition == typeof(OneOfBase<,,,,,,,>) || // genericTypeDefinition == typeof(OneOf<,,,,,,,>)) // { // return (type, typeof(OneOfJson<,,,,,,,>)); // } // // if (genericTypeDefinition == typeof(OneOfBase<,,,,,,,,>) || // genericTypeDefinition == typeof(OneOf<,,,,,,,,>)) // { // return (type, typeof(OneOfJson<,,,,,,,,>)); // } } type = type.BaseType; } return (null, null); } static IOneOf CreateOneOf(JsonSerializerOptions options, int index, JsonDocument doc, Type oneOfType, Type[] types) { var args = new object[types.Length + 1]; args[0] = index; args[index + 1] = doc.Deserialize(types[index], options); var oneOf = Activator.CreateInstance( oneOfType, BindingFlags.Instance | BindingFlags.NonPublic, null, args, null ); return (IOneOf) oneOf; } const string IndexKey = "$index"; class OneOf2JsonConverter : JsonConverter> { static readonly Type OneOfType = typeof(OneOf<,>).MakeGenericType(typeof(T0), typeof(T1)); static readonly Type[] Types = { typeof(T0), typeof(T1) }; public OneOf2JsonConverter(JsonSerializerOptions _) { } public override OneOfBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc = JsonDocument.ParseValue(ref reader); if (!doc.RootElement.TryGetProperty(IndexKey, out var indexElement) || !indexElement.TryGetInt32(out var index) || index is < 0 or > 1) { throw new JsonException("Cannot not find type index or type index is not a valid number"); } var oneOf = CreateOneOf(options, index, doc, OneOfType, Types); return (OneOfBase) Activator.CreateInstance(typeToConvert, oneOf); } public override void Write(Utf8JsonWriter writer, OneOfBase value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WritePropertyName(IndexKey); writer.WriteNumberValue(value.Index); using var doc = value.Match( t0 => JsonSerializer.SerializeToDocument(t0, typeof(T0), options), t1 => JsonSerializer.SerializeToDocument(t1, typeof(T1), options) ); foreach (var prop in doc.RootElement.EnumerateObject()) { prop.WriteTo(writer); } writer.WriteEndObject(); } } class OneOf3JsonConverter : JsonConverter> { static readonly Type OneOfType = typeof(OneOf<,,>).MakeGenericType(typeof(T0), typeof(T1), typeof(T2)); static readonly Type[] Types = { typeof(T0), typeof(T1), typeof(T2) }; public OneOf3JsonConverter(JsonSerializerOptions _) { } public override OneOfBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc = JsonDocument.ParseValue(ref reader); if (!doc.RootElement.TryGetProperty(IndexKey, out var indexElement) || !indexElement.TryGetInt32(out var index) || index is < 0 or > 2) { throw new JsonException("Cannot not find type index or type index is not a valid number"); } var oneOfBase = CreateOneOf(options, index, doc, OneOfType, Types); return (OneOfBase) Activator.CreateInstance(typeToConvert, oneOfBase); } public override void Write(Utf8JsonWriter writer, OneOfBase value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WritePropertyName(IndexKey); writer.WriteNumberValue(value.Index); using var doc = value.Match( t0 => JsonSerializer.SerializeToDocument(t0, typeof(T0), options), t1 => JsonSerializer.SerializeToDocument(t1, typeof(T1), options), t2 => JsonSerializer.SerializeToDocument(t2, typeof(T2), options) ); foreach (var prop in doc.RootElement.EnumerateObject()) { prop.WriteTo(writer); } writer.WriteEndObject(); } } } ```