From a72058ceb7a7d0f01498f5a4e3da03b76976786d Mon Sep 17 00:00:00 2001 From: Joshua Flanagan Date: Tue, 25 Jun 2024 17:18:33 -0500 Subject: [PATCH] Demonstrate backward-compatible deprecation of enums I would like to get rid of using C# enums in the SDK. They unnecessarily constrain the values that a user can send to the ShipEngine API. The API expects a string, the SDK should allow the user to specify a string. An enum requires converting values to an integer, which has no value for our purposes. It also leads to awkwardness when the default is `0`, which will correspond to an enum value we don't necessarily want. It requires the user to update their SDK any time there is a new possible value accepted by the API. We can still provide guidance for valid values via classes that expose static strings. However, there is likely code in the wild where users are setting properties to an enum value. This is an experiment to demonstrate using implicit conversion operators, and a custom JsonConverter, to allow a user to set a "string enum" value using a string or an enum. --- .../CreateLabelFromShipmentDetailsTest.cs | 10 ++- .../CreateLabelFromShipmentDetails/Params.cs | 15 ++-- .../StringEnum.cs | 90 +++++++++++++++++++ 3 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/StringEnum.cs diff --git a/ShipEngine.Tests/ShipEngineMethodTests/CreateLabelFromShipmentDetailsTest.cs b/ShipEngine.Tests/ShipEngineMethodTests/CreateLabelFromShipmentDetailsTest.cs index d810f87a..52e0f79a 100644 --- a/ShipEngine.Tests/ShipEngineMethodTests/CreateLabelFromShipmentDetailsTest.cs +++ b/ShipEngine.Tests/ShipEngineMethodTests/CreateLabelFromShipmentDetailsTest.cs @@ -68,7 +68,9 @@ public CreateLabelFromShipmentDetailsTest() } } }, - ValidateAddress = ValidateAddress.ValidateAndClean + ValidateAddress = ValidateAddress.ValidateAndClean, + LabelLayout = LabelLayout.FourBySix, + LabelFormat = LabelFormat.ZPL }; } @@ -154,6 +156,7 @@ public async Task ValidCreateLabelFromShipmentDetailsTest() [Fact] public void TestParamsSerialization() { + LabelParams.LabelLayout = "A4"; string labelParamsString = JsonSerializer.Serialize(LabelParams, new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, @@ -170,8 +173,9 @@ public void TestParamsSerialization() Assert.Equal("John Doe", parsedJson["shipment"]["ship_from"]["name"].ToString()); Assert.Equal("delivery_mailed", parsedJson["shipment"]["confirmation"].ToString()); - Assert.Null(parsedJson["label_layout"]); - Assert.Null(parsedJson["label_format"]); + Assert.Equal("A4", parsedJson["label_layout"].ToString()); + Assert.Equal("zpl", parsedJson["label_format"].ToString()); + Assert.Null(parsedJson["display_scheme"]); } diff --git a/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/Params.cs b/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/Params.cs index 53207ad7..58682241 100644 --- a/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/Params.cs +++ b/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/Params.cs @@ -43,32 +43,27 @@ public class Params /// /// The possible validate address values /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public ValidateAddress? ValidateAddress { get; set; } + public StringEnum ValidateAddress { get; set; } /// /// There are two different ways to download a label: /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public LabelDownloadType? LabelDownloadType { get; set; } + public StringEnum LabelDownloadType { get; set; } /// /// The file format that you want the label to be in. We recommend pdf format because it is supported by all carriers, whereas some carriers do not support the png or zpl formats. /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public LabelFormat? LabelFormat { get; set; } + public StringEnum LabelFormat { get; set; } /// /// The display format that the label should be shown in. /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public DisplayScheme? DisplayScheme { get; set; } + public StringEnum DisplayScheme { get; set; } /// /// The layout (size) that you want the label to be in. The labelFormat determines which sizes are allowed. 4x6 is supported for all label formats, whereas letter (8.5" x 11") is only supported for pdf format. /// - [JsonConverter(typeof(JsonStringEnumMemberConverter))] - public LabelLayout LabelLayout { get; set; } + public StringEnum LabelLayout { get; set; } /// /// The label image resource that was used to create a custom label image. diff --git a/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/StringEnum.cs b/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/StringEnum.cs new file mode 100644 index 00000000..dba2ebd5 --- /dev/null +++ b/ShipEngine/Models/Dto/CreateLabelFromShipmentDetails/StringEnum.cs @@ -0,0 +1,90 @@ +#nullable disable +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +[JsonConverter(typeof(StringEnumJsonConverterFactory))] +public class StringEnum where T : struct +{ + + public StringEnum(string value) + { + if (Enum.TryParse(value, out T parsed)) + { + IsValidEnum = true; + EnumValue = parsed; + } + StringValue = value; + } + + public StringEnum(T value) + { + EnumValue = value; + IsValidEnum = true; + StringValue = typeof(T) + .GetTypeInfo() + .DeclaredMembers + .SingleOrDefault(x => x.Name == value.ToString()) + ?.GetCustomAttribute(false) + ?.Value ?? value.ToString(); + } + + public T EnumValue { get; } + public string StringValue { get; } + public bool IsValidEnum { get; } + + public static implicit operator StringEnum(T e) => new(e); + public static implicit operator StringEnum(string s) => new(s); + public static implicit operator T(StringEnum se) => se.IsValidEnum ? se.EnumValue : throw new InvalidOperationException($"The custom value {se.StringValue} cannot be represented as a {typeof(T).Name}."); + public static implicit operator string(StringEnum se) => se.StringValue; +} + +public class StringEnumJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(StringEnum<>); + } + + public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) + { + Type[] typeArguments = type.GetGenericArguments(); + Type enumType = typeArguments[0]; + + + JsonConverter converter = (JsonConverter)Activator.CreateInstance( + typeof(StringEnumConverter<>).MakeGenericType( + [enumType]), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: [options], + culture: null)!; + + return converter; + } +} +public class StringEnumConverter : JsonConverter> where T : struct +{ + private readonly JsonConverter _valueConverter; + private readonly JsonConverter _stringConverter; + + public StringEnumConverter(JsonSerializerOptions options) + { + _stringConverter = (JsonConverter)options.GetConverter(typeof(string)); + } + + public override StringEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string readValue = _stringConverter.Read(ref reader, typeToConvert, options); + return new StringEnum(readValue); + } + + public override void Write(Utf8JsonWriter writer, StringEnum value, JsonSerializerOptions options) + { + _stringConverter.Write(writer, value.StringValue, options); + } + +} \ No newline at end of file