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 is centered around RouteScopeBuilder, Router, and RequestContext, so you can plug the routing pipeline into your own transport or hosting model as well.
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
- BuilderMetadata
- ConfigureBuilderDelegate`1
- ExceptionHandlingConfig
- Router
- RouterConfig
- RouterBuilder`2
- EndpointBuilder
- HttpListenerRouter
- RequestContext
- QueryParsingConfig
- UnexpectedParameterBehavior
- ErrorDetails
- ValueParserDelegate
- RequestHandlerDelegate
- 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 that applies to a created router snapshot. Configuration records are immutable, so use ConfigureRouting() with a with expression when you want to replace one or more settings before calling CreateRouter(). The callback uses the same ConfigureBuilderDelegate<TConfig> shape as module-specific configuration methods.
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.ConfigureRouting(config => config with
{
MatchingPrecedence = MatchingPrecedence.ParameterizedFirst,
ParametersCapacity = 8
})
.AddDefaultValueParsers()
.AddEndpoint("GET", "/items/{slug:str}/", endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new { slug = context.Parameters["slug"] });
}))
.CreateRouter();
Created routers are immutable snapshots: later route or configuration changes on the builder do not affect routers that have already been created.
ParametersCapacity sets the initial capacity of the per-request RequestContext.Parameters dictionary. Raise it when most requests collect several route parameters, query bindings, or middleware-added values and you want to avoid resizing.
Module Configuration
Some builder modules expose ConfigureXxx() methods for settings that are shared by later registrations in the same route scope. This supports a "configure once, use anywhere" style when several route registrations should use the same module behavior.
The same pattern also gives composite helpers a clean configuration path. A helper can register lower-level middleware internally while still honoring the configuration that was already stored in the builder scope. This keeps configuration close to the module it affects instead of adding pass-through callback overloads to every higher-level helper that happens to use that module.
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.ConfigureJsonErrorDetails(config => config with
{
PopulateErrorInfo = true
})
.AddJsonErrorDetails("/api/*")
.AddJsonErrorDetails("/admin/*")
.CreateRouter();
ConfigureXxx() methods update the configuration visible from the current route scope. They affect module registrations made after the configuration call. Registrations that have already been added keep the configuration they captured when they were registered.
Prefix scopes follow the same rule as value parsers and metadata: a child scope receives a scoped copy when it is created. Configuration changes made later on the parent do not rewrite existing child scopes, and child changes stay local to that child scope.
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. Use
ConfigureQueryParsing()to reject them instead. - 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.
Use ConfigureQueryParsing() before AddQueryBindings() when you want later query-binding registrations in the same route scope to reject query keys that were not declared in their binding descriptor:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.AddDefaultValueParsers()
.ConfigureQueryParsing(config => config with
{
UnexpectedParameterBehavior = UnexpectedParameterBehavior.Reject
})
.AddQueryBindings("GET", "/items/", "{filter:str(min=3)}")
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler(static async (context, _) =>
{
await Task.CompletedTask;
return HttpResponseMessage.Json(new { filter = context.Parameters["filter"] });
}))
.CreateRouter();
AddQueryBindings() and WithQueryBindings() snapshot the current QueryParsingConfig at registration time. Prefix scopes follow the normal RouteScopeBuilder.Metadata scoping rules, so a prefix can override query parsing before registering its own scoped query-binding middleware. Endpoint builders use the same scoped configuration through EndpointBuilder.Prefix.
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 ConfigureExceptionHandling() before registering exception-handling middleware when you want to customize how specific exception types are normalized:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.ConfigureExceptionHandling(config => config with
{
ExceptionNormalizers = config.ExceptionNormalizers.SetItems
([
ExceptionNormalizer.For<NotSupportedException>
(
static ex =>
{
HttpRequestException.Throw(HttpStatusCode.BadRequest, "Not supported", ex);
return null!;
}
)
])
})
.AddExceptionHandler()
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler((_, _) => throw new NotSupportedException()))
.CreateRouter();
AddExceptionHandler() snapshots the current ExceptionHandlingConfig at registration time. Higher-level helpers that register exception handling internally follow the same rule, so the exception-handling configuration can be customized even when you do not call AddExceptionHandler() directly. Prefix scopes follow the normal RouteScopeBuilder.Metadata scoping rules, so a prefix can override exception normalization before registering its own scoped exception middleware.
JSON Error Details
AddJsonErrorDetails() turns routing and normalized exception failures into JSON ErrorDetails responses. Configure the error payload before adding the middleware when you want to include developer diagnostics or customize ErrorDetails serialization:
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.ConfigureJsonErrorDetails(config => config with
{
PopulateErrorInfo = true
})
.AddJsonErrorDetails()
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler((_, _) => throw new InvalidOperationException("Boom")))
.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.
AddJsonErrorDetails() also registers exception handling internally so unexpected exceptions are normalized before they are rendered as JSON. If you want to customize that normalization, call ConfigureExceptionHandling() before AddJsonErrorDetails(); the internally registered exception handler snapshots the current ExceptionHandlingConfig just like a direct AddExceptionHandler() call would.
HttpListenerRouter router = HttpListenerRouter
.CreateBuilder()
.ConfigureExceptionHandling(config => config with
{
ExceptionNormalizers = config.ExceptionNormalizers.SetItems
([
ExceptionNormalizer.For<NotSupportedException>
(
static ex =>
{
HttpRequestException.Throw(HttpStatusCode.BadRequest, "Not supported", ex);
return null!;
}
)
])
})
.AddJsonErrorDetails()
.AddEndpoint("GET", "/items/", endpoint => endpoint
.WithHandler((_, _) => throw new NotSupportedException()))
.CreateRouter();
AddJsonErrorDetails() snapshots the current JsonErrorDetailsConfig at registration time. Prefix scopes follow the normal RouteScopeBuilder.Metadata scoping rules, so a prefix can override JSON error-detail settings before registering its own scoped error middleware.
Custom Routers
If HttpListenerRouter is not the transport you want, derive from Router<TDescendant, TConfig> and expose your own entry point that prepares an HttpRequestMessage, invokes Handle(), and deals with the returned HttpResponseMessage.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using NanoRoute;
public sealed class InMemoryRouter : Router<InMemoryRouter, RouterConfig>
{
private InMemoryRouter(RouterBuilder<InMemoryRouter, RouterConfig> builder) : base(builder)
{
}
public Task<HttpResponseMessage> Route(HttpRequestMessage request, IServiceProvider services, CancellationToken cancellation = default) =>
Handle(request, services, cancellation);
}
InMemoryRouter router = InMemoryRouter
.CreateBuilder()
.AddEndpoint("GET", "/health/", endpoint => endpoint
.WithHandler(static (_, _) => Task.FromResult(new HttpResponseMessage())))
.CreateRouter();
This keeps the transport-specific concerns in your own router type while still reusing NanoRoute's matching, value parsing, and handler 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.HttpListenerRouter.Route()aborts the activeHttpListenerResponseand then rethrows the cancellation exception.
Common Building Blocks
HttpListenerRouter.CreateBuilder()starts a strongly typed builder forHttpListenerscenarios.ConfigureRouting()customizes router-level behavior such as matching precedence and the initial request-parameter dictionary capacity before creating a router snapshot.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.RouteScopeBuilder.Metadatastores extension-defined build-time settings with prefix-local scoping; it is mainly for extension authors.AddQueryBindings()andEndpointBuilder.WithQueryBindings()bind selected query-string values intoRequestContext.Parameters.ConfigureQueryParsing()customizes query-binding behavior used by subsequently registeredAddQueryBindings()andEndpointBuilder.WithQueryBindings()middleware.AddHandler<TRequestContext>()andEndpointBuilder.WithHandler<TRequestContext>()projectRequestContextinto a typed request object before invoking the handler.ConfigureExceptionHandling()customizes exception normalization used by subsequently registeredAddExceptionHandler()middleware.AddJsonBody()andEndpointBuilder.WithJsonBody()bind JSON request content intoRequestContext.Parameters.AddJsonErrorDetails()turns routing exceptions into JSONErrorDetailsresponses.ConfigureJsonErrorDetails()customizes JSONErrorDetailsresponse diagnostics and serialization metadata used by subsequently registeredAddJsonErrorDetails()middleware.HttpResponseMessage.Json(...)creates JSON responses with the library's serializer defaults.