DEV Community

nausaf
nausaf

Posted on • Edited on

Patterns for Routing in ASP.NET Core minimal APIs

Introduction to Routing in ASP.NET Core

In ASP.NET Core, Routing is the process of mapping a requested URL to a handler.

First, during startup in Program.cs, you have to register a handler against a route template by calling MapXXX method on app or on a RouteGroupBuilder:

routeBuilder.MapGet("/{id}",
 HandleGetProduct).WithName(HandlerNames.GetProduct)
Enter fullscreen mode Exit fullscreen mode

Note that in my apps, I do not call MapXXX directly on app. Instead I always call these on a RouteGroupBuilder object obtained from app for a certain URL prefix e.g. the routeBuilder used above was obtained from app by saying app.MapGroup("/product"). Therefore the route template that was mapped to the handler function HandleCreateProduct is /product/{id}. It would be obtained by concatenating segment with which the called RouteGroupBuilder was initialised with the segment that passed to MapXXX method when registering the handler.

All registered route templates are stored in a dictionary, each against its registered handler.

Next, when an HTTP request for a URL is received, routing is done by the RoutingMiddleware: it first maps the incoming URL to one of the route templates stored as keys in the dictionary, then it maps that route template to its registered handler stored in the dictionary. It sets this handler in HttpContext so that it is available to every subsequent middleware until the request reaches the EndpointMiddleware.

EndpointMiddleware would invoke the handler function, passing to it any parameters using its model binding logic, sourcing them from request and from the DI container. There is one route parameter in the example route template, {id}. If, given the example route template /product/{id}, the URL requested in an HTP request was /product/12, then id parameter would be assigned value 12 during model binding. This would be passed value of an int id parameter to the handler whose signature could be something like this:

public static async Task<Results<Ok<Product>> HandleGetProduct(int id)
{
    //code to retrieve product from database and return it
    //in JSON body of response
}
Enter fullscreen mode Exit fullscreen mode

In addition to routing, i.e. mapping an incoming URL to a handler, the Routing system in ASP.NET Core allows us to perform the reverse process: it allows generation of URL for a given handler.

In a handler, we can call LinkGenerator.GetPathByName (a instance of LinkGenerator - this class is part of the Routing system - is injected from DI if declared as a parameter in a handler) and provide a handler's name to it:

return TypedResults.Created(
  linkGen.GetPathByName(HandlerNames.GetProduct, new { id = result })
);
Enter fullscreen mode Exit fullscreen mode

The name would have have been declared for a handler by chaining .WithName to the MapXXX call when registering the handle with the Routing system. For example in the snippet shown above for registering the function HandleGetProduct as handler, we chained .WithName(HandlerNames.GetProduct) where HandlerNames.GetProduct is a const:

private static class HandlerNames
{
    public const string GetProduct = "get-product";
    public const string CreateProduct = "create-product";

}
Enter fullscreen mode Exit fullscreen mode

The declared name for a handler should be unique among all registered handlers in the app. The naming convention I use - <operation>-<microservice> ensures that.

Referencing a handler by name rather than by name rather than by passing in a delegate to the handler function makes sense: the handler delegate may be private and may not be available throughout the app where it need to be referenced when a URL to it needs to be generated.

If the route template to which the referenced handler was mapped includes route parameters, you can provide values for these in a dictionary passed as second parameter of LinkGenerator.GetPathByName as shown above.

LinkGenerator.GetPathByName generates a absolute URL to the handler but without a host name. LinkGenerator has other methods such as GetUriByName which prefixes includes the hostname/domain name at the beginning also. I avoid this as this can lead to security vulnerabilities such as to a host name spoofing attack if the HostFilteringMiddleware is not properly configured (it is added to the middleware pipeline by default but in a disabled state).

For further information, see MS Docs page Routing in ASP.NET Core.

Patterns and Guidelines I Use to Implement Routing

These are the patterns and guidelines that I use in my minimal API projects to implement Routing:

1. Mapping Route Templates to Handlers

Create a class which collects together all handlers for a cohesive area of business logic, say for a (synchronous) microservice, and registers these in a static MapRoutes method. This method is called from Program.cs.

The advantage of this pattern is that:

  • a microservice is responsible for registering all of its operation handlers with the names and metadata that it sees fit to declare. In particular names, route details and metadata for all of the operations in a cohesive group of handlers or a microservice do not pollute Program.cs.

  • At the same time, Program.cs can choose a route prefix and metadata for the whole group, which can depend on any other groups that Program.cs chooses to register handlers for.

To implement this pattern:

Create Handlers class

  • In Handlers folder, create a class named <service name>Handlers.

  • In this class, create each handler as a private static method named Handle<operation>.

  • Create a nested class private static class HandlerNames to keep string constants for handler names.

  • Create method public static RouteGroupBuilder MapRoutes(RouteGroupBuilder routeBuilder) registers each handler with its name from HandlerNames by calling routeBuilde.MapXXX method for the appropriate HTTP verb, then returns the passed in RouteGroupBuilder.

Example:

using flowmazonapi.Domain;
using flowmazonapi.BusinessLogic;
using flowmazonapi.BusinessLogic.ProductService;

using Microsoft.AspNetCore.Http.HttpResults;

public class ProductHandlers
{

    private static class HandlerNames
    {
        public const string GetProduct = "get-product";
        public const string CreateProduct = "create-product";

    }

    public static async Task<Results<Ok<Product>, ValidationProblem>> HandleGetProduct(int id)
    {
        //throw new NotImplementedException();
    }

    private static async Task<Results<Created, ValidationProblem>> HandleCreateProduct(CreateProductArgs p, IProductService productService, LinkGenerator linkGen, HttpContext httpContext)
    {
        //throw new NotImplementedException();
    }

    public static RouteGroupBuilder MapRoutes(RouteGroupBuilder routeBuilder)
    {
        routeBuilder.MapPost("/", HandleCreateProduct).WithName(HandlerNames.CreateProduct);

        routeBuilder.MapGet("/{id}", HandleGetProduct).WithName(HandlerNames.GetProduct);

        );


        return routeBuilder;

    }
}
Enter fullscreen mode Exit fullscreen mode

Call MapRoutes on a Handlers class from Program.cs

In Program.cs, once all middleware have been added (just before app.Run() is called, call MapRoutes on the handlers class, passing in a RouteGroupBuilder taht has been initialised with the prefix that would be prefixed to route templates that will be registered.

ProductHandlers.MapRoutes(app.MapGroup("/product")).WithTags("product Operations");

app.Run();
Enter fullscreen mode Exit fullscreen mode

Here WithTags is an estension method provided by OpenApiRouteHandlerBuilderExtensions and attaches the tag product Operations to the whole group of routes that was registered by the called ProductHandlers.MapRoutes method. Being able to do this using a fluent syntax shows the value of returning the same RouteGroupBuidler that was passed in to the MapRoutes method.

The nullability checks in modern C# make it very difficult for ProductHandlers.MapRoutes not to return the RouteGroupBuilder that was passed in to it. Therefore I am happy with this convention to achieve a fluent interface.

2. Mapping Handlers to URLs

In a handler that needs to return a URL, e.g. to a resource that was created in the handler:

  • Declare LinkGenerator linkGen parameter in the handler (this would be resolved from DI container when the handler is called by EndpointMiddleware).

  • In the handler, call linkGen.GetPathByName(HandlersName.<const for handler name>, <any route parameters>) to generate a URL to the handler.

  • Always use LinkGenerator.GetPathByName, never use LinkGenerator.GetUriByName to avoid security issues such as host name spoofing mentioned in the intro section above.

Example:

private static async Task<Results<Created, ValidationProblem>> HandleCreateProduct(CreateProductArgs p, IProductService productService, LinkGenerator linkGen, HttpContext httpContext)
{

    var result = await productService.CreateProduct(p);

    return TypedResults.Created(linkGen.GetPathByName(HandlerNames.GetProduct, new { id = result }));

}
Enter fullscreen mode Exit fullscreen mode

3. Go easy on route constraints

Route templates should be as simple as possible. While ASP.NET Core provides a fairly rich set of constraints that may be placed on route parameters (such as {id} in code shown above), these should be avoided as much as possible.

In particular, as the documentation advises, route constraints should not be used for validation of route parameter values.

4. Use ** for catchall parameter rather than *

When using catchall parameters, I only use ** rather than single *.

Both have the same behaviour in mapping URL to a route template and to parameter value during model binding, but in the reverse process, when we use LinkGeneratorclass to generate a URL from a handler's name and route parameter values,*output/as a/in the generated URL whereas the singel asterisk () outputs%2F` which I almost never want.

Top comments (0)