Creating Custom Constraints For Razor Pages Routes

Razor Page already includes a wide range of constraints that can be used to help disambiguate routes. It is also possible for you to create your own custom constraint and then register that with the routing system.

Imagine that you have a page that is designed to show the details of a product. Following convention, you create a page named Details.cshtml in a folder named Product within the Pages folder. You want visitors to reach the page using the URL domain.com/product/details/{productname} where the {productname} is the actual name of a specific product (suitably slugified). So you add a route template accordingly:

@page "{productname}"

Now you can retrieve the RouteData value for productname, and use it to perform a database query. This works fine, but what you soon begin to realise is that sometimes, the database query doesn't return a result. When you review your logs, you see that the value being passed to the database query is not what you expect to see in your URLs. It might be part of the product name, or it might have some extra characters added, or indeed it might bear no resemblance to anything in your database at all. There are countless ways in which links to your site can get broken when they are being shared, or stored by a poorly written bot.

What you really could do with is some way to prevent the wasted processing that these database look-ups for non-existent values incur, and also inform the requester that the page they are looking for doesn't exist. You want to return a 404 HTTP status code.

The solution can be implemented as a custom constraint. Then the routing system will take care of ensuring that the user gets the correct response.

An Example Look-up Service

The constraint will work by matching incoming route values to existing product names. The product names need to obtained from the database. Obviously you don't want to do this for every request - that would defeat the object of the exercise. So you will use caching as part of your strategy. For the purposes of demonstration, however, the service will just return a List

public interface IProductService
{
    List<string> GetProductNames();
}

public class ProductService : IProductService
{
    public List<string> GetProductNames()
    {
        return new List<string>
        {
            "chai",
            "chang",
            "aniseed-syrup",
            "chef-antons-cajun-seasoning",
            "chef-antons-gumbo-mix",
            "grandmas-boysenberry-spread",
            "uncle-bobs-organic-dried-pears",
            "northwoods-cranberry-sauce",
            "mishi-kobe-niku"
        };
    }
}

The service conforms to an interface, which is registered with the dependency injection system:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddTransient<IProductService, ProductService>();
}

The Constraint

Constraints implement the IRouteConstraint interface, which defines one method: Match. This is where the logic that determines whether a value satisfies the constraint is placed. The method returns a bool indicating success:

public class ProductConstraint : IRouteConstraint
{
    private readonly IProductService productService;

    public ProductConstraint(IProductService productService)
    {
        this.productService = productService;
    }

    public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
    {
        var productNames = productService.GetProductNames();
        return productNames.Contains(values[routeKey]?.ToString().ToLowerInvariant());
    }
}

In this example, the ProductService is injected into the constructor. The Match method returns true if there is an entry in the RouteValueDictionary with the specified key that matches any in the database.

Registering And Using The Constraint

The constraint needs to be registered with the routing system. This is done in the ConfigureServices method in Startup:

services.Configure<RouteOptions>(options =>
{
    options.ConstraintMap.Add("product", typeof(ProductConstraint));
});

The entry added to the RoutOption.ConstraintMap consists of a string as the key and a Type as the value. The key is used to to identify the constraint when you apply it to a route value parameter:

@page "{productname:product}"

A valid value in the URL will result in a successful request:

Image

Whereas if you request the Details page without passing in an existing value, the framework returns a 404:

Image

Last updated: 4/1/2019 8:21:19 AM

© 2018 - 2024 - Mike Brind.
All rights reserved.
Contact me at Mike dot Brind at Outlook.com