I am working with an API that handles requests that submit large amounts of data in JSON format (e.g. 10MB+). To date, we have been using Newtonsoft.Json
, but recently we have been experiencing performance issues, mostly related to the amount of time and/or memory required to deserialise the request data into the appropriate types, so we have been looking at switching to System.Text.Json
, which according to our internal benchmarking is significantly better in both metrics.
Where I am having problems in modifying the existing code is that we have some custom deserialisation logic written around processing enums, in the form of a JSON.NET custom converter. For historical reasons, we have some clients of the API who use a different set of enum values to the values that the API expects! So, our enum might look something like this:
[AttributeUsage(AttributeTargets.Field)]
public class AlternativeValueAttribute : Attribute
{
public AlternativeValueAttribute(string code) {
Code = code;
}
public string Code { get; }
}
public enum Allowances
{
[AlternativeValue("CD")] Car = 0,
[AlternativeValue("AD")] Transport = 1,
[AlternativeValue("LD")] Laundry = 2
}
public class AllowanceRequest
{
public Allowances Type { get; set; }
public Allowances? AdditionalType { get; set; }
public decimal Value { get; set; }
}
so client A will submit their data as:
{ "type": "Car", "value": 25.75 }
and client B will submit their data as:
{ "type": 0, "value": 25.75 }
and client C will submit their data as:
{ "type": "CD", "value": 25.75 }
(I've given a single example here, but there are many enums that make use of this custom attribute, so having an enum-specific converter is not really a valid approach - the converter needs to be fairly generic).
I am having difficulties understanding exactly how System.Text.Json
handles custom conversion of enums, as that seems to be a special case and handled differently to a regular class, requiring the use of a JsonConverterFactory
instead of an implementation of JsonConverter<>
.
public class AlternativeValueJsonStringEnumConverter : JsonConverterFactory {
public AlternativeValueJsonStringEnumConverter() {}
public override bool CanConvert(Type typeToConvert) {
var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
return enumType.IsEnum;
}
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
return new CustomStringEnumConverter(options);
}
private class CustomStringEnumConverter : JsonConverter<Enum?>
{
public CustomStringEnumConverter(JsonSerializerOptions options) { }
public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
var isNullable = Nullable.GetUnderlyingType(typeToConvert) != null;
var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
switch (reader.TokenType) {
case JsonTokenType.Null when !isNullable:
throw new JsonException("Cannot deserialise null value to non-nullable field");
case JsonTokenType.String:
var result = ReadStringValue(reader, enumType);
return (Enum?) result;
case JsonTokenType.Number:
return ReadNumberValue(reader, enumType);
default:
return null;
}
}
public override void Write(Utf8JsonWriter writer, Enum? value, JsonSerializerOptions options) {
if (value == null) {
writer.WriteNullValue();
} else {
var description = value.ToString();
writer.WriteStringValue(description);
}
}
public override bool CanConvert(Type typeToConvert) {
var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
return enumType.IsEnum;
}
private static string GetDescription(Enum source) {
var fieldInfo = source.GetType().GetField(source.ToString());
if (fieldInfo == null) {
return source.ToString();
}
var attributes = (System.ComponentModel.DescriptionAttribute[])fieldInfo.GetCustomAttributes(typeof(System.ComponentModel.DescriptionAttribute), false);
return attributes != null && attributes.Length > 0
? attributes[0].Description
: source.ToString();
}
private static object? ReadStringValue(Utf8JsonReader reader, Type enumType) {
var parsedValue = reader.GetString()!;
foreach (var item in Enum.GetValues(enumType))
{
var attribute = item.GetType().GetTypeInfo().GetRuntimeField(item.ToString()).GetCustomAttribute<AlternativeValueAttribute>();
if (attribute == null && Enum.TryParse(enumType, parsedValue, true, out var result)) {
return result;
}
if (attribute != null && attribute.Code == parsedValue &&
Enum.TryParse(enumType, item.ToString(), true, out var attributedResult)) {
return attributedResult;
}
if (parsedValue == item.ToString() && Enum.TryParse(enumType, parsedValue, true, out var parsedResult)) {
return parsedResult;
}
}
return null;
}
private static Enum? ReadNumberValue(Utf8JsonReader reader, Type enumType) {
var result = int.Parse(reader.GetString()!);
var castResult = Enum.ToObject(enumType, result);
foreach (var item in Enum.GetValues(enumType)) {
if (castResult.Equals(item)) {
return (Enum?)Convert.ChangeType(castResult, enumType);
}
}
throw new JsonException($"Could not convert '{result}' to enum of type '{enumType.Name}'.");
}
}
}
I then run the same tests that I wrote for the JSON.NET implementation, to make sure nothing has broken e.g.:
public class WhenUsingCustomSerialiser
{
[Fact]
public void ItShouldDeserialiseWhenValueIsDecorated()
{
var settings = new JsonSerializerOptions { WriteIndented = false };
settings.Converters.Add(new AlternativeValueJsonStringEnumConverter());
var output = JsonSerializer.Deserialize<AllowanceRequest>("{ Type: 'CD', Value: 25.75 }", settings);
output.Should().BeEquivalentTo(new { Type = Allowances.Car, Value = 25.75M });
}
}
But that fails with the following exception:
System.InvalidOperationException
The converter 'AlternativeValueJsonStringEnumConverter+CustomStringEnumConverter' is not compatible with the type 'System.Nullable`1[TestEnum]'.
at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_SerializationConverterNotCompatible(Type converterType, Type type)
at System.Text.Json.JsonSerializerOptions.GetConverterInternal(Type typeToConvert)
at System.Text.Json.JsonSerializerOptions.DetermineConverter(Type parentClassType, Type runtimePropertyType, MemberInfo memberInfo)
at System.Text.Json.Serialization.Metadata.JsonTypeInfo.GetConverter(Type type, Type parentClassType, MemberInfo memberInfo, Type& runtimeType, JsonSerializerOptions options)
at System.Text.Json.Serialization.Metadata.JsonTypeInfo.AddProperty(MemberInfo memberInfo, Type memberType, Type parentClassType, Boolean isVirtual, Nullable`1 parentTypeNumberHandling, JsonSerializerOptions options)
Does anyone have any experience in writing their own custom enum serialization code?