JsonConverter of the OneOf class for serialization
ling921 opened this issue · 7 comments
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;
}
I think the converter could be simplified to only use properties from IOneOf
interface
Lines 3 to 7 in 014d68f
and it could be used for both serialization and deserialization
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:
- string maps to
string
- null maps to the first nullable subtype
- number maps to the first numeric subtype
- True/False map to
boolean
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.
I found a solution for deserialization, see dotnet/runtime#30083 (comment)
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();
}
}
}
@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.
@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.
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
Here's a variant for System.Text.Json that (only!) supports OneOfBase
-based DUs
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<T0, T1> : JsonConverter<OneOfBase<T0, T1>>
{
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<T0, T1> 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<T0, T1>) Activator.CreateInstance(typeToConvert, oneOf);
}
public override void Write(Utf8JsonWriter writer,
OneOfBase<T0, T1> 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<T0, T1, T2> : JsonConverter<OneOfBase<T0, T1, T2>>
{
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<T0, T1, T2> 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<T0, T1, T2>) Activator.CreateInstance(typeToConvert, oneOfBase);
}
public override void Write(Utf8JsonWriter writer,
OneOfBase<T0, T1, T2> 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();
}
}
}