// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using System.Collections.Concurrent;
+using System.Globalization;
using System.Runtime.CompilerServices;
namespace System.Text.Json.Serialization.Converters
internal class JsonConverterEnum<T> : JsonConverter<T>
where T : struct, Enum
{
- private static readonly TypeCode s_enumTypeCode = Type.GetTypeCode(Enum.GetUnderlyingType(typeof(T)));
+ private static readonly TypeCode s_enumTypeCode = Type.GetTypeCode(typeof(T));
- public bool TreatAsString { get; private set; }
+ // Odd type codes are conveniently signed types (for enum backing types).
+ private static readonly string s_negativeSign = ((int)s_enumTypeCode % 2) == 0 ? null : NumberFormatInfo.CurrentInfo.NegativeSign;
+
+ private readonly EnumConverterOptions _converterOptions;
+ private readonly JsonNamingPolicy _namingPolicy;
+ private readonly ConcurrentDictionary<string, string> _nameCache;
public override bool CanConvert(Type type)
{
return type.IsEnum;
}
- public JsonConverterEnum(bool treatAsString)
+ public JsonConverterEnum(EnumConverterOptions options)
+ : this (options, namingPolicy: null)
{
- TreatAsString = treatAsString;
+ }
+
+ public JsonConverterEnum(EnumConverterOptions options, JsonNamingPolicy namingPolicy)
+ {
+ _converterOptions = options;
+ if (namingPolicy != null)
+ {
+ _nameCache = new ConcurrentDictionary<string, string>();
+ }
+ else
+ {
+ namingPolicy = JsonNamingPolicy.Default;
+ }
+ _namingPolicy = namingPolicy;
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- if (TreatAsString)
+ JsonTokenType token = reader.TokenType;
+
+ if (token == JsonTokenType.String)
{
- // Assume the token is a string
- if (reader.TokenType != JsonTokenType.String)
+ if (!_converterOptions.HasFlag(EnumConverterOptions.AllowStrings))
{
ThrowHelper.ThrowJsonException();
return default;
}
+ // Try parsing case sensitive first
string enumString = reader.GetString();
- if (!Enum.TryParse(enumString, out T value))
+ if (!Enum.TryParse(enumString, out T value)
+ && !Enum.TryParse(enumString, ignoreCase: true, out value))
{
ThrowHelper.ThrowJsonException();
return default;
}
+ return value;
}
- if (reader.TokenType != JsonTokenType.Number)
+ if (token != JsonTokenType.Number || !_converterOptions.HasFlag(EnumConverterOptions.AllowNumbers))
{
ThrowHelper.ThrowJsonException();
return default;
}
- // When utf8reader/writer will support all primitive types we should remove custom bound checks
- // https://github.com/dotnet/corefx/issues/36125
-
switch (s_enumTypeCode)
{
- case TypeCode.SByte:
+ // Switch cases ordered by expected frequency
+
+ case TypeCode.Int32:
+ if (reader.TryGetInt32(out int int32))
{
- if (reader.TryGetInt32(out int byte8) && JsonHelpers.IsInRangeInclusive(byte8, sbyte.MinValue, sbyte.MaxValue))
- {
- sbyte byte8Value = (sbyte)byte8;
- return Unsafe.As<sbyte, T>(ref byte8Value);
- }
- break;
+ return Unsafe.As<int, T>(ref int32);
}
- case TypeCode.Byte:
+ break;
+ case TypeCode.UInt32:
+ if (reader.TryGetUInt32(out uint uint32))
{
- if (reader.TryGetUInt32(out uint ubyte8) && JsonHelpers.IsInRangeInclusive(ubyte8, byte.MinValue, byte.MaxValue))
- {
- byte ubyte8Value = (byte)ubyte8;
- return Unsafe.As<byte, T>(ref ubyte8Value);
- }
- break;
+ return Unsafe.As<uint, T>(ref uint32);
}
- case TypeCode.Int16:
+ break;
+ case TypeCode.UInt64:
+ if (reader.TryGetUInt64(out ulong uint64))
{
- if (reader.TryGetInt32(out int int16) && JsonHelpers.IsInRangeInclusive(int16, short.MinValue, short.MaxValue))
- {
- short shortValue = (short)int16;
- return Unsafe.As<short, T>(ref shortValue);
- }
- break;
+ return Unsafe.As<ulong, T>(ref uint64);
}
- case TypeCode.UInt16:
+ break;
+ case TypeCode.Int64:
+ if (reader.TryGetInt64(out long int64))
{
- if (reader.TryGetUInt32(out uint uint16) && JsonHelpers.IsInRangeInclusive(uint16, ushort.MinValue, ushort.MaxValue))
- {
- ushort ushortValue = (ushort)uint16;
- return Unsafe.As<ushort, T>(ref ushortValue);
- }
- break;
+ return Unsafe.As<long, T>(ref int64);
}
- case TypeCode.Int32:
+ break;
+
+ // When utf8reader/writer will support all primitive types we should remove custom bound checks
+ // https://github.com/dotnet/corefx/issues/36125
+ case TypeCode.SByte:
+ if (reader.TryGetInt32(out int byte8) && JsonHelpers.IsInRangeInclusive(byte8, sbyte.MinValue, sbyte.MaxValue))
{
- if (reader.TryGetInt32(out int int32))
- {
- return Unsafe.As<int, T>(ref int32);
- }
- break;
+ sbyte byte8Value = (sbyte)byte8;
+ return Unsafe.As<sbyte, T>(ref byte8Value);
}
- case TypeCode.UInt32:
+ break;
+ case TypeCode.Byte:
+ if (reader.TryGetUInt32(out uint ubyte8) && JsonHelpers.IsInRangeInclusive(ubyte8, byte.MinValue, byte.MaxValue))
{
- if (reader.TryGetUInt32(out uint uint32))
- {
- return Unsafe.As<uint, T>(ref uint32);
- }
- break;
+ byte ubyte8Value = (byte)ubyte8;
+ return Unsafe.As<byte, T>(ref ubyte8Value);
}
- case TypeCode.Int64:
+ break;
+ case TypeCode.Int16:
+ if (reader.TryGetInt32(out int int16) && JsonHelpers.IsInRangeInclusive(int16, short.MinValue, short.MaxValue))
{
- if (reader.TryGetInt64(out long int64))
- {
- return Unsafe.As<long, T>(ref int64);
- }
- break;
+ short shortValue = (short)int16;
+ return Unsafe.As<short, T>(ref shortValue);
}
- case TypeCode.UInt64:
+ break;
+ case TypeCode.UInt16:
+ if (reader.TryGetUInt32(out uint uint16) && JsonHelpers.IsInRangeInclusive(uint16, ushort.MinValue, ushort.MaxValue))
{
- if (reader.TryGetUInt64(out ulong uint64))
- {
- return Unsafe.As<ulong, T>(ref uint64);
- }
- break;
+ ushort ushortValue = (ushort)uint16;
+ return Unsafe.As<ushort, T>(ref ushortValue);
}
+ break;
}
ThrowHelper.ThrowJsonException();
return default;
}
+ private static bool IsValidIdentifier(string value)
+ {
+ // Trying to do this check efficiently. When an enum is converted to
+ // string the underlying value is given if it can't find a matching
+ // identifier (or identifiers in the case of flags).
+ //
+ // The underlying value will be given back with a digit (e.g. 0-9) possibly
+ // preceded by a negative sign. Identifiers have to start with a letter
+ // so we'll just pick the first valid one and check for a negative sign
+ // if needed.
+ return (value[0] >= 'A' &&
+ (s_negativeSign == null || !value.StartsWith(s_negativeSign)));
+ }
+
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
- if (TreatAsString)
+ // If strings are allowed, attempt to write it out as a string value
+ if (_converterOptions.HasFlag(EnumConverterOptions.AllowStrings))
{
- writer.WriteStringValue(value.ToString());
+ string original = value.ToString();
+ if (_nameCache != null && _nameCache.TryGetValue(original, out string transformed))
+ {
+ writer.WriteStringValue(transformed);
+ return;
+ }
+
+ if (IsValidIdentifier(original))
+ {
+ transformed = _namingPolicy.ConvertName(original);
+ writer.WriteStringValue(transformed);
+ if (_nameCache != null)
+ {
+ _nameCache.TryAdd(original, transformed);
+ }
+ return;
+ }
}
- else if (s_enumTypeCode == TypeCode.UInt64)
+
+ if (!_converterOptions.HasFlag(EnumConverterOptions.AllowNumbers))
{
- // Use the ulong converter to prevent conversion into a signed\long value.
- ulong ulongValue = Convert.ToUInt64(value);
- writer.WriteNumberValue(ulongValue);
+ ThrowHelper.ThrowJsonException();
}
- else
+
+ switch (s_enumTypeCode)
{
- // long can hold the signed\unsigned values of other integer types
- long longValue = Convert.ToInt64(value);
- writer.WriteNumberValue(longValue);
+ case TypeCode.Int32:
+ writer.WriteNumberValue(Unsafe.As<T, int>(ref value));
+ break;
+ case TypeCode.UInt32:
+ writer.WriteNumberValue(Unsafe.As<T, uint>(ref value));
+ break;
+ case TypeCode.UInt64:
+ writer.WriteNumberValue(Unsafe.As<T, ulong>(ref value));
+ break;
+ case TypeCode.Int64:
+ writer.WriteNumberValue(Unsafe.As<T, long>(ref value));
+ break;
+ case TypeCode.Int16:
+ writer.WriteNumberValue(Unsafe.As<T, short>(ref value));
+ break;
+ case TypeCode.UInt16:
+ writer.WriteNumberValue(Unsafe.As<T, ushort>(ref value));
+ break;
+ case TypeCode.Byte:
+ writer.WriteNumberValue(Unsafe.As<T, byte>(ref value));
+ break;
+ case TypeCode.SByte:
+ writer.WriteNumberValue(Unsafe.As<T, sbyte>(ref value));
+ break;
+ default:
+ ThrowHelper.ThrowJsonException();
+ break;
}
}
public override void Write(Utf8JsonWriter writer, T value, JsonEncodedText propertyName, JsonSerializerOptions options)
{
- if (TreatAsString)
+ // If strings are allowed, attempt to write it out as a string value
+ if (_converterOptions.HasFlag(EnumConverterOptions.AllowStrings))
{
- writer.WriteString(propertyName, value.ToString());
+ string original = value.ToString();
+ if (_nameCache != null && _nameCache.TryGetValue(original, out string transformed))
+ {
+ writer.WriteString(propertyName, transformed);
+ return;
+ }
+
+ if (IsValidIdentifier(original))
+ {
+ transformed = _namingPolicy.ConvertName(original);
+ writer.WriteString(propertyName, transformed);
+ if (_nameCache != null)
+ {
+ _nameCache.TryAdd(original, transformed);
+ }
+ return;
+ }
}
- else if (s_enumTypeCode == TypeCode.UInt64)
+
+ if (!_converterOptions.HasFlag(EnumConverterOptions.AllowNumbers))
{
- // Use the ulong converter to prevent conversion into a signed\long value.
- ulong ulongValue = Convert.ToUInt64(value);
- writer.WriteNumber(propertyName, ulongValue);
+ ThrowHelper.ThrowJsonException();
}
- else
+
+ switch (s_enumTypeCode)
{
- // long can hold the signed\unsigned values of other integer types.
- long longValue = Convert.ToInt64(value);
- writer.WriteNumber(propertyName, longValue);
+ case TypeCode.Int32:
+ writer.WriteNumber(propertyName, Unsafe.As<T, int>(ref value));
+ break;
+ case TypeCode.UInt32:
+ writer.WriteNumber(propertyName, Unsafe.As<T, uint>(ref value));
+ break;
+ case TypeCode.UInt64:
+ writer.WriteNumber(propertyName, Unsafe.As<T, ulong>(ref value));
+ break;
+ case TypeCode.Int64:
+ writer.WriteNumber(propertyName, Unsafe.As<T, long>(ref value));
+ break;
+ case TypeCode.Int16:
+ writer.WriteNumber(propertyName, Unsafe.As<T, short>(ref value));
+ break;
+ case TypeCode.UInt16:
+ writer.WriteNumber(propertyName, Unsafe.As<T, ushort>(ref value));
+ break;
+ case TypeCode.Byte:
+ writer.WriteNumber(propertyName, Unsafe.As<T, byte>(ref value));
+ break;
+ case TypeCode.SByte:
+ writer.WriteNumber(propertyName, Unsafe.As<T, sbyte>(ref value));
+ break;
+ default:
+ ThrowHelper.ThrowJsonException();
+ break;
}
}
}
--- /dev/null
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.IO;
+using Xunit;
+
+namespace System.Text.Json.Serialization.Tests
+{
+ public class EnumConverterTests
+ {
+ [Fact]
+ public void ConvertDayOfWeek()
+ {
+ JsonSerializerOptions options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+
+ WhenClass when = JsonSerializer.Parse<WhenClass>(@"{""Day"":""Monday""}", options);
+ Assert.Equal(DayOfWeek.Monday, when.Day);
+ DayOfWeek day = JsonSerializer.Parse<DayOfWeek>(@"""Tuesday""", options);
+ Assert.Equal(DayOfWeek.Tuesday, day);
+
+ // We are case insensitive on read
+ day = JsonSerializer.Parse<DayOfWeek>(@"""wednesday""", options);
+ Assert.Equal(DayOfWeek.Wednesday, day);
+
+ // Numbers work by default
+ day = JsonSerializer.Parse<DayOfWeek>(@"4", options);
+ Assert.Equal(DayOfWeek.Thursday, day);
+
+ string json = JsonSerializer.ToString(DayOfWeek.Friday, options);
+ Assert.Equal(@"""Friday""", json);
+
+ // Try a unique naming policy
+ options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter(new ToLower()));
+
+ json = JsonSerializer.ToString(DayOfWeek.Friday, options);
+ Assert.Equal(@"""friday""", json);
+
+ // Undefined values should come out as a number (not a string)
+ json = JsonSerializer.ToString((DayOfWeek)(-1), options);
+ Assert.Equal(@"-1", json);
+
+ // Not permitting integers should throw
+ options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
+ Assert.Throws<JsonException>(() => JsonSerializer.ToString((DayOfWeek)(-1), options));
+ }
+
+ public class ToLower : JsonNamingPolicy
+ {
+ public override string ConvertName(string name) => name.ToLowerInvariant();
+ }
+
+ public class WhenClass
+ {
+ public DayOfWeek Day { get; set; }
+ }
+
+ [Fact]
+ public void ConvertFileAttributes()
+ {
+ JsonSerializerOptions options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+
+ FileState state = JsonSerializer.Parse<FileState>(@"{""Attributes"":""ReadOnly""}", options);
+ Assert.Equal(FileAttributes.ReadOnly, state.Attributes);
+ state = JsonSerializer.Parse<FileState>(@"{""Attributes"":""Directory, ReparsePoint""}", options);
+ Assert.Equal(FileAttributes.Directory | FileAttributes.ReparsePoint, state.Attributes);
+ FileAttributes attributes = JsonSerializer.Parse<FileAttributes>(@"""Normal""", options);
+ Assert.Equal(FileAttributes.Normal, attributes);
+ attributes = JsonSerializer.Parse<FileAttributes>(@"""System, SparseFile""", options);
+ Assert.Equal(FileAttributes.System | FileAttributes.SparseFile, attributes);
+
+ // We are case insensitive on read
+ attributes = JsonSerializer.Parse<FileAttributes>(@"""OFFLINE""", options);
+ Assert.Equal(FileAttributes.Offline, attributes);
+ attributes = JsonSerializer.Parse<FileAttributes>(@"""compressed, notcontentindexed""", options);
+ Assert.Equal(FileAttributes.Compressed | FileAttributes.NotContentIndexed, attributes);
+
+ // Numbers are cool by default
+ attributes = JsonSerializer.Parse<FileAttributes>(@"131072", options);
+ Assert.Equal(FileAttributes.NoScrubData, attributes);
+ attributes = JsonSerializer.Parse<FileAttributes>(@"3", options);
+ Assert.Equal(FileAttributes.Hidden | FileAttributes.ReadOnly, attributes);
+
+ string json = JsonSerializer.ToString(FileAttributes.Hidden, options);
+ Assert.Equal(@"""Hidden""", json);
+ json = JsonSerializer.ToString(FileAttributes.Temporary | FileAttributes.Offline, options);
+ Assert.Equal(@"""Temporary, Offline""", json);
+
+ // Try a unique casing
+ options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter(new ToLower()));
+
+ json = JsonSerializer.ToString(FileAttributes.NoScrubData, options);
+ Assert.Equal(@"""noscrubdata""", json);
+ json = JsonSerializer.ToString(FileAttributes.System | FileAttributes.Offline, options);
+ Assert.Equal(@"""system, offline""", json);
+
+ // Undefined values should come out as a number (not a string)
+ json = JsonSerializer.ToString((FileAttributes)(-1), options);
+ Assert.Equal(@"-1", json);
+
+ // Not permitting integers should throw
+ options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
+ Assert.Throws<JsonException>(() => JsonSerializer.ToString((FileAttributes)(-1), options));
+ }
+
+ public class FileState
+ {
+ public FileAttributes Attributes { get; set; }
+ }
+ }
+}