dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.36k stars 4.74k forks source link

Support for EnumMemberAttribute in JsonConverterEnum #31081

Closed kolkinn closed 3 years ago

kolkinn commented 5 years ago

Hi, I wonder if supporting EnumMemberAttribute in JsonConverterEnum is planned and if so what the ETA is?

Json.NET supports EnumMemberAttribute to customize enum name values. It is a well used feature.

You don't need it in 3.0, but you will get a lot of requests for customizing enum names. Design for adding it in the future.

Originally posted by @JamesNK in https://github.com/dotnet/corefx/pull/38702#issuecomment-503777812

ericstj commented 5 years ago

/cc @steveharter

We should discuss if we want to support this and other attributes from System.Runtime.Serialization before adding them.

ericstj commented 5 years ago

The basic scenario here is to provide control of the value names used for enum serialization. DCS and Json.NET use EnumMemberAttribute, XML uses XmlEnumAttribute.

CodeBlanch commented 4 years ago

For anyone blocked by this, here's a NuGet package with a converter (JsonStringEnumMemberConverter) we can use ahead of the 5.0 drop: Macross.Json.Extensions

Supports nullable enum types and adds EnumMemberAttribute support.

layomia commented 4 years ago

From @AraHaan in https://github.com/dotnet/runtime/issues/36931:

I personally think that EnumMember should be supported, many users try to use it with the JsonStringEnumConverter that comes shipped with System.Text.Json itself. Because of this they can run into an System.Text.Json.JsonException from doing such which sucks.

Take a look at the draft pr that is on hold because this is 1 of the issues they face when trying to eradicate newtonsoft.json: RehanSaeed/Schema.NET#100

Also take a look at a version of my code which did that, then I have to now struggle trying to get my stuff to compile because now I got to create a string extension method that could allow null strings somehow and then filter it to the enum values accordingly manually which also sucks. https://paste.mod.gg/giwezarimo.cs https://paste.mod.gg/nuqofuwamu.cs https://paste.mod.gg/gunoqemufu.cs

You can literally use this code as a minimal example and as you can tell my code will not compile as this stuff is lacking. Note: I only made this change because of that pull request above I seen that it used EnumMember and that it used a converter I did not really know about so I thought "Wait a second so my manual converters is not needed at all?"

jmaine commented 4 years ago

Here is the new pull request for this issue: #37113.

jmaine commented 4 years ago

Here is the proposed API to fix this issue. EnumMemberAttribute was considered, but the serializer prefers custom/dedicated attributes. This proposed API takes this into account and proposes a new attribute for this purpose.

Note: Pinging @layomia as this person expressed interested in this proposal.

Rationale and Usage

This may be a nice-to-have; however, as @mikaelkolkinn states, it is a blocker for migrating off of the Newtonsoft Json serializer/deserializer for some customers.

Proposed API

The proposed attribute would be the following. It would permit the overriding of any JsonNamingPolicy provided in the serializer.

namespace System.Text.Json.Serialization
{
    /// <summary>
    /// Specifies the enum member that is present in the JSON when serializing and deserializing.
    /// This overrides any naming policy specified by <see cref="JsonNamingPolicy"/>.
    /// </summary>
    [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
    public sealed class JsonStringEnumMemberAttribute : JsonAttribute
    {
        /// <summary>
        /// Initializes a new instance of <see cref="JsonStringEnumMemberAttribute"/> with the specified enum member name.
        /// </summary>
        /// <param name="name">The name of the enum member.</param>
        public JsonStringEnumMemberAttribute(string name)
        {
            Name = name;
        }

        /// <summary>
        /// The name of the enum member.
        /// </summary>
        public string Name { get; }
    }
}

The usage of this attribute would be the following.

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum MyCustomJsonStringEnumMemberEnum
{
            [System.Text.Json.Serialization.JsonStringEnumMemberAttribute("one_")]
            One,
            [System.Text.Json.Serialization.JsonStringEnumMemberAttribute("two_")]
            Two,
            [System.Text.Json.Serialization.JsonStringEnumMemberAttribute(null)]
            Null
}

The result of each one of these example enum members when serialized will be the following json values: "one_", "two_", null

Details

The serializer will respect this new attribute on both serialization and deserialization. The serializer will allow any string or null as permissible values in the attribute and will convert it from/to the specified enum member where the attribute is attached to.

This attribute will also override any JsonNamingPolicy provided in the serializer.

Note: this API proposal is not a replacement for #31619, but a compliment to it.

Open Questions

Pull Request

See pull request #37113 for an example implementation.

Updates

MattMinke commented 3 years ago

I have a hunch alot of people right now are working around this issue by using @CodeBlanch recommendation. The nuget package referenced has 500,000 + downloads.

image

CodeBlanch commented 3 years ago

Ya a lot of people using it and some other features have been added based on issues/PRs opened in the repo behind it. It now supports using JsonPropertyName or EnumMemberAttribute (if you are targeting .NET 5+). Has a fix for #31619. Has a feature for defining a fallback value in case something unknown comes in during de-serialization. Also allows you to specify options based on the enum types being converted, or via an attribute, where the stock one you really only can apply settings globally (1 factory per serializer options).

I think the other popular one in the NuGet is the TimeSpan converter. Based on traffic to the blog post about it.

JasonBodley commented 3 years ago

Noddy work-around for those who need it

public class JsonStringEnumConverterEx<TEnum> : JsonConverter<TEnum> where TEnum : struct, System.Enum
  {

    private readonly Dictionary<TEnum, string> _enumToString = new Dictionary<TEnum, string>();
    private readonly Dictionary<string, TEnum> _stringToEnum = new Dictionary<string, TEnum>();

    public JsonStringEnumConverterEx()
    {
      var type = typeof(TEnum);
      var values = System.Enum.GetValues<TEnum>();

      foreach (var value in values)
      {
        var enumMember = type.GetMember(value.ToString())[0];
        var attr = enumMember.GetCustomAttributes(typeof(EnumMemberAttribute), false)
          .Cast<EnumMemberAttribute>()
          .FirstOrDefault();

        _stringToEnum.Add(value.ToString(), value);

        if (attr?.Value != null)
        {
          _enumToString.Add(value, attr.Value);
          _stringToEnum.Add(attr.Value, value);
        } else
        {
          _enumToString.Add(value, value.ToString());
        }
      }
    }

    public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
      var stringValue = reader.GetString();

      if (_stringToEnum.TryGetValue(stringValue, out var enumValue))
      {
        return enumValue;
      }

      return default;
    }

    public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
    {
      writer.WriteStringValue(_enumToString[value]);
    }
  }

...

[JsonConverter(typeof(JsonStringEnumConverterEx<MyEnum>))]
public MyEnum SomeValue { get; set; }
egbertn commented 3 years ago

Noddy work-around for those who need it

Thanks, I had to modify it a bit to get it working for a scenario where the deserialiser, receives numbers instead of the values.

public class JsonStringEnumConverterEx : JsonConverter where TEnum : struct, System.Enum {

    private readonly Dictionary<TEnum, string> _enumToString = new();
    private readonly Dictionary<string, TEnum> _stringToEnum = new();
    private readonly Dictionary<int, TEnum> _numberToEnum = new ();

    public JsonStringEnumConverterEx()
    {
        var type = typeof(TEnum);

        foreach (var value in Enum.GetValues<TEnum>())
        {
            var enumMember = type.GetMember(value.ToString())[0];
            var attr = enumMember.GetCustomAttributes(typeof(EnumMemberAttribute), false)
              .Cast<EnumMemberAttribute>()
              .FirstOrDefault();

            _stringToEnum.Add(value.ToString(), value);
            var num =Convert.ToInt32( type.GetField("value__")
                    .GetValue(value));
            if (attr?.Value != null)
            {
                _enumToString.Add(value, attr.Value);
                _stringToEnum.Add(attr.Value, value);
                _numberToEnum.Add(num, value);
            }
            else
            {
                _enumToString.Add(value, value.ToString());
            }
        }
    }

    public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var type = reader.TokenType;
        if (type == JsonTokenType.String)
        {
            var stringValue = reader.GetString();
            if (_stringToEnum.TryGetValue(stringValue, out var enumValue))
            {
                return enumValue;
            }
        }
        else  if (type == JsonTokenType.Number)
        {
            var numValue = reader.GetInt32();
            _numberToEnum.TryGetValue(numValue, out var enumValue);
            return enumValue;
        }

        return default;
    }

    public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(_enumToString[value]);
    }
}
eiriktsarpalis commented 3 years ago

I'm going to close this in favor of #29975.

mdddev commented 3 years ago

Noddy work-around for those who need it

Thanks, I had to modify it a bit to get it working for a scenario where the deserialiser, receives numbers instead of the values.


public class JsonStringEnumConverterEx<TEnum> : JsonConverter<TEnum> where TEnum : struct, System.Enum
    {

        private readonly Dictionary<TEnum, string> _enumToString = new();
        private readonly Dictionary<string, TEnum> _stringToEnum = new();
        private readonly Dictionary<int, TEnum> _numberToEnum = new ();

        public JsonStringEnumConverterEx()
        {
            var type = typeof(TEnum);

            foreach (var value in Enum.GetValues<TEnum>())
            {
                var enumMember = type.GetMember(value.ToString())[0];
                var attr = enumMember.GetCustomAttributes(typeof(EnumMemberAttribute), false)
                  .Cast<EnumMemberAttribute>()
                  .FirstOrDefault();

                _stringToEnum.Add(value.ToString(), value);
                var num =Convert.ToInt32( type.GetField("value__")
                        .GetValue(value));
                if (attr?.Value != null)
                {
                    _enumToString.Add(value, attr.Value);
                    _stringToEnum.Add(attr.Value, value);
                    _numberToEnum.Add(num, value);
                }
                else
                {
                    _enumToString.Add(value, value.ToString());
                }
            }
        }

        public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var type = reader.TokenType;
            if (type == JsonTokenType.String)
            {
                var stringValue = reader.GetString();
                if (_stringToEnum.TryGetValue(stringValue, out var enumValue))
                {
                    return enumValue;
                }
            }
            else  if (type == JsonTokenType.Number)
            {
                var numValue = reader.GetInt32();
                _numberToEnum.TryGetValue(numValue, out var enumValue);
                return enumValue;
            }

            return default;
        }

        public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(_enumToString[value]);
        }
    }

Hi, thanks for this!

"{\"Name\": \"EnumTest\", \"Value\": 123}" De-serializing works if a number is received. However it also serializes to "123" instead of 123. Can you recommend a modification?