Routing
On this page
Routes are declared in src/routes/. A route file is a function taking a
&mut Router rather than a script running against a static facade.
// src/routes/web.rs
use rainier_framework::prelude::*;
pub fn routes(router: &mut Router) {
router.get("/", HomeController::index).name("home");
router.get("/health", || async { "ok" }).name("health");
router.post("/login", AuthController::login)
.name("login")
.middleware((kernel::web(), ThrottleRequests::per_minute(5)));
}
// src/bootstrap.rs
.with_routes(|router| {
routes::web::routes(router);
routes::api::routes(router);
})
Verbs
router.get("/posts", index);
router.post("/posts", store);
router.put("/posts/{post}", update);
router.patch("/posts/{post}", update);
router.delete("/posts/{post}", destroy);
router.options("/posts", preflight);
router.any("/webhook", receive); // every verb
router.add(vec![Method::GET, Method::POST], "/search", search);
get also answers HEAD — that is why route:list prints GET|HEAD.
Each returns &mut Route, so the modifiers chain:
router.get("/posts/{post}", show)
.name("posts.show")
.middleware(CacheResponse::for_seconds(60))
.where_slug("post");
Route parameters
router.get("/posts/{post}", show); // required
router.get("/posts/{post?}", show); // optional, trailing only
router.get("/files/{path*}", serve); // wildcard: the rest, with slashes
| Pattern | Matches | Captures |
|---|---|---|
{name} | exactly one segment | that segment |
{name?} | one segment or nothing | the segment, if present |
{name*} | everything remaining | including / |
Read them in the handler:
async fn show(request: Req) -> Result<Response> {
let slug = request.route_param("post").unwrap_or_default();
// …
}
or with the Path extractor:
async fn show(Path(slug): Path<String>) -> Result<Response> { … }
Constraints
Constraints are a closed set of named kinds rather than regular expressions:
router.get("/posts/{post}", show).where_slug("post");
router.get("/users/{id}", show).where_number("id");
router.get("/orders/{uuid}", show).where_uuid("uuid");
router.get("/reports/{period}", show).where_in("period", ["daily", "weekly"]);
router.get("/x/{y}", show)
.where_param("y", ParamConstraint::Custom(Arc::new(|v| v.len() == 4)));
| Constraint | Allows |
|---|---|
Number | ASCII digits |
Alpha | ASCII letters |
AlphaNumeric | ASCII letters and digits |
Slug | letters, digits, -, _ |
Uuid | canonical 8-4-4-4-12 hex |
In(values) | one of a fixed set |
Custom(predicate) | anything you like |
Named kinds rather than regexes keeps the crate free of a regex dependency and, more usefully, makes the common constraints declarative and impossible to get subtly wrong. A route whose parameter fails its constraint simply does not match, so the next route gets a chance.
Named routes
router.get("/posts/{post}", show).name("posts.show");
Names are what URL generation works from:
Url::instance().route("posts.show", &[("post", "hello-world")])?; // "/posts/hello-world"
Two routes with the same name is a boot error, not a last-one-wins. URL generation would otherwise be ambiguous and you would find out from a wrong link in production:
Error: two routes are both named `posts.show` — route names must be unique
for URL generation to be unambiguous
Groups
router.group(
GroupAttributes::new()
.prefix("api")
.name("api.")
.middleware(kernel::api()),
|router| {
router.get("/posts", index).name("posts.index"); // GET /api/posts, "api.posts.index"
router.group(
GroupAttributes::new().middleware(kernel::auth("api")),
|router| {
router.post("/posts", store).name("posts.store");
},
);
},
);
| Attribute | Effect |
|---|---|
.prefix(p) | prepends /p to each URI |
.name(n) | prepends n to each route name |
.middleware([…]) | prepends middleware, so outer runs first |
.where_param(k, c) | applies a constraint to every route in the group |
Groups nest. Each attribute is applied after any inner group has applied its own, so the outer prefix ends up outermost and the outer middleware runs first — which is what you would expect.
Resource routes
router.resource("posts", Arc::new(PostController));
The seven RESTful routes, in this order:
| Verb | URI | Action | Name |
|---|---|---|---|
GET | /posts | index | posts.index |
GET | /posts/create | create | posts.create |
POST | /posts | store | posts.store |
GET | /posts/{post} | show | posts.show |
GET | /posts/{post}/edit | edit | posts.edit |
PUT|PATCH | /posts/{post} | update | posts.update |
DELETE | /posts/{post} | destroy | posts.destroy |
The parameter name is the singular of the resource name, computed by
str::singular — posts → {post}.
router.api_resource("posts", Arc::new(PostController)); // the five, no form pages
router.resource_actions("posts", controller, &[ResourceAction::Index, ResourceAction::Show]);
Actions are always registered in the canonical order regardless of the order
you list them, because /posts/create must be declared before
/posts/{post} or the parameter swallows it.
See Controllers for writing the controller.
Redirects and fallbacks
router.redirect("/old", "/new").name("legacy");
router.fallback(|| async { Response::html(render_404()) });
redirect produces a 302. The fallback runs when nothing matched. Without
one, an unmatched path is a 404 with a plain message.
Declaration order decides matches
Routes are tried in declaration order and the first match wins. The table is not sorted by specificity.
router.get("/posts/create", create); // must come first
router.get("/posts/{post}", show); // or this swallows "create"
This is also why the sample project declares web routes before API routes: /
should be matched before any catch-all.
Method mismatch
A path that matches but a verb that does not produces a 405 carrying an
Allow header listing the verbs that would have worked. That header survives
error re-rendering — see
the lifecycle.
Middleware on routes
router.get("/admin", dashboard)
.middleware((kernel::auth("web"), Can::new("admin.access")));
router.get("/public", page).without_middleware::<ThrottleRequests>();
The middleware itself, not a name for it. There is no registry, nothing to
misspell, and .middleware(kernel::web()) is a function call your editor can
follow — see Middleware.
without_middleware::<T>() opts out by type, because that is the only
identity a value has. It drops every stage of that concrete type the group
applied.
Inspecting the table
cargo run -- route:list
cargo run -- route:list --json
METHOD URI NAME MIDDLEWARE
GET|HEAD / home
GET|HEAD /health health
POST /login login AddHeaders, ThrottleRequests
GET|HEAD /api/posts api.posts.index ThrottleRequests
The middleware column shows what is actually compiled into each route's
pipeline, every group flattened and every deferred stage built. The container
holds the same CompiledRouter the kernel serves, so this describes what is
really running.
Programmatically:
let router = app.resolve::<CompiledRouter>()?;
for summary in router.describe() {
println!("{:?} {} {:?}", summary.methods, summary.uri, summary.name);
}
Router::describe() answers the same question without compiling, which
matters more than it sounds. Compiling builds every middleware stage, and a
deferred stage that resolves a service fails if that service is not bound —
right before serving traffic, and wrong for a question as harmless as "what
routes are there?".
let mut router = Router::new();
routes::web(&mut router);
for summary in router.describe() { // no container, nothing to fail
println!("{} {}", summary.uri, summary.middleware.join(", "));
}
The middleware column is then the declared labels rather than the compiled
pipeline — which is what a documentation generator, a test asserting on the
table, or a route:list that must not depend on the container wanted anyway.
Route model binding
Rainier does not resolve models implicitly from parameter names. Do it in the handler, with the repository:
async fn show(request: Req) -> Result<Response> {
let slug = request.route_param("post").unwrap_or_default();
let posts = facade_application().resolve::<PostRepository>()?;
let post = posts.find_by_route_key(slug).await?
.ok_or_else(|| Error::not_found("No Post matches the given key."))?;
Ok(Response::json(&post))
}
find_by_route_key looks up by Model::route_key_name(), which defaults to
the primary key and is overridden to bind by slug or UUID. find_or_fail gives
you the 404 in one call. See Models.
Implicit binding would need the router to know about the database, which is exactly the sideways dependency the architecture exists to prevent.
Up close
This page is the framework’s docs/routing.md at 7b8cb15.
Found a mistake? Edit it on GitHub.