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

API Reference

Complete API reference for the Ultimo web framework.

Core Types

UltimoBody

The response body type (type Response = hyper::Response<UltimoBody>). Two variants: Full (buffered — the fast path, produced by text/json/html) and Stream (produced by Context::stream). BoxError is the streaming error type (Box<dyn std::error::Error + Send + Sync>).

SseEvent

A Server-Sent Event. SseEvent::new(&payload) serializes a Serialize value to the data: field; SseEvent::data(text) / SseEvent::comment(text) for raw text / keep-alive lines; chain .event(name), .id(id), .retry(duration). Pair with sse_channel() -> (SseSender, Stream) for push/broadcast. See SSE.

Ultimo

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

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

Methods

new() -> Self

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

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

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

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

Register route handlers for different HTTP methods:

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

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

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

Start the HTTP server on the specified address.

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

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

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

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

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

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

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

See Static Files.

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

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

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

See Static Files.

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

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

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

See OpenAPI.

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

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

Database Methods (requires feature flags)

With SQLx (requires sqlx feature):

app.with_sqlx(pool);

With Diesel (requires diesel feature):

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

See Sessions.


Context & Request

Context

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

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

Response Methods

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

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

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

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

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

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

ctx.html("<h1>Hello</h1>").await
stream<S>(&self, body: S) -> Result<Response>

Return a chunked/streaming response body from a Stream<Item = Result<Bytes, BoxError>>. No Content-Length is set. Queued header/status are applied.

use hyper::body::Bytes;
use ultimo::response::BoxError;
 
async fn download(ctx: Context) -> Result<Response> {
    let chunks: Vec<std::result::Result<Bytes, BoxError>> = vec![
        Ok(Bytes::from("part-1")),
        Ok(Bytes::from("part-2")),
    ];
    ctx.stream(futures_util::stream::iter(chunks)).await
}
sse<S>(&self, events: S) -> Result<Response>

Return a Server-Sent Events response streaming SseEvents. Sets Content-Type: text/event-stream, Cache-Control: no-cache, and X-Accel-Buffering: no; no Content-Length.

use ultimo::SseEvent;
 
async fn events(ctx: Context) -> Result<Response> {
    let evs = vec![SseEvent::new(&json!({ "tick": 1 }))?.event("tick")];
    ctx.sse(futures_util::stream::iter(evs)).await
}
sse_keep_alive<S>(&self, events: S, interval: Duration) -> Result<Response>

Like sse, but injects a : ping comment when idle for interval.

last_event_id(&self) -> Option<String>

The Last-Event-ID request header, for app-driven resume.

redirect(&self, location: &str) -> Result<Response>

Return a 302 redirect response.

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

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

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

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

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

Cookies

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

Client IP

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

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

let ip = ctx.client_ip();

Session (requires session feature)

session(&self) -> Session

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

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

See Sessions.

JWT claims (requires jwt feature)

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

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

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

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

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

See JWT Authentication.

API-key identity (requires api-key feature)

api_key(&self) -> Option<ApiKeyIdentity>

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

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

Authorization guards (requires jwt or api-key feature)

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

principal(&self) -> Option<Principal>

The normalized authenticated caller, if any.

require_auth(&self) -> Result<Principal>

The Principal, or 401 Unauthorized.

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

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

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

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

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

See Authorization (Guards).

Request

Access request data through ctx.req.

Path Parameters

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

Get a path parameter by name.

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

Get all path parameters as a HashMap.

let all_params = ctx.req.params();

Query Parameters

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

Get a single query parameter.

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

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

let all_queries = ctx.req.queries();

Headers

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

Get a request header value.

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

Get all request headers.

let all_headers = ctx.req.headers();

Body

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

Parse the request body as JSON.

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

Get the request body as a string.

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

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

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

Method & URI

method(&self) -> &Method

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

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

Get the request URI.

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

RPC System

RpcRegistry

Registry for RPC procedures with automatic TypeScript generation.

Creating a Registry

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

Registering Procedures

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

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

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

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

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

Register a procedure with explicit TypeScript type annotations.

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

TypeScript Generation

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

Generate a TypeScript client file with type-safe methods.

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

Generate TypeScript client code as a string.

let ts_code = rpc.generate_client();

RPC Modes

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

JSON-RPC 2.0 Protocol

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

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

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

Supports:

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

Typed extractors

Handlers may take typed parameters instead of a raw Context. Each implements FromRequest and is parsed from the request automatically; a failure short-circuits with the right status.

use ultimo::extract::{Path, Query, Json, Valid};
 
// GET /users/:id?verbose=true
app.get("/users/:id", |Path(id): Path<u32>, Query(opts): Query<Opts>| async move {
    // id: u32, opts: Opts — already parsed
});
 
// POST /users — deserialize the JSON body AND validate it
app.post("/users", |Valid(body): Valid<CreateUser>| async move {
    // body: CreateUser — validated (422 if invalid)
});
ExtractorSourceOn failure
Path<T>route :params — a single scalar (Path<u32>) or a struct of param names400
Query<T>query string400
Json<T>request body400
Valid<T>request body + validator400 (parse) · 422 (invalid)
Contextthe whole request

Handlers may take up to 8 extractors. |ctx: Context| handlers keep working unchanged (Context is itself an extractor). Validation failures return 422 Unprocessable Entity.


Middleware

Built-in Middleware

logger()

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

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

cors()

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

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

powered_by()

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

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

security_headers() / SecurityHeaders

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

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

IpFilter — IP allow/deny (CIDR)

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

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

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

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

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

compression() / Compression (requires compression feature)

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

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

See Compression.

csrf() / Csrf (requires csrf feature)

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

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

Jwt (requires jwt feature)

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

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

ApiKey (requires api-key feature)

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

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

Custom Middleware

Create custom middleware by implementing the middleware function signature:

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

OpenAPI

OpenApiSpec

Generate OpenAPI 3.0 specifications for your API.

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

Methods

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

Create a new OpenAPI specification.

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

Add an API endpoint to the specification.

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

Add a JSON schema definition.

to_json(&self) -> Result<String>

Serialize the specification to JSON.


Validation

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

Validate a value using the validator crate rules.

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

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


Error Handling

UltimoError

The main error type for the framework.

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

Usage

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

Errors automatically serialize to JSON:

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

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


Database Integration

SQLx Support (requires sqlx feature)

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

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

Access in handlers:

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

Diesel Support (requires diesel feature)

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

Access in handlers:

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

Types & Prelude

Prelude Module

Import everything you need in one line:

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

Response Type

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

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


Feature Flags

Enable optional functionality through Cargo features:

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

Available Features

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

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

Next Steps