Route matching and overriding 404 in ASP.NET Web API

Β· 901 words Β· 5 minutes to read

In ASP.NET Web API, if the incoming does not match any route, the framework is simply hard wired to return 404 to the client (or possibly pass through to the next configured middleware, in case of an OWIN hosted Web API). This is done immediately, without entering anywhere further down the pipeline (i.e. message handlers would not be invoked).

However, an interesting question was posted recently at StackOverflow - what if you want to override that hard 404, and given your specific routing requirements, respond to the client with a different status code if a specific route condition fails?

I already answered at StackOverflow, but decided this deserves a blog post regardless.

Default behavior of route matching πŸ”—

By default, all incoming HTTP requests are handled by an HttpServer instance, which uses HttpRoutingDispatcher (a specialized HttpMessageHandler) to determine if there is a Web API route that matches the request.

If no match is found, HttpRoutingDispatcher will arbitrarily short circuit a 404 response without letting anything else in the Web API pipeline touch the request anymore.

Contrary to most Web API services, HttpRoutingDispatcher is not resolved from anywhere, but rather newed up directly right in the heart of the framework, so swapping it with alternative implementation (to changed the 404 behaviour) is pretty much impossible.

The problem πŸ”—

So if you visit that StackOverflow question, the OP wants to respond with 412 (Precondition Failed) instead of 404 if the route is matched, but his constraint is not fulfilled.

[Route("Number/{id:int:min(2):max(10)}")]  
public HttpResponseMessage GetNumber([FromUri] int id)  
{  
//return 200 from here  
//if the constraint in the route failed return 412 not 404  
}  

Implementing a smart IHttpRouteConstraint πŸ”—

To create custom constraining, you need to write a class that implements IHttpRouteConstraint. The interface is simple, as it only has a single method:

public interface IHttpRouteConstraint  
{  
bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection);  
}  

The principle is very straight forward - you have access to all the necessary information such as the route parameter name, its values supplied form the client side and the request and route information. Based on that, if your route pre-condition is matched, return true, otherwise, return false.

The trick is, that you can also throw an exception - and if it’s an HttpResponseException, the Web API infrastructure (the HttpServer) will catch it and convert it into an HttpResponseMessage (if the exception carries a relevant status code) or use the instance of the HttpResponseMessage attached to the exception itself.

The range constraint, with a custom status code, which addresses our problem, is shown below.

public class RangeWithStatusRouteConstraint : IHttpRouteConstraint  
{  
private readonly int _from;  
private readonly int _to;  
private readonly HttpStatusCode _statusCode;

public RangeWithStatusRouteConstraint(int from, int to, string statusCode)  
{  
_from = from;  
_to = to;  
if (!Enum.TryParse(statusCode, true, out _statusCode))  
{  
_statusCode = HttpStatusCode.NotFound;  
}  
}

public RangeWithStatusRouteConstraint(int from, int to, HttpStatusCode statusCode)  
{  
_from = from;  
_to = to;  
_statusCode = statusCode;  
} 

public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection)  
{  
object value;  
if (values.TryGetValue(parameterName, out value) && value != null)  
{  
var stringValue = value as string;  
var intValue = 0;

if (stringValue != null && int.TryParse(stringValue, out intValue))  
{  
if (intValue >= _from && intValue <= \_to) { return true; } //only throw if we had the expected type of value //but it fell out of range throw new HttpResponseException(\_statusCode); } } return false; } }

Note that in attribute routing, all constraint parameters have to be passed as primitives so the status code is passed as string and then cast to the HttpStatusCode enumeration. Alternatively, you can use an int there, since that would cast correctly too. The second constructor (with strongly typed status code) will be used in centralized routing.

If the route value falls outside of the expected range, we throw the Exception, instead of returning false and relying on HttpRoutingDispatcher to issue a 404. If the value is not an int then we do not throw, since the route is not matched correctly anyway. Thanks to this, we can drop the additional integer constraint - since our range will already require it to be an integer anyway.

Remember that this is a greedy constraint - if there is another route with similar structure it would not be reached anymore, as this constraint is going to force a specific response explicitly in case it’s not matched.

Applying the constraint to attribute routing πŸ”—

In order to use this with attribute routing you need to give the constraint a name in the ConstraintMap. This is that at the application startup, through DefaultInlineConstraintResolver.

Instead of simply saying:

config.MapHttpAttributeRoutes();  

You have to say:

var constraintResolver = new DefaultInlineConstraintResolver();  
constraintResolver.ConstraintMap.Add("rangeWithStatus", typeof(RangeWithStatusRouteConstraint));  
config.MapHttpAttributeRoutes(constraintResolver);  

With such setup, you can now modify the original route to:

[Route("Number/{id:rangeWithStatus(2, 10, PreconditionFailed)}")]  
public HttpResponseMessage GetNumber([FromUri] int id)  
{  
return Request.CreateResponse(HttpStatusCode.OK, id);  
}  

Such setup ensures a 412 response in case the routing constraint fails.

Applying the constraint to centralized routing πŸ”—

So far we dealt with direct routing (attribute routing), but the same constraint works equally well with centralized routing. You just need to use the appropriate overload of the MapHttpRoute method.

config.Routes.MapHttpRoute(  
name: "MyRoute",  
routeTemplate: "Number/{id}",  
defaults: new { controller = "Test" },  
constraints: new { id = new RangeWithStatusRouteConstraint(2, 10, HttpStatusCode.PreconditionFailed) }  
);  

In this case, we can use the other constructor of our RangeWithStatusRouteConstraint and pass the status code in the enum format directly

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