# Ultimo > Modern Rust web framework with automatic TypeScript generation ## API-Key Authentication For server-to-server and programmatic clients, Ultimo ships an API-key middleware that validates a presented key against a **pluggable store**, resolves it to an **identity** (id + scopes), and attaches that identity to the request `Context`. Missing or invalid keys are rejected with **401**. It's secure-by-default: the built-in store keeps only **SHA-256 digests** of keys (never the raw secret) and compares them in **constant time**. :::tip API keys identify *machines*, not browsers. Don't embed an API key in front-end JavaScript — anyone can read it. For browser logins use [Sessions](/sessions) or [JWT](/jwt). The examples here use `curl` / server-side clients on purpose. ::: ### Enable the feature ```toml [dependencies] ultimo = { version = "0.6", features = ["api-key"] } ``` ### Quick start ```rust use ultimo::auth::api_key::{ApiKey, StaticKeys}; use ultimo::prelude::*; #[tokio::main] async fn main() -> Result<()> { // Load keys from the environment in production — never hardcode them. let store = StaticKeys::new() .insert("key-abc", "service-a") .with_scopes("key-def", "service-b", ["read", "write"]); let mut app = Ultimo::new_without_defaults(); app.use_middleware(ApiKey::new(store).build()); // header "x-api-key" by default app.get("/me", |ctx: Context| async move { match ctx.api_key().await { Some(id) => ctx.json(serde_json::json!({ "id": id.id, "scopes": id.scopes })).await, None => ctx.json(serde_json::json!({ "id": null })).await, } }); app.listen("127.0.0.1:3000").await } ``` Call it: ```bash # Accepted curl -H "x-api-key: key-def" http://127.0.0.1:3000/me # → {"id":"service-b","scopes":["read","write"]} # Rejected curl -i http://127.0.0.1:3000/me # → 401 Unauthorized curl -i -H "x-api-key: wrong" http://127.0.0.1:3000/me # → 401 Unauthorized ``` ### Reading the identity After the middleware accepts a key, the resolved identity is on the `Context`: ```rust if let Some(identity) = ctx.api_key().await { // identity.id -> the key's label (e.g. "service-b") — never the raw key // identity.scopes -> Vec, for authorization decisions } ``` ### Configuration | Method | Effect | | -------------------- | ----------------------------------------------------------------- | | `ApiKey::new(store)` | Validate keys against `store`, read from the `x-api-key` header. | | `.header_name(name)` | Read the key from a different header. | | `.from_query(name)` | Read the key from a query-string parameter instead. | | `.optional()` | Don't 401 on missing/invalid keys — pass through unauthenticated. | | `.build()` | Build the verification middleware. | ### Database-backed keys `StaticKeys` is for in-memory configuration. For keys stored in a database (with owners, scopes, expiry, revocation), implement the `ApiKeyStore` trait: ```rust use ultimo::auth::api_key::{ApiKeyStore, ApiKeyIdentity}; struct DbKeys { /* pool, etc. */ } #[async_trait::async_trait] impl ApiKeyStore for DbKeys { async fn validate(&self, presented_key: &str) -> Option { // Hash the presented key and look it up BY HASH — never store or query // the raw key. Return the identity (id + scopes) on a match. let hash = sha256_hex(presented_key); self.lookup_by_hash(&hash).await } } app.use_middleware(ApiKey::new(DbKeys { /* … */ }).build()); ``` ### Security notes * **Hash at rest, compare in constant time.** The built-in `StaticKeys` store hashes keys with SHA-256 at construction and never retains the raw value; comparison runs over every entry without early-return, so neither the match position nor whether a key matched leaks via timing. Custom stores should look up **by hash**, not the raw key. * **SHA-256, not bcrypt/argon2.** API keys are high-entropy random secrets, not passwords — a fast cryptographic hash is the correct and standard choice. Password KDFs (bcrypt/argon2) exist to slow down brute-forcing *low-entropy* secrets and would only waste CPU here. * **Generate strong keys** (≥128 bits of entropy from a CSPRNG) and transmit them only over HTTPS. Treat them like passwords in transit. * **Never put an API key in a browser.** It's a server-to-server credential. For user-facing auth use [Sessions](/sessions) or [JWT](/jwt). * **Scope and rotate.** Give each key the least scopes it needs (`with_scopes`), and design your store so keys can be revoked/rotated without redeploying. ## API Reference Complete API reference for the Ultimo web framework. ### Core Types #### `Ultimo` The main application struct that manages routing, middleware, and server lifecycle. ```rust 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). ```rust 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. ```rust let mut app = Ultimo::new_without_defaults(); ``` ##### Routing Methods Register route handlers for different HTTP methods: ```rust // 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. ```rust 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. ```rust 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. ```rust 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`](#client-ip). **Only enable behind a trusted proxy** (these headers are client-spoofable). Defaults to `false`. ```rust 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. ```rust // GET /assets/style.css → reads ./public/style.css app.serve_static("/assets", "./public"); ``` See [Static Files](/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. ```rust app.serve_spa("./dist", "index.html"); ``` See [Static Files](/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`. ```rust use ultimo::openapi::OpenApiBuilder; let spec = OpenApiBuilder::new() .title("My API") .version("1.0.0") .build(); app.serve_docs("/docs", spec); ``` See [OpenAPI](/openapi). ##### `oneshot(&self, req: hyper::Request>) -> Response` Dispatch a fully-buffered request through the app **in-process** (no socket). The seam the [testing utilities](/testing) build on; handy for embedding. ##### Database Methods (requires feature flags) **With SQLx** (requires `sqlx` feature): ```rust app.with_sqlx(pool); ``` **With Diesel** (requires `diesel` feature): ```rust app.with_diesel(pool); ``` ##### Session middleware (requires `session` feature) ```rust use ultimo::session::{session, MemoryStore, SessionConfig}; app.use_middleware(session(MemoryStore::new(), SessionConfig::default())); ``` See [Sessions](/sessions). *** ### Context & Request #### `Context` The request context provides access to the incoming request and allows building responses. ```rust async fn handler(ctx: Context) -> Result { // Access request data through ctx.req // Build responses with ctx methods ctx.json(json!({"message": "Hello"})).await } ``` ##### Response Methods ##### `json(&self, value: T) -> Result` Return a JSON response with `Content-Type: application/json`. ```rust ctx.json(json!({"key": "value"})).await ``` ##### `text(&self, body: impl Into) -> Result` Return a plain text response with `Content-Type: text/plain`. ```rust ctx.text("Hello, World!").await ``` ##### `html(&self, body: impl Into) -> Result` Return an HTML response with `Content-Type: text/html`. ```rust ctx.html("

Hello

").await ``` ##### `redirect(&self, location: &str) -> Result` Return a 302 redirect response. ```rust ctx.redirect("/new-path").await ``` ##### `status(&self, code: u16)` Set the response status code. Can be chained with other response methods. ```rust ctx.status(201); ctx.json(user).await ``` ##### `header(&self, key: &str, value: &str)` Set a response header. Can be chained with other response methods. ```rust ctx.header("X-Custom", "value"); ctx.json(data).await ``` ##### Cookies ```rust use ultimo::cookie::{Cookie, SameSite}; // Read a request cookie let theme = ctx.cookie("theme"); // Option let all = ctx.cookies(); // HashMap // 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` · `peer_addr(&self) -> Option` `client_ip()` is the best-effort originating client — honors `X-Forwarded-For` / `Forwarded` only when [`app.trust_proxy(true)`](#trust-proxy-mut-self-trust-bool---mut-self), else the connection peer. `peer_addr()` is the raw connection peer. See [Security](/security). ```rust let ip = ctx.client_ip(); ``` ##### Session (requires `session` feature) ##### `session(&self) -> Session` The current session (panics if the session middleware isn't installed). ```rust ctx.session().await.set("user_id", &42u64).await?; let id: Option = ctx.session().await.get("user_id").await?; ``` See [Sessions](/sessions). ##### JWT claims (requires `jwt` feature) ##### `jwt_claims(&self) -> Option` The validated JWT claims for this request (`None` if no valid token was presented). ##### `jwt(&self) -> Result` Deserialize the validated claims into a typed struct (errors if absent or shape mismatch). ```rust let claims = ctx.jwt_claims().await; // Option let mine: MyClaims = ctx.jwt::().await?; ``` See [JWT Authentication](/jwt). ##### API-key identity (requires `api-key` feature) ##### `api_key(&self) -> Option` The API-key identity for this request (`id` + `scopes`), or `None` if no valid key was presented. See [API-Key Authentication](/api-keys). ```rust 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` The normalized authenticated caller, if any. ##### `require_auth(&self) -> Result` 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. ```rust app.get("/admin", |ctx: Context| async move { ctx.require_scope("admin").await?; ctx.json(json!({ "ok": true })).await }); ``` See [Authorization (Guards)](/authorization). #### `Request` Access request data through `ctx.req`. ##### Path Parameters ##### `param(&self, name: &str) -> Result<&str>` Get a path parameter by name. ```rust // Route: /users/:id let id = ctx.req.param("id")?; ``` ##### `params(&self) -> &Params` Get all path parameters as a HashMap. ```rust let all_params = ctx.req.params(); ``` ##### Query Parameters ##### `query(&self, name: &str) -> Option` Get a single query parameter. ```rust // GET /search?q=rust let query = ctx.req.query("q"); ``` ##### `queries(&self) -> HashMap>` Get all query parameters. Returns a map of parameter names to their values (supports multiple values per parameter). ```rust let all_queries = ctx.req.queries(); ``` ##### Headers ##### `header(&self, name: &str) -> Option` Get a request header value. ```rust let auth = ctx.req.header("Authorization"); ``` ##### `headers(&self) -> &HeaderMap` Get all request headers. ```rust let all_headers = ctx.req.headers(); ``` ##### Body ##### `json(&self) -> Result` Parse the request body as JSON. ```rust let body: CreateUser = ctx.req.json().await?; ``` ##### `text(&self) -> Result` Get the request body as a string. ```rust let body = ctx.req.text().await?; ``` ##### `bytes(&self) -> Result` · `raw_body(&self) -> Result` Get the request body as raw bytes. The body is buffered and cached, so `json`/`text`/`bytes`/`raw_body` may be called **multiple times**. ```rust let body = ctx.req.bytes().await?; ``` ##### Method & URI ##### `method(&self) -> &Method` Get the HTTP method (GET, POST, etc.). ```rust let method = ctx.req.method(); ``` ##### `uri(&self) -> &Uri` Get the request URI. ```rust let uri = ctx.req.uri(); let path = uri.path(); ``` *** ### RPC System #### `RpcRegistry` Registry for RPC procedures with automatic TypeScript generation. ##### Creating a Registry ```rust 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(&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 ```rust rpc.query("getUser", |input: GetUserInput| async move { Ok(User { /* ... */ }) }); ``` ##### `mutation(&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 ```rust rpc.mutation("createUser", |input: CreateUserInput| async move { Ok(User { /* ... */ }) }); ``` ##### `register_with_types(&self, name: &str, handler: F, input_type: String, output_type: String)` Register a procedure with explicit TypeScript type annotations. ```rust 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. ```rust rpc.generate_client_file("../frontend/src/lib/client.ts")?; ``` ##### `generate_client(&self) -> String` Generate TypeScript client code as a string. ```rust let ts_code = rpc.generate_client(); ``` ##### RPC Modes ```rust 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. ```rust 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 ```rust 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. ```rust app.use_middleware(ultimo::middleware::builtin::logger()); ``` ##### `cors()` Enable CORS with permissive defaults (allows all origins, methods, and headers). ```rust app.use_middleware(ultimo::middleware::builtin::cors()); ``` ##### `powered_by()` Add `X-Powered-By: Ultimo` header to all responses. Included by default in `Ultimo::new()`. ```rust 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](/security). ```rust 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. ```rust 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. ```rust 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`. ```rust 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](/middleware#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](/security). ```rust 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](/jwt). ```rust 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](/api-keys). ```rust 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: ```rust 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. ```rust 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` Serialize the specification to JSON. *** ### Validation #### `validate(value: &T) -> Result<()>` Validate a value using the `validator` crate rules. ```rust 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 { 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. ```rust pub enum UltimoError { BadRequest(String), NotFound(String), Unauthorized(String), Forbidden(String), Internal(String), ValidationError(String), DatabaseError(String), } ``` ##### Usage ```rust // 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: ```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. ```rust use sqlx::postgres::PgPoolOptions; let pool = PgPoolOptions::new() .max_connections(5) .connect("postgres://localhost/mydb") .await?; app.with_sqlx(pool); ``` Access in handlers: ```rust async fn get_user(ctx: Context) -> Result { let db = ctx.db()?; let pool = db.sqlx_pool::()?; 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) ```rust use diesel::r2d2::{self, ConnectionManager}; use diesel::PgConnection; let manager = ConnectionManager::::new("postgres://localhost/mydb"); let pool = r2d2::Pool::builder().build(manager)?; app.with_diesel(pool); ``` Access in handlers: ```rust async fn get_user(ctx: Context) -> Result { let db = ctx.db()?; let pool = db.diesel_pool::()?; let conn = pool.get()?; let user = users::table.find(1).first::(&conn)?; ctx.json(user).await } ``` *** ### Types & Prelude #### Prelude Module Import everything you need in one line: ```rust 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 ```rust pub struct Response { pub status: StatusCode, pub headers: HeaderMap, pub body: Full, } ``` Responses are typically created through `Context` methods, but you can construct them manually if needed. *** ### Feature Flags Enable optional functionality through Cargo features: ```toml [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](/sessions)) * `csrf` - Double-submit-cookie CSRF protection ([Security](/security)) * `jwt` - HS256 JWT authentication middleware ([JWT Authentication](/jwt)) * `api-key` - API-key authentication with a pluggable store ([API-Key Authentication](/api-keys)) * `static-files` - Static file serving + SPA fallback (`serve_static`, `serve_spa`) ([Static Files](/static-files)) * `compression` - Automatic gzip/brotli response compression (`compression()`, `Compression`) ([Compression](/middleware#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](/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 ```toml ultimo = { version = "0.6", features = ["websocket", "session", "sqlx-postgres"] } ``` *** ### Next Steps * [Getting Started](/getting-started) - Build your first Ultimo app * [RPC System](/rpc) - Type-safe APIs with auto-generated clients * [Middleware](/middleware) - Composable request/response handling * [Database](/database) - SQLx and Diesel integration * [Examples](https://github.com/ultimo-rs/ultimo/tree/main/examples) - Sample projects ## Authorization (Guards) Authentication tells you *who* the caller is; authorization decides *what they may do*. Ultimo's guards let a handler declare its access requirements and enforce them uniformly — regardless of whether the caller was authenticated by [JWT](/jwt) or an [API key](/api-keys). ### How it works: the `Principal` Each auth middleware normalizes its result into a single `auth::Principal` on the request `Context`: ```rust pub struct Principal { pub id: Option, // subject (JWT `sub`) or API-key id pub scopes: Vec, // what the caller is allowed to do } ``` * The **API-key** middleware fills it from the matched `ApiKeyIdentity` (id + scopes). * The **JWT** middleware fills it from the token: `id` from `sub`, and `scopes` parsed from the OAuth2-standard `scope` (space-delimited string) plus `scopes` / `scp` (array or string). Because guards read only the `Principal`, your authorization logic doesn't care which method authenticated the request. Model **roles as scopes** if you prefer (e.g. `"role:admin"`) — scopes are the single concept. > If your JWT puts scopes/roles in a non-standard claim, read them yourself via > [`ctx.jwt_claims()`](/jwt) and apply your own check. ### Guards All guards are `async` methods on `Context` that return `Result<()>` (or the `Principal`), so they compose with `?` at the top of a handler: | Guard | Behavior | | -------------------------------------------- | ------------------------------------------------------------------- | | `ctx.principal()` | `Option` — the caller, if authenticated. | | `ctx.require_auth()` | The `Principal`, or **401 Unauthorized**. | | `ctx.require_scope("admin")` | **401** if unauthenticated, **403 Forbidden** if missing the scope. | | `ctx.require_any_scope(&["a", "b"])` | 403 unless at least one scope is present. | | `ctx.require_all_scopes(&["read", "write"])` | 403 unless all scopes are present. | ```rust use ultimo::prelude::*; // Any authenticated caller. app.get("/me", |ctx: Context| async move { let me = ctx.require_auth().await?; // 401 if not authenticated ctx.json(serde_json::json!({ "id": me.id, "scopes": me.scopes })).await }); // Requires a specific scope. app.get("/admin", |ctx: Context| async move { ctx.require_scope("admin").await?; // 401 or 403, else continues ctx.json(serde_json::json!({ "ok": true })).await }); // Requires several scopes. app.post("/articles", |ctx: Context| async move { ctx.require_all_scopes(&["articles:write", "publish"]).await?; ctx.json(serde_json::json!({ "created": true })).await }); ``` A failed guard short-circuits the handler with the appropriate status — `401` (`UltimoError::Unauthorized`) when there's no authenticated caller, `403` (`UltimoError::Forbidden`) when the caller is known but lacks the scope. ### Availability Guards are compiled whenever an auth method is enabled — that is, with the `jwt` **or** `api-key` feature. No separate feature flag. ### Try it The [`examples/jwt-auth`](https://github.com/ultimo-rs/ultimo/tree/main/examples/jwt-auth) demo issues scoped tokens and guards `/api/admin` with `require_scope("admin")`: `cargo run -p jwt-auth-example`, log in as `admin` (granted the scope) vs. any other name (gets a `403`). ## Changelog All notable changes to the Ultimo framework are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### \[Unreleased] ### \[0.6.0] - 2026-07-08 #### Security * Session `clear()` now correctly destroys the store entry and expires the cookie — previously it was marked dirty-and-empty but neither persisted nor removed, silently leaving the old session data resolvable via the still-valid cookie. (#157) * `MemoryStore::with_max_sessions(n)` bounds session-store memory under sustained load, evicting the soonest-to-expire entry once at capacity (opt-in, non-breaking; `MemoryStore::new()` remains unbounded). (#157) * Rate-limiter buckets are now evicted once idle for a full window, bounding memory growth from many distinct keys (e.g. spoofed `X-Forwarded-For` values under `trust_proxy`). (#157) * Internal error variants (`UltimoError::Io` / `Hyper` / `HttpError`) no longer echo raw OS/protocol error text to clients — they now return a generic message, avoiding leaking filesystem paths or internals. (#157) * **WebSocket `Origin` allow-list** (Cross-Site WebSocket Hijacking defense): browsers don't enforce same-origin for WebSocket connections the way they do for `fetch`. Restrict the handshake to trusted origins via `WebSocketUpgrade::with_allowed_origins` / `Ultimo::websocket_with_config_and_origins` (empty/default preserves prior behavior — no check). (#157) * Bumped `jsonwebtoken` 9 → 10, fixing **CVE-2026-25537** (a type-confusion vulnerability in JWT claim validation). (#158) * **BREAKING:** bumped `sqlx` 0.7 → 0.8, fixing **RUSTSEC-2024-0363** (binary protocol misinterpretation, all backends). Because `ultimo`'s SQLx integration re-exposes `sqlx` types directly through its public API (`SqlxPool`, `PgPoolOptions`, …), downstream consumers of the `sqlx`/`sqlx-postgres`/`sqlx-mysql`/`sqlx-sqlite` features need to bump their own `sqlx` dependency to `"0.8"` in lockstep. (#159) #### Added * **Rate limiting middleware** (token bucket): `RateLimiter` / `rate_limiter()`, keyed by client IP, a request header, or globally; returns `429` with `Retry-After`. (#154) * `serve_docs()` — one-line interactive Swagger UI + OpenAPI JSON, the Ultimo equivalent of FastAPI's `/docs`. (#153) * **IP allow/deny middleware** (`IpFilter`) with CIDR support, for allow-listing or blocking client IP ranges. (#131) * `ultimo generate --watch` and scaffolded `generate-client` support in project templates. (#155) ### \[0.5.1] - 2026-06-15 #### Added * `ultimo dev` — hot-reload development server with file watching (#15) ### \[0.5.0] - 2026-06-09 #### Added * **TypeScript client type derivation** (`client-gen` feature): RPC client types are now derived from your Rust types via `ts-rs`. `#[derive(TS)]` on your input/output structs and the generated client emits real `type X = {...}` declarations — no more hand-written type strings or dangling references. `ts_rs::TS` is re-exported as `ultimo::rpc::TS`. (#107) #### Changed * **BREAKING:** `RpcRegistry::query` / `mutation` now take `(name, handler)` and derive their TypeScript input/output types from the Rust types (bounds `I: TS, O: TS`, gated behind `client-gen`). The previous string-typed signatures are preserved as `query_with_types` / `mutation_with_types`. (#107) * `ts-rs` is now an optional dependency behind `client-gen` (previously an unused hard dependency) and was upgraded 8.1 → 12. Default builds no longer pull it. (#107) * Removed the hardcoded `User` interface that was previously injected into every generated client. (#107) ### \[0.4.1] - 2026-06-09 #### Added * **Static file serving** (`static-files` feature): `serve_static` serves assets from disk with automatic `Content-Type`, `ETag`, and `304 Not Modified`; `serve_spa` adds Single Page Application fallback routing; path traversal is blocked at the filesystem level. Adds catch-all (`*name`) wildcard segments to the router. (#101) * **Response compression** (`compression` feature): automatic gzip/brotli middleware (brotli preferred), pure Rust with no C dependencies, configurable via the `Compression` builder. Skips binary content types, already-encoded responses, and small bodies; always sets `Vary: Accept-Encoding`. (#101) #### Changed * Crate install snippets (`ultimo = "…"`) across the README and docs pages are now derived from the workspace version and enforced by the `version-sync` CI gate, eliminating version drift. (#102) ### \[0.4.0] - 2026-06-08 The **Security & Performance** milestone. #### Added * **JWT authentication** (`jwt` feature): HS256 verify + sign, algorithm pinned (`alg: none` rejected), `exp` validated, claims on `Context`. (#84) * **API-key authentication** (`api-key` feature): pluggable `ApiKeyStore` + built-in `StaticKeys` (SHA-256 hashed, constant-time), resolving to an identity (id + scopes). (#86) * **Authorization guards**: unified `auth::Principal`; `ctx.require_auth` / `require_scope` / `require_any_scope` / `require_all_scopes`, fed by both JWT and API-key. (#87) * **Security-headers middleware** (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; opt-in CSP). (#56, #71) * **CSRF protection** (`csrf` feature): double-submit cookie, constant-time compare. (#57, #80) * **Request body-size limit** (`max_body_size`) — 413 on oversize, without buffering the whole body. (#58, #72) * **Real client IP** (`ctx.client_ip`), trusted-proxy aware via `trust_proxy`. (#65, #76) * `#![forbid(unsafe_code)]` (100% safe Rust) + `SECURITY.md` disclosure policy. (#69) #### Performance * **O(1) static route lookup** via a hash index — was O(N) in route count. (#90) * **Framework-overhead benchmark suite** (criterion) + `BENCHMARKS.md` methodology + an advisory CI regression check. (#88, #13) #### Docs * New `/performance`, `/jwt`, `/api-keys`, and `/authorization` pages; an "Authentication" docs group; and an honest "Secure & Fast" website (removed unsubstantiated benchmark numbers). ### \[0.3.0] - 2026-06-04 #### Added * **Sessions** (`session` feature): cookie-based session management — `SessionStore` + `MemoryStore`, `ctx.session()`, secure middleware (256-bit ids, HttpOnly/Secure/SameSite, anti-fixation/DoS), `SessionConfig`. See the session-auth example. * **Cookie helper** (`ultimo::cookie`): parsing + `Set-Cookie` formatting and `ctx.cookie`/`set_cookie`/`remove_cookie`. * **Testing utilities** (`testing` feature): in-process `TestClient`, request builder, response assertions, macros, middleware/DB/fixture helpers. * `Ultimo::oneshot` for in-process dispatch; `Request::raw_body()`. #### Fixed * Router precedence: static routes win over parameterized ones regardless of registration order. #### Changed * **MSRV raised to 1.86.0** (breaking). Dependency floors hardened for RUSTSEC advisories. ### \[0.2.1] - 2026-01-04 #### Fixed * Updated WebSocket pubsub benchmark to match new ChannelManager API * Removed unused imports in React example projects #### Coming Soon * Server-Sent Events (SSE) * Session management * Testing utilities * Multi-language client generation * Per-message deflate compression (RFC 7692) ### \[0.2.0] - 2026-01-04 #### Added **WebSocket Support (Complete)** 🔌 * Zero-dependency RFC 6455 compliant WebSocket implementation * Built on hyper's upgrade mechanism (no tokio-tungstenite required) * Type-safe WebSocketHandler trait with typed context data * Built-in pub/sub system (ChannelManager) for topic-based messaging * Seamless router integration with `app.websocket()` method * Router optimization: Migrated to Radix Tree for O(L) lookups * 279 comprehensive tests (128 unit, 151 integration) * Production-ready features: * **Configuration System** (`WebSocketConfig`) with size limits, timeouts, and buffer sizes * **Message Fragmentation** for large payloads with automatic reassembly * **Automatic Ping/Pong** heartbeat with configurable intervals and timeout detection * **Graceful Shutdown** with `broadcast_all()` and proper close handshakes * **Backpressure Handling** with bounded channels, `on_drain()` callback, and capacity tracking * Two working examples: * Simple HTML/JS chat application * Modern React + TypeScript chat with shadcn/ui **Core Features** * Frame codec supporting all opcodes (text, binary, ping, pong, close, continuation) * Frame masking/unmasking (client frames must be masked per RFC 6455) * Control frame handling (close, ping, pong) * Automatic message fragmentation for large payloads (>max\_frame\_size) * Fragment reassembly with `FragmentAccumulator` * Subscribe/unsubscribe to topics * Publish messages to all topic subscribers with backpressure handling * Automatic cleanup on disconnect * Connection lifecycle callbacks (on\_open, on\_message, on\_close, on\_drain) * Type-safe context data per connection (`WebSocket`) * JSON message helpers (send\_json, recv\_json) **Production Features** * Configurable size limits (max\_message\_size: 10MB, max\_frame\_size: 1MB) * Bounded channels with configurable buffer (default 1024) * Automatic ping/pong heartbeat (configurable interval, default 30s) * Timeout detection for unresponsive clients (default 10s) * Backpressure notifications via `on_drain()` callback * Capacity tracking: `capacity()`, `max_capacity()`, `has_capacity()` * Graceful shutdown with `broadcast_all()` for server-wide notifications * Custom close frames with reason codes **Performance** * Zero additional dependencies (uses existing hyper, tokio, bytes) * Efficient memory usage with BytesMut for frame parsing * O(L) router lookups with Radix Tree optimization **Documentation** * WEBSOCKET\_DESIGN.md - Architecture and design decisions * WEBSOCKET\_TESTING.md - Testing strategy and coverage * Example READMEs with setup instructions ### \[0.1.0] - 2025-11-21 #### Core Features **Framework** * ⚡ High-performance HTTP server built on Hyper * 🎯 Type-safe routing with path parameters * 🔧 Composable middleware system (CORS, Logger, PoweredBy, Custom) * 📊 Built-in RPC support (REST & JSON-RPC modes) * 📝 OpenAPI 3.0 specification generation * ✨ Automatic TypeScript client generation * ✅ Request validation with detailed errors * 🛡️ Comprehensive error handling **Developer Experience** * 🧪 70.7% test coverage (124 tests) * 📈 Custom coverage tool with modern HTML reports * 🔍 Git hooks for code quality (pre-commit, pre-push) * 📚 Complete documentation and examples * 🛠️ CLI tool for client generation * 📦 Monorepo management with Moonrepo **Examples** * Basic REST API * Database integration (SQLx & Diesel) * OpenAPI documentation * React full-stack applications * RPC modes demonstration * Benchmark comparisons #### Technical Details * **MSRV**: Rust 1.75.0 * **Runtime**: Tokio (async) * **HTTP**: Hyper 1.x * **Performance**: Benchmark suite and comparison examples included *** **Initial Release** - Complete type-safe web framework with automatic client generation and comprehensive testing. ## CLI Tools The Ultimo CLI provides commands for generating TypeScript clients, managing projects, and development workflows. ### Installation Install the CLI from your Ultimo project: ```bash cargo install --path ultimo-cli ``` Or use the installation script: ```bash ./install-cli.sh ``` Verify installation: ```bash ultimo --version # ultimo 0.4.1 ``` ### Commands #### Generate Client Generate a TypeScript client from your Rust backend: ```bash ultimo generate --project ./backend --output ./frontend/src/lib/client.ts ``` Short form: ```bash ultimo generate -p ./backend -o ./frontend/src/client.ts ``` This runs a **`generate-client` binary** in your project (`cargo run --bin generate-client -- `), so the client is produced by your real RPC registry — no source parsing, no guessing. Add `src/bin/generate-client.rs` to your project: ```rust // src/bin/generate-client.rs fn main() { let out = std::env::args().nth(1).expect("usage: generate-client "); let rpc = my_app::build_registry(); // build the same RpcRegistry your server uses rpc.generate_client_file(&out).expect("failed to write client"); } ``` The binary receives the `--output` path as its first argument and writes the client there (see [TypeScript Clients](/typescript) for building the registry). Derive your RPC types with `#[derive(ultimo::rpc::TS)]` and enable the `client-gen` feature so the client carries real types. #### Example Output ```typescript // Auto-generated by Ultimo CLI // DO NOT EDIT MANUALLY export interface User { id: number; name: string; email: string; } export interface GetUserInput { id: number; } export class UltimoRpcClient { private baseUrl: string; constructor(baseUrl: string = "/rpc") { this.baseUrl = baseUrl; } async getUser(params: GetUserInput): Promise { return this.call("getUser", params); } // ... more methods } ``` ### Workflow Integration #### Development Script Add client generation to your development workflow: ```bash #!/bin/bash # dev.sh echo "🔄 Generating TypeScript client..." ultimo generate -p ./backend -o ./frontend/src/lib/client.ts echo "🚀 Starting backend..." cd backend && cargo run --release & BACKEND_PID=$! echo "🎨 Starting frontend..." cd frontend && npm run dev & FRONTEND_PID=$! # Cleanup on exit trap "kill $BACKEND_PID $FRONTEND_PID" EXIT wait ``` #### Watch Mode Regenerate client on backend changes: ```bash #!/bin/bash # watch-generate.sh echo "👁️ Watching for changes..." while true; do inotifywait -r -e modify ./backend/src echo "🔄 Regenerating client..." ultimo generate -p ./backend -o ./frontend/src/lib/client.ts done ``` #### Pre-commit Hook Ensure client is up-to-date before commits: ```bash # .git/hooks/pre-commit #!/bin/bash echo "🔄 Regenerating TypeScript client..." ultimo generate -p ./backend -o ./frontend/src/lib/client.ts if [ $? -eq 0 ]; then git add frontend/src/lib/client.ts echo "✅ Client updated and staged" else echo "❌ Client generation failed" exit 1 fi ``` #### CI/CD Pipeline ```yaml # .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Install Ultimo CLI run: cargo install --path ultimo-cli - name: Generate TypeScript client run: ultimo generate -p ./backend -o ./frontend/src/lib/client.ts - name: Check for changes run: | if ! git diff --quiet; then echo "❌ TypeScript client is out of date" exit 1 fi - name: Run tests run: cargo test ``` ### Configuration File Create `ultimo.toml` for project-specific settings: ```toml [project] name = "my-app" version = "1.0.0" [generate] backend_path = "./backend" output_path = "./frontend/src/lib/client.ts" base_url = "/api/rpc" [dev] backend_port = 3000 frontend_port = 5173 hot_reload = true ``` Then use: ```bash ultimo generate # Uses config file settings ``` #### Create a new project Scaffold a new Ultimo project: ```bash ultimo new my-app --template fullstack ``` Available templates: `basic`, `fullstack`, `api-only`, `rpc`, `production`. ### Development Server Start a hot-reload development server that watches for file changes and automatically recompiles and restarts: ```bash ultimo dev # default: 127.0.0.1:3000 ultimo dev --port 8080 # custom port ultimo dev --host 0.0.0.0 # bind to all interfaces ``` The dev server watches `src/**/*.rs` and `Cargo.toml`. On any change it kills the running server, rebuilds with `cargo build`, and restarts. Compilation errors are printed inline — the watcher stays active so the server restarts automatically once you fix the error. ### Future Commands > ⚠️ **Not implemented yet.** These commands exist as placeholders and currently > print a notice. Until they land, use the cargo equivalents. | Command | Status | Use instead | | ---------------------------- | ---------------------------------- | ---------------------------- | | `ultimo build` | Planned — production build wrapper | `cargo build --release` | | `ultimo test` | Not planned yet | `cargo test` | | `ultimo fmt` / `ultimo lint` | Not planned yet | `cargo fmt` / `cargo clippy` | ### CLI Help View help for any command: ```bash # General help ultimo --help # Command-specific help ultimo generate --help ultimo dev --help ultimo build --help ``` ### Best Practices #### ✅ Automate Generation Add to your build script: ```json { "scripts": { "prebuild": "ultimo generate", "build": "vite build", "dev": "ultimo generate && vite" } } ``` #### ✅ Version Control Commit the generated client to track changes: ```gitignore # .gitignore # Don't ignore generated client !frontend/src/lib/client.ts ``` #### ✅ Verify in CI Ensure the client is always up-to-date: ```yaml - name: Verify client is up-to-date run: | ultimo generate git diff --exit-code frontend/src/lib/client.ts ``` #### ✅ Document Commands Add to your README: ```markdown ## Development Generate TypeScript client: \`\`\`bash ultimo generate -p ./backend -o ./frontend/src/lib/client.ts \`\`\` Start development servers: \`\`\`bash ./dev.sh \`\`\` ``` ### Troubleshooting #### Client Generation Fails ```bash # Ensure backend compiles cd backend && cargo check # Verify project structure ultimo generate --verbose # Check output path is writable touch ./frontend/src/lib/client.ts ``` #### Outdated Client ```bash # Regenerate client ultimo generate -p ./backend -o ./frontend/src/lib/client.ts # Clear frontend build cache cd frontend && rm -rf node_modules/.vite ``` #### CLI Not Found ```bash # Reinstall CLI cargo install --path ultimo-cli --force # Add to PATH export PATH="$HOME/.cargo/bin:$PATH" # Verify which ultimo ultimo --version ``` ## Database Integration Ultimo provides first-class support for database integration with both **SQLx** and **Diesel** ORMs. ### Overview Choose the ORM that fits your development style: * **[SQLx](/sqlx)** - Async-first, raw SQL with compile-time verification * **[Diesel](/diesel)** - Type-safe query builder with powerful DSL Both integrations include: * ✅ Connection pooling for optimal performance * ✅ Context integration (`ctx.sqlx()` or `ctx.diesel()`) * ✅ Multiple database support (PostgreSQL, MySQL, SQLite) * ✅ Transaction support * ✅ Health checks ### Quick Comparison | Feature | [SQLx](/sqlx) | [Diesel](/diesel) | | ----------------------- | -------------------------- | ---------------------------- | | **Execution Model** | Fully Async | Sync (with async wrapper) | | **Query Style** | Raw SQL | Type-safe DSL | | **Compile-time checks** | ✅ Yes (optional) | ✅ Yes (always) | | **Migrations** | Built-in CLI | diesel\_cli | | **Performance** | High (async I/O) | High (efficient queries) | | **Learning Curve** | Lower | Moderate | | **Best For** | API servers, microservices | Complex queries, type safety | ### Which to Choose? #### Choose SQLx if you: * ✅ Prefer async/await throughout your application * ✅ Are comfortable writing raw SQL * ✅ Want simpler setup and configuration * ✅ Need quick prototyping * ✅ Value flexibility in query construction #### Choose Diesel if you: * ✅ Want strong compile-time guarantees for all queries * ✅ Prefer a type-safe query builder DSL * ✅ Need complex joins and aggregations * ✅ Want auto-generated schema from migrations * ✅ Value explicit type safety over flexibility ### Quick Start #### SQLx Example ```rust use ultimo::prelude::*; use ultimo::database::sqlx::SqlxPool; use serde::Serialize; use sqlx::FromRow; #[derive(Serialize, FromRow)] struct User { id: i32, name: String, email: String, } #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); let pool = SqlxPool::connect("postgres://localhost/mydb").await?; app.with_sqlx(pool); app.get("/users", |ctx: Context| async move { let db = ctx.sqlx::()?; let users = sqlx::query_as::<_, User>("SELECT id, name, email FROM users") .fetch_all(db) .await?; ctx.json(users).await }); app.listen("127.0.0.1:3000").await } ``` [Learn more about SQLx →](/sqlx) #### Diesel Example ```rust use ultimo::prelude::*; use ultimo::database::diesel::DieselPool; use diesel::prelude::*; use serde::Serialize; // Define schema mod schema { diesel::table! { users (id) { id -> Int4, name -> Varchar, email -> Varchar, } } } // Define model #[derive(Serialize, Queryable, Selectable)] #[diesel(table_name = schema::users)] struct User { id: i32, name: String, email: String, } #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); let pool = DieselPool::::new("postgres://localhost/mydb")?; app.with_diesel(pool); app.get("/users", |ctx: Context| async move { let mut conn = ctx.diesel::()?; let users = schema::users::table .select(User::as_select()) .load(&mut *conn)?; ctx.json(users).await }); app.listen("127.0.0.1:3000").await } ``` [Learn more about Diesel →](/diesel) ### Common Patterns #### Connection Pooling Both ORMs include connection pooling for optimal performance: ```rust // SQLx - Configure pool let pool = SqlxPool::connect_with_options( PgPoolOptions::new() .max_connections(10) .min_connections(2), &database_url ).await?; // Diesel - Configure pool let pool = r2d2::Pool::builder() .max_size(10) .min_idle(Some(2)) .build(manager)?; ``` #### Transactions Execute multiple operations atomically: ```rust // SQLx let mut tx = db.begin().await?; sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2") .bind(amount).bind(from).execute(&mut *tx).await?; sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2") .bind(amount).bind(to).execute(&mut *tx).await?; tx.commit().await?; // Diesel conn.transaction::<_, Error, _>(|conn| { diesel::update(accounts.find(from)) .set(balance.eq(balance - amount)).execute(conn)?; diesel::update(accounts.find(to)) .set(balance.eq(balance + amount)).execute(conn)?; Ok(()) })?; ``` #### Error Handling Convert database errors to user-friendly responses: ```rust // Handle unique constraint violations .map_err(|e| { if e.to_string().contains("unique") { UltimoError::BadRequest("Email already exists".to_string()) } else { UltimoError::Internal(format!("Database error: {}", e)) } }) ``` #### Health Checks Add database health check endpoints: ```rust app.get("/health", |ctx: Context| async move { let db = ctx.sqlx::()?; sqlx::query("SELECT 1").execute(db).await?; ctx.json(json!({"status": "healthy", "database": "connected"})).await }); ``` ### Migration Management #### SQLx Migrations ```bash # Create migration sqlx migrate add create_users # Run migrations sqlx migrate run ``` #### Diesel Migrations ```bash # Create migration diesel migration generate create_users # Run migrations diesel migration run ``` ### Testing Both ORMs support database testing: ```rust #[tokio::test] #[ignore] // Requires test database async fn test_create_user() { let pool = setup_test_db().await; // Test database operations } ``` Run tests with: ```bash DATABASE_URL=postgres://localhost/test_db cargo test -- --ignored ``` ### Examples Explore working examples in the repository: * `examples/database-sqlx/` - Complete SQLx integration * `examples/database-diesel/` - Complete Diesel integration * `examples/database-api-styles/` - Comparing REST vs RPC with databases * `examples/database-with-openapi/` - Database + OpenAPI integration ### Next Steps * **[SQLx Integration →](/sqlx)** - Learn about async database queries * **[Diesel Integration →](/diesel)** - Learn about type-safe query building * **[Testing Guide →](/testing)** - Test database operations ### Getting Help * Check the [SQLx documentation](https://docs.rs/sqlx) * Check the [Diesel documentation](https://diesel.rs) * Browse the examples in `examples/database-*` * Review the [Testing Guide](/testing) for database testing patterns ## Diesel Integration Ultimo provides first-class support for **Diesel**, a safe, extensible ORM with compile-time guarantees. ### Why Diesel? * 🛡️ **Type-Safe DSL** - Build queries with compile-time verification * ⚡ **High Performance** - Zero-cost abstractions for SQL * 🗃️ **Multiple Databases** - PostgreSQL, MySQL, SQLite support * 🚀 **Connection Pooling** - r2d2 integration for connection management * 🔄 **Migrations** - Powerful CLI for schema management * 📦 **Mature Ecosystem** - Battle-tested in production * 🎯 **Strong Typing** - Catch errors at compile time, not runtime ### Installation Add dependencies to `Cargo.toml`: ```toml [dependencies] ultimo = { version = "0.6", features = ["diesel-postgres"] } diesel = { version = "2", features = ["postgres", "r2d2"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } ``` For other databases: * **MySQL**: `features = ["mysql", "r2d2"]` * **SQLite**: `features = ["sqlite", "r2d2"]` Install Diesel CLI: ```bash cargo install diesel_cli --no-default-features --features postgres ``` ### Basic Setup #### 1. Initialize Diesel ```bash # Create diesel.toml and migrations directory diesel setup # Create a migration diesel migration generate create_users # Edit the migration files # migrations/TIMESTAMP_create_users/up.sql # migrations/TIMESTAMP_create_users/down.sql # Run migrations diesel migration run ``` #### 2. Define Schema Diesel generates `src/schema.rs`: ```rust // src/schema.rs - Auto-generated diesel::table! { users (id) { id -> Int4, name -> Varchar, email -> Varchar, created_at -> Timestamptz, } } ``` #### 3. Define Models ```rust // src/models.rs use diesel::prelude::*; use serde::{Deserialize, Serialize}; use crate::schema::users; #[derive(Queryable, Selectable, Serialize)] #[diesel(table_name = users)] pub struct User { pub id: i32, pub name: String, pub email: String, pub created_at: chrono::NaiveDateTime, } #[derive(Insertable, Deserialize)] #[diesel(table_name = users)] pub struct NewUser { pub name: String, pub email: String, } ``` #### 4. Setup Application ```rust use ultimo::prelude::*; use ultimo::database::diesel::DieselPool; use diesel::prelude::*; use diesel::pg::PgConnection; mod schema; mod models; #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); // Connect to database let database_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://postgres:postgres@localhost/mydb".to_string()); let pool = DieselPool::::new(&database_url)?; app.with_diesel(pool); // Routes app.get("/users", get_users); app.post("/users", create_user); app.get("/users/:id", get_user); app.put("/users/:id", update_user); app.delete("/users/:id", delete_user); app.listen("127.0.0.1:3000").await } ``` ### CRUD Operations #### Read (Query) ```rust use crate::schema::users::dsl::*; // List all users app.get("/users", |ctx: Context| async move { let mut conn = ctx.diesel::()?; let results = users .select(User::as_select()) .order(id.asc()) .load(&mut *conn)?; ctx.json(json!({ "users": results, "total": results.len() })).await }); // Get single user app.get("/users/:id", |ctx: Context| async move { let user_id: i32 = ctx.req.param("id")?.parse()?; let mut conn = ctx.diesel::()?; let user = users .find(user_id) .select(User::as_select()) .first(&mut *conn) .optional()?; match user { Some(user) => ctx.json(user).await, None => Err(UltimoError::NotFound("User not found".to_string())), } }); // Filter queries app.get("/search", |ctx: Context| async move { let query_param = ctx.req.query("q")?; let mut conn = ctx.diesel::()?; let results = users .filter(email.like(format!("%{}%", query_param))) .or_filter(name.like(format!("%{}%", query_param))) .select(User::as_select()) .load(&mut *conn)?; ctx.json(json!({ "users": results })).await }); ``` #### Create (Insert) ```rust use crate::models::NewUser; app.post("/users", |ctx: Context| async move { let input: NewUser = ctx.req.json().await?; let mut conn = ctx.diesel::()?; let user = diesel::insert_into(users) .values(&input) .returning(User::as_returning()) .get_result(&mut *conn)?; ctx.status(201).await; ctx.json(user).await }); ``` #### Update ```rust use diesel::prelude::*; #[derive(Deserialize, AsChangeset)] #[diesel(table_name = users)] struct UpdateUser { name: Option, email: Option, } app.put("/users/:id", |ctx: Context| async move { let user_id: i32 = ctx.req.param("id")?.parse()?; let input: UpdateUser = ctx.req.json().await?; let mut conn = ctx.diesel::()?; let user = diesel::update(users.find(user_id)) .set(&input) .returning(User::as_returning()) .get_result(&mut *conn) .optional()?; match user { Some(user) => ctx.json(user).await, None => Err(UltimoError::NotFound("User not found".to_string())), } }); ``` #### Delete ```rust app.delete("/users/:id", |ctx: Context| async move { let user_id: i32 = ctx.req.param("id")?.parse()?; let mut conn = ctx.diesel::()?; let deleted = diesel::delete(users.find(user_id)) .execute(&mut *conn)?; if deleted == 0 { return Err(UltimoError::NotFound("User not found".to_string())); } ctx.status(204).await; Ok(()) }); ``` ### Transactions Execute multiple queries atomically: ```rust use diesel::prelude::*; app.post("/transfer", |ctx: Context| async move { #[derive(Deserialize)] struct Transfer { from_account: i32, to_account: i32, amount: f64, } let transfer: Transfer = ctx.req.json().await?; let mut conn = ctx.diesel::()?; conn.transaction::<_, diesel::result::Error, _>(|conn| { // Deduct from sender diesel::update(accounts.find(transfer.from_account)) .set(balance.eq(balance - transfer.amount)) .execute(conn)?; // Add to receiver diesel::update(accounts.find(transfer.to_account)) .set(balance.eq(balance + transfer.amount)) .execute(conn)?; Ok(()) })?; ctx.json(json!({"success": true, "amount": transfer.amount})).await }); ``` ### Connection Pooling Configure connection pool with r2d2: ```rust use diesel::r2d2::{self, ConnectionManager}; use std::time::Duration; let manager = ConnectionManager::::new(&database_url); let pool = r2d2::Pool::builder() .max_size(10) // Maximum connections .min_idle(Some(2)) // Minimum idle connections .connection_timeout(Duration::from_secs(5)) .idle_timeout(Some(Duration::from_secs(300))) .build(manager)?; let diesel_pool = DieselPool::from_pool(pool); app.with_diesel(diesel_pool); ``` ### Advanced Queries #### Joins ```rust use crate::schema::{users, posts}; #[derive(Queryable, Serialize)] struct UserWithPosts { user: User, posts: Vec, } app.get("/users/:id/posts", |ctx: Context| async move { let user_id: i32 = ctx.req.param("id")?.parse()?; let mut conn = ctx.diesel::()?; let results = users::table .inner_join(posts::table) .filter(users::id.eq(user_id)) .select((User::as_select(), Post::as_select())) .load::<(User, Post)>(&mut *conn)?; ctx.json(results).await }); ``` #### Aggregations ```rust use diesel::dsl::*; app.get("/stats", |ctx: Context| async move { let mut conn = ctx.diesel::()?; let total: i64 = users.count().get_result(&mut *conn)?; let max_id: Option = users.select(max(id)).first(&mut *conn)?; ctx.json(json!({ "total_users": total, "max_id": max_id })).await }); ``` #### Pagination ```rust app.get("/users", |ctx: Context| async move { let page: i64 = ctx.req.query("page")?.parse().unwrap_or(1); let per_page: i64 = ctx.req.query("per_page")?.parse().unwrap_or(10); let mut conn = ctx.diesel::()?; let offset = (page - 1) * per_page; let results = users .select(User::as_select()) .limit(per_page) .offset(offset) .load(&mut *conn)?; let total: i64 = users.count().get_result(&mut *conn)?; ctx.json(json!({ "users": results, "page": page, "per_page": per_page, "total": total, "total_pages": (total as f64 / per_page as f64).ceil() as i64 })).await }); ``` ### Migrations #### Create Migration ```bash diesel migration generate create_posts # Edit migrations/TIMESTAMP_create_posts/up.sql CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); # Edit migrations/TIMESTAMP_create_posts/down.sql DROP TABLE posts; # Run migration diesel migration run # Rollback (if needed) diesel migration revert ``` #### Embed Migrations Run migrations in your app: ```rust use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/"); #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); let pool = DieselPool::::new(&database_url)?; // Run migrations let mut conn = pool.pool().get()?; conn.run_pending_migrations(MIGRATIONS) .expect("Failed to run migrations"); app.with_diesel(pool); // ... rest of setup } ``` ### Error Handling Handle Diesel-specific errors: ```rust app.post("/users", |ctx: Context| async move { let input: NewUser = ctx.req.json().await?; let mut conn = ctx.diesel::()?; let user = diesel::insert_into(users) .values(&input) .returning(User::as_returning()) .get_result(&mut *conn) .map_err(|e| match e { diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::UniqueViolation, _ ) => UltimoError::BadRequest("Email already exists".to_string()), _ => UltimoError::Internal(format!("Database error: {}", e)), })?; ctx.status(201).await; ctx.json(user).await }); ``` ### Testing Write tests with a test database: ```rust #[cfg(test)] mod tests { use super::*; use diesel::prelude::*; fn setup_test_db() -> DieselPool { let pool = DieselPool::new("postgres://localhost/test_db").unwrap(); let mut conn = pool.pool().get().unwrap(); diesel::sql_query( "CREATE TABLE IF NOT EXISTS test_users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL )" ) .execute(&mut conn) .unwrap(); pool } #[test] #[ignore] // Run with: cargo test -- --ignored fn test_create_user() { let pool = setup_test_db(); let mut conn = pool.pool().get().unwrap(); let new_user = NewUser { name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let user = diesel::insert_into(users) .values(&new_user) .returning(User::as_returning()) .get_result(&mut conn) .unwrap(); assert_eq!(user.name, "Alice"); assert_eq!(user.email, "alice@example.com"); } } ``` ### Best Practices 1. **Use schema.rs** - Let Diesel manage your schema, don't edit manually 2. **Type-safe queries** - Leverage Diesel's DSL for compile-time safety 3. **Use migrations** - Track all schema changes with migrations 4. **Connection pooling** - Configure appropriate pool sizes 5. **Transactions** - Use for operations that must succeed/fail together 6. **Indexes** - Add indexes for frequently queried columns 7. **Error handling** - Convert Diesel errors to user-friendly messages 8. **Async wrapping** - Use `tokio::task::spawn_blocking` for CPU-intensive queries ### Async Context Diesel is synchronous but works well with async code: ```rust app.get("/users", |ctx: Context| async move { // Diesel connection pool is Send + Sync let mut conn = ctx.diesel::()?; // For heavy queries, use spawn_blocking let results = tokio::task::spawn_blocking(move || { users .select(User::as_select()) .load(&mut *conn) }) .await??; ctx.json(results).await }); ``` ### Examples Check out the working example: ```bash cd examples/database-diesel DATABASE_URL=postgres://postgres:postgres@localhost/ultimo_test cargo run ``` ### See Also * [Database Overview](/database) * [SQLx Integration](/sqlx) * [Testing Guide](/testing) ## Getting Started Get up and running with Ultimo in minutes. ### Installation Add Ultimo to your `Cargo.toml`: ```toml [dependencies] ultimo = "0.6" tokio = { version = "1.35", features = ["full"] } serde = { version = "1.0", features = ["derive"] } ``` Or install directly with cargo: ```bash cargo add ultimo tokio serde cargo add tokio --features full cargo add serde --features derive ``` ### Hello Ultimo Create a simple API server: ```rust use ultimo::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Serialize)] struct Message { text: String, } #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); app.get("/", |ctx: Context| async move { ctx.json(Message { text: "Hello, Ultimo!".to_string(), }).await }); println!("🚀 Server running on http://127.0.0.1:3000"); app.listen("127.0.0.1:3000").await } ``` ### Run Your Server ```bash cargo run ``` Test it: ```bash curl http://localhost:3000/ # {"text":"Hello, Ultimo!"} ``` ### Next Steps * Learn about [Routing](/routing) and path parameters * Explore [RPC](/rpc) for type-safe APIs * Add [Middleware](/middleware) for logging and CORS * Generate [TypeScript Clients](/typescript) automatically * Create [OpenAPI Specs](/openapi) for documentation ### Project Structure A typical Ultimo project looks like: ``` my-app/ ├── Cargo.toml ├── src/ │ ├── main.rs # Application entry point │ ├── handlers/ # Request handlers │ ├── models/ # Data models │ └── rpc/ # RPC procedures └── frontend/ └── src/ └── lib/ └── client.ts # Auto-generated TypeScript client ``` ### Live Demos Try Ultimo without installing anything (hosted on Render — first request may take \~30s): * [Basic example](https://basic-example-v4wi.onrender.com/) — routing, JSON, params * [Session auth](https://session-auth-example.onrender.com/) — cookie sessions + CSRF * [JWT auth](https://jwt-auth-example-16kq.onrender.com/) — JWT + scope guards * [WebSocket chat](https://websocket-chat-example.onrender.com/) — real-time pub/sub * [SPA demo](https://spa-demo-example.onrender.com/) — static files + SPA fallback * [OpenAPI / Swagger](https://openapi-demo-example.onrender.com/) — interactive API docs ## JWT Authentication Ultimo ships a JWT (JSON Web Token) auth middleware that verifies signed bearer tokens, attaches the validated claims to the request `Context`, and can issue tokens. It's secure-by-default: the signing algorithm is **pinned to HS256**, so `alg: none` and algorithm-confusion attacks are rejected, and token expiry (`exp`) is validated automatically. Verification is delegated to the audited [`jsonwebtoken`](https://crates.io/crates/jsonwebtoken) crate rather than hand-rolled. ### Enable the feature JWT support is opt-in: ```toml [dependencies] ultimo = { version = "0.6", features = ["jwt"] } ``` ### Quick start ```rust use ultimo::auth::jwt::Jwt; use ultimo::prelude::*; #[derive(serde::Serialize)] struct Claims { sub: String, exp: usize } #[tokio::main] async fn main() -> Result<()> { // Load the secret from the environment in production — never hardcode it. let jwt = Jwt::hs256(b"super-secret-key"); let mut app = Ultimo::new_without_defaults(); // Verify tokens on every request and attach claims to the Context. app.use_middleware(jwt.clone().build()); app.get("/me", |ctx: Context| async move { let claims = ctx.jwt_claims().await; // Option ctx.json(serde_json::json!({ "claims": claims })).await }); app.listen("127.0.0.1:3000").await } ``` ### Issuing tokens The same `Jwt` value that verifies can also sign (the HS256 secret is shared). Claims must include an `exp` (expiry) field: ```rust let token = jwt.sign(&Claims { sub: "ada".into(), exp: 1_900_000_000 })?; ``` ### Reading claims in a handler After the middleware accepts a token, the claims are available on the `Context`: ```rust // Raw claims object (None if unauthenticated / optional mode). let claims: Option = ctx.jwt_claims().await; // Or deserialize into your own type (errors if absent or shape mismatch). #[derive(serde::Deserialize)] struct MyClaims { sub: String } let mine: MyClaims = ctx.jwt::().await?; ``` ### Configuration `Jwt` is a builder. All options are chainable: | Method | Effect | | -------------------- | ------------------------------------------------------------------- | | `Jwt::hs256(secret)` | Configure HS256 with a symmetric secret (signs + verifies). | | `.issuer(s)` | Require the `iss` claim to equal `s`. | | `.audience(s)` | Require the `aud` claim to equal `s`. | | `.leeway(secs)` | Clock-skew tolerance applied to `exp`/`nbf`. | | `.from_bearer()` | Read the token from `Authorization: Bearer ` (default). | | `.from_cookie(name)` | Read the token from a named cookie instead. | | `.optional()` | Don't 401 on missing/invalid tokens — pass through unauthenticated. | | `.sign(&claims)` | Issue a signed token. | | `.build()` | Build the verification middleware. | #### Required vs optional By default the middleware returns **`401 Unauthorized`** (with a `WWW-Authenticate: Bearer` header) when a token is missing or invalid. Call `.optional()` for routes that serve both authenticated and anonymous users — the request passes through with no claims attached, and your handler decides what to do when `ctx.jwt_claims()` is `None`. ### Security notes * **HS256 only (today).** RS256/EdDSA (asymmetric keys) are planned. The algorithm is pinned, so `alg: none` and HS/RS confusion tokens are rejected. * **`exp` is validated by default** — always set a short expiry. Following least-privilege guidance, use **15–60 minutes** for sensitive systems and mint a fresh token on login. * **Load the secret from the environment**, never hardcode it, and rotate it if leaked. * **Stateless tokens can't be revoked server-side.** A bearer JWT remains valid until it expires, so "log out everywhere" / instant revocation isn't possible with JWT alone. For those needs use short TTLs plus either the [session](/sessions) middleware (server-side, revocable) or a token blocklist. * **Cookie source:** if you use `.from_cookie(...)`, serve over HTTPS and set the cookie `Secure` + `HttpOnly` so it can't be read by JavaScript or sent over plaintext. * **Audience gotcha:** `jsonwebtoken` validates `aud` by default. If your tokens carry an `aud` claim but you don't call `.audience(...)` on the verifier, they will be **rejected**. Either set `.audience(...)` to match, or don't include `aud` in your tokens. ### Full example A runnable login → protected-route demo lives at [`examples/jwt-auth`](https://github.com/ultimo-rs/ultimo/tree/main/examples/jwt-auth): `cargo run -p jwt-auth-example`, then open `http://127.0.0.1:3000`. ## Middleware Middleware runs around your route handlers — before and after — for cross-cutting concerns like logging, CORS, security headers, and auth. A middleware is a `BoxedMiddleware`: a function of `(Context, Next)` that returns `Result`. It calls `next(ctx)` to invoke the rest of the chain (and the handler), or returns early to short-circuit. Middleware is **global** — it applies to every route, in the order it's added. (For per-route access control, use the [authorization guards](/authorization) inside the relevant handler.) ```rust app.use_middleware(ultimo::middleware::builtin::logger()); ``` ### Built-in middleware All built-ins live in `ultimo::middleware::builtin` and return a `BoxedMiddleware`. #### Logger ```rust use ultimo::middleware::builtin::logger; app.use_middleware(logger()); ``` Logs each request and response (method, path, status, duration) via `tracing`. #### CORS Defaults (allow `*`, `GET`/`POST`, `Content-Type`): ```rust use ultimo::middleware::builtin::cors; app.use_middleware(cors()); ``` Configured, with the `Cors` builder: ```rust use ultimo::middleware::builtin::Cors; app.use_middleware( Cors::new() .allow_origin("https://example.com") .allow_methods(vec!["GET", "POST", "PUT", "DELETE"]) .allow_headers(vec!["Content-Type", "Authorization"]) .build(), ); ``` #### Security headers Secure defaults (HSTS, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`): ```rust use ultimo::middleware::builtin::security_headers; app.use_middleware(security_headers()); ``` Or customize with the `SecurityHeaders` builder (CSP is opt-in — it's off by default because a wrong policy breaks more than it protects): ```rust use ultimo::middleware::builtin::SecurityHeaders; app.use_middleware( SecurityHeaders::new() .csp("default-src 'self'") .frame_options("DENY") .referrer_policy("no-referrer") .build(), ); ``` See [Security](/security) for the full list and defaults. #### Compression (requires `compression` feature) Automatically compress response bodies with **brotli** or **gzip** (pure Rust — no C dependencies). Prefers brotli over gzip when the client accepts both, skips already-encoded responses and binary MIME types, and always sets `Vary: Accept-Encoding`. ```toml ultimo = { version = "0.6", features = ["compression"] } ``` Quick start with sensible defaults (brotli + gzip, 1 KB minimum body): ```rust use ultimo::middleware::builtin::compression; app.use_middleware(compression()); ``` Customized with the `Compression` builder: ```rust use ultimo::middleware::builtin::Compression; app.use_middleware( Compression::new() .gzip() // enable gzip .brotli() // enable brotli (preferred over gzip when both on) .min_size(512) // only compress bodies ≥ 512 bytes .build(), ); ``` **Behavior:** * `Vary: Accept-Encoding` is always set (required for correct caching). * Brotli is chosen first if the client sends `Accept-Encoding: br` (or `br, gzip`). * Responses whose `Content-Type` starts with `image/`, `audio/`, `video/`, or `font/woff`, or whose type is an already-compressed container (`application/zip`, `application/gzip`, etc.) are passed through unchanged. * If the response already carries `Content-Encoding`, the middleware skips it — no double-compression. * Bodies smaller than `min_size` (default: **1024 bytes**) are not compressed because the overhead can exceed the saving. Mount compression **before** static-file serving so text assets (HTML, JS, CSS) are compressed on the fly: ```rust use ultimo::middleware::builtin::compression; app.use_middleware(compression()); app.serve_static("/assets", "./dist/assets"); ``` #### Server identity headers ```rust use ultimo::middleware::builtin::{powered_by, server_headers}; app.use_middleware(powered_by()); // X-Powered-By: Ultimo app.use_middleware(server_headers("MyApp", true)); // Server: MyApp/ ``` ### Writing custom middleware A custom middleware is a function returning `BoxedMiddleware`. The closure takes `(Context, Next)` and returns a pinned, boxed future resolving to `Result`. Call `next(ctx).await` to continue the chain. ```rust use std::sync::Arc; use std::time::Instant; use hyper::header::{HeaderName, HeaderValue}; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::prelude::*; /// Add an `X-Response-Time` header measured around the handler. fn timing() -> BoxedMiddleware { Arc::new(|ctx: Context, next: Next| { Box::pin(async move { let start = Instant::now(); // `next(ctx)` runs the rest of the chain and returns the Response. let mut res = next(ctx).await?; let ms = start.elapsed().as_millis(); res.headers_mut().insert( HeaderName::from_static("x-response-time"), HeaderValue::from_str(&format!("{ms}ms")).unwrap(), ); Ok(res) }) }) } app.use_middleware(timing()); ``` Key points: * The closure signature is `Fn(Context, Next) -> Pin> + Send>>` — wrap the body in `Box::pin(async move { … })`. * `next` is called as `next(ctx)` (it consumes the context) and yields `Result`. * To modify the **response**, capture it from `next(ctx).await?` and mutate it (e.g. `res.headers_mut()`), then return `Ok(res)`. ### Short-circuiting Return without calling `next` to stop the chain early. Returning an `Err` short-circuits with the matching status (e.g. `Unauthorized` → 401); the framework turns it into a JSON error response. ```rust use std::sync::Arc; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::prelude::*; /// Reject requests without a matching API key (illustrative — for real auth use /// the built-in JWT / API-key middleware below). fn require_api_key(expected: &'static str) -> BoxedMiddleware { Arc::new(move |ctx: Context, next: Next| { Box::pin(async move { match ctx.req.header("x-api-key") { Some(key) if key == expected => next(ctx).await, _ => Err(UltimoError::Unauthorized("invalid API key".into())), } }) }) } ``` > `ctx.req.header(name)` returns `Option` (lookup is case-insensitive). ### Authentication & authorization Don't hand-roll auth — Ultimo ships it. Mount one of the built-in auth middlewares and enforce access in your handlers with the guards: ```rust use ultimo::auth::jwt::Jwt; use ultimo::prelude::*; // Verify JWTs and attach the caller to the Context. app.use_middleware(Jwt::hs256(b"super-secret-key").build()); app.get("/admin", |ctx: Context| async move { ctx.require_scope("admin").await?; // 401 if unauthenticated, 403 if missing ctx.json(json!({ "ok": true })).await }); ``` See [JWT](/jwt), [API Keys](/api-keys), and [Authorization](/authorization). ### Sharing data between middleware and handlers The `Context` carries a small **string** key/value store, written and read with async `set`/`get`: ```rust use std::sync::Arc; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::prelude::*; fn request_id() -> BoxedMiddleware { Arc::new(|ctx: Context, next: Next| { Box::pin(async move { // (generate however you like; a counter/uuid/etc.) ctx.set("request_id", "req-123").await; next(ctx).await }) }) } app.get("/whoami", |ctx: Context| async move { let id = ctx.get("request_id").await.unwrap_or_default(); ctx.json(json!({ "request_id": id })).await }); ``` > The store holds `String` values. For structured data, serialize it (e.g. with > `serde_json::to_string`) before `set`, or attach it via a feature that owns its > own typed slot (the `jwt`/`api-key` features expose `ctx.jwt_claims()` / > `ctx.api_key()`). ### Error handling in middleware Wrap `next(ctx)` to observe or transform errors: ```rust use std::sync::Arc; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::prelude::*; fn catch_errors() -> BoxedMiddleware { Arc::new(|ctx: Context, next: Next| { Box::pin(async move { match next(ctx).await { Ok(res) => Ok(res), Err(err) => { tracing::error!("request failed: {err}"); Err(err) // or map to a different UltimoError } } }) }) } ``` ### Order Middleware runs in registration order, wrapping the handler: ```rust use ultimo::middleware::builtin::{logger, cors, security_headers}; app.use_middleware(logger()); // outermost: logs everything app.use_middleware(security_headers()); // adds hardened headers app.use_middleware(cors()); // CORS handling // Flow: logger → security_headers → cors → handler → cors → security_headers → logger ``` ### Complete example ```rust use std::sync::Arc; use std::time::Instant; use hyper::header::{HeaderName, HeaderValue}; use ultimo::middleware::builtin::{cors, logger, security_headers}; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::prelude::*; fn timing() -> BoxedMiddleware { Arc::new(|ctx: Context, next: Next| { Box::pin(async move { let start = Instant::now(); let mut res = next(ctx).await?; res.headers_mut().insert( HeaderName::from_static("x-response-time"), HeaderValue::from_str(&format!("{}ms", start.elapsed().as_millis())) .unwrap(), ); Ok(res) }) }) } #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new_without_defaults(); app.use_middleware(logger()); app.use_middleware(security_headers()); app.use_middleware(cors()); app.use_middleware(timing()); app.get("/", |ctx: Context| async move { ctx.json(json!({ "message": "Hello!" })).await }); app.listen("127.0.0.1:3000").await } ``` ### Best practices * **Keep each middleware focused** — one concern per layer. * **Order matters** — put logging outermost, then security/CORS, then app-specific layers. * **Prefer the built-ins** for auth, CORS, and headers rather than re-implementing them. * **Return errors, don't panic** — an `Err(UltimoError::…)` becomes a proper HTTP error response. ## OpenAPI Support Ultimo automatically generates OpenAPI 3.0 specifications from your RPC procedures, enabling integration with Swagger UI, Postman, and code generation tools. ### Overview The OpenAPI generator creates complete API specifications including: * All RPC endpoints with paths and methods * Request/response schemas from TypeScript type definitions * Parameter definitions (path, query, body) * HTTP status codes and error responses * Server information and metadata ### Basic Usage Generate an OpenAPI spec from your RPC registry: ```rust use ultimo::prelude::*; use ultimo::rpc::RpcMode; let rpc = RpcRegistry::new_with_mode(RpcMode::Rest); // Register procedures rpc.query("getUser", handler, "{ id: number }", "User"); rpc.mutation("createUser", handler, "{ name: string; email: string }", "User"); // Generate OpenAPI spec let openapi = rpc.generate_openapi( "My API", // API title "1.0.0", // Version "/api" // Base path ); // Write to file openapi.write_to_file("openapi.json")?; ``` ### Complete Example ```rust use ultimo::prelude::*; use ultimo::rpc::RpcMode; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct GetUserInput { id: u32, } #[derive(Serialize)] struct User { id: u32, name: String, email: String, created_at: String, } #[derive(Deserialize)] struct CreateUserInput { name: String, email: String, } #[derive(Deserialize)] struct ListUsersInput { page: Option, limit: Option, } #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new_with_mode(RpcMode::Rest); // Register queries (GET) rpc.query( "listUsers", |input: ListUsersInput| async move { Ok(json!({ "users": [], "total": 0, "page": input.page.unwrap_or(1) })) }, r#"{ page?: number; limit?: number; }"#.to_string(), r#"{ users: User[]; total: number; page: number; }"#.to_string(), ); rpc.query( "getUser", |input: GetUserInput| async move { Ok(User { id: input.id, name: "Alice".to_string(), email: "alice@example.com".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(), }) }, "{ id: number }".to_string(), r#"{ id: number; name: string; email: string; created_at: string; }"#.to_string(), ); // Register mutations (POST) rpc.mutation( "createUser", |input: CreateUserInput| async move { Ok(User { id: 1, name: input.name, email: input.email, created_at: "2024-01-01T00:00:00Z".to_string(), }) }, r#"{ name: string; email: string; }"#.to_string(), "User".to_string(), ); rpc.mutation( "deleteUser", |input: GetUserInput| async move { Ok(json!({ "success": true })) }, "{ id: number }".to_string(), "{ success: boolean }".to_string(), ); // Generate OpenAPI specification let openapi = rpc.generate_openapi( "User Management API", "1.0.0", "/api" ); openapi.write_to_file("openapi.json")?; println!("✅ OpenAPI spec generated: openapi.json"); app.listen("127.0.0.1:3000").await } ``` ### Generated OpenAPI Structure The generated `openapi.json` looks like: ```json { "openapi": "3.0.0", "info": { "title": "User Management API", "version": "1.0.0" }, "servers": [ { "url": "http://localhost:3000/api" } ], "paths": { "/listUsers": { "get": { "operationId": "listUsers", "parameters": [ { "name": "page", "in": "query", "schema": { "type": "number" } }, { "name": "limit", "in": "query", "schema": { "type": "number" } } ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "users": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }, "total": { "type": "number" }, "page": { "type": "number" } } } } } } } } }, "/createUser": { "post": { "operationId": "createUser", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string" } }, "required": ["name", "email"] } } } }, "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } } } } } }, "components": { "schemas": { "User": { "type": "object", "properties": { "id": { "type": "number" }, "name": { "type": "string" }, "email": { "type": "string" }, "created_at": { "type": "string" } } } } } } ``` ### View with Swagger UI Use Docker to quickly view your API docs: ```bash docker run -p 8080:8080 \ -e SWAGGER_JSON=/openapi.json \ -v $(pwd)/openapi.json:/openapi.json \ swaggerapi/swagger-ui ``` Then open [http://localhost:8080](http://localhost:8080) ### Mock Server with Prism Create a mock server for testing: ```bash # Install Prism npm install -g @stoplight/prism-cli # Run mock server prism mock openapi.json # Test it curl http://localhost:4010/api/getUser?id=1 ``` ### Generate Clients Use OpenAPI Generator to create clients in any language: ```bash # Install generator npm install -g @openapitools/openapi-generator-cli # Generate TypeScript client openapi-generator-cli generate \ -i openapi.json \ -g typescript-fetch \ -o ./typescript-client # Generate Python client openapi-generator-cli generate \ -i openapi.json \ -g python \ -o ./python-client # Generate Go client openapi-generator-cli generate \ -i openapi.json \ -g go \ -o ./go-client ``` ### Advanced Type Definitions #### Complex Types ```rust rpc.query( "searchUsers", handler, r#"{ query: string; filters?: { role?: 'admin' | 'user' | 'guest'; active?: boolean; createdAfter?: string; }; sort?: { field: 'name' | 'email' | 'created_at'; order: 'asc' | 'desc'; }; pagination?: { page: number; limit: number; }; }"#.to_string(), r#"{ results: User[]; total: number; page: number; hasMore: boolean; }"#.to_string(), ); ``` #### Arrays and Objects ```rust rpc.mutation( "bulkCreateUsers", handler, r#"{ users: Array<{ name: string; email: string; }>; }"#.to_string(), r#"{ created: User[]; failed: Array<{ email: string; error: string; }>; }"#.to_string(), ); ``` #### Union Types ```rust rpc.query( "getResource", handler, "{ id: number }".to_string(), r#"{ data: User | Post | Comment; type: 'user' | 'post' | 'comment'; }"#.to_string(), ); ``` ### RPC Mode Differences #### REST Mode Each procedure gets its own path: ```json { "paths": { "/getUser": { "get": { ... } }, "/createUser": { "post": { ... } } } } ``` #### JSON-RPC Mode Single endpoint with method parameter: ```json { "paths": { "/rpc": { "post": { "requestBody": { "schema": { "properties": { "method": { "type": "string", "enum": ["getUser", "createUser", "deleteUser"] }, "params": { ... } } } } } } } } ``` ### CI/CD Integration Generate specs automatically in CI: ```yaml # .github/workflows/openapi.yml name: Generate OpenAPI Spec on: push: branches: [main] jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Generate OpenAPI spec run: | cargo run --bin generate-openapi - name: Upload artifact uses: actions/upload-artifact@v3 with: name: openapi-spec path: openapi.json ``` ### Best Practices #### ✅ Version Your API ```rust let openapi = rpc.generate_openapi( "My API", "2.1.0", // Semantic versioning "/api/v2" // Version in URL ); ``` #### ✅ Document Complex Types ```rust rpc.query( "getUserProfile", handler, "{ id: number }".to_string(), r#"{ user: { id: number; name: string; email: string; }; stats: { posts: number; followers: number; following: number; }; preferences: { theme: 'light' | 'dark'; notifications: boolean; }; }"#.to_string(), ); ``` #### ✅ Keep Specs Updated Regenerate OpenAPI specs when you change your API: ```bash # Add to your build script cargo run --bin generate-openapi git add openapi.json git commit -m "Update OpenAPI spec" ``` #### ✅ Test with Mock Server Use Prism to test your frontend against the OpenAPI spec: ```bash # Start mock server prism mock openapi.json & # Run frontend tests npm test # Stop mock server killall prism ``` ## Performance Performance is one of Ultimo's two headline pillars (alongside [security](/security)). Our approach is deliberately un-flashy: **measure honestly, guard against regressions, and let you reproduce everything.** No cherry-picked bar charts. ### Why Ultimo is fast * **Built on Hyper 1.0 + Tokio** — the same battle-tested async HTTP core that underpins the fastest Rust servers. Ultimo is a thin, well-organized layer over it, not a re-implementation. * **100% safe Rust, zero `unsafe`** (`#![forbid(unsafe_code)]`) — native, compiled, no garbage collector and no runtime reflection. Safety *and* systems-level speed. * **O(1) static routing** — route lookup is constant-time regardless of how many routes you register (see below). * **Low per-request overhead** — the framework layer (routing, dispatch, response building) is a small, measured constant on top of Hyper. ### What we measure Benchmarks come in two tiers, for two purposes (full detail in [`BENCHMARKS.md`](https://github.com/ultimo-rs/ultimo/blob/main/BENCHMARKS.md)): 1. **Framework-overhead micro-benchmarks** (criterion, in-process via `Ultimo::oneshot`). These isolate Ultimo's own cost from network and OS noise, so they're low-variance and reproducible — the basis for our regression gate. 2. **End-to-end load benchmarks** (`oha` over real HTTP). Real requests-per-second, for cross-framework comparison — only meaningful on controlled hardware, never a shared CI runner. We don't conflate the two, and we don't publish cloud-VM throughput as if it were a controlled measurement. ### A proven result: O(1) routing Our benchmark suite earned its keep the day it landed. It revealed that route matching scaled **linearly** with the number of registered routes — an O(N) scan. We fixed it by indexing static routes in a hash map; lookup is now **constant-time**: | Registered routes | Before | After | | ----------------- | ------------- | -------- | | 10 | baseline | baseline | | 100 | \~3.6× slower | **flat** | | 500 | \~15× slower | **flat** | Found and fixed via the suite itself ([#89](https://github.com/ultimo-rs/ultimo/issues/89)), with the benchmarks proving the improvement. That's the whole point of measuring. ### Regression-guarded Every pull request that touches the framework runs the micro-benchmark suite against the PR's base commit on the same runner and reports the delta. A change that makes Ultimo slower shows up in review — performance is part of the contract, not an afterthought. ### Reproduce it yourself Framework-overhead micro-benchmarks: ```bash make bench # or just the HTTP-overhead suite: cargo bench -p ultimo --bench http_bench ``` End-to-end throughput on **your** hardware (the only numbers you should trust for your workload): ```bash # Run a release build of your app, then load it from a separate machine/core set: oha -z 30s -c 100 --no-tui http://127.0.0.1:3000/ ``` For a fair cross-framework comparison, run the identical endpoint and load profile against each framework on the same hardware in the same session — see [`BENCHMARKS.md`](https://github.com/ultimo-rs/ultimo/blob/main/BENCHMARKS.md) for the full protocol. Published head-to-head numbers will follow once they're generated on dedicated hardware; until then, the recipe above lets you verify on the box that matters to you. ## Roadmap Where Ultimo is headed. The throughline is **type-safe end to end** — define your API in Rust, get a fully-typed TypeScript client with no drift — sequenced toward a stable **1.0**. ### Coming Soon 🚧 #### Type-safe everything (the differentiator) * ~~🧩 **Finish the codegen story** — scaffold `generate-client` into `ultimo new` templates; TypeScript docs/workflow rewrite; `ultimo generate --watch`.~~ ✅ Shipped in 0.5.1 * 🦀 **Rust client generation** — `ultimo generate --target rust` produces a typed Rust crate (with `reqwest`) from your RPC definitions, enabling compile-time-safe service-to-service communication. Publish to a private registry (CodeArtifact, Artifactory, Cloudsmith) for internal microservice contracts — if Service A changes an RPC signature, Service B won't compile until updated. * 📡 **Typed RPC subscriptions / SSE** — server-push with event types derived from your Rust types (tRPC-style subscriptions, in Rust). * ❗ **End-to-end typed errors** — RPC errors surfaced as typed results in the generated client. * 🎯 **Typed handler extractors** — declare typed path / query / body / header params on a handler; they're parsed and validated automatically, with a structured **422** on bad input. Ultimo already gives type-safe *output* (the TS client); this gives type-safe *input* — the handler signature becomes the contract. Inspired by FastAPI / axum. * ✅ **Validation from types** — derive request validation from the type (Pydantic-style), wired into the extractor and RPC boundary. * 🪄 **`#[derive(UltimoType)]`** — a thin wrapper so client types derive without adding `ts-rs` to your own `Cargo.toml`. #### Real-time & streaming * 🌊 **Streaming responses** — large-body / chunked streaming. * 🗜️ **WebSocket compression** — per-message deflate (RFC 7692). #### Auth & sessions * 🔐 **OAuth2** — provider integration on top of the existing JWT/API-key auth. * 🎫 **Redis sessions** — distributed session storage. #### Production-readiness * ~~📦 **Deployment guides** — Docker, Fly.io/Railway, AWS, DigitalOcean, Azure, Google Cloud Run, Kubernetes — each with ready-to-use config.~~ ✅ Shipped in 0.5.1 * ~~🛡️ **Advanced rate limiting** — per-user, per-endpoint limits.~~ ✅ Shipped in 0.5.1 * ⏱️ **Request timeouts** + **HTTP graceful shutdown** (WebSocket graceful shutdown already ships). * ~~🔄 **Hot reload** — `ultimo dev` with file-watching auto-restart.~~ ✅ Shipped in 0.5.1 #### Developer experience * ~~📖 **Turnkey interactive API docs** — one call (`app.serve_docs("/docs")`) to serve Swagger UI / Scalar / ReDoc wired to the generated OpenAPI spec (FastAPI's signature `/docs`). The Swagger UI helper exists today; this makes it one line.~~ ✅ Shipped in 0.5.1 * 🧩 **Built-in middleware pack** — small, expected pieces: `request-id`, `cache-control` / ETag helpers, `server-timing`, basic-auth, trailing-slash normalization (Hono-style breadth). #### Integrations One principle throughout: a **generic capability in core**, plus **thin presets / adapters** and **cookbook guides** — never bundled vendor SDKs (which bloat dependencies and rot as each vendor's API changes). * 🔑 **Auth providers (OIDC/JWKS)** — verify RS256/ES256 JWTs against a provider's JWKS endpoint (caching + key rotation, `iss`/`aud` validation), with presets/guides for Clerk, Cognito, Auth0, Supabase, WorkOS, Keycloak. Extends the `jwt` feature. * 📈 **Observability** — OpenTelemetry traces + metrics, and a Prometheus endpoint (built on the `tracing` already used internally). * 🧰 **Redis** — one integration backing sessions, the rate-limit store, and response/data caching. * 🗄️ **S3-compatible object storage** — pairs with the existing multipart upload support (demand-driven). * ⚙️ **Background tasks** — in-process Tokio job spawning (demand-driven). > Cookbook guides only (app-layer, not framework code): email > (SMTP/Resend/Postmark), payments (Stripe webhooks), and vendor message queues. #### Toward 1.0 * 🧱 **API stabilization** — public-API audit, documentation completeness, SemVer freeze. #### Maybe / demand-driven (post-1.0) * 🤖 **MCP server** — Model Context Protocol server for AI-assisted development. * 🌍 **Python client generation** — extend the codegen engine beyond TypeScript. > Removed from the roadmap: HTTP/2 Push (deprecated in browsers), Long Polling > (covered by SSE + WebSockets), the AI dev-UI cluster (Development Dashboard / > Schema Introspection / Smart Suggestions), Go/Dart/Swift client generation > (focus stays TypeScript-first), and a standalone Mock Server (testing > utilities already ship). > > Considered and declined: edge / multi-runtime deployment (Hono's > Workers/WASM model) — an architectural mismatch with Ultimo's native > Hyper + Tokio core; and server-side rendering / JSX templating — counter to > the "Rust API + typed TypeScript frontend" model (a cookbook at most). ### Feature Status | Feature | Status | Version | | ------------------------------------------------------- | ---------------- | -------- | | **Core Routing** | ✅ Available | 0.1.0 | | **Middleware** | ✅ Available | 0.1.0 | | **RPC System** | ✅ Available | 0.1.0 | | **TypeScript Generation** | ✅ Available | 0.1.0 | | **OpenAPI Support** | ✅ Available | 0.1.0 | | **CLI Tools** | ✅ Available | 0.1.0 | | **CORS** | ✅ Available | 0.1.0 | | **Database Integration** | ✅ Available | 0.1.0 | | **WebSocket Support** | ✅ Available | 0.2.1 | | **Sessions & Cookies** | ✅ Available | 0.3.0 | | **Testing Utilities** | ✅ Available | 0.3.0 | | **100% Safe Rust** (`forbid(unsafe)`) | ✅ Available | 0.3.0 | | Security Headers (HSTS/CSP/…) | ✅ Available | 0.4.0 | | CSRF Protection | ✅ Available | 0.4.0 | | Request Body-Size Limit | ✅ Available | 0.4.0 | | Real Client IP (proxy-aware) | ✅ Available | 0.4.0 | | Auth: JWT middleware | ✅ Available | 0.4.0 | | Auth: API keys | ✅ Available | 0.4.0 | | Auth: authorization guards | ✅ Available | 0.4.0 | | Benchmark Suite (criterion) | ✅ Available | 0.4.0 | | Perf CI Regression Guard | ✅ Available | 0.4.0 | | Static Files + SPA Fallback | ✅ Available | 0.4.1 | | Compression (gzip/brotli) | ✅ Available | 0.4.1 | | TypeScript Type Derivation (`client-gen`) | ✅ Available | 0.5.0 | | `ultimo generate` (runs generate-client) | ✅ Available | 0.5.0 | | IP Allow/Deny (CIDR) | ✅ Available | 0.5.1 | | Codegen: `ultimo new` scaffold + `--watch` | ✅ Available | 0.5.1 | | Rust Client Generation (`--target rust`) | 📋 Planned | 0.6.0 | | Interactive API Docs (`serve_docs`) | ✅ Available | 0.5.1 | | Deployment Guides | ✅ Available | 0.5.1 | | Advanced Rate Limiting | ✅ Available | 0.5.1 | | Typed Handler Extractors + Validation | 📋 Planned | 0.7.0 | | Typed RPC Subscriptions / SSE | 📋 Planned | 0.7.0 | | OAuth2 | 📋 Planned | 0.7.0 | | Auth Providers (OIDC/JWKS + presets) | 📋 Planned | 0.7.0 | | Observability (OpenTelemetry + Prometheus) | 📋 Planned | 0.7.0 | | Streaming Responses | 📋 Planned | 0.7.0 | | Request Timeouts + HTTP Graceful Shutdown | 📋 Planned | 0.7.0 | | Redis (sessions · cache · rate-limit) | 📋 Planned | 0.8.0 | | WebSocket Compression | 📋 Planned | 0.8.0 | | Hot Reload (`ultimo dev`) | ✅ Available | 0.5.1 | | `#[derive(UltimoType)]` | 📋 Planned | 0.8.0 | | Built-in Middleware Pack (request-id, cache-control, …) | 📋 Planned | 0.8.0 | | End-to-end Typed Errors | 📋 Planned | 0.9.0 | | S3-compatible Storage | 💡 Demand-driven | post-1.0 | | Background Tasks | 💡 Demand-driven | post-1.0 | | MCP Server | 💡 Demand-driven | post-1.0 | | Python Client Generation | 💡 Demand-driven | post-1.0 | ### Version Timeline #### v0.1.0 * Core framework features * RPC system with TypeScript generation * OpenAPI support * CLI tools * Database integration (SQLx & Diesel) #### v0.2.1 * ✅ **WebSocket Support (Complete)** - Production-ready RFC 6455 implementation * ✅ **Configuration System** - WebSocketConfig with size limits, timeouts, and buffer sizes * ✅ **Message Fragmentation** - Automatic chunking and reassembly for large payloads * ✅ **Automatic Ping/Pong** - Heartbeat with configurable intervals and timeout detection * ✅ **Graceful Shutdown** - Server-wide notifications with broadcast\_all() * ✅ **Backpressure Handling** - Bounded channels, on\_drain callback, capacity tracking * ✅ **Built-in Pub/Sub System** - Topic-based messaging with backpressure support * ✅ **Type-safe WebSocketHandler** - Typed context data and lifecycle callbacks * ✅ **Router Optimization** - Radix Tree for O(L) lookups * ✅ **279 Comprehensive Tests** - Production-ready with extensive test coverage * ✅ **Two Working Examples** - Simple chat + React chat #### v0.3.0 * ✅ **Session management** — cookie-based sessions, `SessionStore` + in-memory store, secure middleware * ✅ **Cookie helper** — parsing + `Set-Cookie` with security guards * ✅ **Testing utilities** — in-process `TestClient`, assertions, middleware/DB/fixture helpers * ✅ **Router precedence fix**, `Ultimo::oneshot`, `Request::raw_body()` * ✅ MSRV 1.86, hardened dependency floors, automated releases + CI guardrails #### v0.4.0 — Security & Performance Ultimo's two headline pillars: **secure-by-default** and **proven performance**. **Security** (secure-by-default · defense-in-depth · provable) * ✅ Security headers middleware (HSTS, CSP, X-Frame-Options, …) * ✅ CSRF protection (integrates with sessions) * ✅ Auth: JWT + API-key middleware + authorization guards (scopes) * ✅ Request body-size limits + real client IP (trusted-proxy aware) * ✅ `#![forbid(unsafe_code)]` (100% safe Rust), `SECURITY.md` disclosure policy, `cargo-deny` + `cargo-audit` in CI **Performance** (proven · regression-guarded) * ✅ Framework-overhead benchmark suite (criterion) + methodology (BENCHMARKS.md) + advisory CI regression check * ✅ `/performance` page with reproducible methodology + self-serve recipe #### v0.4.1 * ✅ **Static file serving + SPA fallback** — `serve_static`, `serve_spa`, ETag caching, path-traversal protection (`static-files` feature) * ✅ **Response compression** — automatic brotli/gzip middleware, pure Rust, builder API (`compression` feature) #### v0.5.0 — Type-safe clients, for real * ✅ **TypeScript type derivation** (`client-gen` feature) — RPC client types are derived from your Rust types via `ts-rs`; the generated client emits real `type X = {...}` declarations instead of hand-written strings. `ts_rs::TS` re-exported as `ultimo::rpc::TS`. * ✅ **Derived `query`/`mutation`** (breaking) — infer input/output types from `#[derive(TS)]` structs; string-typed `query_with_types`/`mutation_with_types` remain as escape hatches. * ✅ **`ultimo generate` runs for real** — executes the project's `generate-client` binary (no more artifact-hunting); `assert_cmd` CLI tests. #### v0.5.1 * ✅ **Hot reload** — `ultimo dev` with file-watching auto-restart * ✅ **IP allow/deny middleware** — CIDR-aware `IpFilter` (allow-list / deny-list) #### v0.6.0 (Current) — Finish the codegen story + ship-readiness * ~~Codegen: scaffold `generate-client` into `ultimo new` templates; TypeScript docs rewrite; `ultimo generate --watch`~~ ✅ Shipped * **Rust client generation** — `ultimo generate --target rust` for typed service-to-service RPC (microservice contracts via private crate registry) * ~~**Turnkey interactive API docs** — `serve_docs` (Swagger UI / Scalar / ReDoc)~~ ✅ Shipped * ~~Deployment guides (Docker, Fly.io/Railway, AWS, DigitalOcean, Azure, GCR, K8s)~~ ✅ Shipped * ~~Advanced rate limiting (per-user, per-endpoint)~~ ✅ Shipped #### v0.7.0 — Real-time, auth, & production gaps * **Typed handler extractors + validation-from-types** (type-safe input) * Typed RPC subscriptions / SSE (derived event types) * OAuth2 provider integration * **Auth providers (OIDC/JWKS)** — RS256/ES256 + remote JWKS, presets for Clerk / Cognito / Auth0 / Supabase * **Observability** — OpenTelemetry traces + metrics, Prometheus endpoint * Streaming responses * Request timeouts + HTTP graceful shutdown #### v0.8.0 — Distribution & dev experience * **Redis integration** — sessions, cache, and rate-limit store (one dependency) * WebSocket compression (per-message deflate) * `#[derive(UltimoType)]` (drop the `ts-rs` dependency papercut) * Built-in middleware pack (request-id, cache-control/ETag, server-timing, basic-auth) #### v0.9.0 → v1.0.0 — Stabilize * End-to-end typed errors * Public-API audit + SemVer freeze * Documentation completeness * Production battle-testing * *(Demand-driven: S3-compatible storage, background tasks, MCP server, Python client generation)* ### Contributing Want to help? Check out: * [GitHub Issues](https://github.com/ultimo-rs/ultimo/issues) * [Contributing Guide](https://github.com/ultimo-rs/ultimo/blob/main/CONTRIBUTING.md) * [Discord Community](https://discord.gg/ultimo-rs) ### Feature Requests Have an idea? [Open an issue](https://github.com/ultimo-rs/ultimo/issues/new) with the "feature request" label. ### Stay Updated * Follow on [Twitter](https://twitter.com/ultimo_rs) * Join [Discord](https://discord.gg/ultimo-rs) * Watch on [GitHub](https://github.com/ultimo-rs/ultimo) ## 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](/rpc). ### Basic Routes Define routes for different HTTP methods: ```rust 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: ```rust // 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: ```rust // 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: ```rust 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: ```rust // 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: ```rust // 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("

Hello, Ultimo!

").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: ```rust 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: ```json { "error": "NotFound", "message": "User not found" } ``` ### Route Organization Organize routes with route groups: ```rust 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 { ctx.json(json!({"users": []})).await } async fn get_user(ctx: Context) -> ultimo::Result { let id = ctx.req.param("id")?; ctx.json(json!({"id": id})).await } ``` ### Best Practices #### Use Type Conversions ```rust // ✅ 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 ```rust // ✅ 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 ```rust // ✅ Good - descriptive errors if user.is_none() { return Err(UltimoError::NotFound( format!("User {} not found", id) )); } ``` ## RPC System Ultimo's RPC system provides type-safe remote procedure calls with automatic TypeScript generation. ### Overview The RPC system lets you define procedures in Rust that automatically generate type-safe TypeScript clients. Choose between **REST mode** (individual endpoints) or **JSON-RPC mode** (single endpoint). ### REST Mode Each RPC procedure becomes its own HTTP endpoint: ```rust use ultimo::prelude::*; use ultimo::rpc::RpcMode; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct GetUserInput { id: u32, } #[derive(Serialize)] struct User { id: u32, name: String, email: String, } #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); // Create RPC registry in REST mode let rpc = RpcRegistry::new_with_mode(RpcMode::Rest); // Register a query (uses GET) rpc.query( "getUser", |input: GetUserInput| async move { Ok(User { id: input.id, name: "Alice".to_string(), email: "alice@example.com".to_string(), }) }, "{ id: number }".to_string(), "User".to_string(), ); // Register a mutation (uses POST) rpc.mutation( "createUser", |input: CreateUserInput| async move { Ok(User { /* ... */ }) }, "{ name: string; email: string }".to_string(), "User".to_string(), ); // Generate TypeScript client rpc.generate_client_file("../frontend/src/lib/client.ts")?; app.listen("127.0.0.1:3000").await } ``` **Generated endpoints:** * `GET /api/getUser?id=1` * `POST /api/createUser` ### JSON-RPC Mode All procedures use a single endpoint: ```rust use ultimo::prelude::*; #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); // Create RPC registry (JSON-RPC is default) let rpc = RpcRegistry::new(); // Register procedures rpc.register_with_types( "getUser", |input: GetUserInput| async move { Ok(User { /* ... */ }) }, "{ id: number }".to_string(), "User".to_string(), ); // Generate TypeScript client rpc.generate_client_file("../frontend/src/lib/client.ts")?; // Single RPC endpoint app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let req: RpcRequest = ctx.req.json().await?; let result = rpc.call(&req.method, req.params).await?; ctx.json(result).await } }); app.listen("127.0.0.1:3000").await } ``` **All requests go to:** * `POST /rpc` Request body: ```json { "method": "getUser", "params": { "id": 1 } } ``` ### TypeScript Client The generated TypeScript client is the same for both modes: ```typescript import { UltimoRpcClient } from "./lib/client"; const client = new UltimoRpcClient(); // Fully type-safe calls const user = await client.getUser({ id: 1 }); console.log(user.name); // ✅ TypeScript knows the shape! const newUser = await client.createUser({ name: "Bob", email: "bob@example.com", }); ``` ### When to Use Each Mode #### REST Mode **Use when:** * Building public APIs * HTTP caching is important * Want clear URLs in browser DevTools * Need standard HTTP semantics **Benefits:** * ✅ RESTful URLs (`/api/getUser`) * ✅ HTTP caching works out of the box * ✅ Clear in network inspector * ✅ Works with standard HTTP tools #### JSON-RPC Mode **Use when:** * Building internal APIs * Want simple routing * Need request batching * Prefer RPC semantics **Benefits:** * ✅ Single endpoint (`/rpc`) * ✅ Simple routing logic * ✅ Easy to batch requests * ✅ Clear RPC semantics ### Client Generation Timing The `generate_client_file()` call happens **once at application startup** - it does not interrupt your running server or get called on every request. When your Rust app starts, it generates the TypeScript file, then proceeds to start the server normally. #### Development vs Production For optimal workflows, generate the client only in development builds: ```rust #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new(); // Register your RPC methods... rpc.register_with_types(/* ... */); // Generate TypeScript client (development only) #[cfg(debug_assertions)] { rpc.generate_client_file("../frontend/src/lib/client.ts")?; println!("✅ TypeScript client generated"); } app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let req: RpcRequest = ctx.req.json().await?; let result = rpc.call(&req.method, req.params).await?; ctx.json(result).await } }); app.listen("127.0.0.1:3000").await } ``` This approach: * **Development**: Auto-generates client on every restart (when you add/modify RPC methods) * **Production**: Skips generation for faster startup * **No interruption**: Happens before the server starts listening, doesn't affect request handling ### Complete Example #### Backend ```rust use ultimo::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct ListUsersInput { page: Option, limit: Option, } #[derive(Serialize)] struct ListUsersOutput { users: Vec, total: u32, page: u32, } #[derive(Deserialize)] struct CreateUserInput { name: String, email: String, } #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new(); // List users (query) rpc.register_with_types( "listUsers", |input: ListUsersInput| async move { let page = input.page.unwrap_or(1); let limit = input.limit.unwrap_or(10); Ok(ListUsersOutput { users: vec![], total: 0, page, }) }, "{ page?: number; limit?: number }".to_string(), "{ users: User[]; total: number; page: number }".to_string(), ); // Create user (mutation) rpc.register_with_types( "createUser", |input: CreateUserInput| async move { Ok(User { id: 1, name: input.name, email: input.email, }) }, "{ name: string; email: string }".to_string(), "User".to_string(), ); // Generate client rpc.generate_client_file("../frontend/src/lib/client.ts")?; println!("✅ TypeScript client generated"); // Mount RPC endpoint app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let req: RpcRequest = ctx.req.json().await?; let result = rpc.call(&req.method, req.params).await?; ctx.json(result).await } }); app.listen("127.0.0.1:3000").await } ``` #### Generated TypeScript Client The `generate_client_file()` call produces a type-safe client: ```typescript // Auto-generated TypeScript client for Ultimo RPC (REST Mode) // DO NOT EDIT - This file is automatically generated export class UltimoRpcClient { constructor(private baseUrl: string = "/api") {} private async get(path: string, params?: Record): Promise { const url = new URL(this.baseUrl + path, window.location.origin); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.append(key, String(value)); }); } const response = await fetch(url.toString(), { method: "GET", headers: { "Content-Type": "application/json", }, }); if (!response.ok) { const error = await response .json() .catch(() => ({ message: response.statusText })); throw new Error(error.message || "Request failed"); } return response.json(); } private async post(path: string, body: any): Promise { const response = await fetch(this.baseUrl + path, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!response.ok) { const error = await response .json() .catch(() => ({ message: response.statusText })); throw new Error(error.message || "Request failed"); } return response.json(); } async listUsers(params: { page?: number; limit?: number; }): Promise<{ users: User[]; total: number; page: number }> { return this.get("/listUsers", params); } async createUser(params: { name: string; email: string }): Promise { return this.post("/createUser", params); } } // Type Definitions export interface User { id: number; name: string; email: string; } ``` #### Frontend with React Query ```typescript import { UltimoRpcClient } from "./lib/client"; import { useQuery, useMutation } from "@tanstack/react-query"; const client = new UltimoRpcClient("/api/rpc"); function UserList() { const { data } = useQuery({ queryKey: ["users"], queryFn: () => client.listUsers({ page: 1, limit: 10 }), }); const createUser = useMutation({ mutationFn: (input) => client.createUser(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }); }, }); return (
{data?.users.map((user) => (
{user.name}
))}
); } ``` ### Type Definitions You can define complex TypeScript types: ```rust rpc.register_with_types( "searchUsers", handler, // Input type r#"{ query: string; filters?: { role?: 'admin' | 'user'; active?: boolean; }; pagination?: { page: number; limit: number; }; }"#.to_string(), // Output type r#"{ results: User[]; total: number; hasMore: boolean; }"#.to_string(), ); ``` ### Error Handling RPC procedures automatically handle errors: ```rust rpc.register_with_types( "deleteUser", |input: DeleteUserInput| async move { let user = find_user(input.id) .ok_or_else(|| ultimo::Error::NotFound("User not found".to_string()))?; delete_user(user).await?; Ok(json!({ "success": true })) }, "{ id: number }".to_string(), "{ success: boolean }".to_string(), ); ``` TypeScript client catches errors: ```typescript try { await client.deleteUser({ id: 999 }); } catch (error) { console.error("Delete failed:", error.message); } ``` ### Best Practices #### ✅ Use Descriptive Names ```rust // Good rpc.register("getUserProfile", handler); rpc.register("updateUserEmail", handler); // Bad rpc.register("get", handler); rpc.register("update", handler); ``` #### ✅ Separate Queries and Mutations ```rust // Queries - read-only operations rpc.query("listUsers", handler); rpc.query("getUser", handler); // Mutations - write operations rpc.mutation("createUser", handler); rpc.mutation("deleteUser", handler); ``` #### ✅ Validate Input ```rust use validator::Validate; #[derive(Deserialize, Validate)] struct CreateUserInput { #[validate(length(min = 3, max = 50))] name: String, #[validate(email)] email: String, } rpc.register_with_types( "createUser", |input: CreateUserInput| async move { input.validate()?; // ... create user }, // types... ); ``` ### JSON-RPC 2.0 Protocol Ultimo's JSON-RPC mode is fully compliant with the [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). Use `handle_request()` for spec-compliant dispatch with batch support, notifications, and structured errors. #### Basic Setup ```rust use ultimo::prelude::*; #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new(); rpc.register_with_types("add", |input: AddInput| async move { Ok(AddOutput { sum: input.a + input.b }) }, "{ a: number; b: number }".to_string(), "{ sum: number }".to_string()); // Use handle_request for full JSON-RPC 2.0 support app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let body = ctx.req.bytes().await?; let output = rpc.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 } } } }); app.listen("127.0.0.1:3000").await } ``` #### Single Request ```json // Request {"jsonrpc": "2.0", "method": "add", "params": {"a": 3, "b": 4}, "id": 1} // Response {"jsonrpc": "2.0", "result": {"sum": 7}, "id": 1} ``` #### Batch Requests Send multiple calls in a single HTTP request — all execute concurrently: ```json // Request (array) [ {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}, {"jsonrpc": "2.0", "method": "add", "params": {"a": 10, "b": 20}, "id": 2}, {"jsonrpc": "2.0", "method": "getUser", "params": {"id": 42}, "id": 3} ] // Response (array, correlated by id) [ {"jsonrpc": "2.0", "result": {"sum": 3}, "id": 1}, {"jsonrpc": "2.0", "result": {"sum": 30}, "id": 2}, {"jsonrpc": "2.0", "result": {"id": 42, "name": "Alice"}, "id": 3} ] ``` #### Notifications A request without an `id` field is a notification — the server processes it but sends no response: ```json // Notification (no response) { "jsonrpc": "2.0", "method": "logEvent", "params": { "event": "page_view" } } ``` You can mix notifications with regular requests in a batch: ```json [ {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 1}, "id": 1}, {"jsonrpc": "2.0", "method": "logEvent", "params": {"event": "calc"}}, {"jsonrpc": "2.0", "method": "add", "params": {"a": 2, "b": 2}, "id": 2} ] // Response only contains results for requests with id: [ {"jsonrpc": "2.0", "result": {"sum": 2}, "id": 1}, {"jsonrpc": "2.0", "result": {"sum": 4}, "id": 2} ] ``` #### Error Responses Errors follow the JSON-RPC 2.0 error format with standard codes: ```json // Method not found {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Procedure 'unknown' not found"}, "id": 1} // Invalid params {"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid input: ..."}, "id": 2} // Parse error (invalid JSON) {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null} ``` | Code | Meaning | | ------ | ----------------------------------------- | | -32700 | Parse error (invalid JSON) | | -32600 | Invalid request (missing required fields) | | -32601 | Method not found | | -32602 | Invalid params | | -32603 | Internal error | #### TypeScript Client with Batch The generated TypeScript client includes batch and notification support: ```typescript import { UltimoRpcClient } from "./lib/client"; const client = new UltimoRpcClient("/rpc"); // Single call (fully typed) const result = await client.add({ a: 3, b: 4 }); // Batch multiple calls in one HTTP request const results = await client.batch([ { method: "add", params: { a: 1, b: 2 } }, { method: "add", params: { a: 10, b: 20 } }, { method: "getUser", params: { id: 42 } }, ]); // results[0].result === { sum: 3 } // results[1].result === { sum: 30 } // results[2].result === { id: 42, name: "Alice" } // Fire-and-forget notification await client.notify("logEvent", { event: "button_click" }); ``` #### Backward Compatibility If you send a request without the `jsonrpc: "2.0"` field, Ultimo responds in the legacy format: ```json // Legacy request {"method": "add", "params": {"a": 1, "b": 2}} // Legacy response {"result": {"sum": 3}} ``` This means existing clients continue to work without changes. ## Security Security is one of Ultimo's two core pillars (with performance). The framework is **secure by default**, follows **defense in depth**, and aims to be **provable** (audited, no `unsafe`, public disclosure policy). ### 100% Safe Rust The framework enforces `#![forbid(unsafe_code)]` — there is **zero `unsafe`** in Ultimo. Combined with Rust's guarantees, that rules out memory-safety bugs (buffer overflows, use-after-free, data races) in the framework itself. ### Security headers Add secure-by-default response headers with one line: ```rust use ultimo::middleware::builtin::security_headers; app.use_middleware(security_headers()); ``` Sets HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a restrictive `Permissions-Policy`. Content-Security-Policy is opt-in (a wrong CSP breaks more than it protects). Headers are applied only if the handler didn't already set them, so per-route overrides win. Customize: ```rust use ultimo::middleware::builtin::SecurityHeaders; app.use_middleware( SecurityHeaders::new() .csp("default-src 'self'") .frame_options("SAMEORIGIN") .build(), ); ``` ### Request body-size limit Cap request bodies to reject oversized payloads (DoS protection) with **413**: ```rust app.max_body_size(2 * 1024 * 1024); // 2 MB ``` On the live server an oversized body is never fully buffered. No limit by default — set one in production. ### Client IP & trusted proxies ```rust let ip = ctx.client_ip(); // Option — best-effort originating client let peer = ctx.peer_addr(); // Option — direct connection peer ``` By default `client_ip()` returns the connection peer. Behind a trusted proxy/load balancer, enable header trust so it honors `X-Forwarded-For` (leftmost) then `Forwarded: for=`: ```rust app.trust_proxy(true); // ONLY behind a trusted proxy ``` :::warning `X-Forwarded-For` / `Forwarded` are client-spoofable. Enable `trust_proxy` **only** when the app is actually behind a proxy that sets them — otherwise clients can forge their IP. ::: ### IP allow/deny (CIDR filtering) Block or allow requests by client IP using CIDR notation. Respects `trust_proxy` when enabled. ```rust use ultimo::middleware::builtin::IpFilter; // Allow only private networks + localhost: app.use_middleware(IpFilter::allow(&[ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.1", "::1", ]).build()); // Or deny specific ranges: app.use_middleware(IpFilter::deny(&["203.0.113.0/24"]).build()); ``` Denied requests receive a `403 Forbidden` response. Supports IPv4 and IPv6, bare IPs (treated as `/32` or `/128`), and any valid prefix length. ### Sessions & cookies Cookie-based sessions are secure by default (HttpOnly, Secure, SameSite, 256-bit ids, anti session-fixation, server-side storage). See [Sessions](/sessions). ### CSRF protection The `csrf` feature provides **double-submit cookie** CSRF protection. The middleware issues a random token in a (non-HttpOnly) cookie; on unsafe methods (POST/PUT/PATCH/DELETE) the request must echo that token in a header, and the two are compared in **constant time** (mismatch → 403). Safe methods are exempt. ```rust // Cargo.toml: ultimo = { version = "0.6", features = ["csrf"] } app.use_middleware(ultimo::csrf::csrf()); // or customized: app.use_middleware( ultimo::csrf::Csrf::new() .header_name("x-csrf-token") .same_site(ultimo::cookie::SameSite::Strict) .build(), ); ``` The frontend reads the `csrf_token` cookie and sends it back as the `x-csrf-token` header: ```js const token = document.cookie.split('; ') .find((c) => c.startsWith('csrf_token='))?.split('=')[1]; await fetch('/api/action', { method: 'POST', headers: { 'x-csrf-token': token } }); ``` See the [`session-auth` example](https://github.com/ultimo-rs/ultimo/tree/main/examples/session-auth) for a working login flow with CSRF. ### Supply chain * **`cargo audit`** (RUSTSEC advisories) runs in CI on every PR. * **`cargo deny`** gates advisories, banned/duplicate deps, and source pinning. * **`Cargo.lock` is committed** for reproducible builds. * **`cargo-semver-checks`** guards the public API against accidental breakage. * Minimal dependency surface (the WebSocket layer is zero-dependency). ### Defense in depth For production, deploy Ultimo **behind a managed WAF/CDN** (Cloudflare, AWS WAF, …) for full WAF rules, DDoS mitigation, and geo controls. Ultimo provides the in-app building blocks; the edge handles volumetric and signature-based threats. ### Reporting a vulnerability Please report privately — see [SECURITY.md](https://github.com/ultimo-rs/ultimo/blob/main/SECURITY.md) (GitHub Security Advisories). Do not open a public issue for vulnerabilities. ### Roadmap Planned for v0.4.0: auth middleware (JWT / API keys), authorization guards, IP allow/deny, request guards, and geo-blocking hooks. See the [roadmap](/roadmap). ## Sessions Ultimo ships cookie-based session management behind the `session` feature. Cookies are a core helper; sessions are middleware over a pluggable store. ### Enable ```toml [dependencies] ultimo = { version = "0.6", features = ["session"] } ``` ### Register the middleware ```rust use ultimo::session::{session, MemoryStore, SessionConfig}; let mut app = Ultimo::new_without_defaults(); app.use_middleware(session(MemoryStore::new(), SessionConfig::default())); ``` ### Read & write Access the session from any handler with `ctx.session()`. Values are typed (serde): ```rust // write app.post("/login", |ctx: Context| async move { let s = ctx.session().await; s.set("user_id", &42u64).await?; s.regenerate(); // rotate the id on login (session-fixation defense) ctx.text("logged in").await }); // read app.get("/me", |ctx: Context| async move { let id: Option = ctx.session().await.get("user_id").await?; ctx.json(serde_json::json!({ "user_id": id })).await }); // log out app.post("/logout", |ctx: Context| async move { ctx.session().await.destroy(); ctx.text("bye").await }); ``` Other methods: `remove(key)`, `id()`. `clear()` wipes all session data and — like `destroy()` — removes the store entry and expires the cookie, so it doubles as a logout call. ### Security Defaults are secure-by-default: * **256-bit random ids** (`getrandom`) — opaque and unguessable. * **`HttpOnly` + `Secure` + `SameSite=Lax`** cookie by default. * **Server-side data** — only the id is in the cookie (no tampering surface). * **Anti session-fixation** — client-supplied ids are never adopted; call `regenerate()` on login/privilege change (the middleware issues a fresh id and drops the old one). * **Anti-DoS** — empty/untouched sessions are never persisted and get no cookie. `MemoryStore` is unbounded by default; use `MemoryStore::with_max_sessions(n)` in production to cap live sessions (soonest-to-expire evicted first) and bound worst-case memory under sustained load. * **Expiry** — enforced server-side (store TTL) *and* via the cookie `Max-Age`. \:::warning CSRF `SameSite=Lax` is partial CSRF protection. Full CSRF tokens are a planned follow-up — don't rely on sessions alone for CSRF defense yet. \::: #### Local development The `Secure` cookie isn't sent over plain HTTP. For local dev: ```rust SessionConfig::default().secure(false) // dev only ``` ### Configuration ```rust use std::time::Duration; use ultimo::cookie::SameSite; SessionConfig::default() .cookie_name("my_sid") .ttl(Duration::from_secs(3600)) .same_site(SameSite::Strict) .secure(true) ``` `SameSite::None` requires `secure(true)` (enforced at construction). ### Cookies The session feature builds on the core cookie helper, which you can use directly: ```rust use ultimo::cookie::{Cookie, SameSite}; // set ctx.set_cookie( Cookie::new("theme", "dark") .http_only(true) .same_site(SameSite::Lax) .max_age(86_400), ).await?; // read let theme = ctx.cookie("theme"); // Option // delete ctx.remove_cookie("theme").await?; ``` ### Custom stores `MemoryStore` (single-process) is built in — `MemoryStore::new()` is unbounded; `MemoryStore::with_max_sessions(n)` caps it, evicting the soonest-to-expire session to make room once full. Implement `SessionStore` for other backends: ```rust #[async_trait::async_trait] impl SessionStore for MyStore { async fn load(&self, id: &str) -> Option { /* ... */ } async fn store(&self, id: &str, data: &SessionData, ttl: Duration) { /* ... */ } async fn destroy(&self, id: &str) { /* ... */ } } ``` Redis and SQL stores are planned follow-ups. ### Full example See [`examples/session-auth`](https://github.com/ultimo-rs/ultimo/tree/main/examples/session-auth) — a runnable backend serving an HTML+JS login/logout page. ## SQLx Integration Ultimo provides first-class support for **SQLx**, a fully async SQL toolkit with compile-time query verification. ### Why SQLx? * ⚡ **Fully Async** - Built on top of Tokio for maximum performance * 🔍 **Compile-time Verification** - Catch SQL errors at compile time * 🗃️ **Multiple Databases** - PostgreSQL, MySQL, SQLite support * 🚀 **Connection Pooling** - Automatic connection management * 🎯 **Raw SQL** - Write SQL directly with parameter binding * 🔄 **Transactions** - Full transaction support * 📦 **Built-in Migrations** - Database migrations out of the box ### Installation Add dependencies to `Cargo.toml`: ```toml [dependencies] ultimo = { version = "0.6", features = ["sqlx-postgres"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } ``` For other databases: * **MySQL**: `features = ["runtime-tokio", "mysql"]` * **SQLite**: `features = ["runtime-tokio", "sqlite"]` ### Basic Setup ```rust use ultimo::prelude::*; use ultimo::database::sqlx::SqlxPool; use sqlx::FromRow; #[derive(Serialize, FromRow)] struct User { id: i32, name: String, email: String, } #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); // Connect to database let database_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://postgres:postgres@localhost/mydb".to_string()); let pool = SqlxPool::connect(&database_url).await?; app.with_sqlx(pool); // Routes app.get("/users", get_users); app.post("/users", create_user); app.get("/users/:id", get_user); app.put("/users/:id", update_user); app.delete("/users/:id", delete_user); app.listen("127.0.0.1:3000").await } ``` ### CRUD Operations #### Read (Query) ```rust // List all users app.get("/users", |ctx: Context| async move { let db = ctx.sqlx::()?; let users = sqlx::query_as::<_, User>( "SELECT id, name, email FROM users ORDER BY id" ) .fetch_all(db) .await?; ctx.json(json!({ "users": users, "total": users.len() })).await }); // Get single user app.get("/users/:id", |ctx: Context| async move { let id: i32 = ctx.req.param("id")?.parse()?; let db = ctx.sqlx::()?; let user = sqlx::query_as::<_, User>( "SELECT id, name, email FROM users WHERE id = $1" ) .bind(id) .fetch_optional(db) .await?; match user { Some(user) => ctx.json(user).await, None => Err(UltimoError::NotFound("User not found".to_string())), } }); ``` #### Create (Insert) ```rust #[derive(Deserialize)] struct CreateUserInput { name: String, email: String, } app.post("/users", |ctx: Context| async move { let input: CreateUserInput = ctx.req.json().await?; let db = ctx.sqlx::()?; let user = sqlx::query_as::<_, User>( "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email" ) .bind(&input.name) .bind(&input.email) .fetch_one(db) .await?; ctx.status(201).await; ctx.json(user).await }); ``` #### Update ```rust #[derive(Deserialize)] struct UpdateUserInput { name: Option, email: Option, } app.put("/users/:id", |ctx: Context| async move { let id: i32 = ctx.req.param("id")?.parse()?; let input: UpdateUserInput = ctx.req.json().await?; let db = ctx.sqlx::()?; let user = sqlx::query_as::<_, User>( "UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email) WHERE id = $3 RETURNING id, name, email" ) .bind(input.name) .bind(input.email) .bind(id) .fetch_optional(db) .await?; match user { Some(user) => ctx.json(user).await, None => Err(UltimoError::NotFound("User not found".to_string())), } }); ``` #### Delete ```rust app.delete("/users/:id", |ctx: Context| async move { let id: i32 = ctx.req.param("id")?.parse()?; let db = ctx.sqlx::()?; let result = sqlx::query("DELETE FROM users WHERE id = $1") .bind(id) .execute(db) .await?; if result.rows_affected() == 0 { return Err(UltimoError::NotFound("User not found".to_string())); } ctx.status(204).await; Ok(()) }); ``` ### Transactions Execute multiple queries atomically: ```rust app.post("/transfer", |ctx: Context| async move { #[derive(Deserialize)] struct Transfer { from_account: i32, to_account: i32, amount: f64, } let transfer: Transfer = ctx.req.json().await?; let db = ctx.sqlx::()?; // Start transaction let mut tx = db.begin().await?; // Deduct from sender sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2") .bind(transfer.amount) .bind(transfer.from_account) .execute(&mut *tx) .await?; // Add to receiver sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2") .bind(transfer.amount) .bind(transfer.to_account) .execute(&mut *tx) .await?; // Commit transaction tx.commit().await?; ctx.json(json!({"success": true, "amount": transfer.amount})).await }); ``` ### Connection Pooling Configure connection pool settings: ```rust use sqlx::postgres::PgPoolOptions; use std::time::Duration; let pool = PgPoolOptions::new() .max_connections(10) // Maximum number of connections .min_connections(2) // Minimum number of connections .connect_timeout(Duration::from_secs(5)) .idle_timeout(Duration::from_secs(300)) .acquire_timeout(Duration::from_secs(3)) .connect(&database_url) .await?; let sqlx_pool = SqlxPool::from_pool(pool); app.with_sqlx(sqlx_pool); ``` ### Query Macros SQLx provides macros for compile-time verification: ```rust // Verified at compile time! let user = sqlx::query_as!( User, "SELECT id, name, email FROM users WHERE id = $1", id ) .fetch_one(db) .await?; ``` **Note**: Requires `DATABASE_URL` environment variable set at compile time. ### Error Handling Handle database-specific errors gracefully: ```rust app.post("/users", |ctx: Context| async move { let input: CreateUserInput = ctx.req.json().await?; let db = ctx.sqlx::()?; let user = sqlx::query_as::<_, User>( "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email" ) .bind(&input.name) .bind(&input.email) .fetch_one(db) .await .map_err(|e| { // Handle unique constraint violations if let sqlx::Error::Database(db_err) = &e { if db_err.constraint() == Some("users_email_key") { return UltimoError::BadRequest("Email already exists".to_string()); } } UltimoError::Internal(format!("Database error: {}", e)) })?; ctx.status(201).await; ctx.json(user).await }); ``` ### Migrations SQLx includes a built-in migration tool: ```bash # Create migrations directory mkdir -p migrations # Create a migration sqlx migrate add create_users_table # Edit the generated SQL file echo "CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP );" > migrations/$(ls migrations | tail -1) # Run migrations sqlx migrate run ``` Apply migrations in your app: ```rust use sqlx::migrate::Migrator; #[tokio::main] async fn main() -> ultimo::Result<()> { let mut app = Ultimo::new(); let pool = SqlxPool::connect(&database_url).await?; // Run migrations let migrator = Migrator::new(std::path::Path::new("./migrations")).await?; migrator.run(pool.pool()).await?; app.with_sqlx(pool); // ... rest of setup } ``` ### Health Checks Add a health check endpoint: ```rust app.get("/health", |ctx: Context| async move { let db = ctx.sqlx::()?; // Check database connection match sqlx::query("SELECT 1").execute(db).await { Ok(_) => ctx.json(json!({ "status": "healthy", "database": "connected" })).await, Err(e) => { ctx.status(503).await; ctx.json(json!({ "status": "unhealthy", "database": format!("error: {}", e) })).await } } }); ``` ### Testing Write tests with a test database: ```rust #[cfg(test)] mod tests { use super::*; async fn setup_test_db() -> SqlxPool { let pool = SqlxPool::connect("postgres://localhost/test_db") .await .unwrap(); // Create test table sqlx::query( "CREATE TABLE IF NOT EXISTS test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL )" ) .execute(pool.pool()) .await .unwrap(); pool } #[tokio::test] #[ignore] // Run with: cargo test -- --ignored async fn test_create_user() { let pool = setup_test_db().await; let user = sqlx::query_as::<_, User>( "INSERT INTO test_users (name, email) VALUES ($1, $2) RETURNING id, name, email" ) .bind("Alice") .bind("alice@example.com") .fetch_one(pool.pool()) .await .unwrap(); assert_eq!(user.name, "Alice"); assert_eq!(user.email, "alice@example.com"); } } ``` ### Best Practices 1. **Use connection pooling** - Don't create new connections per request 2. **Parameterize queries** - Prevent SQL injection with `$1`, `$2` placeholders 3. **Use transactions** - For operations that must succeed or fail together 4. **Handle errors gracefully** - Convert database errors to user-friendly messages 5. **Add indexes** - For frequently queried columns 6. **Use migrations** - Track database schema changes 7. **Monitor connection pool** - Watch for connection exhaustion ### Examples Check out the working example: ```bash cd examples/database-sqlx DATABASE_URL=postgres://postgres:postgres@localhost/ultimo_test cargo run ``` ### See Also * [Database Overview](/database) * [Diesel Integration](/diesel) * [Testing Guide](/testing) ## Static Files Serve frontend assets — CSS, JavaScript, images — directly from disk, and support Single Page Application (SPA) routing with a fallback to `index.html`. Requires the `static-files` Cargo feature: ```toml ultimo = { version = "0.6", features = ["static-files"] } ``` ### Serving a directory `serve_static(prefix, dir)` registers a `GET {prefix}/*` route that reads files from `dir` on disk. ```rust use ultimo::prelude::*; let mut app = Ultimo::new(); // GET /assets/style.css → reads ./public/style.css app.serve_static("/assets", "./public"); app.listen("127.0.0.1:3000").await ``` **Response headers set automatically:** * `Content-Type` — detected from the file extension. * `ETag` — `"{size}-{mtime_secs}"`, used for conditional GET. * `Content-Length`. **Conditional GET:** If the client sends `If-None-Match` matching the current ETag, the server returns `304 Not Modified` with an empty body, saving bandwidth on repeat visits. ### SPA fallback For Single Page Applications where the client-side router handles URLs (React Router, Vue Router, etc.), every `GET` request that doesn't match an API route should return `index.html`. Use `serve_spa`: ```rust use ultimo::prelude::*; let mut app = Ultimo::new(); // API routes first — they take precedence over the SPA fallback app.get("/api/user", |ctx: Context| async move { ctx.json(serde_json::json!({ "name": "Ada" })).await }); // Serve static assets from ./dist/assets under /assets app.serve_static("/assets", "./dist/assets"); // Fallback: any unmatched GET → ./dist/index.html app.serve_spa("./dist", "index.html"); app.listen("127.0.0.1:3000").await ``` > `serve_spa` only intercepts `GET` requests that returned 404. > `POST`, `PUT`, `DELETE`, etc. 404s pass through unchanged. ### Security Path traversal is prevented at the filesystem level: the resolved path is canonicalized and must remain inside the configured root directory. Requests like `GET /assets/../../etc/passwd` return 404 — no existence leak, no directory escape. Directory listing is not supported — requesting a path that resolves to a directory returns 404. Use `serve_spa` if you want a catch-all. ### Pairing with compression Mount the `compression()` middleware before serving static files to enable automatic gzip/brotli on text assets: ```rust use ultimo::middleware::builtin::compression; use ultimo::prelude::*; let mut app = Ultimo::new(); app.use_middleware(compression()); app.serve_static("/assets", "./dist/assets"); app.serve_spa("./dist", "index.html"); ``` See the [Compression](/middleware#compression) section for details. ### Full example See [`examples/spa-demo`](https://github.com/ultimo-rs/ultimo/tree/main/examples/spa-demo) for a runnable end-to-end demo combining static files, SPA fallback, and compression. ```bash cargo run -p spa-demo # → http://127.0.0.1:3000 ``` ## Testing Ultimo ships first-class testing utilities behind the `testing` feature. The `TestClient` drives your app **in-process** — no socket, no ports, fully deterministic and fast. ### Enable Add `ultimo` with the `testing` feature as a **dev-dependency**: ```toml [dev-dependencies] ultimo = { version = "0.6", features = ["testing"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` ### TestClient ```rust use ultimo::testing::TestClient; use ultimo::{Context, Ultimo}; fn app() -> Ultimo { let mut app = Ultimo::new_without_defaults(); app.get("/hello", |ctx: Context| async move { ctx.text("hi").await }); app.post("/echo", |ctx: Context| async move { let body: serde_json::Value = ctx.req.json().await.unwrap_or_default(); ctx.json(body).await }); app } #[tokio::test] async fn hello_works() { let client = TestClient::new(app()); let res = client.get("/hello").send().await; res.assert_ok(); // 200 assert_eq!(res.text(), "hi"); } ``` ### Building requests The builder from `client.get/post/put/delete/patch/head/options(path)` (or `client.request(method, path)`) is fluent: ```rust let res = client .post("/users") .bearer("my-token") // Authorization: Bearer my-token .header("x-trace", "abc") .query(&[("page", "2"), ("q", "ada")]) // ?page=2&q=ada .json(&serde_json::json!({ "name": "Ada" })) // sets content-type: application/json .send() .await; ``` Other body setters: `.body(bytes)` and `.text("…")`. ### Inspecting responses All accessors are synchronous (the body is already buffered): * `res.status() -> StatusCode` * `res.header(name) -> Option<&str>`, `res.headers()` * `res.text() -> String`, `res.bytes() -> &Bytes` * `res.json::() -> T` Chainable assertions (panic with a clear message; return `&Self`): ```rust res.assert_status(201) .assert_header("content-type", "application/json") .assert_json(&serde_json::json!({ "id": 1 })); ``` Also `assert_ok()` (200) and `assert_status_is_success()` (2xx). ### Macros ```rust ultimo::assert_status!(res, 200); ultimo::assert_json_eq!(res.json::(), serde_json::json!({ "ok": true })); ``` ### Testing middleware in isolation Build a `Context` with `test_context()` and run a single middleware with `run_middleware` (construct the middleware like the built-ins — `Arc::new(|ctx, next| Box::pin(async move { … }))`): ```rust use std::sync::Arc; use ultimo::middleware::{BoxedMiddleware, Next}; use ultimo::testing::{run_middleware, test_context}; use ultimo::Context; fn auth() -> BoxedMiddleware { Arc::new(|ctx: Context, next: Next| { Box::pin(async move { if ctx.req.header("authorization").is_none() { return ultimo::response::ResponseBuilder::new() .status(401) .text("unauthorized") .build(); } next(ctx).await }) }) } #[tokio::test] async fn blocks_unauthenticated() { let ctx = test_context().path("/private").build(); let res = run_middleware(auth(), ctx, |ctx| async move { ctx.text("ok").await }) .await .unwrap(); assert_eq!(res.status(), 401); } ``` ### Database tests with rollback Enable `testing` plus a `sqlx-*` backend. `with_test_transaction` runs your closure inside a transaction that is **always rolled back** — tests never mutate state. Return a boxed future (mirrors sqlx's `Pool::transaction`): ```rust use ultimo::testing::with_test_transaction; let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY)").execute(&pool).await.unwrap(); with_test_transaction(&pool, |tx| { Box::pin(async move { sqlx::query("INSERT INTO t (id) VALUES (1)") .execute(&mut **tx) .await .unwrap(); Ok(()) }) }) .await .unwrap(); // the insert was rolled back ``` ### Fixtures ```rust use ultimo::testing::load_fixture; #[derive(serde::Deserialize)] struct User { id: u32, name: String } let user: User = load_fixture("tests/fixtures/user.json"); ``` Implement the `Fixture` trait (`setup`/`teardown`) for seed/cleanup lifecycles. ### Without the test client `Ultimo::oneshot(req)` dispatches a buffered `http::Request` through the app in-process and returns the `Response` — the seam `TestClient` is built on, handy for lower-level tests or embedding. ## TypeScript Clients Ultimo automatically generates type-safe TypeScript clients from your Rust RPC procedures, enabling seamless full-stack development. ### Quick Start #### 1. Define RPC in Rust ```rust use ultimo::prelude::*; let rpc = RpcRegistry::new(); rpc.register_with_types( "getUser", |input: GetUserInput| async move { Ok(User { /* ... */ }) }, "{ id: number }".to_string(), "User".to_string(), ); // Generate client on server startup rpc.generate_client_file("../frontend/src/lib/client.ts")?; println!("✅ TypeScript client generated!"); ``` #### 2. Use in TypeScript ```typescript import { UltimoRpcClient } from "./lib/client"; const client = new UltimoRpcClient(); // Fully type-safe! const user = await client.getUser({ id: 1 }); console.log(user.name); // ✅ TypeScript autocomplete works ``` ### Generated Client Structure The generated client includes: ```typescript // Auto-generated type definitions interface User { id: number; name: string; email: string; } interface GetUserInput { id: number; } // RPC client class export class UltimoRpcClient { private baseUrl: string; constructor(baseUrl: string = "/rpc") { this.baseUrl = baseUrl; } async getUser(params: GetUserInput): Promise { return this.call("getUser", params); } private async call(method: string, params: any): Promise { const response = await fetch(this.baseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ method, params }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "RPC call failed"); } return response.json(); } } ``` ### Client Generation Timing The `generate_client_file()` call runs **once at application startup** and does not interrupt your server. It generates the TypeScript file before the server starts listening, so there's no impact on request handling or runtime performance. #### Development Workflow For the best development experience, generate the client only in debug builds: ```rust #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new(); // Register RPC methods with type definitions rpc.register_with_types(/* ... */); // Auto-generate TypeScript client in development #[cfg(debug_assertions)] { rpc.generate_client_file("../frontend/src/lib/ultimo-client.ts")?; println!("✅ TypeScript client regenerated"); } app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let req: RpcRequest = ctx.req.json().await?; let result = rpc.call(&req.method, req.params).await?; ctx.json(result).await } }); app.listen("127.0.0.1:3000").await } ``` **Benefits:** * Automatic client updates when you modify RPC methods (just restart the server) * No file generation overhead in production builds * Client generation completes before server accepts connections ### Complete Example #### Backend ```rust use ultimo::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct ListUsersInput { page: Option, limit: Option, search: Option, } #[derive(Serialize)] struct ListUsersOutput { users: Vec, total: u32, page: u32, has_more: bool, } #[derive(Deserialize)] struct CreateUserInput { name: String, email: String, } #[tokio::main] async fn main() -> Result<()> { let mut app = Ultimo::new(); let rpc = RpcRegistry::new(); // List users rpc.register_with_types( "listUsers", |input: ListUsersInput| async move { let page = input.page.unwrap_or(1); let limit = input.limit.unwrap_or(10); let users = fetch_users(page, limit, input.search).await?; let total = count_users().await?; Ok(ListUsersOutput { users, total, page, has_more: (page * limit) < total, }) }, r#"{ page?: number; limit?: number; search?: string; }"#.to_string(), r#"{ users: User[]; total: number; page: number; has_more: boolean; }"#.to_string(), ); // Create user rpc.register_with_types( "createUser", |input: CreateUserInput| async move { validate_email(&input.email)?; let user = create_user(input).await?; Ok(user) }, r#"{ name: string; email: string }"#.to_string(), "User".to_string(), ); // Generate TypeScript client rpc.generate_client_file("../frontend/src/lib/ultimo-client.ts")?; // Mount RPC endpoint app.post("/rpc", move |ctx: Context| { let rpc = rpc.clone(); async move { let req: RpcRequest = ctx.req.json().await?; let result = rpc.call(&req.method, req.params).await?; ctx.json(result).await } }); app.listen("127.0.0.1:3000").await } ``` #### Generated TypeScript Client The `generate_client_file()` call produces `ultimo-client.ts` with full type safety: ```typescript // Auto-generated TypeScript client for Ultimo RPC (REST Mode) // DO NOT EDIT - This file is automatically generated export class UltimoRpcClient { constructor(private baseUrl: string = "/api") {} private async get(path: string, params?: Record): Promise { const url = new URL(this.baseUrl + path, window.location.origin); if (params) { Object.entries(params).forEach(([key, value]) => { url.searchParams.append(key, String(value)); }); } const response = await fetch(url.toString(), { method: "GET", headers: { "Content-Type": "application/json", }, }); if (!response.ok) { const error = await response .json() .catch(() => ({ message: response.statusText })); throw new Error(error.message || "Request failed"); } return response.json(); } private async post(path: string, body: any): Promise { const response = await fetch(this.baseUrl + path, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!response.ok) { const error = await response .json() .catch(() => ({ message: response.statusText })); throw new Error(error.message || "Request failed"); } return response.json(); } async listUsers(params: { page?: number; limit?: number; search?: string; }): Promise<{ users: User[]; total: number; page: number; has_more: boolean; }> { return this.get("/listUsers", params); } async createUser(params: { name: string; email: string }): Promise { return this.post("/createUser", params); } } // Type Definitions export interface User { id: number; name: string; email: string; } ``` #### Frontend with React Query ```typescript import { UltimoRpcClient } from "./lib/ultimo-client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; const client = new UltimoRpcClient("/api/rpc"); function UserList() { const queryClient = useQueryClient(); const [page, setPage] = useState(1); const [search, setSearch] = useState(""); // Query: List users const { data, isLoading, error } = useQuery({ queryKey: ["users", page, search], queryFn: () => client.listUsers({ page, limit: 10, search: search || undefined, }), }); // Mutation: Create user const createUser = useMutation({ mutationFn: (input) => client.createUser(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }); }, }); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; return (
setSearch(e.target.value)} placeholder="Search users..." /> {data.users.map((user) => (

{user.name}

{user.email}

))}
Page {page} of {Math.ceil(data.total / 10)}
); } ``` ### Custom Client Configuration #### Base URL ```typescript // Development const client = new UltimoRpcClient("http://localhost:3000/rpc"); // Production const client = new UltimoRpcClient("/api/rpc"); // Environment variable const client = new UltimoRpcClient(import.meta.env.VITE_API_URL || "/rpc"); ``` #### Authentication Add auth tokens to requests: ```typescript class AuthenticatedUltimoClient extends UltimoRpcClient { private token: string | null = null; setToken(token: string) { this.token = token; } protected async call(method: string, params: any): Promise { const headers: Record = { "Content-Type": "application/json", }; if (this.token) { headers["Authorization"] = `Bearer ${this.token}`; } const response = await fetch(this.baseUrl, { method: "POST", headers, body: JSON.stringify({ method, params }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "RPC call failed"); } return response.json(); } } // Usage const client = new AuthenticatedUltimoClient(); client.setToken("your-jwt-token"); const user = await client.getUser({ id: 1 }); ``` #### Error Handling ```typescript class CustomErrorUltimoClient extends UltimoRpcClient { protected async call(method: string, params: any): Promise { try { return await super.call(method, params); } catch (error) { // Transform errors if (error.message.includes("Unauthorized")) { // Redirect to login window.location.href = "/login"; } // Log errors console.error("RPC Error:", { method, params, error }); throw error; } } } ``` #### Request Interceptors ```typescript class InterceptedUltimoClient extends UltimoRpcClient { private requestId = 0; protected async call(method: string, params: any): Promise { const requestId = ++this.requestId; console.log(`[${requestId}] Calling ${method}`, params); const start = performance.now(); try { const result = await super.call(method, params); const duration = performance.now() - start; console.log(`[${requestId}] Success (${duration.toFixed(2)}ms)`, result); return result; } catch (error) { const duration = performance.now() - start; console.error(`[${requestId}] Error (${duration.toFixed(2)}ms)`, error); throw error; } } } ``` ### React Hooks Create reusable hooks for your RPC calls: ```typescript // hooks/useUltimoRpc.ts import { UltimoRpcClient } from "../lib/ultimo-client"; import { useMemo } from "react"; export function useUltimoClient() { return useMemo(() => new UltimoRpcClient("/api/rpc"), []); } export function useUsers(page: number, search?: string) { const client = useUltimoClient(); return useQuery({ queryKey: ["users", page, search], queryFn: () => client.listUsers({ page, limit: 10, search }), }); } export function useUser(id: number) { const client = useUltimoClient(); return useQuery({ queryKey: ["user", id], queryFn: () => client.getUser({ id }), }); } export function useCreateUser() { const client = useUltimoClient(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (input) => client.createUser(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }); }, }); } ``` Usage: ```typescript function UserList() { const { data, isLoading } = useUsers(1); const createUser = useCreateUser(); // ... } function UserProfile({ id }: { id: number }) { const { data: user, isLoading } = useUser(id); if (isLoading) return
Loading...
; return (

{user.name}

{user.email}

); } ``` ### Testing #### Mock Client ```typescript // __tests__/mocks/ultimo-client.ts export class MockUltimoRpcClient { async getUser(params: { id: number }) { return { id: params.id, name: "Test User", email: "test@example.com", }; } async listUsers(params: any) { return { users: [ { id: 1, name: "User 1", email: "user1@example.com" }, { id: 2, name: "User 2", email: "user2@example.com" }, ], total: 2, page: params.page || 1, has_more: false, }; } async createUser(params: any) { return { id: 999, name: params.name, email: params.email, }; } } ``` #### Test with MSW ```typescript // __tests__/setup.ts import { setupServer } from "msw/node"; import { rest } from "msw"; const server = setupServer( rest.post("/rpc", async (req, res, ctx) => { const { method, params } = await req.json(); if (method === "getUser") { return res( ctx.json({ id: params.id, name: "Test User", email: "test@example.com", }) ); } return res(ctx.status(404)); }) ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); ``` ### Best Practices #### ✅ Regenerate on Backend Changes Add a script to regenerate the client: ```bash # scripts/generate-client.sh #!/bin/bash cd backend cargo run --bin generate-client echo "✅ TypeScript client updated" ``` #### ✅ Version Your Client ```typescript export const CLIENT_VERSION = "1.0.0"; export class UltimoRpcClient { private version = CLIENT_VERSION; protected async call(method: string, params: any): Promise { const headers = { "Content-Type": "application/json", "X-Client-Version": this.version, }; // ... } } ``` #### ✅ Handle Network Errors ```typescript try { const user = await client.getUser({ id: 1 }); } catch (error) { if (error.message.includes("NetworkError")) { // Handle offline showOfflineMessage(); } else { // Handle other errors showErrorMessage(error.message); } } ``` #### ✅ Cache Responses ```typescript const { data } = useQuery({ queryKey: ["user", id], queryFn: () => client.getUser({ id }), staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 10 * 60 * 1000, // 10 minutes }); ``` ## WebSocket Support Ultimo provides zero-dependency, RFC 6455 compliant WebSocket support with built-in pub/sub functionality. ### Features * ✅ **Zero Dependencies** - Built directly on hyper's upgrade mechanism * ✅ **Type-Safe** - WebSocketHandler trait with typed context data * ✅ **Built-in Pub/Sub** - Topic-based messaging with ChannelManager * ✅ **Production Ready** - 93 comprehensive tests, fully compliant with RFC 6455 * ✅ **Easy Integration** - Seamless router integration with `app.websocket()` ### Quick Start #### Basic WebSocket Handler ```rust use ultimo::prelude::*; use ultimo::websocket::{WebSocketHandler, WebSocket, Message}; use async_trait::async_trait; #[derive(Clone)] struct ChatHandler; #[async_trait] impl WebSocketHandler for ChatHandler { type Data = (); async fn on_open(&self, ws: &WebSocket) { println!("Client connected"); ws.send("Welcome to the chat!").await.ok(); } async fn on_message(&self, ws: &WebSocket, msg: Message) { match msg { Message::Text(text) => { println!("Received: {}", text); ws.send(format!("Echo: {}", text)).await.ok(); } Message::Close(_) => { println!("Client disconnected"); } _ => {} } } } #[tokio::main] async fn main() { let mut app = Ultimo::new(); // Add WebSocket route app.websocket("/ws", ChatHandler); app.listen("127.0.0.1:3000").await.unwrap(); } ``` #### Client Connection ```javascript const ws = new WebSocket("ws://localhost:3000/ws"); ws.addEventListener("open", () => { console.log("Connected!"); ws.send("Hello, server!"); }); ws.addEventListener("message", (event) => { console.log("Received:", event.data); }); ``` ### Typed Context Data WebSocket connections can carry typed context data: ```rust use serde::{Serialize, Deserialize}; #[derive(Clone, Serialize, Deserialize)] struct UserContext { user_id: String, username: String, } #[derive(Clone)] struct AuthHandler; #[async_trait] impl WebSocketHandler for AuthHandler { type Data = UserContext; async fn on_open(&self, ws: &WebSocket) { let user = ws.data(); println!("User {} connected", user.username); ws.send(format!("Welcome, {}!", user.username)).await.ok(); } async fn on_message(&self, ws: &WebSocket, msg: Message) { let user = ws.data(); // Access user context in message handler println!("Message from {}: {:?}", user.username, msg); } } ``` ### Pub/Sub System Built-in topic-based pub/sub for broadcasting messages: ```rust #[derive(Clone)] struct ChatRoomHandler; #[async_trait] impl WebSocketHandler for ChatRoomHandler { type Data = String; // User ID async fn on_open(&self, ws: &WebSocket) { let user_id = ws.data(); // Subscribe to chat room ws.subscribe("chat:general").await.ok(); // Announce join let msg = serde_json::json!({ "type": "join", "user_id": user_id, }); ws.publish("chat:general", &msg).await.ok(); } async fn on_message(&self, ws: &WebSocket, msg: Message) { if let Message::Text(text) = msg { let user_id = ws.data(); // Broadcast to all subscribers let msg = serde_json::json!({ "type": "message", "user_id": user_id, "text": text, }); ws.publish("chat:general", &msg).await.ok(); } } async fn on_close(&self, ws: &WebSocket, _code: u16, _reason: &str) { let user_id = ws.data(); // Announce leave let msg = serde_json::json!({ "type": "leave", "user_id": user_id, }); ws.publish("chat:general", &msg).await.ok(); // Cleanup ws.unsubscribe("chat:general").await.ok(); } } ``` ### Lifecycle Callbacks WebSocketHandler provides several lifecycle hooks: ```rust #[async_trait] impl WebSocketHandler for MyHandler { type Data = (); // Called when connection is established async fn on_open(&self, ws: &WebSocket) { // Initialize connection } // Called when message is received async fn on_message(&self, ws: &WebSocket, msg: Message) { // Handle incoming messages } // Called when connection is closed async fn on_close(&self, ws: &WebSocket, code: u16, reason: &str) { // Cleanup resources } } ``` ### Message Types WebSocket supports multiple message types: ```rust use ultimo::websocket::Message; async fn on_message(&self, ws: &WebSocket, msg: Message) { match msg { Message::Text(text) => { println!("Text message: {}", text); } Message::Binary(data) => { println!("Binary message: {} bytes", data.len()); } Message::Ping(data) => { // Pings are automatically handled println!("Ping received"); } Message::Pong(data) => { println!("Pong received"); } Message::Close(frame) => { if let Some(f) = frame { println!("Close: code={}, reason={}", f.code, f.reason); } } } } ``` ### JSON Messages Send and receive JSON messages easily: ```rust use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] struct ChatMessage { user: String, text: String, timestamp: u64, } async fn on_message(&self, ws: &WebSocket, msg: Message) { if let Message::Text(text) = msg { // Parse JSON if let Ok(chat_msg) = serde_json::from_str::(&text) { println!("{} says: {}", chat_msg.user, chat_msg.text); // Send JSON response let response = ChatMessage { user: "Server".to_string(), text: "Message received".to_string(), timestamp: now(), }; ws.send_json(&response).await.ok(); } } } ``` ### Examples Check out the complete examples in the repository: #### Simple Chat A basic HTML/JS chat application demonstrating real-time messaging and pub/sub. ```bash cargo run -p websocket-chat ``` #### React Chat Modern React + TypeScript chat with shadcn/ui components, featuring: * Type-safe WebSocket hook * Real-time message broadcasting * Connection status indicators * Message history with auto-scroll ```bash cargo run -p websocket-chat-react ``` ### Architecture Ultimo's WebSocket implementation: * **Frame Codec** - RFC 6455 compliant frame parsing and encoding * **Connection** - WebSocket connection state management * **Upgrade** - HTTP to WebSocket upgrade mechanism * **Pub/Sub** - ChannelManager for topic-based messaging * **Router** - Optimized Radix Tree for efficient route matching ### Security #### Cross-Site WebSocket Hijacking (CSWSH) Browsers don't enforce same-origin for WebSocket connections the way they do for `fetch`/XHR — a page on any origin can open a WebSocket connection to your server. If the connection relies on ambient cookie authentication, restrict the handshake's `Origin` header with an allow-list: ```rust app.websocket_with_config_and_origins( "/ws", ChatHandler, WebSocketConfig::default(), vec!["https://example.com".to_string()], ); ``` The allow-list is empty by default (no restriction — matches `websocket_with_config`'s prior behavior). Set it whenever the connection carries ambient authority. `"*"` matches any origin; otherwise the match is exact against the full `Origin` value. Once set, a handshake missing the `Origin` header is rejected with `403 Forbidden`. ### Testing The WebSocket implementation includes 93 comprehensive tests: * 21 unit tests (frame codec, pub/sub, upgrade) * 9 integration tests (real connections, JSON, pub/sub) * 12 property-based tests (thousands of random test cases) * 5 router integration tests * 14 error handling tests * 11 concurrency tests * 18 edge case tests ### Performance * **Zero Dependencies** - No tokio-tungstenite or similar libraries * **Efficient** - O(L) router lookups with Radix Tree * **Memory** - Minimal per-connection overhead * **Tested** - 210 tests passing, production-ready ### Coming Later Future enhancements planned: * Per-message deflate compression (RFC 7692) ### Learn More * [WebSocket Design Document](https://github.com/ultimo-rs/ultimo/blob/main/ultimo/WEBSOCKET_DESIGN.md) * [Testing Strategy](https://github.com/ultimo-rs/ultimo/blob/main/ultimo/WEBSOCKET_TESTING.md) * [Examples](https://github.com/ultimo-rs/ultimo/tree/main/examples) ## AWS (ECS + Fargate) Run your Ultimo container on AWS without managing servers. This guide uses **ECS with Fargate** — AWS manages the underlying compute. ### Architecture ``` Internet → ALB (HTTPS) → ECS Service → Fargate Tasks (your container) ↓ RDS / ElastiCache ``` ### Prerequisites * AWS CLI configured (`aws configure`) * Docker installed locally * A [Dockerfile](/deployment/docker) in your project ### Step 1: Push image to ECR ```bash # Create an ECR repository (once) aws ecr create-repository --repository-name my-app --region us-east-1 # Get the repository URI REPO=$(aws ecr describe-repositories --repository-names my-app \ --query 'repositories[0].repositoryUri' --output text) # Authenticate Docker to ECR aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin "$REPO" # Build and push docker build -t my-app . docker tag my-app:latest "$REPO:latest" docker push "$REPO:latest" ``` ### Step 2: Create the ECS cluster ```bash aws ecs create-cluster --cluster-name my-cluster ``` ### Step 3: Task definition Create `task-definition.json`: ```json { "family": "my-app", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "cpu": "256", "memory": "512", "executionRoleArn": "arn:aws:iam:::role/ecsTaskExecutionRole", "containerDefinitions": [ { "name": "app", "image": ".dkr.ecr.us-east-1.amazonaws.com/my-app:latest", "portMappings": [ { "containerPort": 3000, "protocol": "tcp" } ], "environment": [ { "name": "PORT", "value": "3000" }, { "name": "RUST_LOG", "value": "info" } ], "secrets": [ { "name": "DATABASE_URL", "valueFrom": "arn:aws:ssm:us-east-1::parameter/my-app/database-url" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/my-app", "awslogs-region": "us-east-1", "awslogs-stream-prefix": "app" } }, "healthCheck": { "command": [ "CMD-SHELL", "curl -f http://localhost:3000/health || exit 1" ], "interval": 10, "timeout": 3, "retries": 3, "startPeriod": 5 } } ] } ``` Register it: ```bash aws ecs register-task-definition --cli-input-json file://task-definition.json ``` ### Step 4: Application Load Balancer ```bash # Create ALB aws elbv2 create-load-balancer \ --name my-app-alb \ --subnets subnet-xxx subnet-yyy \ --security-groups sg-xxx # Create target group aws elbv2 create-target-group \ --name my-app-tg \ --protocol HTTP \ --port 3000 \ --vpc-id vpc-xxx \ --target-type ip \ --health-check-path /health # Create HTTPS listener (requires ACM certificate) aws elbv2 create-listener \ --load-balancer-arn \ --protocol HTTPS \ --port 443 \ --certificates CertificateArn= \ --default-actions Type=forward,TargetGroupArn= ``` ### Step 5: Create the ECS service ```bash aws ecs create-service \ --cluster my-cluster \ --service-name my-app \ --task-definition my-app \ --desired-count 2 \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={ subnets=[subnet-xxx,subnet-yyy], securityGroups=[sg-xxx], assignPublicIp=ENABLED }" \ --load-balancers "targetGroupArn=,containerName=app,containerPort=3000" ``` ### Secrets management Store secrets in AWS Systems Manager Parameter Store: ```bash aws ssm put-parameter \ --name "/my-app/database-url" \ --type SecureString \ --value "postgres://user:pass@rds-host:5432/db" ``` Reference them in the task definition via the `secrets` field (shown above). ### Auto-scaling ```bash # Register scalable target aws application-autoscaling register-scalable-target \ --service-namespace ecs \ --resource-id service/my-cluster/my-app \ --scalable-dimension ecs:service:DesiredCount \ --min-capacity 1 \ --max-capacity 10 # Scale on CPU utilization aws application-autoscaling put-scaling-policy \ --service-namespace ecs \ --resource-id service/my-cluster/my-app \ --scalable-dimension ecs:service:DesiredCount \ --policy-name cpu-scaling \ --policy-type TargetTrackingScaling \ --target-tracking-scaling-policy-configuration '{ "TargetValue": 70.0, "PredefinedMetricSpecification": { "PredefinedMetricType": "ECSServiceAverageCPUUtilization" } }' ``` ### CI/CD with GitHub Actions ```yaml name: Deploy to ECS on: push: branches: [main] env: AWS_REGION: us-east-1 ECR_REPOSITORY: my-app ECS_CLUSTER: my-cluster ECS_SERVICE: my-app jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ env.AWS_REGION }} - name: Login to ECR id: ecr uses: aws-actions/amazon-ecr-login@v2 - name: Build and push env: REGISTRY: ${{ steps.ecr.outputs.registry }} run: | docker build -t $REGISTRY/$ECR_REPOSITORY:${{ github.sha }} . docker push $REGISTRY/$ECR_REPOSITORY:${{ github.sha }} - name: Deploy to ECS run: | aws ecs update-service \ --cluster $ECS_CLUSTER \ --service $ECS_SERVICE \ --force-new-deployment ``` ### Cost estimate | Component | Monthly (us-east-1) | | ---------------------------------------- | ----------------------- | | Fargate (0.25 vCPU, 512 MB, 1 task 24/7) | \~$9 | | ALB | \~$16 + $0.008/LCU-hour | | ECR (1 GB storage) | \~$0.10 | | CloudWatch Logs | \~$0.50/GB ingested | Total for a small service: **\~$25–30/month**. ## Azure Container Apps [Azure Container Apps](https://azure.microsoft.com/en-us/products/container-apps) is a serverless container platform with built-in autoscaling, HTTPS, and managed identity — no Kubernetes expertise required. ### Prerequisites * Azure CLI (`az`) installed and logged in * A [Dockerfile](/deployment/docker) in your project ### Quick deploy Deploy directly from your Dockerfile: ```bash # Create resource group (once) az group create --name my-rg --location eastus # Create Container Apps environment (once) az containerapp env create \ --name my-env \ --resource-group my-rg \ --location eastus # Deploy (builds from Dockerfile automatically) az containerapp up \ --name my-app \ --resource-group my-rg \ --environment my-env \ --source . \ --target-port 3000 \ --ingress external ``` Your app gets an HTTPS URL like `https://my-app..eastus.azurecontainerapps.io`. ### Configuration #### Environment variables ```bash az containerapp update \ --name my-app \ --resource-group my-rg \ --set-env-vars "RUST_LOG=info" "APP_ENV=production" ``` #### Secrets ```bash # Create a secret az containerapp secret set \ --name my-app \ --resource-group my-rg \ --secrets "db-url=postgres://user:pass@host:5432/db" # Reference in env var az containerapp update \ --name my-app \ --resource-group my-rg \ --set-env-vars "DATABASE_URL=secretref:db-url" ``` #### Scaling rules ```bash az containerapp update \ --name my-app \ --resource-group my-rg \ --min-replicas 0 \ --max-replicas 10 \ --scale-rule-name http-rule \ --scale-rule-type http \ --scale-rule-http-concurrency 100 ``` ### Custom domain ```bash # Add custom domain az containerapp hostname add \ --name my-app \ --resource-group my-rg \ --hostname api.example.com # Bind managed certificate az containerapp hostname bind \ --name my-app \ --resource-group my-rg \ --hostname api.example.com \ --environment my-env \ --validation-method CNAME ``` ### Database connectivity #### Azure Database for PostgreSQL ```bash # Create Postgres (once) az postgres flexible-server create \ --name my-db \ --resource-group my-rg \ --location eastus \ --admin-user dbadmin \ --admin-password \ --sku-name Standard_B1ms # Allow Container Apps to connect az postgres flexible-server firewall-rule create \ --name allow-azure \ --resource-group my-rg \ --server-name my-db \ --start-ip-address 0.0.0.0 \ --end-ip-address 0.0.0.0 ``` ### CI/CD with GitHub Actions ```yaml name: Deploy to Azure Container Apps on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Azure Login uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - name: Deploy uses: azure/container-apps-deploy-action@v2 with: appSourcePath: ${{ github.workspace }} acrName: myregistry containerAppName: my-app resourceGroup: my-rg targetPort: 3000 ``` ### Cost estimate | Scenario | Monthly cost | | --------------------------------- | ---------------- | | Scale-to-zero, occasional traffic | \~$0 (free tier) | | 1 vCPU, 2 GB, always-on | \~$35 | | Auto-scaling 1-5 replicas | \~$35–175 | The free tier includes 2M requests/month and 180,000 vCPU-seconds. ## DigitalOcean App Platform [DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform) is a PaaS that detects your Dockerfile, builds it, and deploys with managed HTTPS, scaling, and monitoring. ### Quick deploy via dashboard 1. Go to [cloud.digitalocean.com/apps](https://cloud.digitalocean.com/apps) → **Create App** 2. Connect your GitHub or GitLab repo 3. DigitalOcean detects the `Dockerfile` and builds automatically 4. Set the HTTP port to `3000` 5. Add environment variables (`RUST_LOG=info`, `DATABASE_URL`, etc.) 6. Choose a plan (Basic $5/mo or Pro $12/mo) 7. Deploy ### Deploy via doctl ```bash # Install doctl brew install doctl # or snap install doctl # Authenticate doctl auth init # Create the app doctl apps create --spec .do/app.yaml ``` #### App spec (`.do/app.yaml`) ```yaml name: my-ultimo-app region: nyc services: - name: api dockerfile_path: Dockerfile http_port: 3000 instance_count: 1 instance_size_slug: apps-s-1vcpu-0.5gb routes: - path: / envs: - key: PORT value: "3000" - key: RUST_LOG value: "info" - key: DATABASE_URL type: SECRET value: "postgres://..." health_check: http_path: /health initial_delay_seconds: 5 period_seconds: 10 source_dir: / github: repo: your-org/your-repo branch: main deploy_on_push: true databases: - name: db engine: PG version: "16" size: db-s-dev-database production: false ``` ### Environment variables ```bash # Set via CLI doctl apps update --spec .do/app.yaml # Or in the dashboard: Settings → App-Level Environment Variables ``` For secrets, set `type: SECRET` in the spec — values are encrypted and hidden. ### Database #### Managed PostgreSQL Add a `databases` block to your app spec (shown above). App Platform automatically injects `${db.DATABASE_URL}` into your service environment. Reference it in the service envs: ```yaml envs: - key: DATABASE_URL value: "${db.DATABASE_URL}" ``` #### External databases Use the connection string directly as a SECRET env var. ### Custom domain 1. **Settings** → **Domains** → **Add Domain** 2. Enter your domain (e.g. `api.example.com`) 3. Add the CNAME record shown 4. TLS is provisioned automatically ### Scaling ```yaml # In app.yaml services: - name: api instance_count: 3 instance_size_slug: apps-s-2vcpu-4gb autoscaling: min_instance_count: 1 max_instance_count: 5 metrics: cpu: percent: 70 ``` ### CI/CD App Platform deploys on every push to the configured branch by default. To trigger manually: ```bash doctl apps create-deployment ``` #### GitHub Actions (alternative) ```yaml name: Deploy to DigitalOcean on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: digitalocean/app_action@v2 with: app_name: my-ultimo-app token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} ``` ### Cost | Plan | Specs | Monthly | | ----- | ------------------------- | ------- | | Basic | 1 vCPU, 512 MB | $5 | | Basic | 1 vCPU, 1 GB | $10 | | Pro | 1 vCPU, 1 GB, autoscaling | $12 | | Pro | 2 vCPU, 4 GB | $25 | Managed PostgreSQL dev database: $7/month. ## Docker Docker is the foundation for most deployment targets. This guide covers building optimized Ultimo images with multi-stage builds, layer caching, and production-ready configurations. ### Multi-stage Dockerfile ```dockerfile # ── Build stage ────────────────────────────────────────────────────── FROM rust:1.86-slim AS builder WORKDIR /app # Cache dependencies — copy manifests first, build a dummy, then copy source COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo 'fn main() {}' > src/main.rs RUN cargo build --release && rm -rf src # Now copy real source and rebuild (only your code recompiles) COPY src/ src/ RUN touch src/main.rs && cargo build --release # ── Runtime stage ──────────────────────────────────────────────────── FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/my-app /usr/local/bin/app # Non-root user for security RUN useradd -r -s /bin/false appuser USER appuser ENV PORT=3000 EXPOSE 3000 CMD ["app"] ``` Replace `my-app` with your binary name (the `[[bin]]` name or package name in `Cargo.toml`). #### Why multi-stage? * **Build image** (\~1.5 GB with Rust toolchain) is discarded * **Runtime image** (\~80 MB) contains only your binary + CA certs * Layer caching means dependency changes rebuild deps, but source changes only recompile your code (\~seconds vs minutes) ### .dockerignore ``` target/ .git/ .env *.md docs/ ``` Keep the build context small — Docker sends everything not ignored to the daemon. ### Build and run ```bash docker build -t my-app . docker run -p 3000:3000 -e RUST_LOG=info my-app ``` ### Docker Compose ```yaml # docker-compose.yml services: app: build: . ports: - "3000:3000" environment: - PORT=3000 - RUST_LOG=info - DATABASE_URL=postgres://user:pass@db:5432/mydb depends_on: db: condition: service_healthy restart: unless-stopped deploy: resources: limits: memory: 128M db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user"] interval: 5s timeout: 3s retries: 5 volumes: pgdata: ``` ```bash docker compose up -d docker compose logs -f app ``` ### Optimizations #### Smaller images with Alpine ```dockerfile FROM rust:1.86-alpine AS builder RUN apk add --no-cache musl-dev WORKDIR /app COPY . . RUN cargo build --release FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --from=builder /app/target/release/my-app /usr/local/bin/app CMD ["app"] ``` Alpine images are \~15 MB total. Note: some crates with C dependencies (OpenSSL, libpq) need extra `apk add` packages in the build stage. #### BuildKit cache mounts Speed up repeated builds with Cargo registry caching: ```dockerfile # syntax=docker/dockerfile:1 FROM rust:1.86-slim AS builder WORKDIR /app COPY . . RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo build --release && \ cp target/release/my-app /usr/local/bin/app FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/local/bin/app /usr/local/bin/app CMD ["app"] ``` Enable BuildKit: `DOCKER_BUILDKIT=1 docker build .` #### Health checks ```dockerfile HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \ CMD curl -f http://localhost:3000/health || exit 1 ``` Or without curl (smaller image): ```dockerfile HEALTHCHECK --interval=10s --timeout=3s \ CMD wget -qO- http://localhost:3000/health || exit 1 ``` ### CI/CD integration #### GitHub Actions ```yaml # .github/workflows/deploy.yml name: Build and Push on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=max ``` This builds your image with full layer caching and pushes to GitHub Container Registry on every merge to `main`. ## Fly.io [Fly.io](https://fly.io) runs your Docker containers on hardware close to your users. Machines boot in \~300 ms, scale to zero when idle, and deploy globally with a single command. ### Prerequisites ```bash # Install the Fly CLI curl -L https://fly.io/install.sh | sh # Sign up / login fly auth login ``` ### First deploy From your project root (with a [Dockerfile](/deployment/docker)): ```bash fly launch ``` Fly auto-detects the Dockerfile, picks a region, and generates `fly.toml`. Accept the defaults or tweak interactively. Then deploy: ```bash fly deploy ``` Your app is live at `https://.fly.dev`. ### fly.toml (reference) ```toml app = "my-ultimo-app" primary_region = "iad" [build] # Uses Dockerfile by default [env] PORT = "3000" RUST_LOG = "info" [http_service] internal_port = 3000 force_https = true auto_stop_machines = "stop" auto_start_machines = true min_machines_running = 0 [http_service.concurrency] type = "connections" hard_limit = 500 soft_limit = 250 [[http_service.checks]] interval = "10s" timeout = "2s" grace_period = "5s" method = "GET" path = "/health" [[vm]] memory = "256mb" cpu_kind = "shared" cpus = 1 ``` ### Scaling #### Multiple regions ```bash # Add machines in London and Tokyo fly scale count 2 --region lhr fly scale count 2 --region nrt ``` #### Vertical scaling ```bash fly scale vm shared-cpu-2x --memory 512 ``` #### Auto-scaling The `auto_stop_machines` / `auto_start_machines` settings in `fly.toml` enable scale-to-zero: machines stop when idle and start on the next request (\~300 ms cold start for Rust binaries). ### Secrets ```bash # Set secrets (encrypted, never visible in logs) fly secrets set DATABASE_URL="postgres://..." JWT_SECRET="..." # List secrets fly secrets list ``` ### Custom domain ```bash fly certs add your-domain.com ``` Then point a CNAME to `.fly.dev` (or an A record to the Fly IP). ### Volumes (persistent storage) ```bash fly volumes create data --size 1 --region iad ``` In `fly.toml`: ```toml [mounts] source = "data" destination = "/data" ``` ### Database #### Fly Postgres ```bash fly postgres create --name my-db fly postgres attach my-db ``` This sets `DATABASE_URL` as a secret automatically. #### External databases Set the connection string as a secret: ```bash fly secrets set DATABASE_URL="postgres://user:pass@host:5432/db?sslmode=require" ``` ### Monitoring ```bash # Live logs fly logs # Machine status fly status # Metrics dashboard fly dashboard ``` ### CI/CD with GitHub Actions ```yaml # .github/workflows/fly-deploy.yml name: Deploy to Fly.io on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - run: flyctl deploy --remote-only env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ``` Generate the token: `fly tokens create deploy -x 999999h` ### WebSocket support Fly.io natively supports WebSocket connections. No extra configuration needed — requests to your app on the same port are automatically upgraded when the client sends an `Upgrade: websocket` header. For long-lived WebSocket connections, consider: ```toml # Increase the idle timeout (default 60s) [http_service] idle_timeout = "300s" ``` ## Google Cloud Run [Cloud Run](https://cloud.google.com/run) runs containers with automatic scaling — including scale-to-zero. You pay only for actual request processing time. ### Prerequisites * Google Cloud CLI (`gcloud`) installed and authenticated * A GCP project with billing enabled * A [Dockerfile](/deployment/docker) in your project ### Quick deploy The fastest path — Cloud Run builds and deploys from source: ```bash # Enable APIs (once) gcloud services enable run.googleapis.com cloudbuild.googleapis.com # Deploy directly from source (Cloud Build handles the Dockerfile) gcloud run deploy my-app \ --source . \ --port 3000 \ --region us-central1 \ --allow-unauthenticated ``` Cloud Run gives you an HTTPS URL like `https://my-app-xxxxx-uc.a.run.app`. ### Deploy from Artifact Registry For more control over your image: ```bash # Create Artifact Registry repository (once) gcloud artifacts repositories create my-repo \ --repository-format=docker \ --location=us-central1 # Build and push gcloud builds submit \ --tag us-central1-docker.pkg.dev//my-repo/my-app:latest # Deploy the specific image gcloud run deploy my-app \ --image us-central1-docker.pkg.dev//my-repo/my-app:latest \ --port 3000 \ --region us-central1 \ --allow-unauthenticated ``` ### Configuration #### Environment variables ```bash gcloud run services update my-app \ --set-env-vars "RUST_LOG=info,APP_ENV=production" \ --region us-central1 ``` #### Secrets (via Secret Manager) ```bash # Create a secret echo -n "postgres://..." | gcloud secrets create db-url --data-file=- # Mount as env var gcloud run services update my-app \ --set-secrets "DATABASE_URL=db-url:latest" \ --region us-central1 ``` #### Resource limits ```bash gcloud run services update my-app \ --memory 256Mi \ --cpu 1 \ --concurrency 250 \ --timeout 60s \ --region us-central1 ``` ### Scaling ```bash # Min/max instances gcloud run services update my-app \ --min-instances 0 \ --max-instances 10 \ --region us-central1 ``` * `min-instances=0` enables scale-to-zero (cold starts \~300 ms for Rust) * `min-instances=1` keeps one instance warm (avoids cold starts, \~$6/month) ### Custom domain ```bash # Map domain gcloud run domain-mappings create \ --service my-app \ --domain api.example.com \ --region us-central1 ``` Then add the DNS records shown in the output. TLS is automatic. ### VPC connectivity Connect to Cloud SQL, Memorystore, or other VPC resources: ```bash # Create a VPC connector (once) gcloud compute networks vpc-access connectors create my-connector \ --region us-central1 \ --range 10.8.0.0/28 # Attach to Cloud Run gcloud run services update my-app \ --vpc-connector my-connector \ --region us-central1 ``` ### Cloud SQL ```bash # Connect via Unix socket (recommended) gcloud run services update my-app \ --add-cloudsql-instances :: \ --set-env-vars "DATABASE_URL=postgres://user:pass@/db?host=/cloudsql/::" \ --region us-central1 ``` ### CI/CD with GitHub Actions ```yaml name: Deploy to Cloud Run on: push: branches: [main] env: PROJECT_ID: my-project REGION: us-central1 SERVICE: my-app jobs: deploy: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 with: workload_identity_provider: projects//locations/global/workloadIdentityPools/github/providers/github service_account: deploy@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v2 with: service: ${{ env.SERVICE }} region: ${{ env.REGION }} source: . ``` ### Cost estimate | Scenario | Monthly cost | | --------------------------------------- | ------------ | | Scale-to-zero, 100k requests/month | \~$0.50 | | 1 instance always-on (0.5 vCPU, 256 MB) | \~$6 | | 5 instances peak, avg 2 | \~$30 | Cloud Run charges per 100ms of actual processing + request count. Idle = free. ### WebSocket support Cloud Run supports WebSocket connections with automatic timeout handling. Set a longer request timeout for long-lived connections: ```bash gcloud run services update my-app \ --timeout 3600s \ --region us-central1 ``` :::warning Cloud Run has a maximum request timeout of 60 minutes. For indefinite WebSocket connections, consider Fly.io or a GKE deployment instead. ::: ## Deployment Overview Ultimo compiles to a **single static binary** — no runtime, no interpreter, no `node_modules`. This makes deployment straightforward on any platform that runs Linux containers or bare-metal binaries. ### Build for release ```bash cargo build --release ``` The optimized binary lands at `target/release/` (typically 10–15 MB). Copy it anywhere and run it — zero dependencies beyond libc. ### Guides | Platform | Best for | | ------------------------------------------------ | ------------------------------------ | | [Docker](/deployment/docker) | Reproducible builds, any hosting | | [Fly.io](/deployment/fly-io) | Global edge deployment, simple CLI | | [Railway](/deployment/railway) | Git-push deploys, zero config | | [AWS](/deployment/aws) | Enterprise, ECS/Fargate | | [Google Cloud Run](/deployment/google-cloud-run) | Serverless containers, scale-to-zero | | [Azure](/deployment/azure) | Container Apps, enterprise Azure | | [DigitalOcean](/deployment/digitalocean) | App Platform, simple PaaS | | [Kubernetes](/deployment/kubernetes) | Full orchestration, multi-cloud | ### Production checklist Before going live: * [ ] **Health check endpoint** — add a `GET /health` route returning 200: ```rust app.get("/health", |ctx: Context| async move { ctx.response().text("ok") }); ``` * [ ] **Environment variables** — don't hardcode secrets; use env vars or a secret manager ```rust let db_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); ``` * [ ] **Logging** — set `RUST_LOG=info` (or `RUST_LOG=ultimo=info,my_app=debug`) * [ ] **HTTPS** — terminate TLS at the load balancer/reverse proxy (Caddy, nginx, cloud LB) — Ultimo serves HTTP, your infra adds the S * [ ] **Security headers** — enable the built-in middleware: ```rust app.use_middleware(ultimo::middleware::builtin::security_headers()); ``` * [ ] **Trust proxy** — if behind a load balancer, enable so `client_ip()` reads `X-Forwarded-For`: ```rust app.trust_proxy(true); ``` * [ ] **IP filtering** — restrict access if needed: ```rust use ultimo::middleware::builtin::IpFilter; app.use_middleware(IpFilter::allow(&["10.0.0.0/8"]).build()); ``` * [ ] **Graceful shutdown** — Ultimo handles `SIGTERM` automatically; HTTP connections drain, WebSocket clients get close frames via `broadcast_all()` * [ ] **Resource limits** — Ultimo binaries are lean; 64–128 MB RAM handles most workloads; set container limits accordingly ### Environment variables Ultimo apps typically read configuration from environment variables. Here's a common pattern: ```rust use std::env; let port: u16 = env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .expect("PORT must be a number"); let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); ``` :::tip In production, always bind to `0.0.0.0` (not `127.0.0.1`) so the container's port mapping works correctly. ::: ## Kubernetes Deploy Ultimo to any Kubernetes cluster — managed (EKS, GKE, AKS, DOKS) or self-hosted. This guide provides production-ready manifests with health checks, resource limits, HPA autoscaling, and TLS ingress. ### Manifests #### Deployment ```yaml # k8s/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: my-app labels: app: my-app spec: replicas: 2 selector: matchLabels: app: my-app strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: my-app spec: containers: - name: app image: ghcr.io/your-org/my-app:latest ports: - containerPort: 3000 name: http env: - name: PORT value: "3000" - name: RUST_LOG value: "info" - name: DATABASE_URL valueFrom: secretKeyRef: name: my-app-secrets key: database-url resources: requests: cpu: 100m memory: 64Mi limits: cpu: 500m memory: 128Mi readinessProbe: httpGet: path: /health port: http initialDelaySeconds: 2 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /health port: http initialDelaySeconds: 10 periodSeconds: 15 failureThreshold: 3 lifecycle: preStop: exec: command: ["sleep", "5"] # Allow in-flight requests to drain terminationGracePeriodSeconds: 30 ``` #### Service ```yaml # k8s/service.yaml apiVersion: v1 kind: Service metadata: name: my-app spec: selector: app: my-app ports: - port: 80 targetPort: http protocol: TCP type: ClusterIP ``` #### Secrets ```bash kubectl create secret generic my-app-secrets \ --from-literal=database-url="postgres://user:pass@db-host:5432/mydb" ``` Or use a YAML manifest (base64-encoded): ```yaml # k8s/secrets.yaml apiVersion: v1 kind: Secret metadata: name: my-app-secrets type: Opaque data: database-url: cG9zdGdyZXM6Ly91c2VyOnBhc3NAZGItaG9zdDo1NDMyL215ZGI= ``` :::warning Don't commit secrets to Git. Use sealed-secrets, external-secrets, or your cloud provider's secret manager integration instead. ::: #### Ingress (with TLS) Using nginx-ingress + cert-manager: ```yaml # k8s/ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-app annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/proxy-body-size: "10m" spec: ingressClassName: nginx tls: - hosts: - api.example.com secretName: my-app-tls rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: my-app port: number: 80 ``` #### Horizontal Pod Autoscaler ```yaml # k8s/hpa.yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: my-app spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: my-app minReplicas: 2 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 ``` ### Apply everything ```bash kubectl apply -f k8s/ ``` ### Helm chart (optional) For teams managing multiple environments, wrap the manifests in a Helm chart: ``` helm/my-app/ ├── Chart.yaml ├── values.yaml ├── values-staging.yaml ├── values-production.yaml └── templates/ ├── deployment.yaml ├── service.yaml ├── ingress.yaml ├── hpa.yaml └── secrets.yaml ``` ```bash helm install my-app ./helm/my-app -f helm/my-app/values-production.yaml helm upgrade my-app ./helm/my-app -f helm/my-app/values-production.yaml ``` ### CI/CD with GitHub Actions ```yaml name: Deploy to Kubernetes on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and push image run: | echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} . docker push ghcr.io/${{ github.repository }}:${{ github.sha }} - name: Set up kubectl uses: azure/setup-kubectl@v4 - name: Configure kubeconfig run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > $HOME/.kube/config - name: Deploy run: | kubectl set image deployment/my-app \ app=ghcr.io/${{ github.repository }}:${{ github.sha }} kubectl rollout status deployment/my-app --timeout=120s ``` ### WebSocket considerations For WebSocket support through the ingress: ```yaml # In ingress annotations metadata: annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/websocket-services: "my-app" ``` ### Observability #### Pod disruption budget ```yaml apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: my-app spec: minAvailable: 1 selector: matchLabels: app: my-app ``` #### Resource recommendations Ultimo apps are lightweight: | Workload | CPU request | Memory request | | ------------------------ | ----------- | -------------- | | Low traffic API | 50m | 32Mi | | Medium traffic | 100m | 64Mi | | High traffic + WebSocket | 250m | 128Mi | | Database-heavy | 200m | 256Mi | Start small and let HPA scale horizontally rather than over-provisioning. ## Railway [Railway](https://railway.app) deploys from your Git repo with zero configuration. Push to `main` and Railway builds, deploys, and gives you a URL. ### First deploy #### 1. Push your code to GitHub Railway deploys from GitHub (or GitLab). #### 2. Create a project Go to [railway.app/new](https://railway.app/new) → **Deploy from GitHub repo** → select your repository. #### 3. Configure the service Railway auto-detects Rust projects. For faster builds, add a [Dockerfile](/deployment/docker) — Railway will use it automatically. Set environment variables in the dashboard or via CLI: | Variable | Value | | -------------- | ------------------------------ | | `PORT` | `${{RAILWAY_PORT}}` (injected) | | `RUST_LOG` | `info` | | `DATABASE_URL` | (if using a database) | :::warning Railway injects `PORT` dynamically. Your app **must** read `PORT` from the environment — don't hardcode 3000. ::: #### 4. Expose the service **Settings** → **Networking** → **Generate Domain** (or add a custom domain). ### railway.toml ```toml [build] builder = "dockerfile" dockerfilePath = "Dockerfile" [deploy] healthcheckPath = "/health" healthcheckTimeout = 3 restartPolicyType = "on_failure" restartPolicyMaxRetries = 3 ``` ### Railway CLI ```bash # Install npm install -g @railway/cli # Login railway login # Link to existing project railway link # Deploy from local railway up # Open the dashboard railway open # Set variables railway variables set RUST_LOG=info # View logs railway logs ``` ### Database #### Provision a database Click **+ New** in your project → **Database** → choose PostgreSQL, MySQL, or Redis. Railway provisions it and injects the connection URL into your service environment automatically. #### Example: PostgreSQL After provisioning, `DATABASE_URL` is available in your service: ```rust let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL not set"); ``` No manual secret management needed. ### Scaling * **Horizontal:** Railway supports multiple instances via the dashboard * **Vertical:** Adjust RAM/CPU in service settings (Pro plan) * **Auto-sleep:** On the free/Hobby plan, services sleep after inactivity ### Custom domains 1. **Settings** → **Networking** → **Custom Domain** 2. Add your domain (e.g. `api.example.com`) 3. Create a CNAME record pointing to the Railway-provided target 4. Railway provisions TLS automatically ### CI/CD Railway deploys on every push to `main` by default. To change: * **Settings** → **Deploy** → select branch * Enable/disable auto-deploy * Add a **Deploy trigger** for specific branches or tags #### Preview environments Railway creates isolated preview environments for pull requests: * Each PR gets its own URL and database * Merging the PR promotes to production * Closing deletes the preview ### Monorepo support If your Ultimo app is in a subdirectory: 1. **Settings** → **Source** → set **Root Directory** to your app path 2. Railway builds from that directory ### Cost optimization * Use the Hobby plan ($5/mo) for small projects — includes 8 GB RAM, 8 vCPU hours * Dockerfile builds cache layers → subsequent deploys are fast * Services auto-sleep on Hobby plan when unused * Add a `railway.toml` with health checks to avoid unnecessary restarts