Using IActionConstraints in ASP.NET Core MVC

Β· 1585 words Β· 8 minutes to read

ASP.NET Core provides a way to constraint parameter values when matching routes via an IRouteConstraint (read more here) interface. This can be very useful, if you want to disambiguate certain routes from one another. This functionality is built into the routing package and is independent from the MVC framework.

However, aside from that, the MVC framework itself also provides an interesting constraint-mechanism - IActionConstraints. Let’s have a look at them today.

IActionContraints in the framework πŸ”—

When you think about filtering and disambiguating the mapping between of a request URL to an actual handler that will run the code to serve a response, action constraints provide an additional layer of configuration that you can use when building your applications.

MVC goes through an action selection process to pick the correct action (typically a method on controller, but not necessarily - perhaps that’s a topic for another blog post). Action constraints let you apply additional rules to that action matching mechanism.

Internally, the framework relies on IActionConstraints quite a bit. For example, there is a built-in HttpMethodActionConstraint which is used to restrict specific actions to handle specific types of HTTP request methods only. This is the reason why, when you use the [Route] attribute on an action, the action handles all types of requests (as long as the URL matches), but when you use [HttpGet] attribute on an action, the action handles GET requests only. In the latter case, the presence of [HttpGet] will internally result in an application of a HttpMethodActionConstraint configured to allow GET requests only.

Authoring custom IActionContraints πŸ”—

You could very easily create your own custom IActionConstraints. The interface is shown below, and is rather self explanatory (the base IActionConstraintMetadata is just an empty marker interface).

public interface IActionConstraint : IActionConstraintMetadata

{

    /// <summary>

    /// The constraint order.

    /// </summary>

    /// <remarks>

    /// Constraints are grouped into stages by the value of <see cref="Order"/>. See remarks on

    /// <see cref="IActionConstraint"/>.

    /// </remarks>

    int Order { get; }



    /// <summary>

    /// Determines whether an action is a valid candidate for selection.

    /// </summary>

    /// 

    /// <returns>True if the action is valid for selection, otherwise false.</returns>

    bool Accept(ActionConstraintContext context);

}

The whole logic is contained in the bool Accept(ActionConstraintContext context) method, which lets you inspect various contextual information, primarily HttpContext. Based on that you can decide whether the constraint should handle (return true) or miss (return false) the request. Note that returning false here doesn’t immediately create any response to the caller, it simply means that other candidate actions selected in the action selection process in the MVC framework can still get a chance to handle the request. Only if none of the candidate actions is deemed to be valid to handle request, that’s when the caller gets issued a 404 response as the MVC framework couldn’t process the request.

A sample custom constraint is shown below:

public class MandatoryHeaderActionConstraint : IActionConstraint, IActionConstraintMetadata

{

    private string _header;



    public MandatoryHeaderActionConstraint(string header)

    {

        _header = header;

    }



    public int Order => 0;



    public bool Accept(ActionConstraintContext context)

    {

        // only allow route to be hit if the predefined header is present

        if (context.RouteContext.HttpContext.Request.Headers.ContainsKey(_header))

        {

            return true;

        }



        return false;

    }

}

This constraint requires a presence of a specific header on the request in order to hand over the request processing to an action.

So how would you go ahead and apply this action constraint to a specific action? There are two primary ways to do it - via a so called “MVC convention” (for example IActionModelConvention) or via a routing attribute.

Applying an action constraint via action convetion πŸ”—

Conventions allow you to apply some extra information to the actions or to the controllers, that the MVC framework will then use for its various operations - such as controller or action selection. Actions constraints are a great example here.

Let’s continue with our example of the mandatory header. Such a mandatory header could for example be a CorrelationId which should be passed by the caller for traceability purposes.

Let’s create the following attribute first:

public class RequireHeaderAttribute : Attribute

{

    public string RequiredHeader { get; set; }



    public RequireHeaderAttribute(string requiredHeader)

    {

        RequiredHeader = requiredHeader;

    }

}

We will use it decorate the actions on which we would like our constraint to be applied. So a potential endpoint might look like this:

[Route("api/values")]

public class ValuesController : Controller

{

    [RequireHeader("CorrelationId")]

    [HttpGet]

    public IEnumerable<string> Get()

    {

        return new string[] { "value1", "value2" };

    }

}

The final piece needed, is the convention that will scan for the RequireHeaderAttribute and inject the constraint whenever needed. This is shown below:

public class MandatoryHeaderConvention : IActionModelConvention

{

    public void Apply(ActionModel action)

    {

        var requiredHeader = action.Attributes.OfType<RequireHeaderAttribute>().FirstOrDefault();

        if (requiredHeader != null)

        foreach (var selector in action.Selectors)

        {

            selector.ActionConstraints.Add(new MandatoryHeaderActionConstraint(requiredHeader.RequiredHeader));

        }

    }

}

In order to register the convention with MVC, it has to be added when you call AddMvc() at application startup.

services.AddMvc(opt =>

{

    opt.Conventions.Add(new MandatoryHeaderConvention());

});

All of that means that hitting /api/values without a CorrelationId header will result in 404.

One final thought. Instead of relying on a custom attribute like we did, there could be other ways of discovering whether an action should have a specific constraint applied to it. For example, the action name itself could be conventionally used to indicate that. In such cases, you’d need to inspect the actionName in the IActionModelConvention and make the decision based on that.

Applying an action constraint via attribute route πŸ”—

It is also possible to apply action constraints using attribute routing. However, the default attributes (such as RouteAttribute or HttpGetAttribute) do not support that out of the box. This means, you will need to subclass them.

Let’s illustrate that by using a different constraint than before - albeit, a similar one.

public class AcceptLanguageActionConstraint : IActionConstraint, IActionConstraintMetadata

{

    private string _locale;



    public AcceptLanguageActionConstraint(string locale)

    {

        _locale = locale;

    }



    public int Order => 0;



    public bool Accept(ActionConstraintContext context)

    {

        var headers = context.RouteContext.HttpContext.Request.GetTypedHeaders();

        // only allow route to be hit if the predefined header is present

        if (headers.AcceptLanguage.Any(x => x.Value.Equals(_locale, StringComparison.OrdinalIgnoreCase)))

        {

            return true;

        }



        return false;

    }

}

The action constraint above restricts actions based on the Accept-Language header. This means, you could have multiple actions handling the same route (which normally would be illegal in MVC), and the disambiguation would happen based on the Accept-Language header.

The custom route attribute, subclassing the default RouteAttribute is shown next.

public class LanguageSpecificRouteAttribute : RouteAttribute, IActionConstraintFactory

{

    private readonly IActionConstraint _constraint;



    public bool IsReusable => true;



    public LanguageSpecificRouteAttribute(string template, string locale) : base(template)

    {

        Order = -10;

        _constraint = new AcceptLanguageActionConstraint(locale);

    }



    public IActionConstraint CreateInstance(IServiceProvider services)

    {

        return _constraint;

    }

}

Interestingly, the route attribute itself can also act as IActionConstraintFactory, which means it can surface a specific IActionConstraint to the framework. This is a very nice and clean approach. The reason why the Order value is -10, is that we’d like this language-specific route to be processed before other routes. We’ll explain this in a moment.

In our case we will force the user of our LanguageSpecificRouteAttribute to supply not just the route template, but also the locale, which will then be used with AcceptLanguageActionConstraint to match the action only when the predefined Accept-Language header value is found.

We could now create two separate controllers, one responsible for handling a different locale.

[LanguageSpecificRoute("api/values", "de-CH")]

public class SwissValuesController : Controller

{

    [HttpGet]

    public IEnumerable<string> Get()

    {

        return new string[] { "value1 for Switzerland", "value2 for Switzerland" };

    }

}



[Route("api/values")]

public class ValuesController : Controller

{

    [HttpGet]

    public IEnumerable<string> Get()

    {

        return new string[] { "value1", "value2" };

    }

}

With this set up, we have two controllers that are both responding to the same route - /api/values. Because we gave LanguageSpecificRouteAttribute an order of -10, that route will be given a priority over a route defined with RouteAttribute. This means we will rely on its constraint to either provide an action match (and letting SwissValuesController process the request) or an action miss. In the latter case, ValuesController will get a chance to respond and since it has no action constraints attach to it, that will be our fallback - in case the caller didn’t provide Accept-Language header, or provided one that didn’t resolve to Switzerland.

Finally, you could generalize this logic, and avoid having a specialized attribute such as our LanguageSpecificRouteAttribute. Instead, you could create some sort of a reusable ConstrainedRouteAttribute.

public class ConstrainedRouteAttribute : RouteAttribute, IActionConstraintFactory

{

    private readonly IActionConstraint _constraint;



    public bool IsReusable => true;



    public ConstrainedRouteAttribute(string template, Type constraint, params object[] constructorParameters) : base(template)

    {

        Order = -10;

        _constraint = Activator.CreateInstance(constraint, constructorParameters) as IActionConstraint; 

    }



    public IActionConstraint CreateInstance(IServiceProvider services)

    {

        return _constraint;

    }

}

This allows you to pass in any type of constraint and tries to apply it to the current action. The usage would then look like this:

[ConstrainedRoute("api/values", typeof(AcceptLanguageActionConstraint), "de-CH")]

public class SwissValuesController : Controller

{

    [HttpGet]

    public IEnumerable<string> Get()

    {

        return new string[] { "value1 for Switzerland", "value2 for Switzerland" };

    }

}

Applying an action constraint in Strathweb.TypedRouting.AspNetCore πŸ”—

I also wanted to mention that if you are using the typed routing library that I built, you can also apply action constraints there.

The syntax would be as follows:

opt.Get("api/values", c => c.Action<VaulesController>(x => Get())). WithConstraints(new MandatoryHeaderConstraint("CustomHeader"));

You can find more info the readme.

Summary πŸ”—

And this is it - hopefully this can be useful when you are building your applications. I personally think that in large code bases, being able to redistribute request handling for identical route between different controllers/actions can be quite beneficial.

About


Hi! I'm Filip W., a cloud 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