Batteries-included web ergonomics, in Rust — built from
stdup, with a tiny binary and an AI agent as a first-class user.
sutegi (Basque: forge / smithy) is a web framework for Rust with zero
third-party dependencies. The HTTP/1.1 server, JSON codec, router, ORM query
builder, and LLM tool layer are all hand-built on the standard library. No
tokio, no hyper, no serde, no clap.
Three design goals, held simultaneously:
| Goal | How |
|---|---|
| From the ground up | Every component is original std-only code you can read in one sitting. |
| Minimum binary size | No async runtime; size-optimized release profile. A minimal core-only service is ~394 KB. |
| Agent-native | Routes, models, and tools are introspectable as JSON at runtime; tools are a first-class concept with a built-in LLM manifest + invocation endpoint. |
cargo run -p todo-example -- 127.0.0.1:8080curl localhost:8080/__introspect # full app surface as JSON
curl localhost:8080/__tools # LLM tool-calling manifest
curl -X POST localhost:8080/__tools/create_todo -d '{"title":"ship sutegi"}'
curl localhost:8080/api/todosA minimal app — handlers take one Ctx and return anything that is
IntoResponse; serve() reads HOST/PORT/WORKERS (or argv[1]) and drains
gracefully on SIGTERM:
use sutegi::prelude::*;
fn main() -> std::io::Result<()> {
App::new("hello")
.get("/", "Health check", |_| "sutegi up")
.get("/hello/:name", "Greet", |c| format!("hi, {}", c.param("name").unwrap_or("world")))
.serve()
}The whole todo demo — typed model, validation, pooled SQLite state, an HTTP
CRUD surface, and an AI tool — is ~60 lines:
use sutegi::prelude::*;
#[derive(Model, Validate)]
#[model(table = "todos")]
struct Todo {
#[model(primary)] id: i64,
#[validate(required, str, min_len = 1, max_len = 200)] title: String,
done: bool,
}
fn main() -> std::io::Result<()> {
let db = Db::open_or_memory("DATABASE_PATH"); // pooled, Send + Sync — no Arc/Mutex
Todo::migrate(&db).unwrap();
App::new("todo")
.state(db)
.get("/api/todos", "list", |c| -> Result<Json, Error> {
Ok(Json::arr(Todo::all_typed(c.db::<Db>())?.iter().map(Todo::to_json).collect()))
})
.get("/api/todos/:id", "show", |c| c.model::<Todo, Db>("id").map(|t| t.to_json()))
.post("/api/todos", "create", |c| {
let todo: Todo = c.validated()?; // parse + validate → 422 on failure
let id = todo.save(c.db::<Db>())?; // typed insert; DB assigns the id
Ok::<_, Error>((201, Todo { id, ..todo }.to_json()))
})
.tool("create_todo", "Create a todo",
schema::object(vec![("title", schema::string("the title"))], &["title"]),
|c, args| {
let todo = Todo::from_input(&args)?; // args already schema-validated
Ok(Todo { id: todo.save(c.db::<Db>())?, ..todo }.to_json())
})
.serve()
}| Crate | Responsibility |
|---|---|
sutegi-json |
JSON value, serializer, parser (deterministic key order). |
sutegi-http |
HTTP/1.1 parsing + thread-pool server on std::net. |
sutegi-web |
Router, App builder, middleware, groups, extractors, streaming (sse/stream), /__introspect, and the agent tool surface (App::tool/stream_tool, schema helpers, ToolCtx, /__tools). |
sutegi-orm |
Typed schema, fluent parameterized query builder, one Backend trait, a JSON key/value store, and two runnable backends: SQLite (sqlite, single-node) and Postgres (postgres, multi-pod). |
sutegi-pg |
Pure-std PostgreSQL driver: wire protocol v3 over blocking TCP, SCRAM-SHA-256 auth, connection pool. No async runtime, no C library. |
sutegi-storage |
File/object storage behind one Storage trait: local fs, database blobs (over Backend), and S3/R2 buckets over a pluggable HTTP transport — plus a pure-std SigV4 presigner. |
sutegi-macros |
#[derive(Model)] (schema, hydration, save, from_input) and #[derive(Validate)] (field-attr rulesets). Compile-time only (syn/quote never reach your binary). |
sutegi-validate |
Fluent Validator-style rule sets and a JSON Schema subset validator, with structured errors. |
sutegi-queue |
Durable job queue over the Backend seam — same SQL on SQLite or Postgres (UPDATE … RETURNING claim, SKIP LOCKED where the backend has it, visibility-timeout retries, priorities, named queues, dedupe keys, dead-letter). |
sutegi-hexagon |
Opinionated hexagonal/clean-architecture primitives: AppError, UseCase ports, respond adapter glue. |
sutegi |
Facade crate + prelude. |
sutegi-cli |
The sutegi command: scaffold apps/models/routes, introspect a live app. |
Only sutegi-json + sutegi-http + sutegi-web are always present. Every other
pillar is an opt-in feature on the sutegi facade, so the binary carries exactly
what you use:
| Feature | Default? | Pulls in |
|---|---|---|
orm |
✓ | schema + query builder + Backend trait + KV store |
derive |
✓ | #[derive(Model)] (build-time syn/quote only) |
validate |
✓ | request / tool validation + Ctx::validate/validated |
sqlite |
SQLite backend — the single-node runnable store (bundled) | |
postgres |
Postgres backend — the multi-pod runnable store (pure std) | |
queue |
durable job queue (SQLite or Postgres) | |
graceful |
SIGTERM/SIGINT draining (libc) | |
hexagon |
hexagonal/clean-architecture primitives | |
session |
signed-cookie sessions (HMAC-SHA256) | |
auth |
the user system: passwords, Users, login sessions, guards, API tokens |
|
template |
Blade-style template engine ({{ }}, @if, @foreach, @include) |
|
mail |
Email builder + themed messages + Transport seam + drivers |
|
auth-mail |
email-verification + password-reset flows on top of both | |
storage |
file storage: local fs, S3/R2 objects, presigned URLs (pure std) | |
storage-db |
blobs in SQLite/Postgres over the same Backend seam |
# Minimal HTTP service — core only:
sutegi = { version = "*", default-features = false }
# Single-node app with the SQLite backend + KV:
sutegi = { version = "*", default-features = false, features = ["sqlite"] }
# Multi-pod app on Postgres:
sutegi = { version = "*", default-features = false, features = ["postgres"] }Measured: the core-only hello example is ~394 KB; the full todo example
(every pillar + bundled SQLite) is ~1.31 MB.
Built-in operational endpoints (always on, no feature needed):
| Endpoint | Purpose |
|---|---|
GET /__health |
liveness — always 200 while the process is up |
GET /__ready |
readiness — 200/503 from your App::readiness(...) probe |
GET /__metrics |
Prometheus text (requests total, in-flight, by status class) |
GET /__introspect |
full app surface (routes/models/tools/endpoints) |
A std-only config layer (sutegi::config::Config): .env loading, typed
accessors, required-var validation, and prefix scoping.
use sutegi::config::Config;
let cfg = Config::load(); // .env (if present) + process env (env wins)
let port = cfg.int("PORT", 8080);
let debug = cfg.bool("DEBUG", false);
let hosts = cfg.list("ALLOWED_HOSTS"); // comma-separated
cfg.require_all(&["DATABASE_URL", "API_KEY"])?; // fail fast, lists all missing
let db = cfg.prefixed("DB_"); // DB_HOST/DB_PORT → HOST/PORTlet ready = db.clone(); // Db is Send + Sync + Clone
App::new("api")
.state(db)
.readiness(move || ready.query("SELECT 1", &[]).is_ok())
.get("/", "health", |_| "ok")
.serve()?; // HOST/PORT/WORKERS from env; SIGTERM → stop accepting → drainserve() is the one-call entrypoint. Under the hood it uses run_graceful (the
graceful feature), which traps SIGTERM/SIGINT, stops accepting new connections,
and lets in-flight requests finish before exit — exactly what a Kubernetes
rolling update needs. (run(addr) serves forever; run_until(addr, flag) gives
manual control without the signal feature.)
State: pick a backend for the deployment. The request/route/AI surface is stateless and scales horizontally. For data, sutegi is opinionated:
- One instance → SQLite (
sqlite). Embedded, zero-ops, single writer. Plus theKvkey/value store for config/cache/sessions/flags. - Many pods → Postgres (
postgres) + the durable queue (queue). A shared, crash-safe source of truth all replicas talk to — pure-stddriver, no async runtime, no C library.
Both backends implement the same Backend trait and drive the same query
builder + Model surface, so moving from one to the other changes the type you
hold, not your handlers.
ontzi (Basque: vessel / container) is a small harness that wraps Docker
Compose so you run the same horizontally-scaled shape locally: N app replicas
behind an nginx load balancer (configured proxy_buffering off, so SSE streams
pass straight through).
./ontzi up 3 # build + 3 app replicas + LB on http://localhost:8080
./ontzi curl /api/todos
./ontzi logs
./ontzi down
./ontzi k8s apply # or apply the Kubernetes manifests (deploy/k8s/)deploy/k8s/deployment.yaml shows the production shape: 3 replicas, liveness/
readiness probes on the built-ins, terminationGracePeriodSeconds + a preStop
hook for clean draining, Prometheus scrape annotations, and small resource asks
(the binary is tiny, so requests: 32Mi).
For non-trivial apps, sutegi ships an opinionated ports & adapters structure
(the hexagon feature). The dependency rule — source dependencies point inward —
keeps your domain free of HTTP/SQL, makes business logic unit-testable without a
server, and lets you expose one use case over many transports.
inbound adapters (HTTP, AI tools) ──▶ application (use cases) ──▶ ports (traits)
│ ▲
▼ │
domain (pure) outbound adapters (SQLite, …)
sutegi::hexagon provides AppError (with a canonical HTTP mapping), the UseCase
port trait, and respond()/respond_created() glue. The
examples/hexagonal app is a full worked reference:
domain → ports → use cases → adapters, with the same CreateTodo use case
exposed via both HTTP and an AI tool, and two interchangeable repositories
(in-memory ↔ SQLite) selected at the composition root (REPO=memory|sqlite).
Full guide: docs/HEXAGONAL.md.
A sutegi app is drivable by an LLM with no source access and no integration code:
GET /__introspect→ discover routes, data models, and tools.GET /__tools→ an Anthropic-style{name, description, input_schema}manifest.POST /__tools/:namewith a JSON body → invoke a tool; required-field validation rejects malformed calls with a clear error.
See AGENTS.md for the full agent-facing contract.
The core ships no database driver — the query builder just emits
(sql, params), keeping the default binary tiny. Opt in to a runnable backend,
and sutegi is opinionated about which:
- SQLite (
--features sqlite) — the single-node store. Embedded, zero-ops, one writer. Bundled; grows the binary to ~1.3 MB. - Postgres (
--features postgres) — the multi-pod store. A shared, durable server many replicas talk to. Purestd, no async runtime, no C library, so the zero-dep core stays ~394 KB.
Both implement the same [Backend] trait, and Model is written once against
it — swap the backend, not your handlers:
use sutegi::prelude::*; // brings in Db / Pg / Kv / Backend when enabled
let db = Db::memory()?; // single-node: or Db::open("app.db")? (pooled)
// let db = Pg::from_env(8)?; // multi-pod: same code from here down
Todo::migrate(&db)?; // CREATE TABLE from the model schema
let id = Todo { id: 0, title: "ship sutegi".into(), done: false }.save(&db)?; // typed insert
let one: Option<Todo> = Todo::find_typed(&db, Value::Int(id))?;
let all: Vec<Todo> = Todo::all_typed(&db)?;Db is a pooled, Send + Sync + Clone handle — hand it to App::state(db) and
read it back with c.db::<Db>(); no Arc<Mutex<…>>. Untyped rows come back as
JSON objects (Todo::find/Todo::all).
The query layer covers reads and writes, all parameterized:
QueryBuilder::table("todos")
.filter_in("id", vec![Value::Int(1), Value::Int(2)])
.order_by("done", false).order_by("id", true) // multi-column
.limit(20).offset(40).build(); // paging
QueryBuilder::table("todos").filter("done", "=", Value::Bool(true)).build_count();
QueryBuilder::table("todos")
.filter("done", "=", Value::Bool(false))
.or_group(&[("priority", "=", Value::Text("high".into())), // AND (a OR b)
("pinned", "=", Value::Bool(true))])
.where_not_null("title")
.like("title", "%sutegi%")
.join("users", "users.id", "todos.user_id") // JOIN / LEFT JOIN
.group_by(&["users.name"]).distinct()
.where_raw("created_at > ?", vec![Value::Int(0)]); // escape hatch
UpdateBuilder::table("todos").set("done", Value::Bool(true)).filter("id", "=", Value::Int(5)).build();
DeleteBuilder::table("todos").filter("id", "=", Value::Int(5)).build();
// Runnable: transactions, counts, existence, upsert, pagination — all on the
// `Backend` trait, identical on SQLite and Postgres. The transaction closure
// receives a `Backend` (SQLite `&Db`, Postgres `Tx`), so the query builder and
// Model helpers work inside it.
db.transaction(|tx| { tx.insert("todos", &[/* … */], "id")?; Ok(()) })?; // COMMIT / ROLLBACK
let n = Todo::count(&db)?; // i64
let ok = db.exists(&Todo::query().filter("id", "=", Value::Int(1)))?; // bool
db.upsert("todos", &[("id", Value::Int(1)), ("title", Value::Text("x".into()))], "id", "id")?; // conflict col, pk
Todo::update(&db, Value::Int(1), &[("done", Value::Bool(true))])?; // by primary key
Todo::delete(&db, Value::Int(1))?;
let page = db.paginate(&Todo::query().order_by("id", true), 2, 20)?; // Page { items, total, page, … }
let one: Option<Todo> = db.fetch_one(&Todo::query().filter("id", "=", Value::Int(1)))?;Not everything wants a schema. Kv is a namespaced JSON key/value store over any
Backend — one table, single-statement reads/writes. It's the natural fit for
config, caches, feature flags, and sessions on a single SQLite node (and works on
Postgres for small shared state). See examples/kv.
use sutegi::prelude::*;
let kv = Kv::new(Db::open("app.db")?);
kv.migrate()?;
kv.set("config", "theme", &Json::str("dark"))?;
let theme = kv.get("config", "theme")?; // Some(Json::Str("dark"))
let flags = kv.scan("flags")?; // Vec<(String, Json)>
kv.delete("config", "theme")?;#[derive(Model, Validate)]
#[model(table = "todos")]
struct Todo {
#[model(primary)]
id: i64,
#[validate(required, str, min_len = 1, max_len = 200)]
title: String,
done: bool, // round-trips cleanly (SQLite stores 0/1, you get a bool)
note: Option<String>,// Option<T> => nullable column
}
let todos: Vec<Todo> = Todo::all_typed(&db)?; // hydrated structs
let one: Option<Todo> = Todo::find_typed(&db, Value::Int(1))?;
let id = Todo { id: 0, title: "x".into(), done: false, note: None }.save(&db)?; // insert, DB assigns id
let body: Json = one.unwrap().to_json(); // booleans serialize as real booleans#[derive(Model)] generates the schema, FromRow hydration, save() (insert),
to_json(), and from_input() (lenient hydrate from a partial client payload).
#[derive(Validate)] turns #[validate(...)] field attributes into the model's
own Ruleset, so c.validated::<Todo>() parses, validates, and hydrates a body
in one step. Build-time deps (syn/quote) never reach your runtime binary; turn
the derives off with default-features = false for hand-written models.
let auth = mw(|req: &Request| {
if req.header("authorization").is_some() { None } // continue
else { Some(text(401, "unauthorized")) } // short-circuit
});
App::new("api")
.group("/api", vec![auth], |g| {
g.get("/todos", "List", list)
.post("/todos", "Create", create)
})Group middleware runs before each route in the group; patterns are prefixed.
Ctx::model hydrates a model straight from a path parameter over the backend in
state, returning a ready 404/500 Error you can ?:
// GET /api/todos/:id — hydrate a Todo from the path param, or 404/500.
.get("/api/todos/:id", "show", |c| c.model::<Todo, Db>("id").map(|t| t.to_json()))The queue (queue feature) runs over the Backend seam, so one jobs table and
one set of SQL work on bundled SQLite and on Postgres. Jobs survive a crash: the
claim stamps a lease instead of deleting the row, so a dead worker's job becomes
visible again after the visibility timeout (at-least-once). Retries, delays,
priorities and dedupe keys are columns, so a restart forgets nothing.
Claims are exclusive on both backends — FOR UPDATE SKIP LOCKED on Postgres,
and on SQLite the serialized writer already gives it, since the second UPDATE
no longer sees the claimed row. Postgres additionally makes that exclusivity
cross-pod; queue.cross_pod() tells you which one you have.
use std::sync::Arc;
use sutegi::queue::Queue;
let mut queue = Queue::new(db.clone()); // any Backend: Db or Pg
queue.register("notify", |job| { /* send job.payload() … */ Ok(()) });
queue.migrate()?; // create sutegi_jobs
// Enqueue from anywhere (any pod, on Postgres):
queue.dispatch("notify", Json::obj(vec![("to", Json::str("a@b.com"))]))?;
// Or shape the dispatch: its own pool, one in flight per key, 3 tries.
queue
.job("video.ingest", Json::obj(vec![("id", Json::str("abc"))]))
.queue("video")
.unique("yt:abc")
.max_attempts(3)
.dispatch()?;
let queue = Arc::new(queue);
let fast = Arc::clone(&queue).start(4); // 4 workers on "default"
let slow = Arc::clone(&queue).start_on("video", 1); // 1 on the slow queue
// … later: fast.stop(); slow.stop();A handler that can outrun the visibility timeout should say it is still alive
(job.heartbeat()) and notice shutdown (job.should_stop()).
--features auth,sqlite (or auth,postgres) gives you the Laravel auth
scaffolding with zero third-party dependencies:
- Passwords — PBKDF2-HMAC-SHA256 as PHC strings (
$pbkdf2-sha256$i=600000$…), per-password random salts, OWASP default work factor, constant-time verify,needs_rehashfor upgrading old hashes at login. Users<B>— register / authenticate / find / roles over anyBackend. Hashes never leave the store; unknown emails burn the same PBKDF2 time as wrong passwords.- Sessions — signed cookies (
sutegi-session) with the expiry stamped inside the signed payload, so a stolen cookie dies on schedule no matter what the client claims. - Guards —
require_auth/require_role/require_token, plugged into route groups. - API tokens — the agent door:
Tokens::issuemintsstg_…bearer tokens (plaintext shown once, only its SHA-256 stored), agents authenticate withAuthorization: Bearerand never touch cookies.
use sutegi::prelude::*;
use std::sync::Arc;
let db = Db::open("app.db").unwrap();
let users = Users::new(db.clone());
users.migrate().unwrap();
let auth = Arc::new(Auth::new(users, Sessions::new(secret.as_bytes())));
App::new("app")
.post("/login", "Log in.", {
let auth = auth.clone();
move |c| {
let body = c.json()?;
let (email, pw) = (body.get("email").and_then(Json::as_str).unwrap_or(""),
body.get("password").and_then(Json::as_str).unwrap_or(""));
match auth.users.authenticate(email, pw)? {
Some(u) => Ok::<_, Error>(auth.login(c.req, &u, json(200, &u.to_json()))),
None => Err(Error::unauthorized("bad credentials")),
}
}
})
.group("/admin", vec![mw(require_role(auth.clone(), "admin"))], |g| {
g.get("/users", "All users.", |c| {
let auth = c.state::<Arc<Auth<Db>>>();
Ok::<_, Error>(json(200, &Json::arr(
auth.users.list()?.iter().map(User::to_json).collect())))
})
})See examples/auth for the full working app (registration, admin bootstrap,
token minting, an agent-guarded /api group).
--features template is a small Blade: compile once, render over Json.
let mut t = Templates::new();
t.add("row", "<li>{{ item.name }}@if(item.admin) ★@endif</li>")?;
t.add("list", "<ul>@foreach(users as item)@include(row)@endforeach</ul>")?;
t.render("list", &ctx)?; // {{ }} escapes; {!! !!} doesn't; loop.index/first/last--features mail is the Laravel Mail shape: build an Email, hand it to a
Mailer, the configured transport moves it.
let mailer = Mailer::from_env()?; // MAIL_DRIVER=log|smtp|sendmail, MAIL_FROM=…
mailer.send(Email::new()
.to("you@example.com")
.subject("Hello")
.text("plain body")
.html("<b>rich body</b>"))?; // both set → multipart/alternativeBuilt-in transports: log (dev default — messages print, nothing escapes),
memory (test assertions), smtp (pure-std blocking client: EHLO, AUTH
PLAIN/LOGIN, dot-stuffing — point it at an in-cluster relay or Mailpit on
localhost:1025; no TLS, same stance as the Postgres driver), and
sendmail (pipe to the local Postfix — the VPS shape).
Third-party providers plug in with one method. Transport hands you the
structured Email and its rendered RFC 2822 form; an adapter for
Resend/SendGrid/Postmark/SES is your HTTP client of choice posting either
shape:
struct Resend { key: String }
impl Transport for Resend {
fn send(&self, email: &Email, _raw: &str, id: &str) -> Result<String, String> {
my_http_post("https://api.resend.com/emails", &self.key, &to_json(email))?;
Ok(id.to_string())
}
}Theme + the fluent MailMessage builder (Laravel's notification mail shape)
produce a clean, email-client-safe HTML card and a matching plain-text
part from the same blocks — every message is multipart/alternative for free:
let theme = Theme::new("MyApp") // defaults: 600px card, ember accent
.brand_color("#7c3aed") // …every piece is configurable
.logo_url("https://cdn.example.com/logo.png")
.footer("MyApp Inc · Bilbao");
mailer.send(theme.message()
.subject("Welcome!")
.greeting("Hi Vera,")
.line("Thanks for signing up — one more step:")
.action("Confirm your email", &url) // brand-colored button + fallback link
.note("This link is valid for 24 hours.")
.email()?
.to("vera@example.com"))?;The outer chrome is a sutegi-template source — swap it wholesale with
Theme::layout(...) while the block rendering keeps working. AuthMail's
verification/reset emails render through this (pass your Theme via
AuthMail::theme).
AuthMail wires the user system to the mailer with Laravel-style defaults —
built-in text+HTML templates, signed expiring links, no state tables:
let mail = AuthMail::new(mailer, secret.as_bytes(), "https://app.example.com", "MyApp");
mail.send_verification(&user)?; // on register; 24h link
mail.confirm_email(&auth.users, &token)?; // flips users.verified_at
mail.send_password_reset(&auth.users, &email)?; // 1h link; unknown emails: silent Ok
mail.reset_password(&auth.users, &token, &new_pw)?; // single-use in effectReset links are bound to the current password hash: the moment the
password changes, every outstanding reset link dies — stateless single-use.
See examples/auth for the full flow wired into routes.
The same swap-the-backend idea, for bytes (--features storage). One
[Storage] trait — put/get/stat/delete/list/get_reader — with an
opinion per backend:
FsStorage— local filesystem: the single-node default. Zero-ops, atomic writes (temp file + rename), real streaming reads.DbStorage<B>(storage-db) — blobs in a database table over any ORMBackend. On Postgres that is multi-pod file storage with zero new infrastructure; honest ceiling ~a few MB per object.S3Storage<T>— a real S3-compatible bucket: AWS S3, Cloudflare R2, MinIO, Garage, Ceph RGW. The backend for objects past a database row's comfort, and the one that survives ephemeral pods. It moves the bytes itself over an injectedHttpTransport, which is how sutegi ships an S3 client while still having no TLS stack and no third-party dependency.S3Store— the credentials behind it, and on its own a pure-stdSigV4 presigner: time-limited GET/PUT/DELETE URLs whose bytes flow directly between the client and the object store, never proxied. Signing reuses the Postgres driver's SCRAM crypto and is verified against AWS's published known-answer vectors — for presigned URLs and signed headers.
use sutegi::prelude::*;
let store = FsStorage::new("data/files")?; // or DbStorage::new(pg)
store.put("reports/q2.pdf", &bytes, "application/pdf")?;
// Same trait, same call sites, a bucket instead of a disk:
let r2 = S3Store::r2(&account, "media", &ak, &sk).storage(SystemCurl::new());
r2.put("reports/q2.pdf", &bytes, "application/pdf")?;
let objects = r2.list("reports/")?; // Vec<ObjectMeta>
// The agent-native shape: a tool mints an upload URL, the agent PUTs the
// bytes itself — your app only ever handles metadata.
let s3 = S3Store::new("bucket", "eu-central-1", &ak, &sk);
app.tool("presign_upload", "Mint a time-limited S3 upload URL.",
schema::object(vec![("key", schema::string("object key"))], &["key"]),
move |_c, args| {
let key = args.get("key").and_then(Json::as_str).unwrap_or("");
Ok(Json::str(s3.presign_put(key, 900)?))
})S3Storage never opens a socket itself. It hands a signed request to an
HttpTransport — one method — and two implementations ship with it:
SystemCurl—httpsby delegating the handshake and certificate verification to the systemcurl. The crypto that must not be hand-rolled isn't, and the dependency count stays at zero. Credentials never reachargv: the URL and every signed header go in on stdin (--config -), soAuthorizationis invisible tops. Protocol pinned with--proto =https, redirects off (a 3xx must not replay a signature elsewhere), TLS 1.2 floor, and no knob anywhere that disables verification.PlainHttp— purestd, and it refuseshttpsrather than pretending. For a store on a trusted path: in-cluster MinIO, sidecar Garage, a dev container. Same stance as the Postgres driver and the SMTP transport.- yours —
impl HttpTransport for MyClientif you already pay forureqorreqwest.
Every request is signed with the real payload hash (x-amz-content-sha256,
never UNSIGNED-PAYLOAD), so a body altered in flight is refused by the store —
integrity that holds even over PlainHttp. Downloads and uploads are checked
against the ETag when it is a plain MD5, giving end-to-end verification on top
of it. list follows continuation tokens and is bounded by max_list_keys: a
ten-million-object bucket errors, never silently truncates.
S3Store deliberately does not implement Storage: minting a URL is a
different contract than moving bytes. S3Store::storage(transport) is the
crossing point. See examples/storage for the working file server + presign
tools.
collect(..) wraps any iterable in a Collection<T> — a fluent, chainable API
for the everyday shaping that raw Iterator makes verbose (filter/reject,
map/filter_map, group_by, partition, chunk, unique, implode,
tap/pipe, …). It's a thin layer over Vec<T>: it Derefs to [T] and
round-trips through Vec/iterators, so it adds no allocation over doing the
work by hand.
use sutegi::collect;
let report = collect(orders)
.filter(|o| o.paid)
.group_by(|o| o.country.clone()) // HashMap<String, Collection<Order>>
.into_iter()
.map(|(country, os)| format!("{country}: {}", os.sum_by(|o| o.total)))
.collect::<Vec<_>>();
// Numeric chains read left-to-right:
let total: i64 = collect(vec![1, 2, 3, 4]).filter(|n| n % 2 == 0).map(|n| n * 10).sum();Three entry points, one structured error shape ({ field: [messages] }). The
terse path is #[derive(Validate)] on the model plus c.validated::<T>(), which
parses, validates, and hydrates a request body in one step (a 422 with the
field errors on failure):
#[derive(Model, Validate)]
struct Signup {
#[validate(required, str, min_len = 3, max_len = 20)] username: String,
#[validate(required, email)] email: String,
#[validate(min = 18)] age: i64,
}
// In a handler:
.post("/signup", "create", |c| {
let signup: Signup = c.validated()?; // 422 { "errors": { "email": [...] } } on failure
Ok::<_, Error>((201, signup.to_json()))
})Or drive a Ruleset directly (what the derive builds), sharing the same shape:
// Fluent request validation
let rules = Ruleset::new()
.field("title", &[Rule::Required, Rule::Str, Rule::MinLen(1), Rule::MaxLen(200)])
.field("email", &[Rule::Required, Rule::Email])
.field("age", &[Rule::Integer, Rule::Between(18.0, 120.0)])
.field("website", &[Rule::Url])
.field("slug", &[Rule::AlphaNum])
.field("role", &[Rule::In(vec!["admin".into(), "user".into()])])
.field("password_confirmation", &[Rule::Same("password".into())]);
rules.validate(&body)?; // Err(ValidationErrors) -> errs.to_json()Rules: Required, Str, Integer, Number, Bool, Email, Url, Alpha,
AlphaNum, Min/Max, Between, MinLen/MaxLen, In, Same.
AI tool arguments are validated automatically against each tool's declared
input_schema (type, required, enum, bounds), so a malformed agent call
gets a precise 422:
{ "error": "validation failed", "errors": { "title": ["expected type 'string'"] } }Because the server is blocking thread-per-connection, streaming is trivial: a handler just writes and flushes over time, and the worker thread provides natural backpressure. No async, no chunked encoding (framing is "read until close", valid HTTP/1.1).
// Server-Sent Events — the natural transport for LLM tokens.
.get("/stream", "SSE demo", |_| sse(|sink| {
for i in 1..=3 {
sink.data(&format!("tick {i}"))?; // each frame is flushed immediately
std::thread::sleep(std::time::Duration::from_millis(80));
}
sink.event("done", "bye")
}))
// Or raw byte streaming (NDJSON, large exports, …):
.get("/export", "stream rows", |_| stream(200, "application/x-ndjson", |sink| {
for row in rows() { sink.write_str(&format!("{}\n", row.to_json()))?; }
Ok(())
}))Streaming AI tools are registered with stream_tool and invoked over SSE at
POST /__tools/:name/stream; the closure shares app state and emits tokens
through the SseSink:
.stream_tool("stream_answer", "Stream an answer token-by-token.",
schema::object(vec![("prompt", schema::string("the prompt"))], &["prompt"]),
|_c, args, sink| {
let prompt = args.get("prompt").and_then(Json::as_str).unwrap_or("");
for tok in prompt.split(' ') { sink.data(tok)?; }
sink.event("done", "{}")
})The /__tools manifest marks these with "streaming": true, so an agent knows
to hit the SSE endpoint. Argument validation happens before the stream opens,
so malformed calls still get a normal JSON 422.
sutegi new blog # scaffold a new app
sutegi make:model Post # src/models/post.rs (table: posts)
sutegi make:route health # src/routes/health.rs with register(app)
sutegi introspect # pretty-print a running app's /__introspectScaffolding follows rigid conventions on purpose — one right shape per artifact — so an LLM can extend a sutegi app correctly with minimal context.
Hot-path microbenchmarks via aatxe (adaptive,
CV-gated sampling; emits a statistical RunReport). Run with make bench
(needs the aatxe repo cloned as a sibling). Indicative --release numbers
from a dev machine:
| Bench | Median | ops/sec |
|---|---|---|
validate_ruleset (2 fields) |
69 ns | 14.4 M |
http_parse_request |
1.01 µs | 994 K |
json_serialize |
1.14 µs | 877 K |
sqlite_insert (in-mem) |
1.78 µs | 561 K |
query_builder |
1.96 µs | 510 K |
json_parse (~150 B) |
2.42 µs | 414 K |
sqlite_select_20 |
5.75 µs | 174 K |
e2e_request (full TCP round-trip) |
112 µs | ~8.9 K |
e2e_request is a complete connect → GET → response → close cycle from a single
sequential client (sutegi is connection-per-request); real throughput scales
with concurrency across the worker pool. Numbers are machine-dependent — run
make bench for yours.
make install-hooks enables a pre-commit hook that re-runs the aatxe bench
suite against benches/baselines/local.json and blocks the commit if any
hot-path benchmark regresses past the statistical noise gate. It only runs
when staged changes touch performance-sensitive paths (Cargo.*, crates/,
benches/, examples/). To bypass for a single commit, use
SKIP_BENCH=1 git commit ....
Early but increasingly capable. Typed models (#[derive(Model)]), a query
builder + Backend trait over two runnable backends (SQLite single-node,
pure-std Postgres multi-pod), a JSON Kv store, validation (requests + AI tool
args), route groups + middleware, route-model binding, and a durable, cross-pod
job queue (Postgres) all work and are exercised by the todo/kv examples.
Streaming responses (SSE + raw) and streaming AI tools are supported. Every pillar
is an opt-in compile feature; the runtime ships health/readiness/metrics endpoints
and graceful shutdown for pods, with an ontzi Docker/k8s harness. HTTP is 1.1,
connection-per-request. Next: TLS to Postgres, form-encoded bodies, keep-alive,
relations/joins in the query builder.
MIT © 2026 Eneko Sarasola