Serializing an enum with System.Text.Json

Turns out that json serialized enums with System.Text.Json is pretty annoying. I just wanted the enum serialization/deserialization to work, but they don’t. Here is how I expected them to work.

  1. Serialize to string.
    Not Met: They serialize to a number by default.
  2. Deserialize from string.
    Not Met: They deserialize only from numbers by default and throw an exception if a string is passed in.
  3. Deserialize from case-insensitive string.
    Not Met: They deserialize only from numbers by default and throw an exception if a string is passed in.
  4. Deserialize from a number.
    Met: This worked.

So, I wrote (with the help of AI) a Json enum converter that does this. It took a few classes

using System.Text.Json.Serialization;

namespace Rhyous.Serialization;

/// <summary>A JsonConverter factory.</summary>
public static class EnumJsonConverterFactory
{
    /// <summary>Creates a JsonConverter.</summary>
    /// <typeparam name="T">The type of JsonConverter to create.</typeparam>
    /// <param name="useCamelCase">Whether to use camel case or not.</param>
    /// <returns>The JsonConverter.</returns>
    public static JsonConverter CreateConverter<T>(bool useCamelCase) where T : struct, Enum
    {
        return new JsonStringOrNumberEnumConverter<T>(useCamelCase);
    }
}
using Session.Extensions;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Rhyous.Serialization;

/// <summary>Custom JsonConverter for enums to handle both string and numeric representations, including support for EnumMemberAttribute.</summary>
/// <typeparam name="T">The enum type to be converted.</typeparam>
public class JsonStringOrNumberEnumConverter<T> : JsonConverter<T> where T : struct, Enum
{
    private static readonly ConcurrentDictionary<Type, ConcurrentDictionary<string, T>> _enumMemberCache = new();

    private readonly bool _camelCase;

    /// <summary>The constuctor.</summary>
    /// <param name="camelCase"></param>
    public JsonStringOrNumberEnumConverter(bool camelCase = false)
    {
        _camelCase = camelCase;
    }

    /// <summary>Reads and converts the JSON to the enum value.</summary>
    /// <param name="reader">The Utf8JsonReader.</param>
    /// <param name="typeToConvert">The type to convert.</param>
    /// <param name="options">The JsonSerializerOptions.</param>
    /// <returns>The enum value.</returns>
    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.String)
        {
            var enumText = reader.GetString();
            if (TryGetEnumValue(enumText, out T value))
            {
                return value;
            }
        }
        else if (reader.TokenType == JsonTokenType.Number)
        {
            if (reader.TryGetInt32(out int intValue))
            {
                if (Enum.IsDefined(typeof(T), intValue))
                {
                    return (T)(object)intValue;
                }
            }
        }
        throw new JsonException($"Unable to convert json value to enum {typeToConvert}.");
    }

    /// <summary>Writes the enum value as a string.</summary>
    /// <param name="writer">The Utf8JsonWriter.</param>
    /// <param name="value">The value to write.</param>
    /// <param name="options">The JsonSerializerOptions.</param>
    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        var enumMemberValue = GetEnumMemberValue(value);
        writer.WriteStringValue(enumMemberValue);
    }

    private static bool TryGetEnumValue(string? value, out T enumValue)
    {
        if (value == null)
        {
            enumValue = default;
            return false;
        }

        var enumType = typeof(T);

        var enumMembers = _enumMemberCache.GetOrAdd(enumType, type =>
        {
            var members = new ConcurrentDictionary<string, T>(StringComparer.OrdinalIgnoreCase);
            foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static))
            {
                var enumMemberAttr = field.GetCustomAttribute<EnumMemberAttribute>();
                var enumValue = (T)field.GetValue(null)!; // An enum can't be null
                if (enumMemberAttr != null && enumMemberAttr.Value != null)
                {
                    members.TryAdd(enumMemberAttr.Value, enumValue);
                }
                else
                {
                    members.TryAdd(field.Name, enumValue!);
                }
                // Add the numeric value as a string
                var enumNumericValue = Convert.ToInt32(enumValue).ToString();
                members.TryAdd(enumNumericValue, enumValue);
            }
            return members;
        });

        return enumMembers.TryGetValue(value, out enumValue);
    }

    private string GetEnumMemberValue(T value)
    {
        var enumType = typeof(T);
        var field = enumType.GetField(value.ToString());
        if (field == null)
        {
            throw new InvalidOperationException($"Enum value {value} not found in {enumType}.");
        }
        var enumMemberAttr = field.GetCustomAttribute<EnumMemberAttribute>();
        if (enumMemberAttr != null && enumMemberAttr.Value != null)
        {
            return enumMemberAttr.Value;
        }
        return _camelCase ? ToCameCase(value.ToString()) : value.ToString();
    }

    private string ToCameCase(string value)
    {
        return char.ToLowerInvariant(value[0]) + value.Substring(1);
    }
}
using System.Runtime.Serialization;
using System.Text.Json.Serialization;

namespace Rhyous.Serialization;

/// <summary>Custom JsonConverter Attribute for enums to handle both string and numeric representations, including support for <see cref="EnumMemberAttribute"/>,
/// and for writing enum values in camel case.</summary>
/// <remarks>If <see cref="EnumMemberAttribute"/> is used, it is output as is and the camel case setting is ignored.</remarks>
[AttributeUsage(AttributeTargets.Enum)]
public class JsonStringOrNumberEnumConverterAttribute : JsonConverterAttribute
{
    private readonly bool _camelCase;

    /// <summary>The constructor.</summary>
    /// <param name="camelCase">Whether to use camel case or not.</param>
    public JsonStringOrNumberEnumConverterAttribute(bool camelCase = false)
    {
        _camelCase = camelCase;
    }

    /// <summary>Creates the converter.</summary>
    /// <param name="typeToConvert">The type fo convert.</param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException"></exception>
    public override JsonConverter CreateConverter(Type typeToConvert)
    {
        var enumType = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
        var isNullable = Nullable.GetUnderlyingType(typeToConvert) != null;

        if (!enumType.IsEnum)
        {
            throw new InvalidOperationException("JsonStringOrNumberEnumConverter can only be used with enum types.");
        }

        var createMethod = typeof(EnumJsonConverterFactory)
            .GetMethod(nameof(EnumJsonConverterFactory.CreateConverter))!
            .MakeGenericMethod(enumType);

        return (JsonConverter)createMethod!.Invoke(null, new object[] { _camelCase })!;
    }
}

Leave a Reply

How to post code in comments?