From 5f972f6d4b461227802114fdf97690d5d260800f Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Tue, 19 Mar 2024 16:50:44 +0300 Subject: [PATCH] Import `EnumExtensions` and JSON converters (#228) --- .../Converters/EnumConverterHelper.cs | 317 ++++++++++++++++++ .../Converters/JsonIPAddressConverter.cs | 35 ++ .../Converters/JsonIPNetworkConverter.cs | 39 +++ .../JsonStringEnumMemberConverter.cs | 87 +++++ .../Converters/ThrowHelper.cs | 63 ++++ .../Extensions/EnumExtensions.cs | 59 ++++ .../Converters/JsonIPAddressConverterTests.cs | 50 +++ .../Converters/JsonIPNetworkConverterTests.cs | 52 +++ .../JsonStringEnumMemberConverterTests.cs | 282 ++++++++++++++++ 9 files changed, 984 insertions(+) create mode 100644 src/Tingle.Extensions.Primitives/Converters/EnumConverterHelper.cs create mode 100644 src/Tingle.Extensions.Primitives/Converters/JsonIPAddressConverter.cs create mode 100644 src/Tingle.Extensions.Primitives/Converters/JsonIPNetworkConverter.cs create mode 100644 src/Tingle.Extensions.Primitives/Converters/JsonStringEnumMemberConverter.cs create mode 100644 src/Tingle.Extensions.Primitives/Converters/ThrowHelper.cs create mode 100644 src/Tingle.Extensions.Primitives/Extensions/EnumExtensions.cs create mode 100644 tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPAddressConverterTests.cs create mode 100644 tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPNetworkConverterTests.cs create mode 100644 tests/Tingle.Extensions.Primitives.Tests/Converters/JsonStringEnumMemberConverterTests.cs diff --git a/src/Tingle.Extensions.Primitives/Converters/EnumConverterHelper.cs b/src/Tingle.Extensions.Primitives/Converters/EnumConverterHelper.cs new file mode 100644 index 0000000..f1779fc --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Converters/EnumConverterHelper.cs @@ -0,0 +1,317 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Primitives.Converters; + +internal static class EnumConverterHelper +{ + internal const DynamicallyAccessedMemberTypes RequiredMembersTypes = + DynamicallyAccessedMemberTypes.PublicFields | + DynamicallyAccessedMemberTypes.NonPublicFields; +} + +internal class EnumConverterHelper<[DynamicallyAccessedMembers(EnumConverterHelper.RequiredMembersTypes)] TEnum> where TEnum : struct, Enum +{ + private readonly struct EnumInfo(string name, TEnum value, ulong raw, string preferred) + { + public string Name { get; } = name; + public TEnum Value { get; } = value; + public ulong Raw { get; } = raw; + + public string Preferred { get; } = preferred; + + public override string ToString() => $"{Preferred} ({Value})"; + } + + private readonly bool allowIntegerValues; + [DynamicallyAccessedMembers(EnumConverterHelper.RequiredMembersTypes)] + private readonly Type type = typeof(TEnum); + private readonly TypeCode enumTypeCode; + private readonly bool isFlags; + private readonly Dictionary mapping; + private readonly Dictionary lookup; + + public EnumConverterHelper(JsonNamingPolicy? namingPolicy, bool allowIntegerValues) + { + this.allowIntegerValues = allowIntegerValues; + enumTypeCode = Type.GetTypeCode(type); + isFlags = type.IsDefined(typeof(FlagsAttribute), true); + + var names = type.GetEnumNames(); + var builtInValues = type.GetEnumValues(); + + int numberOfNames = names.Length; + + mapping = new Dictionary(numberOfNames); + lookup = new Dictionary(numberOfNames, StringComparer.OrdinalIgnoreCase); + + var bindings = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static; + for (int i = 0; i < numberOfNames; i++) + { + var value = (Enum?)builtInValues.GetValue(i); + if (value is null) continue; + + var raw = GetEnumValue(enumTypeCode, value); + + var name = names[i]; + var field = type.GetField(name, bindings)!; + + TryGetCustomAttribute(field, true, out var ema); + TryGetCustomAttribute(field, true, out var jpna); + var chosen = ema?.Value ?? jpna?.Name ?? namingPolicy?.ConvertName(name) ?? name; + + if (value is not TEnum typed) + throw new NotSupportedException(); + + var info = new EnumInfo(name, typed, raw, chosen); + mapping[typed] = info; + + if (ema?.Value is not null) lookup[ema.Value] = info; + if (jpna?.Name is not null) lookup[jpna.Name] = info; + if (namingPolicy is not null) lookup[namingPolicy.ConvertName(name)] = info; + lookup[name] = info; + lookup[raw.ToString()] = info; + } + } + + public TEnum Read(ref Utf8JsonReader reader) + { + JsonTokenType token = reader.TokenType; + + if (token is JsonTokenType.String or JsonTokenType.PropertyName) + { + string enumString = reader.GetString()!; + + if (lookup.TryGetValue(enumString, out EnumInfo enumInfo)) + return enumInfo.Value; + + if (isFlags) + { + var calculatedValue = 0UL; + + var flagValues = enumString.Split(", "); + foreach (var flagValue in flagValues) + { + // Case sensitive search attempted first. + if (lookup.TryGetValue(flagValue, out enumInfo)) + { + calculatedValue |= enumInfo.Raw; + } + else + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(type, flagValue); + } + } + + return (TEnum)Enum.ToObject(type, calculatedValue); + } + + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(type, enumString); + } + + if (token != JsonTokenType.Number || !allowIntegerValues) + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(type); + } + + switch (enumTypeCode) + { + case TypeCode.Int32: + if (reader.TryGetInt32(out int int32)) + { + return (TEnum)Enum.ToObject(type, int32); + } + break; + case TypeCode.Int64: + if (reader.TryGetInt64(out long int64)) + { + return (TEnum)Enum.ToObject(type, int64); + } + break; + case TypeCode.Int16: + if (reader.TryGetInt16(out short int16)) + { + return (TEnum)Enum.ToObject(type, int16); + } + break; + case TypeCode.Byte: + if (reader.TryGetByte(out byte ubyte8)) + { + return (TEnum)Enum.ToObject(type, ubyte8); + } + break; + case TypeCode.UInt32: + if (reader.TryGetUInt32(out uint uint32)) + { + return (TEnum)Enum.ToObject(type, uint32); + } + break; + case TypeCode.UInt64: + if (reader.TryGetUInt64(out ulong uint64)) + { + return (TEnum)Enum.ToObject(type, uint64); + } + break; + case TypeCode.UInt16: + if (reader.TryGetUInt16(out ushort uint16)) + { + return (TEnum)Enum.ToObject(type, uint16); + } + break; + case TypeCode.SByte: + if (reader.TryGetSByte(out sbyte byte8)) + { + return (TEnum)Enum.ToObject(type, byte8); + } + break; + } + + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(type); + } + + public void Write(Utf8JsonWriter writer, TEnum value) + { + if (mapping.TryGetValue(value, out EnumInfo enumInfo)) + { + writer.WriteStringValue(enumInfo.Preferred); + return; + } + + ulong rawValue = GetEnumValue(enumTypeCode, value); + + if (isFlags) + { + ulong calculatedValue = 0; + + var builder = new StringBuilder(); + foreach (var kvp in mapping) + { + enumInfo = kvp.Value; + if (!value.HasFlag(enumInfo.Value)) continue; + + // Track the value to make sure all bits are represented. + calculatedValue |= enumInfo.Raw; + + if (builder.Length > 0) builder.Append(", "); + builder.Append(enumInfo.Preferred); + } + if (calculatedValue == rawValue) + { + string finalName = builder.ToString(); + writer.WriteStringValue(finalName); + return; + } + } + + if (!allowIntegerValues) + throw new JsonException($"Enum type {type} does not have a mapping for integer value '{rawValue.ToString(CultureInfo.CurrentCulture)}'."); + + switch (enumTypeCode) + { + case TypeCode.Int32: + writer.WriteNumberValue((int)rawValue); + break; + case TypeCode.Int64: + writer.WriteNumberValue((long)rawValue); + break; + case TypeCode.Int16: + writer.WriteNumberValue((short)rawValue); + break; + case TypeCode.Byte: + writer.WriteNumberValue((byte)rawValue); + break; + case TypeCode.UInt32: + writer.WriteNumberValue((uint)rawValue); + break; + case TypeCode.UInt64: + writer.WriteNumberValue(rawValue); + break; + case TypeCode.UInt16: + writer.WriteNumberValue((ushort)rawValue); + break; + case TypeCode.SByte: + writer.WriteNumberValue((sbyte)rawValue); + break; + default: + throw new JsonException(); // GetEnumValue should have already thrown. + } + } + + public void WritePropertyName(Utf8JsonWriter writer, TEnum value) + { + if (mapping.TryGetValue(value, out EnumInfo enumInfo)) + { + writer.WritePropertyName(enumInfo.Preferred); + return; + } + + ulong rawValue = GetEnumValue(enumTypeCode, value); + + if (isFlags) + { + ulong calculatedValue = 0; + + var builder = new StringBuilder(); + foreach (var kvp in mapping) + { + enumInfo = kvp.Value; + if (!value.HasFlag(enumInfo.Value)) continue; + + // Track the value to make sure all bits are represented. + calculatedValue |= enumInfo.Raw; + + if (builder.Length > 0) builder.Append(", "); + builder.Append(enumInfo.Preferred); + } + if (calculatedValue == rawValue) + { + string finalName = builder.ToString(); + writer.WritePropertyName(finalName); + return; + } + } + } + + internal static ulong GetEnumValue(TypeCode enumTypeCode, object value) + { + return enumTypeCode switch + { + TypeCode.Int32 => (ulong)(int)value, + TypeCode.Int64 => (ulong)(long)value, + TypeCode.Int16 => (ulong)(short)value, + TypeCode.Byte => (byte)value, + TypeCode.UInt32 => (uint)value, + TypeCode.UInt64 => (ulong)value, + TypeCode.UInt16 => (ushort)value, + TypeCode.SByte => (ulong)(sbyte)value, + _ => throw new NotSupportedException($"Enum '{value}' of {enumTypeCode} type is not supported."), + }; + } + + /// + /// Tries to retrieve a custom attribute of a specified type that is applied to the member, + /// and optionally inspects the ancestors of that member. + /// + /// The type of attribute to search for. + /// The member to inspect. + /// true to inspect the ancestors of element; otherwise, false. + /// A custom attribute that matches T, or null if no such attribute is found. + /// + /// element is null + /// element is not a constructor, method, property, event, type, or field. + /// More than one of the requested attributes was found. + /// A custom attribute type cannot be loaded. + internal static bool TryGetCustomAttribute(MemberInfo element, bool inherit, [NotNullWhen(true)] out T? attribute) + where T : Attribute + { + ArgumentNullException.ThrowIfNull(element); + + attribute = element.GetCustomAttribute(inherit); + return attribute != null; + } +} diff --git a/src/Tingle.Extensions.Primitives/Converters/JsonIPAddressConverter.cs b/src/Tingle.Extensions.Primitives/Converters/JsonIPAddressConverter.cs new file mode 100644 index 0000000..698c1d8 --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Converters/JsonIPAddressConverter.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Primitives.Converters; + +/// +/// to convert to and from strings. +/// +public class JsonIPAddressConverter : JsonConverter +{ + /// + public override IPAddress? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.String) + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(typeof(IPAddress)); + } + + var value = reader.GetString()!; + + try + { + return IPAddress.Parse(value); + } + catch (Exception ex) + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(typeof(IPAddress), value, ex); + } + } + + /// + public override void Write(Utf8JsonWriter writer, IPAddress value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/src/Tingle.Extensions.Primitives/Converters/JsonIPNetworkConverter.cs b/src/Tingle.Extensions.Primitives/Converters/JsonIPNetworkConverter.cs new file mode 100644 index 0000000..ca23175 --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Converters/JsonIPNetworkConverter.cs @@ -0,0 +1,39 @@ +#if NET8_0_OR_GREATER +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Primitives.Converters; + +/// +/// to convert to and from strings. +/// +public class JsonIPNetworkConverter : JsonConverter +{ + /// + public override IPNetwork Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.String) + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(typeof(IPNetwork)); + } + + var value = reader.GetString()!; + + try + { + return IPNetwork.Parse(value); + } + catch (Exception ex) + { + throw ThrowHelper.GenerateJsonException_DeserializeUnableToConvertValue(typeof(IPNetwork), value, ex); + } + } + + /// + public override void Write(Utf8JsonWriter writer, IPNetwork value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} +#endif diff --git a/src/Tingle.Extensions.Primitives/Converters/JsonStringEnumMemberConverter.cs b/src/Tingle.Extensions.Primitives/Converters/JsonStringEnumMemberConverter.cs new file mode 100644 index 0000000..012eda3 --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Converters/JsonStringEnumMemberConverter.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Primitives.Converters; + +/// +/// to convert enums to and from strings, respecting decorations. Supports nullable enums. +/// +/// The enum type that this converter targets. +/// Optional naming policy for writing enum values. +/// +/// True to allow undefined enum values. When true, if an enum value isn't +/// defined it will output as a number rather than a string. +/// +public class JsonStringEnumMemberConverter<[DynamicallyAccessedMembers(EnumConverterHelper.RequiredMembersTypes)] TEnum>(JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true) + : JsonConverterFactory where TEnum : struct, Enum +{ + private readonly JsonNamingPolicy? namingPolicy = namingPolicy; + private readonly bool allowIntegerValues = allowIntegerValues; + + /// Initializes a new instance of the class. + public JsonStringEnumMemberConverter() : this(null, true) { } + + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(TEnum); + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => new EnumMemberConverter(namingPolicy, allowIntegerValues); +} + +/// +/// to convert enums to and from strings, respecting decorations. Supports nullable enums. +/// +/// Optional naming policy for writing enum values. +/// +/// True to allow undefined enum values. When true, if an enum value isn't +/// defined it will output as a number rather than a string. +/// +[RequiresDynamicCode( + "JsonStringEnumMemberConverter cannot be statically analyzed and requires runtime code generation. " + + "Applications should use the generic JsonStringEnumMemberConverter instead")] +public class JsonStringEnumMemberConverter(JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true) : JsonConverterFactory +{ + private readonly JsonNamingPolicy? namingPolicy = namingPolicy; + private readonly bool allowIntegerValues = allowIntegerValues; + + /// Initializes a new instance of the class. + public JsonStringEnumMemberConverter() : this(null, true) { } + + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum; + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { +#pragma warning disable IL2070 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of method does not have matching annotations. + return (JsonConverter?)Activator.CreateInstance( + typeof(EnumMemberConverter<>).MakeGenericType(typeToConvert), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: [namingPolicy, allowIntegerValues], + culture: null); +#pragma warning restore IL2070 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of method does not have matching annotations. + } +} + +internal class EnumMemberConverter<[DynamicallyAccessedMembers(EnumConverterHelper.RequiredMembersTypes)] TEnum>(JsonNamingPolicy? namingPolicy, bool allowIntegerValues) + : JsonConverter where TEnum : struct, Enum +{ + private readonly EnumConverterHelper helper = new(namingPolicy, allowIntegerValues); + + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => helper.Read(ref reader); + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + => helper.Write(writer, value); + + public override TEnum ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => helper.Read(ref reader); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + => helper.WritePropertyName(writer, value); +} diff --git a/src/Tingle.Extensions.Primitives/Converters/ThrowHelper.cs b/src/Tingle.Extensions.Primitives/Converters/ThrowHelper.cs new file mode 100644 index 0000000..a51672f --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Converters/ThrowHelper.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; + +namespace Tingle.Extensions.Primitives.Converters; + +internal static class ThrowHelper +{ + private static readonly PropertyInfo? s_JsonException_AppendPathInformation + = typeof(JsonException).GetProperty("AppendPathInformation", BindingFlags.NonPublic | BindingFlags.Instance); + + /// + /// Generate a using the internal + /// JsonException.AppendPathInformation property that will + /// eventually include the JSON path, line number, and byte position in + /// line. + /// + /// The final message of the exception looks like: The JSON value could + /// not be converted to {0}. Path: $.{JSONPath} | LineNumber: + /// {LineNumber} | BytePositionInLine: {BytePositionInLine}. + /// + /// + /// Property type. + /// . + public static JsonException GenerateJsonException_DeserializeUnableToConvertValue(Type propertyType) + { + Debug.Assert(s_JsonException_AppendPathInformation != null); + + JsonException jsonException = new($"The JSON value could not be converted to {propertyType}."); + s_JsonException_AppendPathInformation?.SetValue(jsonException, true); + return jsonException; + } + + /// + /// Generate a using the internal + /// JsonException.AppendPathInformation property that will + /// eventually include the JSON path, line number, and byte position in + /// line. + /// + /// The final message of the exception looks like: The JSON value '{1}' + /// could not be converted to {0}. Path: $.{JSONPath} | LineNumber: + /// {LineNumber} | BytePositionInLine: {BytePositionInLine}. + /// + /// + /// Property type. + /// Value that could not be parsed into + /// property type. + /// Optional inner . + /// . + public static JsonException GenerateJsonException_DeserializeUnableToConvertValue( + Type propertyType, + string propertyValue, + Exception? innerException = null) + { + Debug.Assert(s_JsonException_AppendPathInformation != null); + + JsonException jsonException = new( + $"The JSON value '{propertyValue}' could not be converted to {propertyType}.", + innerException); + s_JsonException_AppendPathInformation?.SetValue(jsonException, true); + return jsonException; + } +} diff --git a/src/Tingle.Extensions.Primitives/Extensions/EnumExtensions.cs b/src/Tingle.Extensions.Primitives/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..95c7e91 --- /dev/null +++ b/src/Tingle.Extensions.Primitives/Extensions/EnumExtensions.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Serialization; + +namespace System; + +/// Extensions for Enums. +public static class EnumExtensions // TODO: unit test this +{ + private const DynamicallyAccessedMemberTypes MembersTypesForEnums = + DynamicallyAccessedMemberTypes.PublicFields | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicEvents | + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicNestedTypes; + + /// Gets the value declared on the member using . + /// The of the enum. + /// The value of the enum member/field. + /// + public static string? GetEnumMemberAttrValue([DynamicallyAccessedMembers(MembersTypesForEnums)] this Type type, object value) + { + ArgumentNullException.ThrowIfNull(type); + if (!type.IsEnum) throw new ArgumentException("Only enum types are allowed.", nameof(type)); + + var mi = type.GetMember(value.ToString()!); + var attr = mi.FirstOrDefault()?.GetCustomAttribute(inherit: false); + + return attr?.Value; + } + + /// Gets the value declared on the member using or the default. + /// The of the enum. + /// The value of the enum member/field. + /// + public static string GetEnumMemberAttrValueOrDefault([DynamicallyAccessedMembers(MembersTypesForEnums)] this Type type, object value) + { + return type.GetEnumMemberAttrValue(value) ?? value.ToString()!.ToLowerInvariant(); + } + + /// Gets the value declared on the using . + /// The value of the enum member/field. + public static string? GetEnumMemberAttrValue(this Enum value) => GetEnumMemberAttrValue(value.GetType(), value); + + /// Gets the value declared on the using or the default. + /// The value of the enum member/field. + public static string GetEnumMemberAttrValueOrDefault(this Enum value) => GetEnumMemberAttrValueOrDefault(value.GetType(), value); + + /// Gets the value declared on the member using . + /// The of the enum. + /// The value of the enum member/field. + public static string? GetEnumMemberAttrValue<[DynamicallyAccessedMembers(MembersTypesForEnums)] T>(this T value) where T : struct, Enum => GetEnumMemberAttrValue(typeof(T), value); + + /// Gets the value declared on the member using or the default. + /// The of the enum. + /// The value of the enum member/field. + public static string GetEnumMemberAttrValueOrDefault<[DynamicallyAccessedMembers(MembersTypesForEnums)] T>(this T value) where T : struct, Enum => GetEnumMemberAttrValueOrDefault(typeof(T), value); +} diff --git a/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPAddressConverterTests.cs b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPAddressConverterTests.cs new file mode 100644 index 0000000..9a0b72c --- /dev/null +++ b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPAddressConverterTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.Extensions.Primitives.Converters; + +namespace Tingle.Extensions.Primitives.Tests.Converters; + +public class JsonIPAddressConverterTests +{ + [Fact] + public void Serialization_Works() + { + var json = JsonSerializer.Serialize(new TestClass { Value = IPAddress.Loopback, }); + Assert.Equal(@"{""Value"":""127.0.0.1""}", json); + + json = JsonSerializer.Serialize(new TestClass { Value = IPAddress.IPv6Loopback, }); + Assert.Equal(@"{""Value"":""::1""}", json); + + json = JsonSerializer.Serialize(new TestClass { Value = null, }); + Assert.Equal(@"{""Value"":null}", json); + } + + [Fact] + public void Deserialization_Works() + { + var actual = JsonSerializer.Deserialize(@"{""Value"":""127.0.0.1""}"); + Assert.NotNull(actual); + Assert.Equal(IPAddress.Loopback, actual.Value); + + actual = JsonSerializer.Deserialize(@"{""Value"":""::1""}"); + Assert.NotNull(actual); + Assert.Equal(IPAddress.IPv6Loopback, actual.Value); + + actual = JsonSerializer.Deserialize(@"{""Value"":null}"); + Assert.NotNull(actual); + Assert.Null(actual.Value); + } + + [Fact] + public void IPAddressInvalidTypeDeserializationTest() => Assert.Throws(() => JsonSerializer.Deserialize(@"{""Value"":1}")); + + [Fact] + public void IPAddressInvalidValueDeserializationTest() => Assert.Throws(() => JsonSerializer.Deserialize(@"{""Value"":""invalid_value""}")); + + private class TestClass + { + [JsonConverter(typeof(JsonIPAddressConverter))] + public IPAddress? Value { get; set; } + } +} diff --git a/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPNetworkConverterTests.cs b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPNetworkConverterTests.cs new file mode 100644 index 0000000..2e7a114 --- /dev/null +++ b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonIPNetworkConverterTests.cs @@ -0,0 +1,52 @@ +#if NET8_0_OR_GREATER +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.Extensions.Primitives.Converters; + +namespace Tingle.Extensions.Primitives.Tests.Converters; + +public class JsonIPNetworkConverterTests +{ + [Fact] + public void Serialization_Works() + { + var json = JsonSerializer.Serialize(new TestClass { Value = IPNetwork.Parse("192.168.0.4/32"), }); + Assert.Equal(@"{""Value"":""192.168.0.4/32""}", json); + + json = JsonSerializer.Serialize(new TestClass { Value = IPNetwork.Parse("30.0.0.0/27"), }); + Assert.Equal(@"{""Value"":""30.0.0.0/27""}", json); + + json = JsonSerializer.Serialize(new TestClass { Value = null, }); + Assert.Equal(@"{""Value"":null}", json); + } + + [Fact] + public void Deserialization_Works() + { + TestClass? actual = JsonSerializer.Deserialize(@"{""Value"":""192.168.0.4/32""}"); + Assert.NotNull(actual); + Assert.Equal(IPNetwork.Parse("192.168.0.4/32"), actual.Value); + + actual = JsonSerializer.Deserialize(@"{""Value"":""30.0.0.0/27""}"); + Assert.NotNull(actual); + Assert.Equal(IPNetwork.Parse("30.0.0.0/27"), actual.Value); + + actual = JsonSerializer.Deserialize(@"{""Value"":null}"); + Assert.NotNull(actual); + Assert.Null(actual.Value); + } + + [Fact] + public void IPNetworkInvalidTypeDeserializationTest() => Assert.Throws(() => JsonSerializer.Deserialize(@"{""Value"":1}")); + + [Fact] + public void IPNetworkInvalidValueDeserializationTest() => Assert.Throws(() => JsonSerializer.Deserialize(@"{""Value"":""invalid_value""}")); + + private class TestClass + { + [JsonConverter(typeof(JsonIPNetworkConverter))] + public IPNetwork? Value { get; set; } + } +} +#endif diff --git a/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonStringEnumMemberConverterTests.cs b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonStringEnumMemberConverterTests.cs new file mode 100644 index 0000000..ad305c0 --- /dev/null +++ b/tests/Tingle.Extensions.Primitives.Tests/Converters/JsonStringEnumMemberConverterTests.cs @@ -0,0 +1,282 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.Extensions.Primitives.Converters; + +namespace Tingle.Extensions.Primitives.Tests.Converters; + +public class JsonStringEnumMemberConverterTests +{ + [Fact] + public void AsDictionaryKeyTest() + { + var dict = new Dictionary + { + [EnumDefinition.First] = "Mercedes", + [EnumDefinition.Second] = "Toyota", + }; + + var options = new JsonSerializerOptions(); + var json = JsonSerializer.Serialize(dict, options); + Assert.Equal(@"{""First"":""Mercedes"",""Second"":""Toyota""}", json); + + options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumMemberConverter(allowIntegerValues: true)); + json = JsonSerializer.Serialize(dict, options); + Assert.Equal(@"{""First"":""Mercedes"",""_second"":""Toyota""}", json); + + var dict_rev = JsonSerializer.Deserialize>(json, options)!; + Assert.Equal>(dict, dict_rev); + } + + [Fact] + public void EnumMemberSerializationTest() + { + string Json = JsonSerializer.Serialize(FlagDefinitions.Four); + Assert.Equal(@"""four value""", Json); + + Json = JsonSerializer.Serialize(FlagDefinitions.Four | FlagDefinitions.One); + Assert.Equal(@"""one value, four value""", Json); + + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumMemberConverter(allowIntegerValues: true)); + Json = JsonSerializer.Serialize((FlagDefinitions)255, options); + Assert.Equal("255", Json); + } + + [Fact] + public void EnumMemberDeserializationTest() + { + FlagDefinitions Value = JsonSerializer.Deserialize(@"""all values"""); + Assert.Equal(FlagDefinitions.All, Value); + + Value = JsonSerializer.Deserialize(@"""two value, three value"""); + Assert.Equal(FlagDefinitions.Two | FlagDefinitions.Three, Value); + + Value = JsonSerializer.Deserialize(@"""tWo VALUE"""); + Assert.Equal(FlagDefinitions.Two, Value); + } + + [Theory] + [InlineData("null")] + [InlineData(@"""invalid_value""")] + public void EnumMemberInvalidDeserializationTest(string json) => Assert.Throws(() => JsonSerializer.Deserialize(json)); + + [Fact] + public void EnumMemberInvalidNumericValueDeserializationTest() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumMemberConverter(allowIntegerValues: false)); + + Assert.Throws(() => JsonSerializer.Serialize((FlagDefinitions)255, options)); + } + + [Fact] + public void NullableEnumSerializationTest() + { + var Options = new JsonSerializerOptions(); + Options.Converters.Add(new JsonStringEnumMemberConverter(allowIntegerValues: true)); + + string Json = JsonSerializer.Serialize((DayOfWeek?)null, Options); + Assert.Equal("null", Json); + + Json = JsonSerializer.Serialize((DayOfWeek?)DayOfWeek.Monday, Options); + Assert.Equal(@"""Monday""", Json); + + Json = JsonSerializer.Serialize((EnumDefinition?)255, Options); + Assert.Equal("255", Json); + } + + [Fact] + public void NullableEnumDeserializationTest() + { + var Options = new JsonSerializerOptions(); + Options.Converters.Add(new JsonStringEnumMemberConverter(allowIntegerValues: true)); + + DayOfWeek? Value = JsonSerializer.Deserialize("null", Options); + Assert.Null(Value); + + Value = JsonSerializer.Deserialize(@"""Friday""", Options); + Assert.Equal(DayOfWeek.Friday, Value); + + EnumDefinition? EnumValue = JsonSerializer.Deserialize(@"""fIrSt""", Options); + Assert.Equal(EnumDefinition.First, EnumValue); + + EnumValue = JsonSerializer.Deserialize(@"255", Options); + Assert.Equal(255, (int)EnumValue!); + } + + [Fact] + public void EnumMemberSerializationOptionsTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter(JsonNamingPolicy.CamelCase) } + }; + + string json = JsonSerializer.Serialize(EnumDefinition.First, options); + Assert.Equal(@"""first""", json); + + json = JsonSerializer.Serialize(EnumDefinition.Second, options); + Assert.Equal(@"""_second""", json); + } + + [Fact] + public void EnumMemberDeserializationOptionsTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter(JsonNamingPolicy.CamelCase) } + }; + + EnumDefinition Value = JsonSerializer.Deserialize(@"""first""", options); + Assert.Equal(EnumDefinition.First, Value); + + Value = JsonSerializer.Deserialize(@"""_second""", options); + Assert.Equal(EnumDefinition.Second, Value); + } + + [Fact] + public void EnumMemberInvalidDeserializationOptionsTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter() } + }; + + var ex = Assert.Throws(() => JsonSerializer.Deserialize(@"""invalid_value""", options)); + } + + [Fact] + public void EnumMemberInvalidTypeDeserializationOptionsTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter(allowIntegerValues: false) } + }; + + Assert.Throws(() => JsonSerializer.Deserialize(@"255", options)); + } + + [Fact] + public void EnumMemberInvalidDeserializationIncludesJsonPathInMessageTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter() } + }; + + var ex = Assert.Throws(() => JsonSerializer.Deserialize(@"""invalid_value""", options)); + Assert.Contains(". Path: $", ex.Message); + } + + [Fact] + public void EnumMemberFlagInvalidDeserializationIncludesJsonPathInMessageTest() + { + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumMemberConverter() } + }; + + var ex = Assert.Throws(() => JsonSerializer.Deserialize(@"""invalid_value""", options)); + Assert.Contains(". Path: $", ex.Message); + } + + [Fact] + public void JsonPropertyNameSerializationTest() + { + string Json = JsonSerializer.Serialize(MixedEnumDefinition.First); + Assert.Equal(@"""_first""", Json); + + Json = JsonSerializer.Serialize(MixedEnumDefinition.Second); + Assert.Equal(@"""_second""", Json); + + Json = JsonSerializer.Serialize(MixedEnumDefinition.Third); + Assert.Equal(@"""_third_enumMember""", Json); + } + + [Fact] + public void JsonPropertyNameDeserializationTest() + { + MixedEnumDefinition Value = JsonSerializer.Deserialize(@"""_first"""); + Assert.Equal(MixedEnumDefinition.First, Value); + + Value = JsonSerializer.Deserialize(@"""_second"""); + Assert.Equal(MixedEnumDefinition.Second, Value); + + Value = JsonSerializer.Deserialize(@"""_third_enumMember"""); + Assert.Equal(MixedEnumDefinition.Third, Value); + } + + [Fact] + public void WorksWithGenericConverter() + { + var dict = new Dictionary + { + [EnumWithGenericConverter.First] = "Mercedes", + [EnumWithGenericConverter.Second] = "Toyota", + }; + + var options = new JsonSerializerOptions(); + var json = JsonSerializer.Serialize(dict, options); + Assert.Equal(@"{""First"":""Mercedes"",""_second"":""Toyota""}", json); + + var dict_rev = JsonSerializer.Deserialize>(json, options)!; + Assert.Equal>(dict, dict_rev); + } + + [Flags] + [JsonConverter(typeof(JsonStringEnumMemberConverter))] + public enum FlagDefinitions + { + //None = 0x00, + + [EnumMember(Value = "all values")] + All = One | Two | Three | Four, + + [EnumMember(Value = "one value")] + One = 0x01, + + [EnumMember(Value = "two value")] + Two = 0x02, + + [EnumMember(Value = "three value")] + [JsonPropertyName("jsonPropertyName.is.ignored")] + Three = 0x04, + + [JsonPropertyName("four value")] + Four = 0x08, + } + + public enum EnumDefinition + { + First, + + [EnumMember(Value = "_second")] + Second, + } + + [JsonConverter(typeof(JsonStringEnumMemberConverter))] + public enum MixedEnumDefinition + { + [EnumMember(Value = "_first")] + First, + + [JsonPropertyName("_second")] + Second, + + // Note: We use EnumMember over JsonPropertyName if both are specified. + [JsonPropertyName("_third_jsonPropertyName")] + [EnumMember(Value = "_third_enumMember")] + Third + } + + [JsonConverter(typeof(JsonStringEnumMemberConverter))] + public enum EnumWithGenericConverter + { + First, + + [EnumMember(Value = "_second")] + Second, + } +}