As you write in a comment, you'll need the URL from your question to work as-is. Then we'll have to do some changes in how asp.net Core binds query parameters, and that may be accomplished by implementing a custom model binder.
Please note that this is a simple binder targeting a specific problem. For more generic solutions, have a look at the source for e.g. Microsoft.AspNetCore.Mvc.ModelBinding.Binders.CollectionModelBinder
public class CustomUserIdsBinder : IModelBinder
{
private const string NullValue = "null";
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
var result = new List<int?>();
foreach (var currentValue in valueProviderResult)
{
// remove this code block if you want to filter out null-values
if (string.IsNullOrEmpty(currentValue)
|| NullValue.Equals(currentValue, StringComparison.OrdinalIgnoreCase))
{
result.Add(null);
continue;
}
if (int.TryParse(currentValue, out var currentIntValue))
{
result.Add(currentIntValue);
}
}
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
}
To verify what we've done so far, change the signature for your controller method like this:
public ActionResult<List<int?>> Test(
[FromQuery][ModelBinder(BinderType = typeof(CustomUserIdsBinder))]
List<int?> userIds = null)
You don't want to repeat annotating all controller methods like above, so let's apply this binder to all controllers. First, a binder provider:
public class CustomUserIdsBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
return context.Metadata.ModelType == typeof(List<int?>)
? new BinderTypeModelBinder(typeof(CustomUserIdsBinder))
: null;
}
}
Instead of inserting the new binder provider at index = 0 (which is done in many examples), let's find a proper position for the new binder provider using this extension method:
public static class BinderProviderExtensions
{
public static void UseCustomUserIdsBinderProvider(this MvcOptions options)
{
var collectionBinderProvider = options.ModelBinderProviders
.FirstOrDefault(x => x.GetType() == typeof(CollectionModelBinderProvider));
if (collectionBinderProvider == null)
{
return;
}
// indexToPutNewBinderProvider = 15 in my test-app
var indexToPutNewBinderProvider = options.ModelBinderProviders.IndexOf(collectionBinderProvider);
options.ModelBinderProviders.Insert(indexToPutNewBinderProvider, new CustomUserIdsBinderProvider());
}
}
Then change Startup#ConfigureServices like this:
services.AddControllers(options => options.UseCustomUserIdsBinderProvider());
With the above changes, you can now use your original controller, and the above binder will be applied.
Finally, end-point tests used when writing the above code:
public class ControllerWithCustomBindingTests : IClassFixture<WebApplicationFactory<Startup>>
{
private const string TestUrl = "/api/v1/Sessions/Test?userIds=null&userIds=1&userIds=2";
private readonly WebApplicationFactory<Startup> _webApplicationFactory;
public ControllerWithCustomBindingTests(WebApplicationFactory<Startup> factory) => _webApplicationFactory = factory;
[Theory]
[InlineData(TestUrl)]
public async Task SessionTest_UrlWithNull_ReceiveOk(string url) =>
Assert.Equal(HttpStatusCode.OK, (await _webApplicationFactory.CreateClient().GetAsync(url)).StatusCode);
[Theory]
[InlineData(TestUrl)]
public async Task SessionTest_UrlWithNull_ReceiveListOfThreeItems(string url)
{
var items = await
(await _webApplicationFactory.CreateClient().GetAsync(url))
.Content.ReadFromJsonAsync<IEnumerable<int?>>();
Assert.Equal(3, items?.Count());
}
}
Environment used during devel/testing: asp.net Core 5, Kestrel, XUnit, Rider.