[ Web Proxy ]
URL:
Viewing: https://shinylib.net/httpserver/routing/ [Back]  [Original]

Routing | Shiny.NETSkip to content
Search
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Routing

app.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
app.MapPost("/notes", async ctx => { });
app.MapDelete("/notes/{id:int}", ctx => { });

MapGet/MapPost/MapPut/MapDelete/MapPatch follow the ASP.NET Core spelling, so what you know from minimal APIs carries over. Map(method, pattern, handler) covers any other verb.

Handlers come in two shapes and both are first class:

// RequestDelegate: write the response yourself
app.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
// Return an IResult and let it write
app.MapGet("/widgets/{id:int}", ctx =>
{
ctx.Request.RouteValues.TryGetInt32("id", out var id);
return store.Find(id) is { } w ? Results.Ok(w, AppJson.Default.Widget) : Results.NotFound();
});

MapRoute(...) returns the RouteEndpoint it registered, which is what you pass to Unmap later.

A template is a /-delimited list of segments:

Syntax Matches
/users The literal text, compared case-insensitively
/users/{id} Exactly one path segment, captured as id
/users/{id:int} The same, but only when the segment parses as an int
/files/{name?} A trailing optional parameter matches with and without the segment
/files/{*path} A catch-all: the rest of the path, slashes included

Templates are parsed once at registration, never per request. A bad template throws RouteTemplateException there and then rather than silently never matching.

Three rules the parser enforces:

  • A catch-all must be the last segment.
  • An optional parameter must be the last segment.
  • A segment is either literal or a parameter, never v{version}. Mixed segments are what make a binder complicated, and rejecting them loudly beats matching in a way nobody predicted.

Reading the captured values from a raw handler:

var name = ctx.Request.RouteValues["name"]; // string?
ctx.Request.RouteValues.TryGetInt32("id", out var id); // also TryGetInt64, TryGetGuid

Typed endpoints bind these into method parameters instead see Typed Endpoints.

Constraints are a closed set evaluated by a switch, not a pluggable IRouteConstraint resolved from a container. A closed set is trim-safe and allocation-free, and it covers what route matching is for anything richer belongs in the handler, where it can return a meaningful error instead of a bare 404.

Constraint Matches
:byte byte.TryParse 0255
:short short.TryParse
:int int.TryParse
:long long.TryParse
:float float.TryParse, invariant
:double double.TryParse, invariant
:decimal decimal.TryParse, invariant
:bool bool.TryParse
:guid Guid.TryParse
:alpha One or more ASCII letters
:datetime DateTime.TryParse, invariant
:dateonly DateOnly.TryParse, invariant
:timeonly TimeOnly.TryParse, invariant
:timespan TimeSpan.TryParse, invariant
:minlength(n) At least n characters
:maxlength(n) At most n characters
:length(n) Exactly n characters
:min(n) An integer >= n. n may be negative
:max(n) An integer <= n. n may be negative
:range(a,b) An integer in [a, b] inclusive

The integer widths are real filters rather than synonyms: {id:byte} does not match 300, and {id:short} does not match 32768. The three length constraints count characters; min, max and range compare the value.

The temporal constraints parse with the invariant culture, so a route means the same thing wherever the server happens to be running. A path segment can hold an ISO timestamp /logs/2026-08-11T14:30:00 matches {on:datetime} since neither T nor : needs escaping inside a segment. A / does, so a date written 11/08/2026 has to be url-encoded or split into segments.

An unknown constraint name is a registration-time error, not a route that quietly never matches. The endpoint generator rejects the same names at compile time, and the two vocabularies are held in step by tests.

Turning a matched segment into the handlers parameter type is the binders job, and it handles every IParsable<T> so short, DateTime, TimeSpan, Guid and friends bind whether or not you constrain them. Declaring {id:int} on a handler that takes a long is legal and does exactly what it says: match integer-shaped segments, hand the handler a long.

The distinction shows up in the status code. A segment the constraint refuses is a 404 no route matched. A segment that matched but will not parse into the parameter is a 400 from the binder.

There is no regex constraint, and that is the same design decision rather than an omission: it would put an attacker-influenced pattern on the routing hot path for every request, which is a denial-of-service surface. A route that needs a regular expression is a route whose handler should be explaining what was wrong with the input.

The route table is a prefix trie, and the walk backtracks. In order of preference:

  1. Literals beat parameters. /users/me wins over /users/{id} for /users/me.
  2. Constrained parameters beat unconstrained ones. /{id:int} wins over /{slug} for /42, regardless of registration order.
  3. Catch-all is the last resort.

A parameter must capture something, so /users//orders does not bind an empty string to {id} and reach a handler with no way to tell it apart from a real value. A trailing slash is ignored: /users and /users/ select the same endpoint.

  • A path that matches nothing falls through to the OnRequest handler, then to the servers own 404. Falling through rather than answering immediately is what lets static files or a SPA index be served from the same pipeline.
  • A path that exists but not for this method is a 405 with an Allow header listing the methods that path does support never a 404.
  • A HEAD request is served by the GET handler and the body is dropped on the way out, so every route gets HEAD for free.

Registering two handlers for the same method and template throws at registration:

Cannot register 'GET /users/{id}': the route 'GET /users/{id}' already handles it.

For generated endpoints the same collision is caught at build time as SWS005.

MapGroup gives a set of routes a shared prefix:

app.MapGroup("/api/v2", api =>
{
api.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
api.MapPost("/reset", ctx => ).RequireAuthorization("admin");
});

The builder handed to the callback also has its own MapGroup, so groups nest.

An IEndpointModule is a set of routes that registers itself a plugin, a feature only mounted when a licence says so, an admin surface that appears when a toggle flips.

public sealed class AdminModule : IEndpointModule
{
public void Map(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/admin/stats", ctx => );
endpoints.MapPost("/admin/reset", ctx => ).RequireAuthorization("admin");
}
}
app.MapModule(new AdminModule()); // or app.MapModules() for every one in the container
app.UnmapModule<AdminModule>(); // takes all of its routes away again

Every route a module registers is tagged with the module type, which is what makes unmounting the whole group a single call.

The route table is an immutable trie behind a volatile field. Adding or removing a route builds a new table and publishes it with one write, so a request in flight sees the whole change or none of it, and matching never takes a lock.

var endpoint = app.MapRoute("GET", "/preview/{id}", handler); // reachable on the very next request
app.Unmap(endpoint); // stops matching immediately
app.Unmap("GET", "/preview/{id}"); // by method + template
app.UnmapAll(e => e.Method == "GET"); // by predicate; returns how many went
app.ClearRoutes(); // everything (the OnRequest handler stays)

A change that would produce a duplicate throws and leaves the live table exactly as it was.

Middleware is a different matter that pipeline is composed once, when the server starts, and RestartAsync does not recompose it. Anything that needs to be switchable at runtime belongs in a route or in a check inside the middleware itself.

app.Router is the table itself, if you want to enumerate it or subscribe to changes:

foreach (var e in app.Router.Endpoints)
Console.WriteLine(e.DisplayName); // "GET /users/{id}"
app.Router.Changed += (_, count) => logger.LogInformation("{Count} routes registered", count);

Everything conditional the server does authorization, CORS, rate limits, IP filters, OpenAPI is metadata attached to the endpoint, read after routing has selected it. On a raw route, the Require methods apply to the route just mapped:

app.MapGet("/status", ctx => ctx.Response.WriteAsync("ok"))
.RequireCors("public")
.DisableRateLimiting();
app.MapGet("/admin/keys", ctx => )
.RequireAuthorization("admin")
.RequireIpFilter("admin");

On a generated endpoint the same thing is an attribute. Either way it is metadata resolved at registration, never discovered at runtime.


Web Proxy Viewer  |  New URL  |  Original Page