Routing
Define routes and handle parameters
Routes in Rapina map HTTP methods and URL patterns to handler functions. The router matches incoming requests and extracts path parameters automatically.
Basic Routing
Use the Router to define your API endpoints:
use rapina::prelude::*;
let router = Router::new()
.get("/", home)
.get("/about", about)
.post("/users", create_user)
.put("/users/:id", update_user)
.patch("/users/:id", patch_user)
.delete("/users/:id", delete_user);HTTP Methods
Rapina provides convenience methods for common HTTP verbs:
| Method | Description |
|---|---|
.get(pattern, handler) | GET requests (read) |
.post(pattern, handler) | POST requests (create) |
.put(pattern, handler) | PUT requests (full update) |
.patch(pattern, handler) | PATCH requests (partial update) |
.delete(pattern, handler) | DELETE requests (remove) |
.route(Method, pattern, handler) | Any HTTP method |
Using Macros
For cleaner syntax, use the route macros:
use rapina::prelude::*;
#[get("/")]
async fn home() -> &'static str {
"Welcome to Rapina!"
}
#[post("/users")]
async fn create_user(body: Json<CreateUser>) -> Result<Json<User>> {
// Create user...
Ok(Json(user))
}
#[put("/users/:id")]
async fn update_user(id: Path<u64>, body: Json<UpdateUser>) -> Result<Json<User>> {
// Full update...
Ok(Json(user))
}
#[patch("/users/:id")]
async fn patch_user(id: Path<u64>, body: Json<PatchUser>) -> Result<Json<User>> {
// Partial update...
Ok(Json(user))
}
#[delete("/users/:id")]
async fn delete_user(id: Path<u64>) -> StatusCode {
// Delete user...
StatusCode::NO_CONTENT
}Auto-Discovery
Instead of wiring every handler to a Router manually, call .discover() on the app builder. Rapina collects all functions annotated with #[get], #[post], #[put], #[patch], or #[delete] at link time and registers them automatically:
use rapina::prelude::*;
#[get("/")]
async fn home() -> &'static str {
"Welcome to Rapina!"
}
#[get("/users/:id")]
async fn get_user(id: Path<u64>) -> String {
format!("User ID: {}", *id)
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
Rapina::new()
.discover()
.listen("127.0.0.1:3000")
.await
}No Router, no .get("/users/:id", get_user) — the macros carry enough information to handle it.
Mixing Discovery and Manual Routes
.discover() and .router() are additive. You can use both when you need a manual route alongside discovered ones:
let extra = Router::new()
.route(Method::GET, "/custom", my_custom_handler);
Rapina::new()
.router(extra)
.discover()
.listen("127.0.0.1:3000")
.awaitPublic Routes with Discovery
When using .discover() with authentication enabled, routes annotated with #[public] are automatically registered as public — no .public_route() calls needed:
#[public]
#[post("/login")]
async fn login(body: Json<LoginRequest>, auth: State<AuthConfig>) -> Result<Json<TokenResponse>> {
// ...
}
Rapina::new()
.with_auth(auth_config)
.discover()
.listen("127.0.0.1:3000")
.awaitSee Authentication for details.
Route Groups
When using auto-discovery, you can nest routes under a common prefix with the group parameter:
#[get("/users", group = "/api")]
async fn list_users() -> Json<Vec<User>> {
// accessible at /api/users
}
#[get("/users/:id", group = "/api")]
async fn get_user(id: Path<u64>) -> Result<Json<User>> {
// accessible at /api/users/:id
}
#[post("/users", group = "/api")]
async fn create_user(body: Json<CreateUser>) -> (StatusCode, Json<User>) {
// accessible at /api/users
}The prefix is joined with the path at compile time — no runtime overhead. This replaces the need for Router::group() when using discovery.
group composes with other attributes:
#[public]
#[get("/health", group = "/api")]
async fn health() -> &'static str {
"ok"
}
// Public route at /api/healthPath Parameters
Extract dynamic values from URL segments using the :param syntax:
#[get("/users/:id")]
async fn get_user(id: Path<u64>) -> String {
format!("User ID: {}", *id)
}Parameter Types
Path parameters are automatically parsed to their target type:
#[get("/items/:id")]
async fn get_item(id: Path<u64>) -> Result<Json<Item>> {
// id is parsed as u64
let item = find_item(*id).await?;
Ok(Json(item))
}
#[get("/products/:slug")]
async fn get_product(slug: Path<String>) -> Result<Json<Product>> {
// slug is kept as String — deref coercion gives &str
let product = find_by_slug(&slug).await?;
Ok(Json(product))
}If parsing fails (e.g., non-numeric value for u64), Rapina returns a 400 Bad Request with error details.
Multiple Path Parameters
When a route has more than one dynamic segment, use a tuple inside Path<(T1, T2, ...)>. The parameters are bound in the order they appear in the URL:
// Two parameters
#[get("/orgs/:org_id/repos/:repo_id")]
async fn get_repo(Path((org_id, repo_id)): Path<(u64, u64)>) -> String {
format!("org={} repo={}", org_id, repo_id)
}
// Three parameters
#[get("/orgs/:org_id/repos/:repo_id/issues/:issue_id")]
async fn get_issue(Path((org_id, repo_id, issue_id)): Path<(u64, u64, u64)>) -> String {
format!("org={} repo={} issue={}", org_id, repo_id, issue_id)
}
// Mixed types
#[get("/projects/:project_id/tasks/:task_name")]
async fn get_task(Path((project_id, task_name)): Path<(u32, String)>) -> String {
format!("project={} task={}", project_id, task_name)
}Each segment is parsed independently — if any segment fails to parse, Rapina returns a 400 Bad Request.
Struct Parameters
For routes with many parameters, or when you want named access, use a struct with #[derive(Deserialize)]. Parameters are matched by field name:
#[derive(Deserialize)]
struct MemberParams {
org_id: u64,
team_id: u64,
member_id: u64,
}
#[get("/orgs/:org_id/teams/:team_id/members/:member_id")]
async fn get_member(Path(p): Path<MemberParams>) -> String {
format!("org={} team={} member={}", p.org_id, p.team_id, p.member_id)
}Route Matching
Routes are matched in the order they are added. More specific routes should be defined before generic ones:
let router = Router::new()
// Specific route first
.get("/users/me", get_current_user)
// Generic route second
.get("/users/:id", get_user);Trailing Slashes
Trailing slashes are treated as different routes:
/usersand/users/are not equivalent- Define both if you want to handle both patterns
let router = Router::new()
.get("/users", list_users)
.get("/users/", list_users); // Optional: handle trailing slashNamed Routes
For better introspection and documentation, use named routes:
let router = Router::new()
.get_named("/users", "list_users", list_users)
.post_named("/users", "create_user", create_user)
.get_named("/users/:id", "get_user", get_user);Named routes appear in the introspection endpoint at /__rapina/routes.
Route Introspection
Enable introspection to expose your API structure:
let app = Rapina::new()
.with_introspection(true)
.router(router);Then access GET /__rapina/routes to see all registered routes:
[
{
"method": "GET",
"path": "/users",
"name": "list_users"
},
{
"method": "POST",
"path": "/users",
"name": "create_user"
}
]Complete Example
use rapina::prelude::*;
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
#[get("/")]
async fn home() -> &'static str {
"Welcome to the User API"
}
#[get("/users")]
async fn list_users() -> Json<Vec<User>> {
Json(vec![
User { id: 1, name: "Alice".into(), email: "alice@example.com".into() },
User { id: 2, name: "Bob".into(), email: "bob@example.com".into() },
])
}
#[get("/users/:id")]
async fn get_user(id: Path<u64>) -> Result<Json<User>> {
let user = User {
id: *id,
name: "Alice".into(),
email: "alice@example.com".into(),
};
Ok(Json(user))
}
#[post("/users")]
async fn create_user(body: Json<CreateUser>) -> (StatusCode, Json<User>) {
let user = User {
id: 3,
name: body.name.clone(),
email: body.email.clone(),
};
(StatusCode::CREATED, Json(user))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
Rapina::new()
.with_introspection(true)
.discover()
.listen("127.0.0.1:3000")
.await
}