Routing
Path-based routing with named parameters, wildcards, and virtual-host scoping using Router and the routes! macro.
routes! macro
The routes! macro is the recommended way to declare routes for new code. It builds an AppWithState<S> (or any builder that exposes .get(), .post(), etc.) from a declarative table.
use rust_web_server::app::App;
use rust_web_server::routes;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
struct Db;
fn list_users(_req: &Request, _params: &PathParams, _conn: &ConnectionInfo, _db: &Db) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
fn create_user(_req: &Request, _params: &PathParams, _conn: &ConnectionInfo, _db: &Db) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n201_created.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string();
r
}
let app = routes! {
App::with_state(Db),
GET "/users" => list_users,
POST "/users" => create_user,
};Syntax:
routes! {
<builder_expression>,
METHOD "pattern" => handler_or_closure,
...
}Valid methods: GET, POST, PUT, PATCH, DELETE (all caps). A trailing comma after the last route is optional.
Router — direct usage
Use Router directly when you need to compose multiple routers or call handle inside a custom Application::execute.
use rust_web_server::router::{Router, PathParams};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
let router = Router::new()
.get("/users/:id", |_req, params, _conn| {
let id = params.get("id").unwrap_or("unknown");
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(
format!("user {}", id).into_bytes(),
MimeType::TEXT_PLAIN.to_string(),
)];
r
})
.delete("/users/:id", |_req, params, _conn| {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n204_no_content.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string();
r
});
// Inside Application::execute:
// if let Some(response) = router.handle(request, connection) {
// return Ok(response);
// }router.handle(request, connection) returns Some(Response) on the first match, None if no route matches. Routes are tried in registration order.
Pattern syntax
| Syntax | Description | Example |
|---|---|---|
/literal |
Exact path segment | /users |
/:name or /{name} |
Named segment — captures one segment | /users/:id, /users/{id} |
/*name or /{*name} |
Wildcard — captures everything after the prefix | /files/*path, /files/{*path} |
/{{text}} |
Literal braces | /raw/{{id}} matches /raw/{id} |
The braced forms (since 18.7.0) are what axum 0.8 and OpenAPI use; the two syntaxes mean the same thing and can be mixed. A brace parameter takes a whole segment — /file.{ext} is a literal, not a parameter.
Rules:
- A wildcard (
*name,{*name}) must be the last segment in a pattern. - The query string is stripped before matching; only the path is used.
- Patterns are matched segment-by-segment;
/usersdoes not match/users/42.
// /users/42/posts/7 → id="42", post_id="7"
router.get("/users/:id/posts/:post_id", handler);
// /files/a/b/c.txt → path="a/b/c.txt"
router.get("/files/*path", handler);PathParams extraction
Inside a handler, call params.get("name") to retrieve a named segment value. It returns Option<&str>.
.get("/articles/:slug", |_req, params, _conn| {
let slug = params.get("slug").unwrap_or("unknown");
// use slug ...
Response::new()
})Virtual-host routing
Call .with_host("example.com") before registering routes to restrict a router to requests whose SNI hostname (TLS) or Host header (plain HTTP) matches that value.
let api_router = Router::new()
.with_host("api.example.com")
.get("/status", api_status_handler);
let www_router = Router::new()
.with_host("www.example.com")
.get("/", home_handler);
// In Application::execute:
// api_router.handle(request, connection)
// .or_else(|| www_router.handle(request, connection))handle() returns None immediately when .with_host() is set and the incoming request's hostname does not match, so composing multiple host-scoped routers is safe and efficient.
Relationship to App's built-in routes
App's built-in controllers — static file serving, /healthz//readyz//metrics, form handling, the 404 fallback — deliberately don't go through Router. That set is small, static, and known at compile time, so a fixed if-chain is simpler and just as fast as a segment matcher would be there. Router is for your routes, with dynamic path params and wildcards a fixed if-chain can't express cleanly.
AppWithState and AsyncAppWithState already compose the two: they hold a Router internally, try it first, and fall through to App's built-in controller chain for anything the router doesn't match — so static files, health checks, and metrics keep working automatically even in a fully custom, stateful app.
Typed handlers: #[get] with extractors
Since 18.28.0 a handler can take extractors as arguments and return anything that implements IntoResponse; the route attribute (macros feature) generates the adapter and the app takes the method and pattern from it:
# Cargo.toml
rust-web-server = { version = "18", features = ["macros", "serde"] }
use rust_web_server::app::App;
use rust_web_server::error::AppError;
use rust_web_server::handler::{Json, Path, State};
use rust_web_server::{get, post};
struct Db { /* pool, … */ }
#[get("/posts/{id}")]
fn show_post(Path(id): Path<u64>, State(db): State<Db>) -> Result<Json<Post>, AppError> {
db.find(id).map(Json).ok_or_else(|| AppError::NotFound(format!("no post {id}")))
}
#[post("/posts")]
fn create_post(Json(new): Json<NewPost>, db: &Db) -> Result<Json<Post>, AppError> {
db.insert(new).map(Json)
}
let app = App::with_state(Db { /* … */ })
.route(show_post)
.route(create_post);#[get],#[post],#[put],#[patch],#[delete]and#[route(METHOD, "/path")]turn the function into a unit struct of the same name implementinghandler::Handler<S>(andAsyncHandler<S>)..route(h)registers it onAppWithState, onRouter(handlers without state arguments) and onAsyncAppWithState, whereasync fnhandlers go too. The function as written stays callable asshow_post::call(Path(1), State(db)), e.g. in unit tests.- The generated
handleruns the extractors in argument order and calls the function: the same work as a handler that calls them by hand, behind the same route dispatch, with no extra allocation for a classic handler's path. - The first extractor that fails answers the request with its rejection (
400,415,422, … as problem details); the function doesn't run. show_post::METHODandshow_post::PATTERN(traithandler::RouteSpec) are the attribute's arguments.
Checked at compile time — the route's parameters against the Path argument:
| Mistake | Error |
|---|---|
#[get("/posts/{id}")] fn f(Path(post_id): Path<u64>) |
`post_id` is not a parameter of route `GET /posts/{id}` |
#[get("/posts/{id}")] fn f(Path((id, slug)): Path<(u64, String)>) |
the tuple takes 2, the route has 1 |
#[get("/posts/{id}/{slug}")] fn f(Path(id): Path<u64>) |
a scalar takes one parameter |
#[post("/posts/{id}/comments")] fn f(State(db): State<Db>) |
{id} isn't taken by any Path (or &PathParams) argument |
#[get("/posts")] fn f(Path(id): Path<u64>) |
the route has no parameters |
a #[derive(FromPath)] struct whose fields aren't exactly the route's parameters |
a const assertion naming the route |
A name bound inside Path(…) names the route parameter it takes (as in Rocket), so a tuple's bindings may come in any order: Path((slug, id)) for /posts/{id}/{slug}. A plain binding (p: Path<(u64, String)>) or _ takes them in route order. /posts/:id and /posts/{id} are the same route.
Classic handlers are unchanged. On a function with the classic signature — (&Request, &PathParams, &ConnectionInfo) plus the state for AppWithState (or AsyncAppWithState's owned form) — the attribute only adds a Route: METHOD /path doc comment, as before 18.28.0, and you register it with .get(pattern, handler) or routes!.
Route conflicts
A route registered after one with the same method that matches every path it does never runs: routes are tried in order and the first match wins. AppWithState, AsyncAppWithState and Router report these when the route is added — one line on stderr per distinct conflict — and list them with route_conflicts():
let app = App::with_state(db)
.get("/users/:id", show_user)
.get("/users/me", me) // shadowed: /users/:id answers /users/me
.get("/users/{uid}", other); // duplicate: same pattern as /users/:id
for conflict in app.route_conflicts() {
eprintln!("{conflict}");
}
// rws: warning: route GET /users/me never runs: GET /users/:id, registered before it, matches every path it doesEach RouteConflict has the method, the pattern that never runs, the earlier one that answers instead, and a kind (Duplicate: the same pattern, parameter names aside; Shadowed: the earlier one has a parameter or wildcard where it has a literal). It's a warning only — the app is built and serves as before; register the specific route first (/users/me, then /users/:id). Patterns that only overlap (/a/:x and /:y/b) aren't reported. A test can assert app.route_conflicts().is_empty().
See the API reference for the full list of public types.