Deserialize enums using the EnumMember value in AspNet [FromQuery] model binding
Asked Answered
L

2

8

I have an endpoint in .NET 6 Microsoft.NET.Sdk.Web project that deserialize query strings into a .NET object by using the standard [FromQuery]

[Route("[controller]")]
public class SamplesController
    : ControllerBase
{
    [HttpGet]
    public IActionResult Get([FromQuery]QueryModel queryModel)
    {
        if (!queryModel.Status.HasValue)
        {
            return BadRequest("Problem in deserialization");
        }
        
        return Ok(queryModel.Status.Value.GetEnumDisplayName());
    }
}

The model contains an enum

public class QueryModel
{
    /// <summary>
    /// The foo parameter
    /// </summary>
    /// <example>bar</example>
    public string? Foo { get; init; } = null;
    
    /// <summary>
    /// The status
    /// </summary>
    /// <example>on-hold</example>
    public Status? Status { get; set; } = null;
}

And the enum has EnumMember attributes which value I want to use to deserialize from.

public enum Status
{
    [EnumMember(Value = "open")]
    Open,
    
    [EnumMember(Value = "on-hold")]
    OnHold
}

By default, .NET 6 does not take into consideration the EnumMember when deserializing.

The goal is to be able to send requests such as

http://localhost:5000/Samples?Foo=bar&Status=on-hold 

and have the controller's action deserialize the QueryModel with the proper Status.OnHold value by using its EnumMember

I have tried without luck an extensions library that contains a converter, but the converter is not getting triggered when using [FromQuery]. See https://github.com/Macross-Software/core/issues/30

I have added a project to reproduce problem and as a sandbox to provide a solution** https://gitlab.com/sunnyatticsoftware/issues/string-to-enum-mvc/-/tree/feature/1-original-problem

NOTE: I would need a solution where the Enum and the does not require any external dependency (just .NET sdk).

Lattice answered 6/7, 2022 at 16:6 Comment(9)
How do I customize de [FromQuery] model binder (assuming that's the best solution)?If you only want to use it for parameters of actions,maybe you can apply the model binder to parameters directly.Lajuanalake
@YiyiYou yes, but the FromQuery model binder gets the payload from the bindingContext.HttpContext.Request.Query. I could follow that approach if I knew how exactly AspNet Mvc deserializes the IQueryCollection to rely on the default mechanism except for the enumsLattice
I've just found this similar #36660827Lattice
As an aside, JsonStringEnumMemberConverter from github.com/Macross-Software is a JSON converter. It is used to bind enums to application/json body content in the JSON format. Query parameters aren't in JSON format, so a System.Text.Json or Json.NET for that matter do not apply.Sayres
there is an ugly way, you can access the request by using middleware before serialization and then you should replace "-" with "" empty value (the problem is how you know that key is enum) helps to serialize but there is another case which is string query key can contain "anything" may cause another issue. but you can filter by httpContext.Request.RouteValues which contains controller,action names to get type of parameters to modify. There is strict rule enum can not contain dashes "-" we can understand. Or if it is unique case just filter by fullpath in middlewareBatt
Or simply use underscore _Batt
@MustafaSalihASLIM using underscore in enum member does not make any difference when deserializing with default [FromQuery] mechanism or when plugging JsonStringEnumMemberConverter which, as @dbc, doesn't work for query paramsLattice
@Lattice , I mean there is no way to use dashes with Enum/EnumMember (Including serialization, you may need JsonConverter for response but not request), you should use on_hold as an enum variable, then you may use DescriptionAttribute to get string value for the request?, this is easiest way. checkout: #3422907Batt
I refactored the code used by JsonEnumMemberStringEnumConverter from System.Text.Json: How do I specify a custom name for an enum value? (demo here) and used it to make an EnumMemberModelBinderProvider here: dotnetfiddle.net/rpXo77. Can you check to see if this does what you want? Composite numeric values for flag enums (e.g. "2,8") aren't working yet. (Honestly the enum API is a pain. There isn't even a ToString() method that returns a local-invariant result!)Sayres
P
6

A custom Enum converter might be your choice. By leveraging the existing EnumConverter class what we need is to have a customized ConvertFrom method:

public class CustomEnumConverter : EnumConverter
{
    public CustomEnumConverter([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.PublicFields)] Type type) : base(type)
    {
    }

    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (value is string strValue)
        {
            try
            {
                foreach (var name in Enum.GetNames(EnumType))
                {
                    var field = EnumType.GetField(name);
                    if (field != null)
                    {
                        var enumMember = (EnumMemberAttribute)(field.GetCustomAttributes(typeof(EnumMemberAttribute), true).Single());
                        if (strValue.Equals(enumMember.Value, StringComparison.OrdinalIgnoreCase))
                        {
                            return Enum.Parse(EnumType, name, true);
                        }
                    }
                }
            }
            catch (Exception e)
            {
                throw new FormatException((string)value, e);
            }
        }

        return base.ConvertFrom(context, culture, value);
    }
}

And then decorate the converter to your Model class:

[TypeConverter(typeof(CustomEnumConverter))]
public enum Status
{
    [EnumMember(Value = "open")]
    Open,
    
    [EnumMember(Value = "on-hold")]
    OnHold
}

then we can get the "on-hold" parsed. You might also want to override the ConverTo() for printing the EnumMember value to swagger. It is a bit hacky, but if you want a pure .NET solution this should be one of the minimal viable solutions.

enter image description here

Poco answered 14/7, 2022 at 11:22 Comment(2)
I used this answers [TypeConverter] on the Enum itself. I also used [JsonConverter(typeof(StringEnumMemberConverter))] from Macross.Json.Extensions.Json in the API Client. Then I was able to send my enum in the query string using values defined in the EnumMemberAttributes and correctly consume it in the API using [FromQuery]Pugging
In my case I was backporting a .NET 6 API using bool? into a .NET Standard 2.0 API using an enum that supports true, false, and <nothing> because .NET Standard 2.0 does not support nullable reference types.Pugging
S
1

Following the documentation guide Custom Model Binding in ASP.NET Core, you can create your own versions of Microsoft's classes EnumTypeModelBinderProvider, EnumTypeModelBinder (and base class SimpleTypeModelBinder) that replace incoming enum value names that have been renamed via EnumMemberAttribute with the original enum names before binding:

// Begin code for enum model binding
public class EnumMemberEnumTypeModelBinderProvider : IModelBinderProvider 
{
    public EnumMemberEnumTypeModelBinderProvider(MvcOptions options) { }

    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));
        
        if (context.Metadata.IsEnum)
        {
            var enumType = context.Metadata.UnderlyingOrModelType;
            Debug.Assert(enumType.IsEnum);
            var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
            if (EnumExtensions.TryGetEnumMemberOverridesToOriginals(enumType, out var overridesToOriginals))
                return new EnumMemberEnumTypeModelBinder(suppressBindingUndefinedValueToEnumType: true, enumType, loggerFactory, overridesToOriginals);
        }
        
        return null;
    }
}

public class EnumMemberEnumTypeModelBinder : ExtensibleSimpleTypeModelBinder
{
    // Adapted from https://github.com/dotnet/aspnetcore/blob/c85baf8db0c72ae8e68643029d514b2e737c9fae/src/Mvc/Mvc.Core/src/ModelBinding/Binders/EnumTypeModelBinder.cs#L58
    readonly Type enumType;
    readonly bool isFlagged;
    readonly Dictionary<ReadOnlyMemory<char>, string> overridesToOriginals;
    readonly TypeConverter typeConverter;

    public EnumMemberEnumTypeModelBinder(bool suppressBindingUndefinedValueToEnumType, Type modelType, ILoggerFactory loggerFactory, Dictionary<ReadOnlyMemory<char>, string> overridesToOriginals) : base(modelType, loggerFactory)
    {
        this.enumType = Nullable.GetUnderlyingType(modelType) ?? modelType;
        if (!this.enumType.IsEnum)
            throw new ArgumentException();
        this.isFlagged = Attribute.IsDefined(enumType, typeof(FlagsAttribute));
        this.overridesToOriginals = overridesToOriginals ?? throw new ArgumentNullException(nameof(overridesToOriginals));
        this.typeConverter = TypeDescriptor.GetConverter(this.enumType);
    }
    
    protected override string? GetValueFromBindingContext(ValueProviderResult valueProviderResult) => 
        EnumExtensions.ReplaceRenamedEnumValuesToOriginals(base.GetValueFromBindingContext(valueProviderResult), isFlagged, overridesToOriginals);

    protected override void CheckModel(ModelBindingContext bindingContext, ValueProviderResult valueProviderResult, object? model)
    {
        if (model == null)
        {
            base.CheckModel(bindingContext, valueProviderResult, model);
        }
        else if (IsDefinedInEnum(model, bindingContext))
        {
            bindingContext.Result = ModelBindingResult.Success(model);
        }
        else
        {
            bindingContext.ModelState.TryAddModelError(
                bindingContext.ModelName,
                bindingContext.ModelMetadata.ModelBindingMessageProvider.ValueIsInvalidAccessor(
                    valueProviderResult.ToString()));
        }
    }

    private bool IsDefinedInEnum(object model, ModelBindingContext bindingContext)
    {
        // Adapted from https://github.com/dotnet/aspnetcore/blob/c85baf8db0c72ae8e68643029d514b2e737c9fae/src/Mvc/Mvc.Core/src/ModelBinding/Binders/EnumTypeModelBinder.cs#L58
        var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;

        // Check if the converted value is indeed defined on the enum as EnumTypeConverter
        // converts value to the backing type (ex: integer) and does not check if the value is defined on the enum.
        if (bindingContext.ModelMetadata.IsFlagsEnum)
        {
            var underlying = Convert.ChangeType(
                model,
                Enum.GetUnderlyingType(modelType),
                CultureInfo.InvariantCulture).ToString();
            var converted = model.ToString();
            return !string.Equals(underlying, converted, StringComparison.OrdinalIgnoreCase);
        }
        return Enum.IsDefined(modelType, model);
    }
}

public class ExtensibleSimpleTypeModelBinder : IModelBinder
{
    // Adapted from https://github.com/dotnet/aspnetcore/blob/c85baf8db0c72ae8e68643029d514b2e737c9fae/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs
    private readonly TypeConverter _typeConverter;
    private readonly ILogger _logger;

    public ExtensibleSimpleTypeModelBinder(Type type, ILoggerFactory loggerFactory) : this(type, loggerFactory, null) { }
    
    public ExtensibleSimpleTypeModelBinder(Type type, ILoggerFactory loggerFactory, TypeConverter? typeConverter)
    {
        if (type == null)
            throw new ArgumentNullException(nameof(type));
        if (loggerFactory == null)
            throw new ArgumentNullException(nameof(loggerFactory));
        _typeConverter = typeConverter ?? TypeDescriptor.GetConverter(type);
        _logger = loggerFactory.CreateLogger<ExtensibleSimpleTypeModelBinder>();
    }

    protected virtual string? GetValueFromBindingContext(ValueProviderResult valueProviderResult) => valueProviderResult.FirstValue;

    /// <inheritdoc />
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));

        //_logger.AttemptingToBindModel(bindingContext);

        var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (valueProviderResult == ValueProviderResult.None)
        {
            //_logger.FoundNoValueInRequest(bindingContext);
            // no entry
            //_logger.DoneAttemptingToBindModel(bindingContext);
            return Task.CompletedTask;
        }

        bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);

        try
        {
            var value = GetValueFromBindingContext(valueProviderResult);

            object? model;
            if (bindingContext.ModelType == typeof(string))
            {
                // Already have a string. No further conversion required but handle ConvertEmptyStringToNull.
                if (bindingContext.ModelMetadata.ConvertEmptyStringToNull && string.IsNullOrWhiteSpace(value))
                    model = null;
                else
                    model = value;
            }
            else if (string.IsNullOrWhiteSpace(value))
            {
                // Other than the StringConverter, converters Trim() the value then throw if the result is empty.
                model = null;
            }
            else
            {
                model = _typeConverter.ConvertFrom(context: null,culture: valueProviderResult.Culture, value: value);
            }

            CheckModel(bindingContext, valueProviderResult, model);

            //_logger.DoneAttemptingToBindModel(bindingContext);
            return Task.CompletedTask;
        }
        catch (Exception exception)
        {
            var isFormatException = exception is FormatException;
            if (!isFormatException && exception.InnerException != null)
            {
                // TypeConverter throws System.Exception wrapping the FormatException,
                // so we capture the inner exception.
                exception = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
            }

            bindingContext.ModelState.TryAddModelError(bindingContext.ModelName,exception, bindingContext.ModelMetadata);

            // Were able to find a converter for the type but conversion failed.
            return Task.CompletedTask;
        }
    }

    /// <inheritdoc/>
    protected virtual void CheckModel(
        ModelBindingContext bindingContext,
        ValueProviderResult valueProviderResult,
        object? model)
    {
        // When converting newModel a null value may indicate a failed conversion for an otherwise required
        // model (can't set a ValueType to null). This detects if a null model value is acceptable given the
        // current bindingContext. If not, an error is logged.
        if (model == null && !bindingContext.ModelMetadata.IsReferenceOrNullableType)
        {
            bindingContext.ModelState.TryAddModelError(
                bindingContext.ModelName,
                bindingContext.ModelMetadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
                    valueProviderResult.ToString()));
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Success(model);
        }
    }
}

// End code for enum model binding

/********************************************************/
// Begin general enum parsing code

public class CharMemoryComparer : IEqualityComparer<ReadOnlyMemory<char>>
{
    public static CharMemoryComparer OrdinalIgnoreCase { get; } = new CharMemoryComparer(StringComparison.OrdinalIgnoreCase);
    public static CharMemoryComparer Ordinal { get; }  = new CharMemoryComparer(StringComparison.Ordinal);

    readonly StringComparison comparison;
    CharMemoryComparer(StringComparison comparison) => this.comparison = comparison;
    public bool Equals(ReadOnlyMemory<char> x, ReadOnlyMemory<char> y) => MemoryExtensions.Equals(x.Span, y.Span, comparison);
    public int GetHashCode(ReadOnlyMemory<char> obj) => String.GetHashCode(obj.Span, comparison);
}

public static partial class EnumExtensions
{
    public const char FlagSeparatorChar = ',';
    public const string FlagSeparatorString = ", ";
    
    public static bool TryGetEnumMemberOverridesToOriginals(Type enumType, [System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out Dictionary<ReadOnlyMemory<char>, string>? overridesToOriginals)
    {
        if (enumType == null)
            throw new ArgumentNullException(nameof(enumType));
        if (!enumType.IsEnum)
            throw new ArgumentException(nameof(enumType));
        overridesToOriginals = null;
        foreach (var name in Enum.GetNames(enumType))
        {
            if (TryGetEnumAttribute<EnumMemberAttribute>(enumType, name, out var attr) && !string.IsNullOrWhiteSpace(attr.Value))
            {
                overridesToOriginals = overridesToOriginals ?? new(CharMemoryComparer.OrdinalIgnoreCase);
                overridesToOriginals.Add(attr.Value.AsMemory(), name);
            }
        }
        return overridesToOriginals != null;
    }
    
    public static bool TryGetEnumAttribute<TAttribute>(Type type, string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out TAttribute? attribute) where TAttribute : System.Attribute
    {
        var member = type.GetMember(name).SingleOrDefault();
        attribute = member?.GetCustomAttribute<TAttribute>(false);
        return attribute != null;
    }
    
    public static string? ReplaceRenamedEnumValuesToOriginals(string? value, bool isFlagged, Dictionary<ReadOnlyMemory<char>, string> overridesToOriginals)
    {
        if (string.IsNullOrWhiteSpace(value))
            return value;
        var trimmed = value.AsMemory().Trim();
        if (overridesToOriginals.TryGetValue(trimmed, out var @override))
            value = @override;
        else if (isFlagged && trimmed.Length > 0)
        {
            var sb = new StringBuilder();
            bool replaced = false;
            foreach (var n in trimmed.Split(EnumExtensions.FlagSeparatorChar, StringSplitOptions.TrimEntries))
            {
                ReadOnlySpan<char> toAppend;
                if (overridesToOriginals.TryGetValue(n, out var @thisOverride))
                {
                    toAppend = thisOverride.AsSpan();
                    replaced = true;
                }
                else
                    toAppend = n.Span;
                sb.Append(sb.Length == 0 ? null : EnumExtensions.FlagSeparatorString).Append(toAppend);
            }
            if (replaced)
                value = sb.ToString();
        }
        return value;
    }
}

public static class StringExtensions
{
    public static IEnumerable<ReadOnlyMemory<char>> Split(this ReadOnlyMemory<char> chars, char separator, StringSplitOptions options = StringSplitOptions.None)
    {
        int index;
        while ((index = chars.Span.IndexOf(separator)) >= 0)
        {
            var slice = chars.Slice(0, index);
            if ((options & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries)
                slice = slice.Trim();
            if ((options & StringSplitOptions.RemoveEmptyEntries) == 0 || slice.Length > 0)
                yield return slice;
            chars = chars.Slice(index + 1);
        }
        if ((options & StringSplitOptions.TrimEntries) == StringSplitOptions.TrimEntries)
            chars = chars.Trim();
        if ((options & StringSplitOptions.RemoveEmptyEntries) == 0 || chars.Length > 0)
            yield return chars;
    }
}

Then add the binder in ConfigureServices() like so:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers(options =>
                            {
                                options.ModelBinderProviders.Insert(0, new EnumMemberEnumTypeModelBinderProvider(options));
                            });
}       

Notes:

  • EnumTypeModelBinder and base class SimpleTypeModelBinder provide no useful extension points to customize the parsing of the incoming value string, thus it was necessary to copy some of their logic.

  • Precisely emulating the logic of SimpleTypeModelBinder is somewhat difficult because it supports both numeric and textual enum values -- including mixtures of both for flags enums. The binder above retains that capability, but at a cost of also allowing original enum names to be bound successfully. Thus the values on-hold and onhold will be bound to Status.OnHold.

  • Conversely, if you do not want to support binding of numeric values for enums, you could adapt the code of JsonEnumMemberStringEnumConverter from this answer to System.Text.Json: How do I specify a custom name for an enum value?. Demo fiddle here. This approach also avoids binding to the original, unrenamed enum names.

  • Matching of override names with original enum names is case-insensitive, so override names that differ only in case are not supported.

Sayres answered 15/7, 2022 at 16:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.