NanoRoute
NanoRoute is a small, dependency-light router for HttpRequestMessage pipelines, with optional transport adapters and focused helpers for JSON payloads and error handling.
The core library includes HttpMessageRouter for already materialized HttpRequestMessage requests and HttpListenerRouter for listener-hosted requests. RouterBase<TConfig>, RouteScopeBuilder, and RequestContext remain available when you want to plug the routing pipeline into your own transport or hosting model.
NanoRoute targets netstandard2.0 and netstandard2.1, and is compatible with Native AOT scenarios.
For AWS Lambda integrations, use the separate NanoRoute.AwsLambda package.
Quick Start
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NanoRoute;
// UserRepository is your application service that implements IUserRepository.
IServiceProvider services = new ServiceCollection()
.AddSingleton<IUserRepository, UserRepository>()
.BuildServiceProvider();
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddJsonErrorDetails()
.AddEndpoint("GET", "/api/users/{user_id:int}/", endpoint => endpoint
.WithHandler(static async (GetUserRequest request) =>
{
return HttpResponseMessage.Json(HttpStatusCode.OK, new UserResponse
{
Id = request.UserId,
Name = await request.Users.GetNameAsync(request.UserId)
});
}))
.AddEndpoint("POST", "/api/users/", endpoint => endpoint
.WithJsonBody<CreateUserBody>(nameof(CreateUserRequest.Body))
.WithHandler(static async (CreateUserRequest request) =>
{
int userId = await request.Users.CreateAsync(request.Body.Name);
return HttpResponseMessage.Json(HttpStatusCode.Created, new UserResponse
{
Id = userId,
Name = request.Body.Name
});
}))
.CreateRouter();
HttpListener listener = new();
listener.Prefixes.Add("http://localhost:8080/");
listener.Start();
HttpListenerContext context = await listener.GetContextAsync();
await router.Route(context, services);
public sealed class GetUserRequest
{
[ValueSource(ValueSource.Parameter, Name = "user_id")]
public int UserId { get; set; }
[ValueSource(ValueSource.ServiceLocator)]
public IUserRepository Users { get; set; } = null!;
}
public sealed class CreateUserRequest
{
public CreateUserBody Body { get; set; } = null!;
[ValueSource(ValueSource.ServiceLocator)]
public IUserRepository Users { get; set; } = null!;
}
public sealed class CreateUserBody
{
public string Name { get; set; } = string.Empty;
}
public sealed class UserResponse
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public interface IUserRepository
{
Task<int> CreateAsync(string name);
Task<string> GetNameAsync(int userId);
}
AddEndpoint() is the recommended application-level entry point for most routes. It captures an HTTP verb or verbs and a route pattern once, then endpoint helpers such as WithHandler() and WithJsonBody() add endpoint-local middleware without repeating the route. Typed handlers bind route values, JSON bodies, services, and framework values into request objects before your handler runs.
AddHandler() remains available as the lower-level pipeline primitive for custom middleware composition, prefix pipelines, and extension authors.
Core Types
- RouteScopeBuilder
- ExceptionHandlingOptions
- JsonErrorDetailsOptions
- RouterConfig
- RouterBase`1
- RouterBuilder`2
- EndpointBuilder
- HttpMessageRouter
- HttpListenerRouter
- RequestContext
- UnexpectedParameterBehavior
- ErrorDetails
- ValueParserDelegate
- RequestHandlerDelegate
- HttpMethodExtensions
- NanoRouteHandlerExtensions
- NanoRouteEndpointExtensions
- NanoRoutePrefixExtensions
- ValueSource
- ValueSourceAttribute
Matching Rules
- Exact route patterns must start and end with
/, for example/items/. - Prefix route patterns must start with
/and end with/*, for example/items/*. /itemsis invalid as a route pattern; use/items/for an exact route or/items/*for a prefix route.- Repeated
/separators in route patterns, such as//or/items//details, are invalid. - Literal segments are matched case-insensitively.
- Parser-backed segments use registered parsers such as
{user_id:int},{int}, or{slug:str(min=3,max=32)}. - The parameter name is optional. Segments like
{int}still validate the path but do not add an entry toRequestContext.Parameters. - When multiple handlers match within the selected route branch, NanoRoute evaluates compatible handlers from shorter prefixes toward more specific matches.
RequestContext.RemainingPathis updated for each matched handler. Prefix handlers receive the unmatched path tail with its leading/, exact handlers receive an empty value when no path remains, and query strings are not included.- At the same path depth,
RouterConfig.MatchingPrecedencedecides whether literal or parameterized child segments are selected first. - Once NanoRoute selects a child branch at a given depth, it does not return to sibling branches later in the pipeline.
Router Configuration
RouterConfig controls runtime behavior for one router snapshot. Pass a configuration callback to CreateRouter(...) when that snapshot should use non-default matching behavior.
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddEndpoint("GET", "/items/{slug:str}/", endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new { slug = context.Parameters["slug"] });
}))
.CreateRouter(config =>
{
config.MatchingPrecedence = MatchingPrecedence.ParameterizedFirst;
});
Created routers are immutable snapshots: later route changes on the builder and later CreateRouter(...) configuration callbacks do not affect routers that have already been created.
Prefix Scopes
When several routes share the same prefix, AddPrefix() lets you define that prefix once and register child routes relative to it. If you want to hold onto a child RouteScopeBuilder and add routes incrementally, use CreatePrefix().
RouterBuilder<HttpListenerRouter, HttpListenerRouterConfig> builder = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddJsonErrorDetails();
builder.AddPrefix("/api/users/{user_id:int}/*", users => users
.AddHandler("GET", "/*", async (context, next) =>
{
context.Parameters["user"] = $"user-{context.Parameters["user_id"]}";
return await next();
})
.AddEndpoint("GET", "/details/", endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new
{
id = context.Parameters["user_id"],
name = context.Parameters["user"]
});
})));
HttpListenerRouter router = builder.CreateRouter();
This produces the same effective routes as registering /api/users/{user_id:int}/* and /api/users/{user_id:int}/details/ directly, but keeps repeated base patterns out of endpoint registrations.
Inside prefix middleware, context.RemainingPath exposes the current request path tail that has not been matched by that handler's route pattern. For /api/users/{user_id:int}/* handling /api/users/42/details, the value is /details; the final /details/ endpoint sees an empty value.
Endpoint Builders
AddEndpoint() and CreateEndpoint() capture an endpoint's HTTP verb or verbs and exact or prefix route pattern once. Endpoint-aware helpers such as WithHandler(), WithJsonBody(), and WithQueryBindings() then register middleware for that captured endpoint without repeating the route.
public sealed class CreateItemRequest
{
public string Name { get; set; } = string.Empty;
}
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddJsonErrorDetails()
.AddDefaultValueParsers()
.AddEndpoint("POST", "/items/{id:int}/", endpoint => endpoint
.WithQueryBindings("{source:str}")
.WithJsonBody<CreateItemRequest>("body")
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(HttpStatusCode.Created, new
{
id = context.Parameters["id"],
source = context.Parameters["source"],
body = context.Parameters["body"]
});
}))
.CreateRouter();
Endpoint builders are useful when several pieces of endpoint-local middleware need the same verbs and pattern. Multiple WithHandler() calls run in registration order, and each handler can call the supplied next delegate to continue the endpoint pipeline. WithQueryBindings() uses the endpoint's captured verbs and match kind, so query parsing stays local to that endpoint. CreateEndpoint() returns an EndpointBuilder when you want to configure an endpoint incrementally, and EndpointBuilder.Prefix exposes the endpoint's scoped route builder for endpoint-aware extensions that need the lower-level builder surface.
Value Parsers
NanoRoute supports both synchronous and asynchronous value parsers:
AddValueParser("name", SyncValueParserDelegate)for lightweight synchronous parsing.AddValueParser("name", ValueParserDelegate)when parsing needs request services or async work.AddValueParser("name", BindArgumentsDelegate, ...)when the route template includes parser arguments such as{id:int(min=1)}.
BindArgumentsDelegate receives the raw parser arguments as a case-insensitive dictionary and can turn them into any cached object. That object is then exposed as ValueParserContext.Arguments for async parsers or as the arguments parameter for sync parsers.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NanoRoute;
RouterBuilder<HttpListenerRouter, HttpListenerRouterConfig> builder = HttpListenerRouter
.CreateBuilder()
.AddValueParser
(
"int",
static (IReadOnlyDictionary<string, string> rawArgs) => (
Min: rawArgs.TryGetValue("min", out string? min) ? int.Parse(min, CultureInfo.InvariantCulture) : null,
Max: rawArgs.TryGetValue("max", out string? max) ? int.Parse(max, CultureInfo.InvariantCulture) : null
),
static (ReadOnlyMemory<char> segment, object? arguments, out object? parsed) =>
{
(int? Min, int? Max) limits = ((int? Min, int? Max)) arguments!;
parsed = null;
if (!int.TryParse(segment.Span, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
return false;
if (limits.Min is int min && value < min)
return false;
if (limits.Max is int max && value > max)
return false;
parsed = value;
return true;
}
)
.AddValueParser("user", static async (ValueParserContext context) =>
{
if (!Guid.TryParse(context.Segment.Span, out Guid userId))
return ValueParseResult.False;
IUserRepository repository = context.Services.GetRequiredService<IUserRepository>();
object? user = await repository.TryGetAsync(userId, context.Cancellation);
return new ValueParseResult(user is not null, user);
});
Asynchronous parsers can return ValueParseResult.False for the common non-match result where Success is false and Parsed is null.
Built-in parsers use the same mechanism:
intsupportsminandmax.strsupportsminandmax.regexsupports requiredpattern, optionaltimeoutMsthat defaults to50, and optionalcaseSensitivethat defaults tofalse; timed-out matches are treated as non-matches.guidandbooldo not take arguments.
Value Parser Syntax
{parameterName:parserName}parses a segment and stores the parsed value underparameterName.{parserName}parses a segment without storing it inRequestContext.Parameters.{parameterName:parserName(arg=value, text='hello')}also passes a case-insensitive raw argument map through the parser'sBindArgumentsDelegate.- Query bindings may add
[]after the parser definition, such as{tag:str[]}or{tag:str(min=2)[]}, to collect repeated query keys into aList<object?>in request order. - List parser syntax is supported for query bindings only, not route path parameters.
- Parser arguments support
null,trueorfalse, numbers, and single-quoted strings with\'escaping.
Use AddValueParser() to register custom parsers, or AddDefaultValueParsers() to register the built-in int, guid, bool, str, and regex parsers.
Query Bindings
AddQueryBindings() lets you validate and parse selected query-string values with the same registered value parsers used by route segments.
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddPrefix("/items/*", items => items
.AddQueryBindings("GET", RouteScopeBuilder.CurrentExact, "{filter:str(min=3)}&{page?:int(min=1)}&{tag:str(min=2)[]}")
.AddEndpoint("GET", RouteScopeBuilder.CurrentExact, endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new
{
filter = context.Parameters["filter"],
page = context.Parameters.TryGetValue("page", out object? page) ? page : null,
tags = context.Parameters.TryGetValue("tag", out object? tags) ? tags : null
});
})))
.CreateRouter();
- Add
?to the query parameter name to make it optional, for example{page?:int(min=1)}. - Add
[]after the query value parser definition to collect repeated query keys, for example{tag:str[]}or{tag:str(min=2)[]}for?tag=red&tag=blue. - Query parameter names may contain ASCII letters, digits, and underscores.
- Parsed values are stored in
RequestContext.Parametersunder the configured key. - List query bindings store a
List<object?>containing each parsed value in request order. - Query keys are matched case-insensitively using the normalized key exposed by
Uri.Query. - Repeated declared scalar query parameters are rejected with
400 Bad Request. - Undeclared query parameters are ignored by default. Pass
unexpected: UnexpectedParameterBehavior.Rejectto reject them for a specific binding. - List value parsers are supported only for query bindings, not route path parameters.
- As with JSON binding and prefix handlers, later middleware can overwrite earlier values in
RequestContext.Parameters.
Pass unexpected: UnexpectedParameterBehavior.Reject when a query-binding registration should reject query keys that were not declared in its binding descriptor:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddQueryBindings("GET", "/items/", "{filter:str(min=3)}", unexpected: UnexpectedParameterBehavior.Reject)
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new { filter = context.Parameters["filter"] });
}))
.CreateRouter();
AddQueryBindings() and WithQueryBindings() capture their unexpected behavior at registration time. Use the overload on each binding that needs non-default behavior; other query bindings continue to ignore undeclared query parameters.
Typed Handlers
Typed handlers let you describe the data a route needs as a request object instead of reading everything manually from RequestContext.Parameters.
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using NanoRoute;
public sealed class GetItemRequest
{
public int Id { get; set; }
[ValueSource(ValueSource.Parameter, Name = "query_filter")]
public string Filter { get; set; } = null!;
[ValueSource(ValueSource.ServiceLocator)]
public IItemService Items { get; set; } = null!;
[ValueSource(ValueSource.Skip)]
public string? DiagnosticsLabel { get; set; }
public CancellationToken Cancellation { get; set; }
}
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.AddQueryBindings("GET", "/items/{id:int}/", "{query_filter:str(min=3)}")
.AddEndpoint("GET", "/items/{id:int}/", endpoint => endpoint
.WithHandler(async (GetItemRequest request) =>
{
Item item = await request.Items.GetAsync(request.Id, request.Filter, request.Cancellation);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(item.Name)
};
}))
.CreateRouter();
Binding rules:
- Writable public properties are bound from
RequestContext.Parametersby default, using the property name as the key. RequestContextproperties receive the current request context automatically.CancellationTokenproperties receive the active request token automatically.[ValueSource(ValueSource.Parameter, Name = "...")]binds from a different parameter or query-binding name.[ValueSource(ValueSource.ServiceLocator)]resolves a service fromRequestContext.Services.[ValueSource(ValueSource.ServiceLocator, Name = "...")]resolves a keyed service.[ValueSource(ValueSource.Skip)]leaves the property untouched and does not allowName.- Read-only properties are ignored.
- Missing required values or services fail fast with
InvalidOperationException.
Typed endpoint handlers support the same route selection shapes as regular endpoint handlers: a single verb plus pattern, or an IEnumerable<string> of verbs plus pattern.
They also have middleware-style overloads that receive CallNextHandlerDelegate:
.AddEndpoint(["GET"], "/items/{id:int}/", endpoint => endpoint
.WithHandler(async (GetItemRequest request, CallNextHandlerDelegate next) =>
{
HttpResponseMessage response = await next();
response.Headers.Add("X-Filter", request.Filter);
return response;
}))
Exception Handling
AddExceptionHandler() adds middleware that converts unexpected exceptions into enriched HttpRequestException values. Existing HttpRequestException values are passed through unchanged, and OperationCanceledException still propagates to the caller.
Use the Status, Errors, and DeveloperMessages extension properties on HttpRequestException to read or customize NanoRoute error metadata.
Pass an options callback to AddExceptionHandler() when one exception-handling middleware should customize how specific exception types are normalized. Normalizers are matched against the thrown exception's runtime type first, then against its base exception types, so a base-type normalizer handles derived exceptions unless a more specific normalizer is registered:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddExceptionHandler(options => options.Map<NotSupportedException>
(
static ex =>
{
HttpRequestException.Throw(HttpStatusCode.BadRequest, "Not supported", ex);
return null!;
}
))
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler((_, _) => throw new NotSupportedException()))
.CreateRouter();
The options callback configures only the exception-handling middleware being registered. Other AddExceptionHandler() calls keep the default normalizers unless they receive their own options callback.
JSON Error Details
AddJsonErrorDetails() turns routing and normalized exception failures into JSON ErrorDetails responses. Pass an options callback when the error payload should include developer diagnostics, custom ErrorDetails serialization metadata, or custom exception normalization:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddJsonErrorDetails(options =>
{
options.PopulateErrorInfo = true;
options.ErrorDetailsTypeInfo = MyJsonContext.Default.ErrorDetails;
options.Map<NotSupportedException>
(
static ex =>
{
HttpRequestException.Throw(HttpStatusCode.BadRequest, "Not supported", ex);
return null!;
}
);
})
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler((_, _) => throw new NotSupportedException()))
.CreateRouter();
PopulateErrorInfo can expose exception messages or stack traces, so keep it disabled for production responses unless the caller is trusted to see those details. Set ErrorDetailsTypeInfo when Native AOT or custom serialization settings require source-generated ErrorDetails metadata.
AddJsonErrorDetails() also registers exception handling internally so unexpected exceptions are normalized before they are rendered as JSON. The same JsonErrorDetailsOptions callback configures that internally registered exception handler because it derives from ExceptionHandlingOptions:
builder.AddJsonErrorDetails(options => options.Map<NotSupportedException>
(
static ex =>
{
HttpRequestException.Throw(HttpStatusCode.BadRequest, "Not supported", ex);
return null!;
}
));
HTTP Message Routing
Use HttpMessageRouter when your application or test already has an HttpRequestMessage and wants the matching, value parsing, middleware, and handler pipeline without binding to a network listener.
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using NanoRoute;
HttpMessageRouter router = HttpMessageRouter
.CreateBuilder()
.AddEndpoint("GET", "/health/", endpoint => endpoint
.WithHandler(static (_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("ok")
})))
.CreateRouter();
using HttpRequestMessage request = new(HttpMethod.Get, "https://example.test/health");
using HttpResponseMessage response = await router.Route(request, services);
The returned HttpResponseMessage is owned by the caller. Dispose it after reading the response body.
Custom Routers
If neither HttpMessageRouter nor HttpListenerRouter fits the transport you want, derive from RouterBase<TConfig>. Your router entry point prepares an HttpRequestMessage, calls the protected Route() method, and deals with the returned HttpResponseMessage.
RouterBase<TConfig> stores the immutable router configuration and snapshots the supplied route scope into a reusable pipeline.
Cancellation
- NanoRoute exposes the caller-provided cancellation token to async value parsers and handlers through
ValueParserContext.CancellationandRequestContext.Cancellation. OperationCanceledExceptionis not converted into an HTTP error byAddExceptionHandler()orAddJsonErrorDetails(). It propagates to the caller or transport adapter unchanged.HttpMessageRouter.Route()rethrows the cancellation exception and leaves response ownership with the caller.HttpListenerRouter.Route()aborts the activeHttpListenerResponseand then rethrows the cancellation exception.
Common Building Blocks
HttpMessageRouter.CreateBuilder()starts a strongly typed builder for already materializedHttpRequestMessagescenarios.HttpListenerRouter.CreateBuilder()starts a strongly typed builder forHttpListenerscenarios.RouterBase<TConfig>stores router configuration and runs a captured request pipeline for custom transports.AddDefaultValueParsers()registers the built-inint,guid,bool,str, andregexvalue parsers.AddPrefix("/prefix/*", ...)configures a scoped route subtree and returns the current builder.CreatePrefix("/prefix/*")creates a scoped child builder for a route subtree.AddEndpoint()andCreateEndpoint()capture an endpoint's verbs and route pattern once.EndpointBuilder.WithHandler(),EndpointBuilder.WithJsonBody(), andEndpointBuilder.WithQueryBindings()register endpoint-local handlers, JSON body middleware, and query bindings.AddQueryBindings()andEndpointBuilder.WithQueryBindings()bind selected query-string values intoRequestContext.Parameters.- Query-binding overloads that take
UnexpectedParameterBehaviorcan reject undeclared query-string keys per registration. AddHandler<TRequestContext>()andEndpointBuilder.WithHandler<TRequestContext>()projectRequestContextinto a typed request object before invoking the handler.AddExceptionHandler(options => ...)customizes exception normalization for that middleware registration.HttpRequestExceptionextension properties expose routing status, client-facing errors, and developer-facing messages.AddJsonBody()andEndpointBuilder.WithJsonBody()bind JSON request content intoRequestContext.Parameters.AddJsonErrorDetails(options => ...)turns routing exceptions into JSONErrorDetailsresponses and configures diagnostics, serialization metadata, and exception normalization for that middleware registration.HttpMethod.For(...)returns shared knownHttpMethodinstances and creates custom methods for valid extension verbs.HttpResponseMessage.Json(...)creates JSON responses with the library's serializer defaults.