Churro + Rust — a backend web framework inspired by Kotlin's Ktor.
Simple, secure, robust, easy to learn.
Guides: User documentation (quick start, plugins, recipes) · API: docs.rs/churust
Churust gives you Ktor's developer experience in Rust: an application engine, a
routing DSL, an install(plugin) system, and a phased interceptor pipeline —
built on a battle-tested async stack (tokio + hyper + rustls). Churust owns
the ergonomic layer; it does not reinvent HTTP parsing or TLS.
use churust::prelude::*;
#[churust::main]
async fn main() -> std::io::Result<()> {
Churust::server()
.port(8080)
.routing(|r| {
r.get("/", |_call: Call| async { "Hello from Churust 🌀" });
r.get("/users/{id}", |Path(id): Path<u64>| async move {
format!("user #{id}")
});
})
.start()
.await
}[dependencies]
churust = "0.3"That is the whole dependency list. Churust re-exports the runtime it is built
on as churust::tokio, and #[churust::main] uses that re-export, so no
separate tokio entry is needed. Add one only if you want a tokio feature
Churust does not enable — Cargo unifies the two.
Plugins and transports are opt-in features on the umbrella crate — depend on
churust and enable what you need rather than pulling in the plugin crates
directly:
churust = { version = "0.3", features = ["full", "ws", "fs", "tls"] }| Feature | Pulls in | Gives you |
|---|---|---|
json |
churust-json |
Json<T> extractor/responder, content negotiation |
logging |
churust-logging |
CallLogging via tracing |
cors |
churust-cors |
preflight + CORS headers |
auth |
churust-auth |
Bearer/Basic/JWT, Principal<P> |
ratelimit |
churust-ratelimit |
RateLimit, GCRA, keyed on the peer address (IPv6 by prefix) |
compression |
churust-compression |
brotli / gzip / deflate response bodies |
templates |
churust-templates |
Templates + Renderer, minijinja, HTML-escaped whatever the template is named |
full |
the seven above | the whole plugin set |
redis |
churust-redis |
RedisStore: server-side sessions, revocable |
client |
churust-client |
outbound HTTP client with transparent gzip/deflate decode (client-tls for HTTPS) |
openapi |
churust-openapi |
an OpenAPI 3.1 document from the router |
ws |
churust-core/ws |
WebSocket upgrade + WebSocket/Message |
fs |
churust-core/fs |
StaticFiles, conditional GET, byte ranges |
multipart |
churust-core/multipart |
multipart/form-data, buffered or streamed |
tls |
churust-core/tls |
rustls-backed HTTPS |
http3 |
churust-core/http3 |
HTTP/3 over QUIC (implies tls) |
Default features are empty, so a plain churust = "0.3" compiles the core
engine and nothing else.
Every Churust crate is released in lockstep on one version number, so
churust-core, churust-json, and the rest always match the umbrella version.
- Hybrid handlers — write call-style (
|call: Call|) or with typed extractors (Path<T>— including tuples and structs —Query<T>,Form<T>,Header<T, N>,State<T>,Json<T>,Payload,Either<A, B>,BearerToken,Principal<P>) — mix freely in one handler. Wrap any of them inOption<T>when the input is optional; absent isNone, malformed is still an error. install(plugin)— Ktor-style plugins composed over a named pipeline (Setup → Monitoring → Plugins → Call → Fallback) for deterministic order, plusinterceptfor middleware scoped to part of the route tree.- Route guards — several routes may share a path when
guard::header,guard::hostor a custom predicate distinguishes them. - Your own error pages —
on_errorrenders any4xx/5xx, including routing failures. - Request correlation — a request id on every log line, echoed as
x-request-id, continuing an inbound W3Ctraceparent. - Built-in plugins — JSON content negotiation, request logging, CORS, and authentication (Bearer / Basic / JWT).
- Cookies and sessions — a cookie primitive with safe defaults
(
HttpOnly,SameSite=Lax), and HMAC-SHA256 signed cookie sessions. - Streaming request bodies —
Payloadreads an upload incrementally, so it is not capped by memory. - Peer address —
call.peer_addr()for rate limiting and audit logs. - Deployment knobs — idle keep-alive, listen backlog, connection cap, TLS handshake cap and a deadline that bounds queueing for it as well as the handshake itself, HTTP/2 stream and header limits, a bounded shutdown grace period that actually drains, several bind addresses, and Unix domain sockets that refuse to unlink a live one.
- File uploads —
Multipartbuffers the whole body;MultipartStreamreads fields and their content incrementally, so memory stops scaling with upload size. Both behind themultipartfeature. - Typed app state / DI —
.state(T)then extractState<T>. - Layered config — defaults <
churust.toml< env (CHURUST_*) < code DSL. - Secure by default — security headers on every response, including the
413s,400s and408s a transport writes for itself without reaching a handler; body-size limits; request and header-read timeouts, the latter covering a connection from the moment it is accepted rather than from the moment a protocol is chosen, so a peer that connects and says nothing is still bounded (slow-loris); header and path-depth caps; WebSocket frame and message caps; panic isolation (a panicking handler returns 500, never crashes the server); no version banner; opt-in rustls TLS. - HTTP/1.1, HTTP/2 and HTTP/3 — h2 over TLS via ALPN, h2c by prior knowledge
in plaintext, negotiated per connection; h3 over QUIC on its own listener
behind the
http3feature, withadvertise_http3emitting theAlt-Svcheader clients need in order to try it. - Response compression — brotli, gzip and deflate, negotiated from
Accept-Encoding, streaming bodies included (featurecompression). - Rate limiting — GCRA, so bursts are smoothed and
Retry-Afteris exact rather than estimated. IPv6 peers are bucketed by network prefix, because a subscriber is handed a whole/64and limiting each address in it separately limits nobody (featureratelimit). - Templating — minijinja, parsed at startup, HTML-escaped whatever the
template is called, since the rendered output is served as HTML either way
(feature
templates). - Server-side sessions — Redis-backed, so logging out actually revokes, and
a revocation that could not be carried out is an error rather than a cheerful
200over a session that is still live (featureredis). - An HTTP client — pooled, bounded, on the same hyper the server runs on;
transparent gzip/deflate decode with a decompression-bomb ceiling
(feature
client). - OpenAPI 3.1 — paths and parameters from the router, prose and schemas from
you, with drift reported in both directions (feature
openapi). - Login and logout — an identity layer over sessions with both an absolute and an idle deadline.
- Correct HTTP — automatic
HEADandOPTIONS(includingOPTIONS *), oneAllowheader that both405andOPTIONSagree on, conditional GET (ETag/Last-Modified/304) and byte ranges (206/416) for static files. - One URL per resource —
PathPolicydecides what happens to//aand/a//b: refuse (default),308to the canonical form, or collapse. Silent collapsing makes prefix-based auth checks bypassable and cache identity ambiguous. - Ambiguity is refused, not guessed — a request carrying both
Transfer-EncodingandContent-Lengthnever keeps its connection, whichever order the two headers arrive in, so no leftover byte becomes the next request behind a proxy that read the length differently. This is why hyper1.11is a floor rather than a preference: earlier versions delete theContent-Lengthwhile parsing and leave the connection open, and no check above hyper can see what was deleted. A repeated query key on a scalar field is an error rather than a silent first-or-last-wins. - Borrow the ecosystem — the
towerfeature runs anytower::Serviceas middleware, sotower-http's layers work without Churust reimplementing them. - Errors that carry — implement
IntoErroron your own error type and?works in handlers, without leaking itsDisplaytext to clients. - Hard to misuse — a missing static root or a duplicate route fails at
startup rather than silently at runtime;
secure_comparefor secrets. - Fast, in-process tests —
TestClientdrives the full pipeline without binding a socket.
| Crate | Docs | What it is |
|---|---|---|
churust |
docs.rs | Umbrella crate + prelude. Depend on this. Plugins behind features. |
churust-core |
docs.rs | Engine, routing, pipeline, Call, extractors, config, state, TLS, WebSockets, static files, test harness. |
churust-macros |
docs.rs | #[churust::main]. |
churust-json |
docs.rs | Json<T> extractor/responder + ContentNegotiation plugin (feature json). |
churust-logging |
docs.rs | CallLogging plugin via tracing (feature logging). |
churust-cors |
docs.rs | Cors plugin — preflight + headers (feature cors). |
churust-auth |
docs.rs | Auth (Bearer/Basic/JWT) + Principal<P> (feature auth). |
churust-ratelimit |
docs.rs | RateLimit plugin, GCRA (feature ratelimit). |
churust-compression |
docs.rs | Compression plugin — brotli/gzip/deflate (feature compression). |
churust-templates |
docs.rs | Templates + Renderer over minijinja (feature templates). |
churust-redis |
docs.rs | RedisStore, a revocable server-side SessionStore (feature redis). |
churust-client |
docs.rs | Outbound HTTP client (feature client, HTTPS via client-tls). |
churust-openapi |
docs.rs | OpenAPI 3.1 document generation (feature openapi). |
churust-lab |
docs.rs | Incubator. Never reaches 1.0; expect breaking changes on most releases. |
Runnable examples:
| Example | Run it | Shows |
|---|---|---|
examples/hello |
cargo run -p hello |
Minimal server, path params. |
examples/api |
cargo run -p api |
JSON CRUD with all four plugins + auth-gated routes. |
examples/chat |
cargo run -p chat |
WebSocket echo endpoint and a broadcast room. |
examples/static |
cargo run -p static-example |
StaticFiles plus a streamed response body. |
use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Clone, Serialize, Deserialize)]
struct Note { id: u64, text: String }
#[derive(Deserialize)]
struct NewNote { text: String }
#[derive(Clone)]
struct Admin { name: String }
struct Store { notes: Mutex<Vec<Note>> }
#[churust::main]
async fn main() -> std::io::Result<()> {
Churust::server()
.state(Store { notes: Mutex::new(Vec::new()) })
.install(CallLogging::new())
.install(ContentNegotiation::new()) // renders errors as JSON
.install(Cors::new().allow_origin("http://localhost:3000"))
.install(Auth::bearer(|token: String| async move {
(token == "admin-token").then(|| Admin { name: "admin".into() })
}))
.routing(|r| {
r.get("/notes", |s: State<Store>| async move {
Json(s.notes.lock().unwrap().clone())
});
// Asking for `Principal<Admin>` enforces auth (401 otherwise).
r.post("/notes", |Principal(_a): Principal<Admin>,
s: State<Store>,
Json(input): Json<NewNote>| async move {
let mut notes = s.notes.lock().unwrap();
let note = Note { id: notes.len() as u64 + 1, text: input.text };
notes.push(note.clone());
(StatusCode::CREATED, Json(note))
});
})
.start()
.await
}$ curl localhost:8080/notes
[]
$ curl -X POST localhost:8080/notes -d '{"text":"hi"}'
{"error":"authentication required","status":401}
$ curl -X POST localhost:8080/notes -H 'authorization: Bearer admin-token' \
-H 'content-type: application/json' -d '{"text":"hi"}'
{"id":1,"text":"hi"}
Opt in with features = ["ws"]. A handler takes the WebSocketUpgrade
extractor and calls on_upgrade to switch the request into a bidirectional
socket (a plain GET to the route is rejected with 426 Upgrade Required):
use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};
r.get("/echo", |ws: WebSocketUpgrade| async move {
ws.on_upgrade(|mut sock| async move {
while let Some(Ok(msg)) = sock.recv().await {
if matches!(msg, Message::Close) || sock.send(msg).await.is_err() {
break;
}
}
})
});churust = { version = "0.3", features = ["ws"] }See examples/chat for an echo endpoint plus a broadcast room.
Response bodies are a Body — either buffered bytes or a lazy stream — so large
or dynamic payloads never have to be fully materialized in memory. Body is
always available; StaticFiles is behind the opt-in fs feature.
use churust::prelude::*; // brings StaticFiles into scope under `fs`
use churust::Body;
r.get(
"/{path...}",
StaticFiles::dir("./public").index("index.html").handler(),
);
r.get("/numbers", |_c: Call| async {
let chunks = futures_util::stream::iter(
(1..=5).map(|i| Ok::<_, std::io::Error>(bytes::Bytes::from(format!("{i}\n")))),
);
Response::stream("text/plain", Body::from_stream(chunks))
});StaticFiles detects the Content-Type from the file extension, serves an
optional index file for directories, rejects path traversal (.., absolute
paths, symlink escapes) with 404, and streams the file in chunks. See
examples/static.
churust = { version = "0.3", features = ["fs"] }A handler is an async closure/fn returning anything that implements
IntoResponse. Arguments are extractors:
FromCallParts(borrow the request head) — any position:Path<T>,Query<T>,Header<T, N>,State<T>,BearerToken,Principal<P>.FromCall(consume the body) — last argument only:Json<T>,Form<T>,Bytes,String,Payload,Multipart,Either<A, B>,Call.
The split is enforced by the compiler, not by convention: the body is a
one-shot stream, so two body-consuming arguments do not compile. Every
FromCallParts is also usable last.
Option<T> works for any extractor implementing OptionalFromCallParts
(Query, Path, Header, BearerToken). Absent yields None; malformed is
still an error — so a typo'd query string does not quietly become a default.
Call itself is the call-style base case (|call: Call| ...).
A Plugin registers Middleware into a Phase. Middleware own the Call,
may mutate it, call next.run(call), and post-process the Response (the onion
model). Phase order is fixed and deterministic regardless of install order.
# churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000 # idle connections; 0 answers and closes
max_connections = 25000 # connections served at once; 0 is unlimited
max_tls_handshakes = 256 # much smaller: a handshake is asymmetric work
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000 # bounded drain, so exit is never held hostage
path_policy = "strict" # strict | redirect | collapse
[tls] # requires the `tls` feature
cert = "cert.pem"
key = "key.pem"Churust::from_config() loads churust.toml + CHURUST_* env vars; chained
DSL setters override. Example: CHURUST_SERVER_PORT=9090.
use churust::TestClient;
let app = build_app();
let client = TestClient::new(app);
let res = client.get("/users/1").send().await;
assert_eq!(res.status(), StatusCode::OK);Requires Rust 1.96+ (the MSRV, and what CI pins).
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo test --workspaceWarnings are errors in CI. Before opening a PR, run the full gate — the exact
command list is in CONTRIBUTING.md, and
it covers fmt, the clippy feature matrix, the test feature matrix, the
examples, and a docs build.
Published on crates.io — the badge above carries the current version. Pre-1.0, so the API is settling rather than settled: expect breaking changes in minor releases until 1.0.
Everything documented above works today:
- Core — routing, hybrid extractors, the four plugins, named phases, layered
config, typed state, timeouts, TLS,
#[churust::main] - WebSockets — opt-in
wsfeature - Streaming bodies — the always-on
Bodytype - Static files —
StaticFiles, opt-infsfeature
Also working, each behind its own feature: response compression, rate limiting, templating, HTTP/3 over QUIC, Redis-backed sessions, a login/logout layer, an outbound HTTP client, OpenAPI generation, and a streaming multipart parser. Several of those were listed as deliberate non-goals until 2026-07-25; the production-readiness audit records what the original objection was and what the implementation does about it, because "we changed our mind" is more useful to a reader than a silently edited list.
Still not supported, on purpose. Actor integration.
multipart/byteranges for multi-range requests, which RFC 9110 permits omitting.
WebSockets over HTTP/3, which upgrade through Extended CONNECT (RFC 9220) rather
than the HTTP/1.1 handshake the ws feature implements. Revocation from a
client-side session store, which has no server-side record to withdraw — that is
what churust-redis is for. And max_body_bytes still bounds every request:
streaming changed what an upload costs in memory, not what it is allowed to
weigh.
Those are tracked and ordered in
docs/design/2026-07-25-roadmap-to-parity.md,
which also says which are deliberate scope choices and which are simply not
built yet. Want one sooner? Make the case in
Discussions.
Design specs live in
docs/design/
and implementation plans in
docs/plans/.
Those documents label work as v1 / v2.0 / v2.1 — those are internal build
milestones, not published versions.
Contributions are welcome — read CONTRIBUTING.md first. The short version: Churust keeps a narrow scope, tests come first, and anything optional is feature-gated so default builds never change.
| Ask a question | Discussions → Q&A |
| Propose a feature | Discussions → Ideas |
| Report a bug | Issues |
| Report a vulnerability | SECURITY.md — privately, never a public issue |
| Get help | SUPPORT.md |
| Cut a release (maintainers) | RELEASING.md |
Everyone taking part is held to the Code of Conduct.
Churust is built and maintained in spare time. If it saves you some, you can support the work through GitHub Sponsors.
Sponsorship funds maintenance — issue triage, security response, keeping up with tokio/hyper/rustls releases — not feature bounties. Features are decided on merit in Discussions, and that stays true regardless of who is sponsoring.
MIT — see LICENSE.