ESC
Type to search...

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:

MethodDescription
.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")
    .await

Public 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")
    .await

See 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/health

Path 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:

  • /users and /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 slash

Named 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
}