Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

API Reference

Complete API reference for the Ultimo web framework.

Core Types

Ultimo

The main application struct that manages routing, middleware, and server lifecycle.

use ultimo::prelude::*;
 
let mut app = Ultimo::new();

Methods

new() -> Self

Create a new Ultimo application with default middleware (includes X-Powered-By: Ultimo header).

let mut app = Ultimo::new();
new_without_defaults() -> Self

Create a new Ultimo application without any default middleware. Use this for full control over middleware configuration.

let mut app = Ultimo::new_without_defaults();
Routing Methods

Register route handlers for different HTTP methods:

// GET route
app.get("/path", handler);
 
// POST route
app.post("/path", handler);
 
// PUT route
app.put("/path", handler);
 
// DELETE route
app.delete("/path", handler);
 
// PATCH route
app.patch("/path", handler);
use_middleware(&mut self, middleware: impl IntoMiddleware) -> &mut Self

Add middleware to the application. Middleware executes in the order it's added.

app.use_middleware(ultimo::middleware::builtin::logger());
app.use_middleware(ultimo::middleware::builtin::cors());
listen(&mut self, addr: &str) -> Result<()>

Start the HTTP server on the specified address.

app.listen("127.0.0.1:3000").await?;
max_body_size(&mut self, bytes: usize) -> &mut Self

Reject requests whose body exceeds bytes with 413 Payload Too Large (the oversized body is never fully buffered). No limit by default.

app.max_body_size(2 * 1024 * 1024); // 2 MB
trust_proxy(&mut self, trust: bool) -> &mut Self

Trust X-Forwarded-For / Forwarded headers for Context::client_ip. Only enable behind a trusted proxy (these headers are client-spoofable). Defaults to false.

app.trust_proxy(true);
serve_static(&mut self, prefix: &str, dir: &str) (requires static-files feature)

Register a GET {prefix}/* route that reads files from dir on disk. Sets Content-Type, ETag, and Content-Length automatically; returns 304 Not Modified when If-None-Match matches. Rejects path traversal attempts.

// GET /assets/style.css → reads ./public/style.css
app.serve_static("/assets", "./public");

See Static Files.

serve_spa(&mut self, dir: &str, fallback: &str) (requires static-files feature)

Configure an SPA fallback: any GET request that doesn't match a registered route returns the specified file from dir. POST / PUT / DELETE etc. 404s are not intercepted.

app.serve_spa("./dist", "index.html");

See Static Files.

serve_docs(&mut self, path: &str, spec: OpenApiSpec)

Serve interactive API documentation (Swagger UI) at the given path. Registers two routes: GET {path} (Swagger UI page) and GET {path}/openapi.json (the spec). One-liner equivalent of FastAPI's /docs.

use ultimo::openapi::OpenApiBuilder;
 
let spec = OpenApiBuilder::new()
    .title("My API")
    .version("1.0.0")
    .build();
app.serve_docs("/docs", spec);

See OpenAPI.

oneshot(&self, req: hyper::Request<Full<Bytes>>) -> Response

Dispatch a fully-buffered request through the app in-process (no socket). The seam the testing utilities build on; handy for embedding.

Database Methods (requires feature flags)

With SQLx (requires sqlx feature):

app.with_sqlx(pool);

With Diesel (requires diesel feature):

app.with_diesel(pool);
Session middleware (requires session feature)
use ultimo::session::{session, MemoryStore, SessionConfig};
app.use_middleware(session(MemoryStore::new(), SessionConfig::default()));

See Sessions.


Context & Request

Context

The request context provides access to the incoming request and allows building responses.

async fn handler(ctx: Context) -> Result<Response> {
    // Access request data through ctx.req
    // Build responses with ctx methods
    ctx.json(json!({"message": "Hello"})).await
}

Response Methods

json<T: Serialize>(&self, value: T) -> Result<Response>

Return a JSON response with Content-Type: application/json.

ctx.json(json!({"key": "value"})).await
text(&self, body: impl Into<String>) -> Result<Response>

Return a plain text response with Content-Type: text/plain.

ctx.text("Hello, World!").await
html(&self, body: impl Into<String>) -> Result<Response>

Return an HTML response with Content-Type: text/html.

ctx.html("<h1>Hello</h1>").await
redirect(&self, location: &str) -> Result<Response>

Return a 302 redirect response.

ctx.redirect("/new-path").await
status(&self, code: u16)

Set the response status code. Can be chained with other response methods.

ctx.status(201);
ctx.json(user).await
header(&self, key: &str, value: &str)

Set a response header. Can be chained with other response methods.

ctx.header("X-Custom", "value");
ctx.json(data).await

Cookies

use ultimo::cookie::{Cookie, SameSite};
 
// Read a request cookie
let theme = ctx.cookie("theme");          // Option<String>
let all = ctx.cookies();                  // HashMap<String, String>
 
// Set / remove a response cookie
ctx.set_cookie(Cookie::new("theme", "dark").http_only(true).same_site(SameSite::Lax)).await?;
ctx.remove_cookie("theme").await?;

Client IP

client_ip(&self) -> Option<IpAddr> · peer_addr(&self) -> Option<SocketAddr>

client_ip() is the best-effort originating client — honors X-Forwarded-For / Forwarded only when app.trust_proxy(true), else the connection peer. peer_addr() is the raw connection peer. See Security.

let ip = ctx.client_ip();

Session (requires session feature)

session(&self) -> Session

The current session (panics if the session middleware isn't installed).

ctx.session().await.set("user_id", &42u64).await?;
let id: Option<u64> = ctx.session().await.get("user_id").await?;

See Sessions.

JWT claims (requires jwt feature)

jwt_claims(&self) -> Option<serde_json::Value>

The validated JWT claims for this request (None if no valid token was presented).

jwt<T: DeserializeOwned>(&self) -> Result<T>

Deserialize the validated claims into a typed struct (errors if absent or shape mismatch).

let claims = ctx.jwt_claims().await;          // Option<serde_json::Value>
let mine: MyClaims = ctx.jwt::<MyClaims>().await?;

See JWT Authentication.

API-key identity (requires api-key feature)

api_key(&self) -> Option<ApiKeyIdentity>

The API-key identity for this request (id + scopes), or None if no valid key was presented. See API-Key Authentication.

if let Some(identity) = ctx.api_key().await {
    // identity.id, identity.scopes
}

Authorization guards (requires jwt or api-key feature)

Both auth middlewares populate a normalized auth::Principal { id, scopes }, which these guards read. They compose with ? and short-circuit with 401/403.

principal(&self) -> Option<Principal>

The normalized authenticated caller, if any.

require_auth(&self) -> Result<Principal>

The Principal, or 401 Unauthorized.

require_scope(&self, scope: &str) -> Result<()>

401 if unauthenticated, 403 Forbidden if the scope is missing.

require_any_scope(&self, scopes: &[&str]) -> Result<()> · require_all_scopes(&self, scopes: &[&str]) -> Result<()>

403 unless (respectively) at least one / all of the scopes are present.

app.get("/admin", |ctx: Context| async move {
    ctx.require_scope("admin").await?;
    ctx.json(json!({ "ok": true })).await
});

See Authorization (Guards).

Request

Access request data through ctx.req.

Path Parameters

param(&self, name: &str) -> Result<&str>

Get a path parameter by name.

// Route: /users/:id
let id = ctx.req.param("id")?;
params(&self) -> &Params

Get all path parameters as a HashMap.

let all_params = ctx.req.params();

Query Parameters

query(&self, name: &str) -> Option<String>

Get a single query parameter.

// GET /search?q=rust
let query = ctx.req.query("q");
queries(&self) -> HashMap<String, Vec<String>>

Get all query parameters. Returns a map of parameter names to their values (supports multiple values per parameter).

let all_queries = ctx.req.queries();

Headers

header(&self, name: &str) -> Option<String>

Get a request header value.

let auth = ctx.req.header("Authorization");
headers(&self) -> &HeaderMap

Get all request headers.

let all_headers = ctx.req.headers();

Body

json<T: DeserializeOwned>(&self) -> Result<T>

Parse the request body as JSON.

let body: CreateUser = ctx.req.json().await?;
text(&self) -> Result<String>

Get the request body as a string.

let body = ctx.req.text().await?;
bytes(&self) -> Result<Bytes> · raw_body(&self) -> Result<Bytes>

Get the request body as raw bytes. The body is buffered and cached, so json/text/bytes/raw_body may be called multiple times.

let body = ctx.req.bytes().await?;

Method & URI

method(&self) -> &Method

Get the HTTP method (GET, POST, etc.).

let method = ctx.req.method();
uri(&self) -> &Uri

Get the request URI.

let uri = ctx.req.uri();
let path = uri.path();

RPC System

RpcRegistry

Registry for RPC procedures with automatic TypeScript generation.

Creating a Registry

use ultimo::rpc::{RpcRegistry, RpcMode};
 
// JSON-RPC mode (default)
let rpc = RpcRegistry::new();
 
// REST mode
let rpc = RpcRegistry::new_with_mode(RpcMode::Rest);

Registering Procedures

query<F, I, O>(&self, name: &str, handler: F)

Register a query procedure (idempotent, read-only operations).

  • REST mode: Maps to GET endpoint
  • JSON-RPC mode: Part of RPC protocol
rpc.query("getUser", |input: GetUserInput| async move {
    Ok(User { /* ... */ })
});
mutation<F, I, O>(&self, name: &str, handler: F)

Register a mutation procedure (non-idempotent operations that modify state).

  • REST mode: Maps to POST endpoint
  • JSON-RPC mode: Part of RPC protocol
rpc.mutation("createUser", |input: CreateUserInput| async move {
    Ok(User { /* ... */ })
});
register_with_types<F, I, O>(&self, name: &str, handler: F, input_type: String, output_type: String)

Register a procedure with explicit TypeScript type annotations.

rpc.register_with_types(
    "getUser",
    |input: GetUserInput| async move { Ok(User { /* ... */ }) },
    "{ id: number }".to_string(),
    "User".to_string(),
);

TypeScript Generation

generate_client_file(&self, path: &str) -> Result<()>

Generate a TypeScript client file with type-safe methods.

rpc.generate_client_file("../frontend/src/lib/client.ts")?;
generate_client(&self) -> String

Generate TypeScript client code as a string.

let ts_code = rpc.generate_client();

RPC Modes

pub enum RpcMode {
    /// Each procedure becomes its own HTTP endpoint
    /// - Queries: GET /api/{name}
    /// - Mutations: POST /api/{name}
    Rest,
 
    /// All procedures use a single POST endpoint
    /// POST /rpc with JSON-RPC 2.0 protocol
    JsonRpc,
}

JSON-RPC 2.0 Protocol

handle_request(&self, body: &[u8]) -> JsonRpcOutput

Full JSON-RPC 2.0 dispatch. Parses the body, validates, dispatches (concurrently for batch), and returns spec-compliant responses.

let body = ctx.req.bytes().await?;
let output = registry.handle_request(&body).await;
match output.into_body() {
    Some(bytes) => {
        let value: serde_json::Value = serde_json::from_slice(&bytes)?;
        ctx.json(value).await
    }
    None => {
        ctx.status(204).await;
        ctx.text("").await
    }
}

Supports:

  • Single requests: {"jsonrpc": "2.0", "method": "...", "params": {...}, "id": 1}
  • Batch requests: Array of requests, executed concurrently
  • Notifications: Requests without id produce no response
  • Standard error codes: -32700 (parse), -32601 (method not found), etc.
  • Backward compatible: Legacy {method, params} format still works
JSON-RPC 2.0 Types
use ultimo::rpc::{JsonRpcRequest, JsonRpcResponse, JsonRpcErrorResponse, JsonRpcError, JsonRpcOutput, error_code};

Middleware

Built-in Middleware

logger()

Log all incoming requests with method, path, and response time.

app.use_middleware(ultimo::middleware::builtin::logger());

cors()

Enable CORS with permissive defaults (allows all origins, methods, and headers).

app.use_middleware(ultimo::middleware::builtin::cors());

powered_by()

Add X-Powered-By: Ultimo header to all responses. Included by default in Ultimo::new().

app.use_middleware(ultimo::middleware::builtin::powered_by());

security_headers() / SecurityHeaders

Secure-by-default response headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy; CSP opt-in). See Security.

app.use_middleware(ultimo::middleware::builtin::security_headers());
// or customized:
app.use_middleware(
    ultimo::middleware::builtin::SecurityHeaders::new()
        .csp("default-src 'self'")
        .build(),
);

IpFilter — IP allow/deny (CIDR)

Filters requests by client IP against an allow-list or deny-list of CIDR networks. Respects X-Forwarded-For when trust_proxy is enabled.

use ultimo::middleware::builtin::IpFilter;
 
// Allow only private networks:
app.use_middleware(IpFilter::allow(&[
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",
    "127.0.0.1",
]).build());
 
// Or deny specific ranges:
app.use_middleware(IpFilter::deny(&["203.0.113.0/24"]).build());

rate_limiter() / RateLimiter — Rate limiting (token bucket)

Per-IP, per-header, or global rate limiting. Returns 429 Too Many Requests with Retry-After header when the limit is exceeded.

use ultimo::middleware::builtin::{rate_limiter, RateLimiter, RateLimitKey};
 
// Default: 100 requests per 60 seconds, keyed by client IP
app.use_middleware(rate_limiter());
 
// Custom: 10 requests per second, keyed by API key header
app.use_middleware(
    RateLimiter::new(10, 1)
        .key(RateLimitKey::Header("X-API-Key".into()))
        .build()
);
 
// Global rate limit (all requests share one bucket)
app.use_middleware(
    RateLimiter::new(1000, 60)
        .key(RateLimitKey::Global)
        .build()
);

compression() / Compression (requires compression feature)

Automatic response compression — brotli preferred, gzip fallback. Pure Rust (no C deps). Skips binary MIME types, already-encoded responses, and bodies below min_size (default 1 024 bytes). Always sets Vary: Accept-Encoding.

use ultimo::middleware::builtin::{compression, Compression};
 
// Defaults: brotli + gzip, min_size = 1024
app.use_middleware(compression());
 
// Customized:
app.use_middleware(
    Compression::new()
        .gzip()
        .brotli()
        .min_size(512)
        .build(),
);

See Compression.

csrf() / Csrf (requires csrf feature)

Double-submit-cookie CSRF protection (constant-time compare; unsafe methods must echo the cookie token in a header). See Security.

app.use_middleware(ultimo::csrf::csrf());
// or: ultimo::csrf::Csrf::new().header_name("x-csrf-token").build()

Jwt (requires jwt feature)

HS256 JWT auth — verifies signed bearer/cookie tokens, attaches claims to the Context, and issues tokens. The algorithm is pinned (alg: none rejected) and exp is validated by default. Builder: hs256(secret), issuer(s), audience(s), leeway(secs), from_bearer(), from_cookie(name), optional(), sign(&claims), build(). See JWT Authentication.

use ultimo::auth::jwt::Jwt;
let jwt = Jwt::hs256(b"super-secret-key");
app.use_middleware(jwt.clone().build());
let token = jwt.sign(&claims)?;

ApiKey (requires api-key feature)

API-key auth — validates a presented key against a pluggable ApiKeyStore, resolving it to an ApiKeyIdentity { id, scopes } attached to the Context; 401 on missing/invalid. The built-in StaticKeys store hashes keys with SHA-256 and compares in constant time. Builder: new(store), header_name(name), from_query(name), optional(), build(). See API-Key Authentication.

use ultimo::auth::api_key::{ApiKey, StaticKeys};
let store = StaticKeys::new().insert("key-abc", "service-a");
app.use_middleware(ApiKey::new(store).build()); // header "x-api-key"

Custom Middleware

Create custom middleware by implementing the middleware function signature:

use ultimo::middleware::Next;
 
fn my_middleware() -> impl IntoMiddleware {
    |ctx: Context, next: Next| async move {
        // Before handler
        println!("Before: {}", ctx.req.uri());
 
        // Call next middleware/handler
        let mut response = next(ctx).await?;
 
        // After handler
        response.headers.insert("X-Custom", "value".parse().unwrap());
 
        Ok(response)
    }
}
 
app.use_middleware(my_middleware());

OpenAPI

OpenApiSpec

Generate OpenAPI 3.0 specifications for your API.

use ultimo::openapi::OpenApiSpec;
 
let mut spec = OpenApiSpec::new("My API", "1.0.0");
 
spec.add_endpoint(
    "/users",
    "get",
    "Get all users",
    Some("UserListInput"),
    "UserList",
);
 
let json = spec.to_json()?;

Methods

new(title: &str, version: &str) -> Self

Create a new OpenAPI specification.

add_endpoint(&mut self, path: &str, method: &str, summary: &str, request_schema: Option<&str>, response_schema: &str)

Add an API endpoint to the specification.

add_schema(&mut self, name: &str, schema: Value)

Add a JSON schema definition.

to_json(&self) -> Result<String>

Serialize the specification to JSON.


Validation

validate<T: Validate>(value: &T) -> Result<()>

Validate a value using the validator crate rules.

use ultimo::prelude::*;
use validator::Validate;
 
#[derive(Deserialize, Validate)]
struct CreateUser {
    #[validate(length(min = 3, max = 50))]
    name: String,
 
    #[validate(email)]
    email: String,
}
 
async fn create_user(ctx: Context) -> Result<Response> {
    let input: CreateUser = ctx.req.json().await?;
 
    // Validate input
    validate(&input)?;
 
    // Process valid data
    ctx.json(json!({"success": true})).await
}

Validation errors return a 400 Bad Request with detailed error messages.


Error Handling

UltimoError

The main error type for the framework.

pub enum UltimoError {
    BadRequest(String),
    NotFound(String),
    Unauthorized(String),
    Forbidden(String),
    Internal(String),
    ValidationError(String),
    DatabaseError(String),
}

Usage

// Return errors from handlers
if user.is_none() {
    return Err(UltimoError::NotFound("User not found".to_string()));
}
 
// Or use the ? operator
let id: u32 = ctx.req.param("id")?.parse()?;

Errors automatically serialize to JSON:

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

Errors that wrap internal machinery (I/O errors, hyper/HTTP errors) return a generic "internal server error" message to the client instead of the raw underlying error text — OS error strings can contain filesystem paths and other details that shouldn't reach callers. The full error is still available server-side via Display/Debug for logging. Client-input errors (JSON parse failures, validation errors) are unaffected and still return their specific message.


Database Integration

SQLx Support (requires sqlx feature)

Ultimo's SQLx integration re-exposes sqlx types (sqlx::Pool, PgPoolOptions, …) directly through its own public API, so your sqlx dependency version must match the major version Ultimo was built against (currently 0.8). Bumping Ultimo's sqlx requirement is treated as a breaking change for exactly this reason.

use sqlx::postgres::PgPoolOptions;
 
let pool = PgPoolOptions::new()
    .max_connections(5)
    .connect("postgres://localhost/mydb")
    .await?;
 
app.with_sqlx(pool);

Access in handlers:

async fn get_user(ctx: Context) -> Result<Response> {
    let db = ctx.db()?;
    let pool = db.sqlx_pool::<sqlx::Postgres>()?;
 
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(1)
        .fetch_one(pool)
        .await?;
 
    ctx.json(user).await
}

Diesel Support (requires diesel feature)

use diesel::r2d2::{self, ConnectionManager};
use diesel::PgConnection;
 
let manager = ConnectionManager::<PgConnection>::new("postgres://localhost/mydb");
let pool = r2d2::Pool::builder().build(manager)?;
 
app.with_diesel(pool);

Access in handlers:

async fn get_user(ctx: Context) -> Result<Response> {
    let db = ctx.db()?;
    let pool = db.diesel_pool::<PgConnection>()?;
 
    let conn = pool.get()?;
    let user = users::table.find(1).first::<User>(&conn)?;
 
    ctx.json(user).await
}

Types & Prelude

Prelude Module

Import everything you need in one line:

use ultimo::prelude::*;
 
// Includes:
// - Ultimo (app)
// - Context (request context)
// - Result, UltimoError (error types)
// - RpcRegistry, RpcRequest, RpcResponse (RPC system)
// - middleware (middleware module)
// - validate (validation function)
// - serde::{Deserialize, Serialize}
// - serde_json::json
// - validator::Validate

Response Type

pub struct Response {
    pub status: StatusCode,
    pub headers: HeaderMap,
    pub body: Full<Bytes>,
}

Responses are typically created through Context methods, but you can construct them manually if needed.


Feature Flags

Enable optional functionality through Cargo features:

[dependencies]
ultimo = { version = "0.6", features = ["sqlx-postgres"] }

Available Features

All features are off by default (default = []).

  • websocket - RFC 6455 WebSocket support (zero extra dependencies)
  • session - Cookie-based session management (Sessions)
  • csrf - Double-submit-cookie CSRF protection (Security)
  • jwt - HS256 JWT authentication middleware (JWT Authentication)
  • api-key - API-key authentication with a pluggable store (API-Key Authentication)
  • static-files - Static file serving + SPA fallback (serve_static, serve_spa) (Static Files)
  • compression - Automatic gzip/brotli response compression (compression(), Compression) (Compression)
  • client-gen - Derive RPC client TypeScript types from Rust types via ts-rs; query/mutation infer types from #[derive(TS)] structs (string-typed query_with_types/mutation_with_types remain as escape hatches)
  • testing - Testing utilities: TestClient, assertions, helpers (Testing)
  • test-helpers - WebSocket test helpers (for integration tests)
  • sqlx-postgres / sqlx-mysql / sqlx-sqlite - SQLx integration per backend
  • diesel-postgres / diesel-mysql / diesel-sqlite - Diesel integration per backend
ultimo = { version = "0.6", features = ["websocket", "session", "sqlx-postgres"] }

Next Steps