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)
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
}
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 })
);
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";
}
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 thatProgram.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 namedHandle<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 fromHandlerNames
by callingrouteBuilde.MapXXX
method for the appropriate HTTP verb, then returns the passed inRouteGroupBuilder
.
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;
}
}
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();
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 byEndpointMiddleware
).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 useLinkGenerator.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 }));
}
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)