Skip to content

REST API Routing

Ultimo provides a fast, intuitive routing system for building traditional REST APIs. This page covers the standard HTTP routing with methods like GET, POST, PUT, and DELETE. For RPC-style APIs with TypeScript client generation, see the RPC documentation.

Basic Routes

Define routes for different HTTP methods:

use ultimo::prelude::*;
 
let mut app = Ultimo::new();
 
app.get("/users", |ctx| async move {
    ctx.json(json!({"users": ["Alice", "Bob"]})).await
});
 
app.post("/users", |ctx| async move {
    let body: CreateUser = ctx.req.json().await?;
    ctx.json(body).await
});
 
app.put("/users/:id", |ctx| async move {
    let id = ctx.req.param("id")?;
    ctx.json(json!({"id": id, "updated": true})).await
});
 
app.delete("/users/:id", |ctx| async move {
    let id = ctx.req.param("id")?;
    ctx.json(json!({"deleted": id})).await
});

Path Parameters

Capture dynamic segments from the URL:

// Single parameter
app.get("/users/:id", |ctx| async move {
    let id: u32 = ctx.req.param("id")?.parse()?;
    ctx.json(json!({"id": id})).await
});
 
// Multiple parameters
app.get("/users/:user_id/posts/:post_id", |ctx| async move {
    let user_id = ctx.req.param("user_id")?;
    let post_id = ctx.req.param("post_id")?;;
    ctx.json(json!({
        "user_id": user_id,
        "post_id": post_id
    })).await
});

Query Parameters

Access query string parameters:

// GET /search?q=rust&page=2
app.get("/search", |ctx| async move {
    let query = ctx.req.query("q")?;
    let page: u32 = ctx.req.query("page")?.parse().unwrap_or(1);
 
    ctx.json(json!({
        "query": query,
        "page": page,
        "results": search_results(query, page)
    })).await
});

Request Body

Parse JSON request bodies:

use serde::{Deserialize, Serialize};
 
#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
 
#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}
 
app.post("/users", |ctx| async move {
    let input: CreateUser = ctx.req.json().await?;
 
    let user = User {
        id: 1,
        name: input.name,
        email: input.email,
    };
 
    ctx.status(201);
    ctx.json(user).await
});

Headers

Access and set headers:

// Read headers
app.get("/check-auth", |ctx| async move {
    let auth = ctx.req.header("Authorization")?;
    ctx.json(json!({"auth": auth})).await
});
 
// Set response headers
app.get("/with-headers", |ctx| async move {
    ctx.header("X-Custom-Header", "value");
    ctx.header("Cache-Control", "max-age=3600");
    ctx.json(json!({"message": "Check the headers!"})).await
});

Response Types

Return different response types:

// JSON
app.get("/json", |ctx| async move {
    ctx.json(json!({"key": "value"})).await
});
 
// Plain text
app.get("/text", |ctx| async move {
    ctx.text("Hello, world!").await
});
 
// HTML
app.get("/html", |ctx| async move {
    ctx.html("<h1>Hello, Ultimo!</h1>").await
});
 
// Redirect
app.get("/old-path", |ctx| async move {
    ctx.redirect("/new-path").await
});
 
// Custom status code
app.get("/not-found", |ctx| async move {
    ctx.status(404);
    ctx.json(json!({"error": "Not found"})).await
});

Error Handling

Routes automatically handle errors with structured responses:

app.get("/users/:id", |ctx| async move {
    let id: u32 = ctx.req.param("id")?.parse()?;
 
    let user = find_user(id)
        .ok_or_else(|| UltimoError::NotFound("User not found".to_string()))?;
 
    ctx.json(user).await
});

Errors return JSON like:

{
  "error": "NotFound",
  "message": "User not found"
}

Route Organization

Organize routes with route groups:

let mut app = Ultimo::new();
 
// User routes
app.get("/api/users", list_users);
app.get("/api/users/:id", get_user);
app.post("/api/users", create_user);
app.put("/api/users/:id", update_user);
app.delete("/api/users/:id", delete_user);
 
// Post routes
app.get("/api/posts", list_posts);
app.get("/api/posts/:id", get_post);
app.post("/api/posts", create_post);
 
// Handler functions
async fn list_users(ctx: Context) -> ultimo::Result<Response> {
    ctx.json(json!({"users": []})).await
}
 
async fn get_user(ctx: Context) -> ultimo::Result<Response> {
    let id = ctx.req.param("id")?;
    ctx.json(json!({"id": id})).await
}

Best Practices

Use Type Conversions

// ✅ Good - parse with error handling
let id: u32 = ctx.req.param("id")?.parse()?;
 
// ❌ Bad - panics on invalid input
let id: u32 = ctx.req.param("id").unwrap().parse().unwrap();

Return Early on Errors

// ✅ Good - explicit error handling
app.get("/users/:id", |ctx| async move {
    let id: u32 = ctx.req.param("id")?.parse()?;
    let user = find_user(id)?;
    ctx.json(user).await
});

Use Structured Error Types

// ✅ Good - descriptive errors
if user.is_none() {
    return Err(UltimoError::NotFound(
        format!("User {} not found", id)
    ));
}