Churust π
Churro + Rust β a backend web framework inspired by Ktor. Simple, secure, robust, and easy to learn.
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.
Coming from Kotlin/Ktor? See From Ktor to Churust
for side-by-side examples (routes, install, JSON, auth, config, tests).
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 }
What you get
| Area | Highlights |
|---|---|
| Handlers | Call-style or typed extractors (Path, Query, Json, State, β¦) |
| Plugins | install(...) into a fixed phase order: Setup β Monitoring β Plugins β Call β Fallback |
| Security | Body limits, timeouts, panic isolation, security headers, opt-in TLS |
| Protocols | HTTP/1.1, HTTP/2, optional HTTP/3, WebSockets, static files |
| Ecosystem | Opt-in JSON, CORS, auth, rate limit, compression, templates, Redis, OpenAPI, client |
How this site is organized
| Section | Purpose |
|---|---|
| Getting started | Install, run your first server |
| From Ktor to Churust | Side-by-side Ktor (Kotlin) β Churust (Rust) |
| Fundamentals | Routing, extractors, plugins, config, tests |
| Plugins & features | One page per optional capability |
| Recipes | End-to-end examples from the repo |
| Reference | Matrices, config keys, security, non-goals |
| Community | Contributing and help channels |
Docs map
| Resource | Use it for |
|---|---|
| This guide | How to use Churust day to day |
| docs.rs/churust | Full API reference for types and traits |
| examples/ | Runnable apps (hello, api, chat, static) |
| README | Short pitch and install blurb |
Status
Published on crates.io. Pre-1.0: the API is settling rather than settled β expect breaking changes in minor releases until 1.0. Current series documented here: 0.3.
MSRV: Rust 1.96+.
Next step
β Quick start
Quick start
Get a Churust server running in a few minutes.
Prerequisites
- Rust 1.96+ (
rustc --version) - A terminal and
cargo
1. Create a project
cargo new hello-churust
cd hello-churust
2. Add Churust
cargo add churust@0.3
Or in Cargo.toml:
[dependencies]
churust = "0.3"
That is the whole dependency list for a minimal server. Churust re-exports the
runtime it is built on as churust::tokio, and #[churust::main] uses that
re-export β no separate tokio entry is required.
3. Write the server
Replace src/main.rs with:
use churust::prelude::*; #[churust::main] async fn main() -> std::io::Result<()> { Churust::server() .host("127.0.0.1") .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 }
4. Run it
cargo run
In another terminal:
curl localhost:8080/
# Hello from Churust π
curl localhost:8080/users/7
# user #7
What just happened?
#[churust::main]β builds a multi-threaded tokio runtime and runs your asyncmain(the Churust equivalent of#[tokio::main]).Churust::server()β starts the builder for an HTTP application..routing(|r| { ... })β registers routes; path segments in{braces}become extractors..start().awaitβ binds and serves until the process exits.
Try the official example
From a clone of the framework:
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo run -p hello
That example also shows query parameters and typed app state. See
examples/hello.
Next steps
- Know Ktor already? β From Ktor to Churust
- Enable plugins and transports β Installation & features
- Learn extractors in depth β Handlers & extractors
- Build a JSON API β JSON API recipe
From Ktor to Churust
Churust deliberately reuses Ktorβs mental model so you can keep the same shape of application while writing Rust on tokio + hyper + rustls.
| You think in Ktor⦠| In Churust⦠|
|---|---|
Application / embeddedServer | Churust::server() builder |
routing { β¦ } | .routing(|r| { β¦ }) |
get / post / β¦ | r.get / r.post / β¦ |
install(β¦) | .install(β¦) |
| Application plugins + interceptors | Plugins into fixed phases |
call | Call or typed extractors |
call.parameters["id"] | Path(id): Path<u64> |
call.receive<T>() | Json(t): Json<T> (feature json) |
| Auth principal | Principal<P> (feature auth) |
| Application attributes / DI | .state(T) + State<T> |
application.conf + env | churust.toml + CHURUST_* + DSL |
testApplication { β¦ } | TestClient (in-process) |
The syntax is Rust. The structure of a service β build, install, route, start β should already be familiar.
1. A minimal route
Ktor (Kotlin)
fun Application.module() {
routing {
get("/") {
call.respondText("Hello from Ktor")
}
}
}
Churust (Rust)
use churust::prelude::*; #[churust::main] async fn main() -> std::io::Result<()> { Churust::server() .routing(|r| { r.get("/", |_call: Call| async { "Hello from Churust π" }); }) .start() .await }
Same idea: declare routes inside a routing block.
Rust difference: an explicit async main, and #[churust::main] (like
#[tokio::main]) bootstraps the runtime Churust re-exports.
2. Path parameters
Ktor
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respondText("user #$id")
}
}
Churust
#![allow(unused)] fn main() { r.get("/users/{id}", |Path(id): Path<u64>| async move { format!("user #{id}") }); }
| Ktor | Churust |
|---|---|
Read call.parameters["id"] as a string | Path<u64> parses and types the segment |
Manual toLongOrNull + 400 | Malformed path β error before the handler body |
| Optional/nullable handling by hand | Use Option<Path<β¦>> / optional extractors where supported |
You still can take Call and dig into parts yourself; extractors are the
short path for the common case.
3. Install plugins
Ktor
fun Application.module() {
install(CallLogging)
install(ContentNegotiation) {
json()
}
install(CORS) {
allowHost("app.example.com", schemes = listOf("https"))
}
routing {
// β¦
}
}
Churust
#![allow(unused)] fn main() { Churust::server() .install(CallLogging::new()) .install(ContentNegotiation::new()) .install(Cors::new().allow_origin("https://app.example.com")) .install(Compression::new()) .install(RateLimit::per_minute(120)) .routing(|r| { // β¦ }) }
| Ktor | Churust |
|---|---|
install(Feature) inside Application.module | .install(plugin) on the server builder |
| Features often pull in Kotlinx serialization, etc. | Cargo features gate crates (json, cors, β¦) |
| Interceptor phases / plugin order | Fixed pipeline phases; install order is stable within that model |
Enable features in Cargo.toml the same way you would add dependencies in
Gradle β then install only what you compiled in:
churust = { version = "0.3", features = ["full"] }
See Installation & features and Pipeline & plugins.
4. JSON body + response
Ktor
@Serializable
data class NewNote(val text: String)
@Serializable
data class Note(val id: Long, val text: String)
routing {
post("/notes") {
val input = call.receive<NewNote>()
call.respond(Note(id = 1, text = input.text))
}
}
Churust
#![allow(unused)] fn main() { #[derive(Deserialize)] struct NewNote { text: String } #[derive(Serialize)] struct Note { id: u64, text: String } r.post("/notes", |Json(input): Json<NewNote>| async move { Json(Note { id: 1, text: input.text }) }); }
| Ktor | Churust |
|---|---|
call.receive<T>() | Json<T> extractor (last argument if it consumes the body) |
call.respond(T) with content negotiation | Json(T) implements IntoResponse |
| kotlinx.serialization (typical) | serde derives |
5. Authentication / principal
Ktor (sketch)
install(Authentication) {
bearer("auth") {
authenticate { credential ->
if (credential.token == expected) UserIdPrincipal("admin") else null
}
}
}
routing {
authenticate("auth") {
get("/me") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText(principal.name)
}
}
}
Churust
#![allow(unused)] fn main() { #[derive(Clone)] struct Admin { name: String } Churust::server() .install(Auth::bearer(|token: String| async move { (token == expected).then(|| Admin { name: "admin".into() }) })) .routing(|r| { // Asking for Principal enforces auth (401 otherwise). r.get("/me", |Principal(u): Principal<Admin>| async move { u.name }); }) }
| Ktor | Churust |
|---|---|
Named auth configs + authenticate("β¦") blocks | Install Auth, then require Principal<P> on the handler |
call.principal<T>() | Principal<T> extractor |
Forgetting authenticate is a common footgun | No Principal β route is public; with Principal β type system enforces it |
Prefer secure_compare (or a real store) over == on secrets in production.
See Authentication.
6. Shared application state
Ktor
// Often: attributes, singletons, or a DI framework
val store = NoteStore()
// β¦ put store where handlers can reach it
Churust
#![allow(unused)] fn main() { struct Store { notes: Mutex<Vec<Note>> } Churust::server() .state(Store { notes: Mutex::new(Vec::new()) }) .routing(|r| { r.get("/notes", |s: State<Store>| async move { // β¦ }); }) }
Typed .state(T) + State<T> replace βreach into the application container.β
Use interior mutability (Mutex, channels, β¦) when handlers update shared data.
7. Configuration
Ktor
# application.conf
ktor {
deployment {
port = 8080
port = ${?PORT}
}
}
Churust
# churust.toml
[server]
host = "0.0.0.0"
port = 8080
export CHURUST_SERVER_PORT=9090
#![allow(unused)] fn main() { Churust::from_config() .port(8080) // code still wins over env/file when chained .routing(|r| { /* β¦ */ }) }
Precedence: defaults < churust.toml < CHURUST_* < builder DSL.
Details: State & configuration.
8. Testing
Ktor
testApplication {
application { module() }
val response = client.get("/ping")
assertEquals(HttpStatusCode.OK, response.status)
}
Churust
#![allow(unused)] fn main() { let app = Churust::server() .routing(|r| { r.get("/ping", |_c: Call| async { "pong" }); }) .build(); let res = TestClient::new(app).get("/ping").send().await; assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.text(), "pong"); }
No socket bind: the full pipeline runs in-process, same idea as
testApplication. See Testing.
What stays βRustβ on purpose
Churust does not try to feel like Kotlin. It reuses Ktorβs application shape so you do not relearn βwhat a server is,β then leans on Rust where it matters:
| Topic | Expectation |
|---|---|
| Ownership & lifetimes | Handlers own or borrow what extractors give them; no hidden GC |
| Errors | Prefer Result / IntoError / ? over silent nulls |
| Async | async/.await on tokio (via churust::tokio or your own features) |
| Types | Path/query/JSON failures surface as typed HTTP errors, not null |
| Features | Opt-in Cargo features keep unused protocol surface out of the binary |
You still learn Rust. You should not have to learn a fragmented web stack at the same time.
Concept map (cheat sheet)
Ktor Churust
ββββ βββββββ
Application.module() β Churust::server()β¦start()
routing { } β .routing(|r| { })
install(Feature) β .install(plugin) + Cargo feature
call β Call | extractors
receive / respond β Json / IntoResponse
principal β Principal<P>
attributes / DI β .state(T) + State<T>
application.conf β churust.toml + CHURUST_* + DSL
testApplication β TestClient
embeddedServer / Netty β hyper (HTTP) + rustls (TLS) + tokio
Next
- Quick start β run your first binary
- Handlers & extractors β depth on types
- JSON API recipe β plugins + auth end-to-end
- Pipeline & plugins β phase model
Installation & features
Depend on the umbrella crate
[dependencies]
churust = "0.3"
Plugins and transports are opt-in features on churust. Prefer enabling
features on the umbrella crate rather than depending on plugin crates directly:
churust = { version = "0.3", features = ["full", "ws", "fs", "tls"] }
Default features are empty: 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.
Feature matrix
| 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 |
compression | churust-compression | brotli / gzip / deflate response bodies |
templates | churust-templates | Templates + Renderer (minijinja) |
full | the seven above | The whole plugin set |
redis | churust-redis | RedisStore: server-side sessions, revocable |
client | churust-client | Outbound HTTP client (client-tls for HTTPS) |
openapi | churust-openapi | OpenAPI 3.1 document from the router (implies json) |
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) |
full enables the plugin set. Transports (ws, fs, tls, http3,
multipart) stay opt-in individually β each is a protocol surface, not only an
ergonomic helper.
Common combinations
# JSON API with auth and logging
churust = { version = "0.3", features = ["json", "logging", "cors", "auth"] }
# same as:
churust = { version = "0.3", features = ["full"] }
# Real-time chat
churust = { version = "0.3", features = ["ws"] }
# Static site + streaming
churust = { version = "0.3", features = ["fs"] }
# Production HTTPS
churust = { version = "0.3", features = ["tls"] }
Serde
Typed query/path/JSON bodies need Serde where you define types:
serde = { version = "1", features = ["derive"] }
Tokio
Usually do not add tokio yourself. Use churust::tokio for channels,
timers, and select!. If you need a tokio feature Churust does not enable, add
tokio with that feature β Cargo unifies the two.
MSRV
Churust requires Rust 1.96+. CI pins that toolchain.
API reference
Project layout
Your application (typical)
my-api/
βββ Cargo.toml
βββ churust.toml # optional layered config
βββ src/
β βββ main.rs
βββ public/ # if you serve static files
βββ index.html
Minimal Cargo.toml:
[package]
name = "my-api"
version = "0.1.0"
edition = "2021"
[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Workspace crates (the framework itself)
If you browse or contribute to Churust:
| Crate | Role |
|---|---|
churust | Umbrella + prelude. Depend on this. |
churust-core | Engine, routing, pipeline, extractors, config, TLS, WS, static, tests |
churust-macros | #[churust::main] |
churust-json | JSON + content negotiation |
churust-logging | Request logging |
churust-cors | CORS |
churust-auth | Auth + Principal |
churust-ratelimit | Rate limiting |
churust-compression | Response compression |
churust-templates | HTML templates |
churust-redis | Redis session store |
churust-client | Outbound HTTP client |
churust-openapi | OpenAPI generation |
churust-lab | Incubator β never reaches 1.0; expect breaks |
Runnable examples live under examples/:
| Example | Command | Shows |
|---|---|---|
hello | cargo run -p hello | Routes, path/query, state |
api | cargo run -p api | JSON + plugins + auth |
chat | cargo run -p chat | WebSockets |
static | cargo run -p static-example | Static files + streamed body |
Where docs live
| Path | Audience |
|---|---|
book/ (this site) | Users learning the framework |
| docs.rs | API reference |
docs/design/, docs/plans/ | Maintainers / design history |
Next
β Routing & guards
Routing & guards
Routes are registered on a RouteBuilder inside .routing(|r| { ... }).
HTTP methods
#![allow(unused)] fn main() { r.get("/items", handler); r.post("/items", handler); r.put("/items/{id}", handler); r.patch("/items/{id}", handler); r.delete("/items/{id}", handler); // and other methods supported by the builder }
Path parameters
Segments in {braces} are captured and available via the Path extractor:
#![allow(unused)] fn main() { r.get("/users/{id}", |Path(id): Path<u64>| async move { format!("user #{id}") }); }
Path supports scalars, tuples, and structs that deserialize from path parts.
Catch-all segments
A trailing {name...} matches the rest of the path (used by static file
serving):
#![allow(unused)] fn main() { r.get( "/{path...}", StaticFiles::dir("./public").index("index.html").handler(), ); }
Requires the fs feature for StaticFiles.
Automatic HEAD and OPTIONS
Churust provides correct HTTP behavior out of the box:
- Automatic HEAD for GET routes
- Automatic OPTIONS, including
OPTIONS * - One
Allowheader that both405andOPTIONSagree on
Route guards
Several routes may share a path when a guard distinguishes them, for example
guard::header, guard::host, or a custom predicate. The first matching route
with a satisfied guard wins; ambiguity is refused rather than guessed.
Path policy
PathPolicy decides what happens to non-canonical paths such as //a or
/a//b:
| Policy | Behavior |
|---|---|
strict (default) | Refuse non-canonical paths |
redirect | 308 to the canonical form |
collapse | Collapse silently |
Silent collapsing can make prefix-based auth checks bypassable and cache
identity ambiguous β prefer strict or explicit redirects.
Configure via churust.toml (path_policy) or the builder DSL. See
Configuration keys.
Mounting groups
Compose related routes under a prefix when the API grows; keep handlers small
and push shared middleware with intercept where you need route-tree-scoped
behavior (see Pipeline & plugins).
Next
Handlers & extractors
A handler is an async closure or function that returns anything implementing
IntoResponse. Arguments are extractors.
Two styles
Call-style β take the whole Call:
#![allow(unused)] fn main() { r.get("/", |_call: Call| async { "ok" }); }
Extractor-style β declare only what you need:
#![allow(unused)] fn main() { r.get( "/greet", |Query(s): Query<Search>, g: State<Greeter>| async move { format!("{}, {}!", g.prefix, s.q) }, ); }
You can mix both styles across the app freely.
Extractor categories
FromCallParts (request head)
Borrow the request head. Usable in any argument position:
| Extractor | Purpose |
|---|---|
Path<T> | Path parameters |
Query<T> | Query string |
Header<T, N> | Named header |
State<T> | Typed app state |
BearerToken | Raw bearer token string |
Principal<P> | Authenticated principal (auth feature) |
FromCall (request body)
Consume the body. Last argument only β the body is a one-shot stream:
| Extractor | Purpose |
|---|---|
Json<T> | JSON body (json feature) |
Form<T> | Form body |
Bytes / String | Raw body |
Payload | Streaming body (not capped by materializing whole upload) |
Multipart / stream variants | Uploads (multipart feature) |
Either<A, B> | One of two body shapes |
Call | Full call (also body-capable) |
The split is enforced by the compiler, not convention: two body-consuming
arguments do not compile. Every FromCallParts type is also usable last.
Optional extractors
Wrap any extractor that implements OptionalFromCallParts in Option<T>
(Query, Path, Header, BearerToken, β¦):
- Absent β
None - Malformed β still an error
A typoβd query string does not quietly become a default.
Hybrid example
#![allow(unused)] fn main() { r.post( "/notes", |Principal(_admin): Principal<AdminUser>, s: State<Store>, Json(input): Json<NewNote>| async move { // ... (StatusCode::CREATED, Json(note)) }, ); }
Here auth and state come from parts; the JSON body is last.
Handler return types
Anything IntoResponse:
&str/String/BytesStatusCode(StatusCode, T)Json<T>(featurejson)Response(fully controlled)- Streaming via
Response::streamandBody::from_stream
Errors in handlers
Implement IntoError on your error type so ? works in handlers without
leaking internal Display text to clients. See
Responses & errors.
Next
Responses & errors
Building responses
Handlers return types that implement IntoResponse:
#![allow(unused)] fn main() { // 200 text/plain-ish defaults for strings r.get("/ping", |_c: Call| async { "pong" }); // Explicit status + body r.post("/items", |_c: Call| async { (StatusCode::CREATED, "created") }); // JSON (feature `json`) r.get("/note", |_c: Call| async { Json(serde_json::json!({ "ok": true })) }); }
Streaming
Body is always available (no feature flag). Stream large or dynamic payloads
without buffering everything in memory:
#![allow(unused)] fn main() { use churust::Body; 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)) }); }
Custom error pages
Use on_error on the app builder to render any 4xx / 5xx, including routing
failures, with your own HTML or JSON.
With ContentNegotiation installed (feature json), errors can be rendered as
JSON for API clients.
IntoError
Implement IntoError on your domain error type so handlers can use ?. Churust
maps the error to an HTTP status and a safe client-facing body without exposing
internal details from Display by default.
Transport-level errors
Security headers and limits apply even when a handler never runs β for example
413 (body too large), 400, and 408 produced by the transport. See
Security defaults.
Correct HTTP semantics
- Conditional GET for static files (
ETag/Last-Modified/304) - Byte ranges (
206/416) for static files - Ambiguous framing (both
Transfer-EncodingandContent-Length) is refused so leftover bytes cannot become the next request behind a proxy - Repeated query keys on a scalar field are an error, not silent first/last wins
Next
Pipeline & plugins
Churust borrows Ktorβs mental model: plugins install middleware into a named pipeline.
Phases
Phase order is fixed and deterministic, regardless of install order:
- Setup
- Monitoring
- Plugins
- Call
- Fallback
Middleware owns the Call, may mutate it, call next.run(call), and
post-process the Response (onion model).
Installing plugins
#![allow(unused)] fn main() { Churust::server() .install(CallLogging::new()) .install(ContentNegotiation::new()) .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| { /* ... */ }) }
Order of .install(...) can still matter for relative stacking within a
phase, but you never get a surprise reordering across phases.
Built-in plugins (feature-gated)
| Plugin | Feature | Role |
|---|---|---|
CallLogging | logging | Structured request logs |
ContentNegotiation | json | JSON errors / negotiation |
Cors | cors | CORS + preflight |
Auth (Bearer / Basic / JWT) | auth | Authentication |
RateLimit | ratelimit | GCRA rate limiting |
Compression | compression | Response compression |
Templates | templates | Template engine setup |
See Plugins & features for one page per capability.
Scoped middleware
Use intercept when middleware should apply only to part of the route tree
rather than the whole application.
Tower interoperability
With the tower integration path, you can run any tower::Service as
middleware so tower-http layers work without Churust reimplementing them.
Writing your own plugin
Implement the Plugin trait: register one or more Middleware values into the
phase that matches their job (logging β Monitoring, auth β Plugins, etc.). Keep
side effects explicit and prefer failing at startup when configuration is
invalid.
Next
State & configuration
Typed app state
Attach shared state with .state(T), then extract it with State<T>:
#![allow(unused)] fn main() { #[derive(Clone)] struct Greeter { prefix: String, } Churust::server() .state(Greeter { prefix: "Hello".into(), }) .routing(|r| { r.get( "/greet", |Query(s): Query<Search>, g: State<Greeter>| async move { format!("{}, {}!", g.prefix, s.q) }, ); }) }
T must be Send + Sync + 'static. Use interior mutability (Mutex,
RwLock, channels, etc.) when handlers need to update shared data.
You can register multiple distinct state types; extractors select by type.
Layered configuration
Precedence (lowest β highest):
- Built-in defaults
churust.toml- Environment variables (
CHURUST_*) - Code DSL (
.host(),.port(), β¦)
churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000
max_connections = 25000
max_tls_handshakes = 256
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000
path_policy = "strict" # strict | redirect | collapse
[tls] # requires the `tls` feature
cert = "cert.pem"
key = "key.pem"
Loading config
#![allow(unused)] fn main() { Churust::from_config() // churust.toml + env .host("127.0.0.1") // DSL wins .port(8080) .routing(|r| { /* ... */ }) .start() .await }
Environment example: CHURUST_SERVER_PORT=9090.
Code-only
#![allow(unused)] fn main() { Churust::server() .host("127.0.0.1") .port(8080) }
Deployment knobs
Useful builder / config fields include:
- Idle keep-alive
- Listen backlog
- Connection cap
- TLS handshake cap and timeout (bounds queueing as well as the handshake)
- HTTP/2 stream and header limits
- Shutdown grace period (bounded drain)
- Multiple bind addresses
- Unix domain sockets that refuse to unlink a live socket
Full key list: Configuration keys.
Cookies & sessions
Core includes a cookie primitive with safe defaults (HttpOnly,
SameSite=Lax) and HMAC-SHA256 signed cookie sessions. For revocable
server-side sessions, use Redis sessions (redis
feature).
Next
Testing with TestClient
Drive the full pipeline in-process without binding a socket.
Basic usage
Build an App with .build() instead of .start(), then wrap it:
use churust::prelude::*; use churust::TestClient; #[churust::main] async fn main() { let app = Churust::server() .routing(|r| { r.get("/ping", |_c: Call| async { "pong" }); }) .build(); let res = TestClient::new(app).get("/ping").send().await; assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.text(), "pong"); }
In real tests, use your test harnessβs async runtime (for example
#[tokio::test] or Churustβs main only in binaries).
Requests
#![allow(unused)] fn main() { let client = TestClient::new(app); let res = client .post("/notes") .header("authorization", "Bearer admin-token") .header("content-type", "application/json") .body(r#"{"text":"hi"}"#) .send() .await; assert_eq!(res.status(), StatusCode::CREATED); }
Helpers include get, post, put, delete, and a generic request(method, uri).
Assertions
#![allow(unused)] fn main() { res.status() res.headers() res.header("content-type") res.body_bytes() res.text() }
Tips
- Install the same plugins your production app uses so tests exercise CORS, auth, and content negotiation.
- Prefer small pure handlers plus a thin
fn app() -> Appfactory shared by binary and tests. - Failures at startup (duplicate routes, missing static root) surface when
you call
.build()/.start()β keep that in CI.
Next
β Plugins overview
Plugins & features overview
Everything below is opt-in. Core routing and handlers work with zero features. Enable only what you need.
Plugin crates (via churust features)
| Page | Feature | Install API |
|---|---|---|
| JSON | json | ContentNegotiation, Json<T> |
| Logging | logging | CallLogging |
| CORS | cors | Cors |
| Auth | auth | Auth::bearer / basic / jwt |
| Rate limit | ratelimit | RateLimit |
| Compression | compression | Compression |
| Templates | templates | Templates + Renderer |
| Redis | redis | RedisStore |
| Client | client | Client |
| OpenAPI | openapi | OpenApi |
features = ["full"] enables: json, logging, cors, auth, ratelimit,
compression, templates.
Transport / core features
| Page | Feature |
|---|---|
| WebSockets | ws |
| Static files & streaming | fs (+ always-on Body) |
| Multipart | multipart |
| TLS | tls |
| HTTP/3 | http3 |
Pattern
churust = { version = "0.3", features = ["json", "auth"] }
#![allow(unused)] fn main() { use churust::prelude::*; Churust::server() .install(/* plugins */) .routing(|r| { /* routes that use extractors from those plugins */ }) }
Full matrix: Feature flags.
JSON & content negotiation
feature: json
Enable
churust = { version = "0.3", features = ["json"] }
Json<T>
Extractor (request body) and responder (response body):
#![allow(unused)] fn main() { use churust::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct NewNote { text: String } #[derive(Serialize)] struct Note { id: u64, text: String } r.post("/notes", |Json(input): Json<NewNote>| async move { let note = Note { id: 1, text: input.text }; (StatusCode::CREATED, Json(note)) }); }
Json<T> consumes the body β use it as the last handler argument (or only
body consumer).
ContentNegotiation
Install so errors and negotiated responses can render as JSON for API clients:
#![allow(unused)] fn main() { .install(ContentNegotiation::new()) }
With auth (typed principal)
#![allow(unused)] fn main() { r.post( "/notes", |Principal(_a): Principal<Admin>, Json(input): Json<NewNote>| async move { Json(input) }, ); }
Requires auth as well as json. Full walkthrough:
JSON API recipe.
API reference
Logging & request IDs
feature: logging
Enable
churust = { version = "0.3", features = ["logging"] }
Install
#![allow(unused)] fn main() { .install(CallLogging::new()) }
CallLogging uses the tracing ecosystem for structured request logs.
Request correlation
Churust attaches a request id to every log line, echoes it as
x-request-id, and continues an inbound W3C traceparent when present. That
behavior is part of the core request path; pair it with CallLogging for
readable structured logs in development and production.
Tips
- Configure a
tracingsubscriber inmain(or your process bootstrap) so logs actually appear. - Prefer request ids over grepping timestamps when correlating client and server logs.
API reference
CORS
feature: cors
Enable
churust = { version = "0.3", features = ["cors"] }
Restrictive (recommended for production)
Cors::new() starts with no allowed origins, GET/POST only, no
credentials. Name the origins your browser client is actually served from:
#![allow(unused)] fn main() { .install( Cors::new() .allow_origin("http://localhost:3000") .allow_origin("https://app.example.com") ) }
Chain builder methods for methods, headers, credentials, and max-age as needed
(see docs.rs).
Development / public read-only APIs
#![allow(unused)] fn main() { .install(Cors::allow_any_origin_insecure()) }
The name is deliberate: reflecting any origin is fine for a public, unauthenticated, read-only API and convenient locally. It is dangerous if ambient credentials (cookies, extension-injected tokens) can authorize requests.
Note:
Access-Control-Allow-Origin: *cannot be combined with credentials. Use an explicit origin list and.allow_credentials(true)when you need cookies on cross-origin calls.
Preflight
The plugin handles CORS preflight (OPTIONS) according to the configured
policy.
API reference
Authentication
feature: auth
Enable
churust = { version = "0.3", features = ["auth"] }
Model
- Install an
Authplugin that verifies credentials and produces a principal typeP. - Handlers that need authentication take
Principal<P>. - If verification failed or the header was missing, the handler never runs β
the client gets
401. Auth is enforced by the type system, so you cannot βforgetβ the check on a protected route.
Bearer
#![allow(unused)] fn main() { #[derive(Clone)] struct Admin { name: String } .install(Auth::bearer(|token: String| async move { (token == "admin-token").then(|| Admin { name: "admin".into() }) })) .routing(|r| { r.get("/me", |Principal(u): Principal<Admin>| async move { u.name }); }) }
curl -H 'authorization: Bearer admin-token' localhost:8080/me
Production warning
Do not compare tokens with == against a hard-coded secret in real apps.
Use churust::secure_compare (or constant-time
compare against a store) so timing does not leak how much of a guess was right.
Basic
#![allow(unused)] fn main() { .install(Auth::basic(|user, pass| async move { // Only over TLS β Basic is reversibly encoded, not encrypted. verify_user(user, pass).await })) }
JWT
Auth::jwt(...) validates JWTs and exposes claims as the principal. See
docs.rs/churust-auth for the builder and claim
types.
Login / logout layer
Churust also provides an identity layer over sessions with absolute and idle deadlines. Pair with cookie sessions or Redis when you need server-side revocation.
Example
Full JSON CRUD with bearer auth:
examples/api
and the JSON API recipe.
API reference
Rate limiting
feature: ratelimit
Enable
churust = { version = "0.3", features = ["ratelimit"] }
Install
GCRA-based limiting β bursts are smoothed and Retry-After is exact rather than
estimated:
#![allow(unused)] fn main() { use std::time::Duration; .install( RateLimit::per_minute(60) .burst(10) ) }
Helpers: RateLimit::per_second, per_minute, per_hour, or
RateLimit::per(limit, period).
Keying
By default keys use the peer address. IPv6 peers are bucketed by network
prefix (typically /64), because a subscriber is handed a whole prefix and
limiting each address separately limits nobody.
Custom keys (return None to skip limiting for that request):
#![allow(unused)] fn main() { .install( RateLimit::per_minute(60).by(|call| { // e.g. API key header call.header("x-api-key").map(str::to_owned) }) ) }
Tuning
| Method | Purpose |
|---|---|
.burst(n) | Burst capacity |
.by(f) | Custom key function |
.max_keys(n) | Cap on tracked keys |
API reference
Compression
feature: compression
Enable
churust = { version = "0.3", features = ["compression"] }
Install
Negotiates Accept-Encoding and compresses response bodies (including streams)
with brotli, gzip, and/or deflate:
#![allow(unused)] fn main() { .install( Compression::new() .min_size(1024) .level(Level::Default) ) }
Options
| Method | Purpose |
|---|---|
.min_size(bytes) | Skip small responses |
.level(Level) | Compression level |
.encodings([...]) | Restrict encodings |
.compressible(f) | Custom content-type predicate |
compressible_by_default(content_type) documents which media types compress by
default.
API reference
Templates
feature: templates
Enable
churust = { version = "0.3", features = ["templates"] }
Setup
Templates are parsed at startup (fail fast). Output is HTML-escaped for safety regardless of the template file name, because the rendered response is served as HTML either way.
#![allow(unused)] fn main() { // From directory let templates = Templates::from_dir("templates")?; // Or build incrementally let templates = Templates::new() .add("hello", "Hello, {{ name }}!")?; // Optional minijinja environment tweaks let templates = templates.configure(|env| { // env.add_filter(...); }); }
Install as a plugin so handlers receive a Renderer:
#![allow(unused)] fn main() { .install(templates) }
Render in a handler
#![allow(unused)] fn main() { r.get("/", |r: Renderer| async move { r.render("hello", context! { name => "Churust" }) }); }
Renderer::render / render_with_status return a Response. The context!
macro is provided by the templates crate (re-exported through the umbrella when
the feature is on β check prelude / crate docs for the exact import path in
your version).
API reference
Redis sessions
feature: redis
Enable
churust = { version = "0.3", features = ["redis"] }
Why Redis?
Cookie-only sessions cannot be revoked from the server without a shared
store. RedisStore is a revocable server-side SessionStore: logging out
actually invalidates the session, and a revocation that could not be carried
out is an error rather than a cheerful 200 over a session that is still
live.
Construct
#![allow(unused)] fn main() { use churust::prelude::*; // Redis client type comes from the redis crate dependency of churust-redis let client = redis::Client::open("redis://127.0.0.1/")?; let store = RedisStore::from_client(client) .ttl(3600) .prefix("churust:sess:") .sliding(true); }
| Method | Purpose |
|---|---|
.ttl(secs) | Session time-to-live |
.prefix(...) | Key prefix in Redis |
.sliding(bool) | Sliding expiration on activity |
Wire the store into your session / login configuration as documented on docs.rs for the version you ship.
API reference
HTTP client
feature: client client-tls for HTTPS
Enable
churust = { version = "0.3", features = ["client"] }
# HTTPS:
churust = { version = "0.3", features = ["client-tls"] }
Overview
Outbound client built on the same hyper stack as the server: pooled, bounded, with transparent gzip/deflate decode and a decompression-bomb ceiling.
#![allow(unused)] fn main() { use churust::client::Client; // path may be re-exported via prelude when feature is on let client = Client::new() .timeout(std::time::Duration::from_secs(10)) .user_agent("my-app/0.1")?; let res = client .get("https://httpbin.org/get") .header("x-request-id", "demo") .send() .await?; println!("{} {}", res.status(), res.text()?); }
Builder highlights
| Method | Purpose |
|---|---|
.timeout(d) | Overall / request timeout |
.max_response_bytes(n) | Response size cap |
.max_redirects(n) | Redirect limit |
.auto_decompress(bool) | Transparent decode |
.default_header / .user_agent | Defaults for all requests |
Request builder
#![allow(unused)] fn main() { client.post("https://api.example.com/items") .bearer(token) .json(&payload) .send() .await?; }
Also: .query, .form, .body, .put, .patch, .delete, .head.
API reference
OpenAPI
feature: openapi
Implies json.
Enable
churust = { version = "0.3", features = ["openapi"] }
Idea
Generate an OpenAPI 3.1 document from the router. Paths and parameters come from routes; prose and schemas come from you. Drift is reported in both directions:
undescribed()β routes missing documentationstale()β documented operations that no longer exist on the router
Build a document
#![allow(unused)] fn main() { use churust::prelude::*; use http::Method; let doc = OpenApi::new("Notes API", "0.1.0") .description("CRUD notes") .server("http://localhost:8080", Some("local")) .from_routes(&routes) // or build from your RouteBuilder export .operation( Method::POST, "/notes", Operation::new() .summary("Create note") .accepts("application/json") .response(201, "Created"), ); }
Mount the JSON document on a path:
#![allow(unused)] fn main() { // Inside routing, e.g.: doc.mount(&mut r, "/openapi.json"); }
Exact mount helpers may take the document by reference β see docs.rs for the signature on your version.
Workflow tip
Fail CI if undescribed() or stale() is non-empty so the document cannot rot
silently.
API reference
WebSockets
feature: ws
Enable
churust = { version = "0.3", features = ["ws"] }
Upgrade
A handler takes WebSocketUpgrade and calls on_upgrade. A plain GET to the
route is rejected with 426 Upgrade Required.
#![allow(unused)] fn main() { 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; } } }) }); }
Broadcast room
Hold a broadcast channel in app state and select! between socket reads and
channel receives. See the full example:
- Source:
examples/chat - Recipe: Chat room
cargo run -p chat
websocat ws://localhost:8080/echo
websocat ws://localhost:8080/room
Use churust::tokio for broadcast and select! β no separate tokio
dependency required.
Limits
WebSocket frame and message caps are part of the secure defaults. WebSockets over HTTP/3 (Extended CONNECT) are not supported yet β see What is not supported.
API reference
- docs.rs/churust (
wsmodule when feature is enabled)
Static files & streaming
feature: fs for StaticFiles Β·
Body is always available
Enable static files
churust = { version = "0.3", features = ["fs"] }
Serve a directory
#![allow(unused)] fn main() { use churust::prelude::*; r.get( "/{path...}", StaticFiles::dir("./public").index("index.html").handler(), ); }
Behavior:
Content-Typefrom file extension- Optional directory
index - Path traversal (
.., absolute paths, symlink escapes) β 404 - Streams file content in chunks
- Conditional GET (
ETag/Last-Modified/304) and byte ranges (206/416)
mkdir -p public && echo '<h1>Churust</h1>' > public/index.html
cargo run -p static-example
curl localhost:8080/
# Traversal rejected (curl otherwise normalizes ".." away):
curl -i --path-as-is 'localhost:8080/../Cargo.toml' # 404
A missing static root fails at startup, not silently at runtime.
Streaming dynamic bodies
No feature flag:
#![allow(unused)] fn main() { use churust::Body; 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)) }); }
Example
Multipart uploads
feature: multipart
Enable
churust = { version = "0.3", features = ["multipart"] }
Two APIs
| Type | Behavior |
|---|---|
Multipart | Buffers the whole body |
MultipartStream | Reads fields and content incrementally so memory does not scale with upload size |
Both parse multipart/form-data. Prefer the streaming API for large uploads.
Body size still applies
Streaming changed what an upload costs in memory, not what it is allowed
to weigh. max_body_bytes still bounds every request.
Handler sketch
#![allow(unused)] fn main() { // Buffered r.post("/upload", |form: Multipart| async move { // iterate fields... StatusCode::NO_CONTENT }); // Streamed β keep MultipartStream as the body extractor (last arg) // r.post("/upload", |stream: MultipartStream| async move { ... }); }
Check docs.rs for the exact field iteration APIs on your version.
Non-goal
multipart/byteranges for multi-range requests is intentionally unsupported
(RFC 9110 permits omitting it). See non-goals.
API reference
- docs.rs/churust /
churust-coremultipart module
TLS
feature: tls
Enable
churust = { version = "0.3", features = ["tls"] }
Config file
# churust.toml
[tls]
cert = "cert.pem"
key = "key.pem"
#![allow(unused)] fn main() { Churust::from_config() .routing(|r| { /* ... */ }) .start() .await }
Programmatic
Use the core helper to build a rustls acceptor from PEM files (see
acceptor_from_pem on docs.rs) and the builderβs TLS setters for your version.
Knobs
| Setting | Meaning |
|---|---|
max_tls_handshakes | Concurrent handshakes (asymmetric work) |
tls_handshake_timeout_ms | Bounds queueing and the handshake itself |
HTTP/2 over TLS is negotiated via ALPN. Plaintext h2c uses prior knowledge.
Production tips
- Prefer a reverse proxy (Caddy, nginx, Traefik) for cert automation when that fits your deploy model β or terminate TLS in-process with rustls when you need end-to-end in one binary.
- Never enable HTTP Basic auth without TLS.
Related
- HTTP/3 (requires TLS material for QUIC)
- Deployment
HTTP/3
feature: http3
Implies tls / QUIC credentials.
Enable
churust = { version = "0.3", features = ["http3"] }
What you get
HTTP/3 over QUIC on its own listener. Use advertise_http3 so responses can
emit the Alt-Svc header clients need in order to try h3.
Setup sketch
- Enable the feature and provide TLS certificates suitable for QUIC.
- Configure the h3 listener (bind address + server config from PEM helpers in core).
- Enable advertisement so browsers/clients discover the endpoint.
Exact builder methods live on docs.rs for your version
(server_config_from_pem, h3 bind helpers).
Limits
WebSockets over HTTP/3 (RFC 9220 Extended CONNECT) are not implemented; the
ws feature uses the HTTP/1.1 upgrade handshake.
API reference
- docs.rs/churust-core
http3module
Recipe: JSON API
Build a small notes API with logging, CORS, JSON, and bearer auth β the same
shape as examples/api.
Dependencies
[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Code
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, Debug)] struct AdminUser { _name: String, } struct Store { notes: Mutex<Vec<Note>>, } #[churust::main] async fn main() -> std::io::Result<()> { Churust::server() .host("127.0.0.1") .port(8080) .state(Store { notes: Mutex::new(Vec::new()), }) .install(CallLogging::new()) .install(ContentNegotiation::new()) .install(Cors::new().allow_origin("http://localhost:3000")) .install(Auth::bearer(|token: String| async move { // Demo only β use secure_compare + a real store in production. if token == "admin-token" { Some(AdminUser { _name: "admin".into(), }) } else { None } })) .routing(|r| { r.get("/notes", |s: State<Store>| async move { let notes = s.notes.lock().unwrap().clone(); Json(notes) }); r.post( "/notes", |Principal(_admin): Principal<AdminUser>, s: State<Store>, Json(input): Json<NewNote>| async move { let mut notes = s.notes.lock().unwrap(); let id = notes.len() as u64 + 1; let note = Note { id, text: input.text, }; notes.push(note.clone()); (StatusCode::CREATED, Json(note)) }, ); }) .start() .await }
Try it
cargo run -p api # from the Churust repo
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"}
Takeaways
Principal<AdminUser>makes the POST route uncallable without auth.ContentNegotiationkeeps error bodies JSON-friendly.- CORS names real browser origins β not
*β when credentials matter.
Recipe: chat room
WebSocket echo + broadcast room from
examples/chat.
Dependencies
[dependencies]
churust = { version = "0.3", features = ["ws"] }
Code
use churust::prelude::*; use churust::tokio::sync::broadcast; use churust::ws::{Message, WebSocketUpgrade}; use std::sync::Arc; #[derive(Clone)] struct Room { tx: Arc<broadcast::Sender<String>>, } #[churust::main] async fn main() -> std::io::Result<()> { let (tx, _rx) = broadcast::channel::<String>(100); Churust::server() .host("127.0.0.1") .port(8080) .state(Room { tx: Arc::new(tx) }) .routing(|r| { 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) { break; } if sock.send(msg).await.is_err() { break; } } }) }); r.get( "/room", |room: State<Room>, ws: WebSocketUpgrade| async move { let tx = room.tx.clone(); let mut rx = tx.subscribe(); ws.on_upgrade(move |mut sock| async move { loop { churust::tokio::select! { incoming = sock.recv() => match incoming { Some(Ok(Message::Text(t))) => { let _ = tx.send(t); } Some(Ok(Message::Close)) | None => break, Some(Err(_)) => break, _ => {} }, outgoing = rx.recv() => { if let Ok(text) = outgoing { if sock.send_text(text).await.is_err() { break; } } } } } }) }, ); }) .start() .await }
Try it
cargo run -p chat
websocat ws://localhost:8080/echo
# two terminals:
websocat ws://localhost:8080/room
Plain HTTP GET β 426 Upgrade Required.
Recipe: SPA & static assets
Serve a frontend build directory and optional dynamic routes.
Dependencies
[dependencies]
churust = { version = "0.3", features = ["fs"] }
Pattern
use churust::prelude::*; #[churust::main] async fn main() -> std::io::Result<()> { Churust::server() .host("0.0.0.0") .port(8080) .routing(|r| { // API routes first r.get("/api/health", |_c: Call| async { "ok" }); // Then the SPA / asset root r.get( "/{path...}", StaticFiles::dir("./dist").index("index.html").handler(), ); }) .start() .await }
Notes
- Register API routes before the catch-all
/{path...}. index("index.html")covers directory and SPA entry.- For client-side routers that need βfallback to index.html on 404β, confirm
whether your Churust versionβs
StaticFilessupports a fallback option; if not, serveindex.htmlfrom a custom handler for unknown GET paths. - Path traversal is rejected; the directory you pass is the whole reachable tree (symlinks that escape the root are refused).
Streaming alongside static
See examples/static
for Body::from_stream next to StaticFiles.
Recipe: deployment
Binary
cargo build --release -p my-api
# target/release/my-api
Pin features you actually need to keep compile times and attack surface small.
Configuration in production
Prefer env over baking secrets into images:
export CHURUST_SERVER_HOST=0.0.0.0
export CHURUST_SERVER_PORT=8080
export CHURUST_SERVER_MAX_BODY_BYTES=2097152
./my-api
Or ship a churust.toml next to the binary and override with env/DSL as needed.
See Configuration keys.
Process model
| Concern | Suggestion |
|---|---|
| TLS | Terminate at reverse proxy or enable tls in-process |
| Logs | tracing subscriber β stdout; ship to your aggregator |
| Health | A cheap GET /health or /ready route |
| Graceful stop | shutdown_timeout_ms bounds drain; send SIGTERM from orchestrator |
| Connections | Tune max_connections, keep-alive, timeouts for your traffic |
Reverse proxy sketch (nginx)
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSockets:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
If you rate-limit by peer address behind a proxy, key on a trusted
forwarded client IP header instead of the proxyβs address β use
RateLimit::by(...).
Containers
FROM rust:1.96 as build
WORKDIR /app
COPY . .
RUN cargo build --release -p my-api
FROM gcr.io/distroless/cc-debian12
COPY --from=build /app/target/release/my-api /my-api
ENV CHURUST_SERVER_HOST=0.0.0.0
ENV CHURUST_SERVER_PORT=8080
EXPOSE 8080
CMD ["/my-api"]
Adjust the runtime image for your glibc/musl and TLS needs.
Checklist
- Features limited to production needs
- Body/timeout/connection caps set consciously
- CORS origins are explicit
- Secrets via env or secret manager
- Security headers left on (see Security defaults)
- Health/readiness probes
- Load test with realistic keep-alive and payload sizes
Feature flags
All features on the churust crate. Default features are empty.
Plugins
| Feature | Crate | Notes |
|---|---|---|
json | churust-json | Json<T>, ContentNegotiation |
logging | churust-logging | CallLogging |
cors | churust-cors | Cors |
auth | churust-auth | Auth, Principal<P> |
ratelimit | churust-ratelimit | RateLimit (GCRA) |
compression | churust-compression | brotli / gzip / deflate |
templates | churust-templates | minijinja templates |
full | (meta) | All seven plugins above |
Data & docs
| Feature | Crate | Notes |
|---|---|---|
redis | churust-redis | RedisStore sessions |
client | churust-client | Outbound HTTP |
client-tls | churust-client | HTTPS for the client (enables client) |
openapi | churust-openapi | OpenAPI 3.1 (enables json) |
Transports
| Feature | Enables | Notes |
|---|---|---|
ws | churust-core/ws | WebSockets |
fs | churust-core/fs | StaticFiles |
multipart | churust-core/multipart | Multipart parsers |
tls | churust-core/tls | rustls HTTPS |
http3 | churust-core/http3 | QUIC / HTTP/3 (implies TLS stack) |
Examples
churust = "0.3"
churust = { version = "0.3", features = ["full"] }
churust = { version = "0.3", features = ["full", "ws", "fs", "tls"] }
churust = { version = "0.3", features = ["client-tls", "openapi"] }
Lockstep versioning: every churust-* crate shares the umbrella version.
Crate map
| Crate | Docs | What it is |
|---|---|---|
churust | docs.rs | Umbrella + 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> + ContentNegotiation |
churust-logging | docs.rs | CallLogging |
churust-cors | docs.rs | Cors |
churust-auth | docs.rs | Auth + Principal |
churust-ratelimit | docs.rs | RateLimit |
churust-compression | docs.rs | Compression |
churust-templates | docs.rs | Templates + Renderer |
churust-redis | docs.rs | RedisStore |
churust-client | docs.rs | Outbound HTTP client |
churust-openapi | docs.rs | OpenAPI 3.1 generation |
churust-lab | docs.rs | Incubator. Never reaches 1.0. |
Dependency graph (conceptual)
your-app
βββ churust (umbrella)
βββ churust-core
βββ churust-macros
βββ optional plugin crates (features)
Prefer churust = { features = [...] } over depending on plugin crates
directly so versions stay aligned.
Configuration keys
Loading
#![allow(unused)] fn main() { Churust::from_config() // defaults < churust.toml < env < later DSL }
Environment variables use the CHURUST_ prefix with nested keys separated by
_. Example: CHURUST_SERVER_PORT=9090.
[server]
| Key | Example | Meaning |
|---|---|---|
host | "0.0.0.0" | Bind address |
port | 8080 | Port |
max_body_bytes | 1048576 | Max request body size |
request_timeout_ms | 30000 | Request timeout |
keep_alive_ms | 75000 | Idle keep-alive; 0 answers and closes |
max_connections | 25000 | Concurrent connections; 0 unlimited |
max_tls_handshakes | 256 | Concurrent TLS handshakes |
tls_handshake_timeout_ms | 10000 | Handshake (+ queue) deadline |
shutdown_timeout_ms | 30000 | Bounded drain on shutdown |
path_policy | "strict" | strict | redirect | collapse |
Header-read timeouts cover a connection from accept time, so a peer that connects and stays silent is still bounded (slow-loris protection).
[tls]
Requires the tls feature.
| Key | Meaning |
|---|---|
cert | Path to certificate PEM |
key | Path to private key PEM |
Example file
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000
max_connections = 25000
max_tls_handshakes = 256
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000
path_policy = "strict"
[tls]
cert = "cert.pem"
key = "key.pem"
Additional knobs (HTTP/2 limits, multi-bind, Unix sockets, backlog) may be available on the builder β see docs.rs/churust-core and State & configuration.
Security defaults
Churust aims to be secure by default. This page summarizes guarantees you get without turning on optional features.
Always on
| Control | Behavior |
|---|---|
| Security headers | Applied on every response, including transport-written 413 / 400 / 408 |
| Body size limits | max_body_bytes bounds every request |
| Request timeouts | Bound handler work |
| Header-read timeouts | From accept, not only after protocol selection (slow-loris) |
| Header / path-depth caps | Limit parser work |
| Panic isolation | Panicking handler β 500; process keeps serving |
| Version banner | Not advertised |
| Path policy | Default strict refuses non-canonical paths |
Cookies
Cookie helper defaults: HttpOnly, SameSite=Lax. Prefer signed
sessions; use Redis when revocation must be real.
Secrets
Use secure_compare for token/password compares. Never use plain == on
secrets in production examples you copy forward.
Auth
- Bearer / Basic / JWT behind
auth - Basic only over TLS
Principal<P>so protected handlers cannot forget the check
CORS
Default builder is restrictive (no origins). Wildcard
allow_any_origin_insecure is explicit and uncomfortable on purpose.
TLS / HTTP
- Opt-in rustls TLS
- Ambiguous
Transfer-Encoding+Content-Lengthrefused - WebSocket frame/message caps when
wsis enabled
Static files
Traversal and symlink escape β 404. Missing root fails at startup.
What security is not
Churust does not replace:
- Application authorization design
- Secrets management
- Dependency auditing (
cargo audit) - Network policy / WAF
See also What is not supported.
What is not supported
Deliberate scope choices and known gaps. Tracked in detail in the production-readiness audit and roadmap.
On purpose
| Item | Why |
|---|---|
| Actor integration | Out of framework scope |
multipart/byteranges | Multi-range responses; RFC 9110 allows omitting |
| WebSockets over HTTP/3 | Would need Extended CONNECT (RFC 9220); ws is HTTP/1.1 upgrade |
| Revocation from client-only sessions | No server-side record β use churust-redis |
| Unlimited body via streaming | Streaming lowers memory cost; max_body_bytes still applies |
Pre-1.0
The public API may break in minor releases until 1.0. Pin versions in production and read the changelog.
Want something sooner?
Make the case in Discussions β Ideas.
Changelog & migration
Releases
Full history lives in the repository:
Current series documented in this guide: 0.3.x.
How Churust versions
- All
churust*crates share one version (lockstep). - Pre-1.0: breaking changes may appear in minor bumps (
0.3β0.4). - Prefer caret pins on the minor you tested:
churust = "0.3".
Migrating between minors
- Read the changelog section for the target version.
cargo update -p churust(or bump the pin) and rebuild with-D warningsif you match project CI.- Re-run examples or your
TestClientsuite. - Check renamed APIs (example: CORS
permissiveβallow_any_origin_insecurein 0.3).
docs.rs
API docs for every published version:
This book
The site tracks main of the guide source under book/. Older framework
versions may differ slightly; when in doubt, match code samples to the crate
version on crates.io.
Contributing
Contributions are welcome. Read the full guide first:
Short version
- Churust keeps a narrow scope
- Tests come first
- Anything optional is feature-gated so default builds never change
- Warnings are errors in CI
- MSRV is 1.96
Before you open a PR
Run the full gate listed in CONTRIBUTING (fmt, clippy feature matrix, test
matrix, examples, docs). For this documentation site:
cargo install mdbook --locked --version 0.4.48 # once
cd book && mdbook build
Channels
| Ask a question | Discussions β Q&A |
| Propose a feature | Discussions β Ideas |
| Report a bug | Issues |
| Report a vulnerability | SECURITY.md β privately |
| Code of conduct | CODE_OF_CONDUCT.md |
Design specs live under
docs/design/
(internal milestones, not product version numbers).
Support
Start here
| You want to | Go to |
|---|---|
| Learn the API | docs.rs/churust |
| Follow a guide | This site (sidebar) |
| See working code | examples/ |
| Understand a design decision | docs/design/ |
| Ask a question | Discussions β Q&A |
| Report a bug | Issues |
| Suggest a feature | Discussions β Ideas |
| Report a vulnerability | SECURITY.md β not a public issue |
Question or bug?
Use Discussions for βhow do Iβ¦β or βis this supposed toβ¦β. Use Issues when you can show something is broken.
Making questions answerable
Include:
- Churust version and enabled features
rustc --version- Smallest code that shows the problem
- Expected vs actual behavior
- Full error text
Response times
Maintained in spare time. Expect a few days; security reports follow SECURITY.md.
Sponsor
GitHub Sponsors funds maintenance, not feature bounties. Features are decided on merit in Discussions.
Full text: SUPPORT.md.