Kasbolat Kumakhov
Kasbolat Kumakhov

Reputation: 721

ASP .Net Core empty array query argument binding exception

I have a WebAPI controller which looks like this:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]
    public ActionResult<string> Get([FromQuery]DataFilter dataFilter)
    {
        return string.Join(Environment.NewLine, dataFilter?.Filter?.Select(f => f.ToString()) ?? Enumerable.Empty<string>());
    }
}

Nothing fancy, just a controller which recevies some data from query string and outputs it as a response. The class it receives looks like this:

public class DataFilter
{
    public IEnumerable<FilterType> Filter { get; set; }
}

public enum FilterType
{
    One,
    Two,
    Three,
}

These are just sample classes to illustrate the problem, which is a validation error when trying to call this method like this:

/api/values?filter=

And the response:

{
  "errors": {
    "Filter": [
      "The value '' is invalid."
    ]
  },
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "80000164-0002-fa00-b63f-84710c7967bb"
}

If i set my FilterType to be nullable, it works, but the array simply contains null values in this case. And if used like this:

/api/values?filter=&filter=&filter=

It will simply contain 3 null values. And i wanted it to be simply empty or null, since there are no real values passed.

The ASP .Net Core github account contains some similiar issues, but it is repored that they were fixed in 2.2, which is i'm using. But perhaps they are different or i missunderstand something.

EDIT_0: Just to show what i meant about nullable.

If i change my class to this:

public IEnumerable<FilterType?> Filter { get; set; } //notice that nullable is added to an Enum, not the list

Then when called like this:

/api/values?filter=&filter=&filter=

I get 3 elements in my "Filter" property. All nulls. Not exactly what i expect. Good as a workaround, but not a solution at all.

Upvotes: 2

Views: 3588

Answers (3)

Kasbolat Kumakhov
Kasbolat Kumakhov

Reputation: 721

I've soled this case using custom TypeConverter and moved to JSON format for passing arrays (e.g. filter=["one","two"])

Here is how i defined it:

public class JsonArrayTypeConverter<T> : TypeConverter
{
    private static readonly TypeConverter _Converter = TypeDescriptor.GetConverter(typeof(T));

    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        sourceType == typeof(string) || TypeDescriptor.GetConverter(sourceType).CanConvertFrom(context, sourceType);

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        try
        {
            return JsonConvert.DeserializeObject<IEnumerable<T>>((string)value);
        }
        catch (Exception)
        {
            var dst = _Converter.ConvertFrom(context, culture, value); //in case this is not an array or something is broken, pass this element to a another converter and still return it as a list
            return new T[] { (T)dst };
        }
    }
}

And global registration:

TypeDescriptor.AddAttributes(typeof(IEnumerable<FilterType>), new TypeConverterAttribute(typeof(JsonArrayTypeConverter<FilterType>)));

Now i don't get null items in my filter list and also have support for JSON lists with multiple type support (enums, strings, integers, etc.).

The only downside is that this won't work with passing elements as before (e.g. filter=one&filter=two&filter=three). And not nice looking query string in browser address bar.

Upvotes: 0

Alexander
Alexander

Reputation: 9642

You can create custom model binder whose task is removing validation errors generated by default CollectionModelBinder. This should be sufficient in your case because default model binder works as it needed for you, doesn't add invalid values to collection.

public class EmptyCollectionModelBinder : CollectionModelBinder<FilterType>
{
    public EmptyCollectionModelBinder(IModelBinder elementBinder) : base(elementBinder)
    {
    }

    public override async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await base.BindModelAsync(bindingContext);
        //removing validation only for this collection
        bindingContext.ModelState.ClearValidationState(bindingContext.ModelName);
    }
}

Create and register model binder provider

public class EmptyCollectionModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.ModelType == typeof(IEnumerable<FilterType>))
        {
            var elementBinder = context.CreateBinder(context.MetadataProvider.GetMetadataForType(typeof(FilterType)));

            return new EmptyCollectionModelBinder(elementBinder);
        }

        return null;
    }
}

Startup.cs

services
    .AddMvc(options =>
    {
        options.ModelBinderProviders.Insert(0, new EmptyCollectionModelBinderProvider());
    })

Upvotes: 1

johnny 5
johnny 5

Reputation: 21005

You have a few options. You can create a custom model binder to handle your filter type Or:

You can create your IEnumerable With Nullables:

public IEnumerable<FilterType?> Filter { get; set; }

And Filter out the Nulls in the Calling Code:

return string.Join(Environment.NewLine, dataFilter?.Filter?.Where(f => f != null)
   .Select(f => f.ToString()) ?? Enumerable.Empty<string>());

Upvotes: 0

Related Questions