Customizing query string parameter binding in ASP.NET Core MVC

Β· 1538 words Β· 8 minutes to read

A few years ago I blogged about binding parameters from URI in ASP.NET Web API. One of the examples in that post was how to bind a comma-separated collection passed to your API as a query string parameter.

Technologies change, and we now work with ASP.NET Core (and the MVC Core framework), but problems remain the same - so let’s have a look at how we can customize the way parameters are bound from query string in ASP.NET Core MVC.

Background πŸ”—

Just as a reminder - the default behavior of both old ASP.NET Web API, and ASP.NET Core MVC is to use the following format for array binding:

GET /products?sizes=s&sizes=m&sizes=l

To make things nicer for the caller, let’s customize the framework to support the following syntax instead:

GET /products?sizes=s,m,l

Building a custom IValueProvider πŸ”—

The old Web API example walked us through creating a custom IModelBinder. Similar extensibility point also exists in ASP.NET Core MVC - we could for example work with ArrayModelBinder.

However, this is a bit invasive, because we do not really need to change the process of how array parameters are instantiated in ASP.NET Core, which is what IModelBinder allows us to do. Instead, we’d only like to change how the values used as an input for an array are read from the query string.

When it comes to translating a value from some binding source, such as query string or header, to a binder input, ASP.NET Core MVC gives us IValueProvider as a perfect extensibility point. In the query string case, the base class QueryStringValueProvider can be overridden and customized.

The IValueProvider interface is defined by the framework as follows:

// Summary:

//     Defines the methods that are required for a value provider.

public interface IValueProvider

{

    //

    // Summary:

    //     Determines whether the collection contains the specified prefix.

    //

    // Parameters:

    //   prefix:

    //     The prefix to search for.

    //

    // Returns:

    //     true if the collection contains the specified prefix; otherwise, false.

    bool ContainsPrefix(string prefix);

    //

    // Summary:

    //     Retrieves a value object using the specified key.

    //

    // Parameters:

    //   key:

    //     The key of the value object to retrieve.

    //

    // Returns:

    //     The value object for the specified key. If the exact key is not found, null.

    ValueProviderResult GetValue(string key);

}

The important implementation point to deal with, is the GetValue method, which is resolved for a given query string parameter (key).

It is important to note that value provider is applied against a method (an action), not against an individual parameter of that action. This means that typically, if you create a custom value provider strategy it would be applied to all parameters of an action. We will, however, remedy that in our implementation - more on that in a moment.

Let’s have a look at its implementation. For simplicity, we will subclass the built-in QueryStringValueProvider rather than implement the interface directly.

    public class SeparatedQueryStringValueProvider : QueryStringValueProvider

    {

        private readonly string _key;

        private readonly string _separator;

        private readonly IQueryCollection _values;



        public SeparatedQueryStringValueProvider(IQueryCollection values, string separator)

            : this(null, values, separator)

        {

        }



        public SeparatedQueryStringValueProvider(string key, IQueryCollection values, string separator)

            : base(BindingSource.Query, values, CultureInfo.InvariantCulture)

        {

            _key = key;

            _values = values;

            _separator = separator;

        }



        public override ValueProviderResult GetValue(string key)

        {

            var result = base.GetValue(key);



            if (_key != null && _key != key)

            {

                return result;

            }



            if (result != ValueProviderResult.None && result.Values.Any(x => x.IndexOf(_separator, StringComparison.OrdinalIgnoreCase) > 0))

            {

                var splitValues = new StringValues(result.Values

                    .SelectMany(x => x.Split(new[] { _separator }, StringSplitOptions.None)).ToArray());

                return new ValueProviderResult(splitValues, result.Culture);

            }



            return result;

        }

    }

We provide two constructors. First one that takes in the query string values and a separator (for us it could be a comma , but also anything else, if you want to delimit your collections differently). In that version of the value provider, the strategy will be applicable to all parameters.

The second constructor additionally takes a key. That key will allow us to apply our logic only to that specific query string key - so only to a specific parameter.

Inside the GetValue, we will always call the base QueryStringValueProvider. Out of the box, it will always consider the comma separated values as a single string value. If we configured our provider to run for a specific key only (by passing it through the constructor), and it’s not equal to the current key, then we just exist with the default (base) behavior. In other cases, we will try to split the values based on our separator. Using the QueryStringValueProvider as the base class has the comfy added advantage of being able to fall back to the default behavior.

Once we do all that, we will return the split values as ValueProviderResult.

Next step is to add an IValueProviderFactory. Factories are - as the name suggests - responsible for providing instances of value providers. Our factory is very simple:

    public class SeparatedQueryStringValueProviderFactory : IValueProviderFactory

    {

        private readonly string _separator;

        private readonly string _key;



        public SeparatedQueryStringValueProviderFactory(string separator) : this(null, separator)

        {

        }



        public SeparatedQueryStringValueProviderFactory(string key, string separator)

        {

            _key = key;

            _separator = separator;

        }



        public Task CreateValueProviderAsync(ValueProviderFactoryContext context)

        {

            context.ValueProviders.Insert(0, new SeparatedQueryStringValueProvider(_key, context.ActionContext.HttpContext.Request.Query, _separator));

            return Task.CompletedTask;

        }

    }

Similarly to the value provider, it will handle cases where we want to pass a specific key (that is, apply the value provider to a specific parameter only), or not.

This is it on the actual implementation side - now we just need to make sure the the value provider can “light up” when we want it to. And there are several ways to achieve that.

Registering a value provider globally πŸ”—

To apply the value provider globally to our entire API, we can register the factory in the MVC Options, at application startup.

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(o =>

    {

        o.ValueProviderFactories.Insert(0, new SeparatedQueryStringValueProviderFactory(","));

    });

}

This approach has a certain downside though. Notice that we have never defined that the value provider should only be taken into account for arrays. And rightfully so - the value provider doesn’t know about the underlying model that we will be binding too. It only cares about taking a query string value and exposing it to the model binder in the form of StringValues object.

This means that if we register our provider globally, it will run for all query strings, even the non-array ones - i.e. it would apply itself to the following action too:

[HttpGet("Product")]

public IActionResult Get(string size)

{

    // do stuff

}

In such case, if we passed in a regular string value that contained a comma, it would get inadvertently split too, and only the first element of the split array would be passed into the action - which is probably something we’d like to avoid.

Registering a value provider via IResourceFilter πŸ”—

Another possibility is to use a more precise targeting via resource filters. Resource filters run in the MVC pipeline before model binding, so are a suitable mechanism for injecting value providers (which, after all, are needed before model binding too).

Here is an example of a resource filter that can be applied either at controller or at action level, that will register our custom value provider. Such value provider would be applicable only in the context of a given controller or action. It would still apply to all the parameters though.

An example of such filter is shown below:

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)]

public class SeparatedQueryStringAttribute : Attribute, IResourceFilter

{

    private readonly SeparatedQueryStringValueProviderFactory _factory;



    public SeparatedQueryStringAttribute() : this(",")

    {

    }



    public SeparatedQueryStringAttribute(string separator)

    {

        _factory = new SeparatedQueryStringValueProviderFactory(separator);

    }



    public SeparatedQueryStringAttribute(string key, string separator)

    {

        _factory = new SeparatedQueryStringValueProviderFactory(key, separator);

    }



    public void OnResourceExecuted(ResourceExecutedContext context)

    {

    }



    public void OnResourceExecuting(ResourceExecutingContext context)

    {

        context.ValueProviderFactories.Insert(0, _factory);

    }

} 

The action could then look like this:

[HttpGet("Products")]

[SeparatedQueryString]

public IActionResult Get(IEnumerable<string> sizes)

{

    // do stuff

}

Finally, probably the nicest way to do this is to just target specific parameters.

Registering a value provider via conventions πŸ”—

Let’s create a marker attribute that we will use to annotate the parameters that should use our custom value provider.

[AttributeUsage(AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)]

public class CommaSeparatedAttribute : Attribute

{

}

Next we can create an IActionModelConvention, which, at application startup, based on whether a parameter is annotated with CommaSeparatedAttribute, will inject the IResourceFilter we just created earlier into the filter collection of that action. This of course, will, indirectly, register our value provider.

    public class CommaSeparatedQueryStringConvention : IActionModelConvention

    {

        public void Apply(ActionModel action)

        {

            foreach (var parameter in action.Parameters)

            {

                if (parameter.Attributes.OfType<CommaSeparatedAttribute>().Any() && !parameter.Action.Filters.OfType<SeparatedQueryStringAttribute>().Any())

                {

                    parameter.Action.Filters.Add(new SeparatedQueryStringAttribute(parameter.ParameterName, ","));

                }

            }

        }

    }

At that moment we can use our other code path - where we’d only apply the value provider based on the specific query string key (so in our case, a parameter name). By passing the parameter name into SeparatedQueryStringAttribute, we ensure the value provider will only be used for that query string.

Such a convention needs to be registered at application startup.

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(o =>

    {

        o.Conventions.Add(new CommaSeparatedQueryStringConvention());

    });

}

We can now use it as follows, to only target the specific parameters.

[HttpGet("Products")]

public IActionResult Get([CommaSeparated]IEnumerable<string> sizes, string filterText)

{

    // do stuff

}

And this is it. The source code for the blog is available here on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP