Churust πŸŒ€

Churro + Rust β€” a backend web framework inspired by Ktor. Simple, secure, robust, and easy to learn.

crates.io docs.rs CI MSRV 1.96

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

AreaHighlights
HandlersCall-style or typed extractors (Path, Query, Json, State, …)
Pluginsinstall(...) into a fixed phase order: Setup β†’ Monitoring β†’ Plugins β†’ Call β†’ Fallback
SecurityBody limits, timeouts, panic isolation, security headers, opt-in TLS
ProtocolsHTTP/1.1, HTTP/2, optional HTTP/3, WebSockets, static files
EcosystemOpt-in JSON, CORS, auth, rate limit, compression, templates, Redis, OpenAPI, client

How this site is organized

SectionPurpose
Getting startedInstall, run your first server
From Ktor to ChurustSide-by-side Ktor (Kotlin) ↔ Churust (Rust)
FundamentalsRouting, extractors, plugins, config, tests
Plugins & featuresOne page per optional capability
RecipesEnd-to-end examples from the repo
ReferenceMatrices, config keys, security, non-goals
CommunityContributing and help channels

Docs map

ResourceUse it for
This guideHow to use Churust day to day
docs.rs/churustFull API reference for types and traits
examples/Runnable apps (hello, api, chat, static)
READMEShort 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?

  1. #[churust::main] β€” builds a multi-threaded tokio runtime and runs your async main (the Churust equivalent of #[tokio::main]).
  2. Churust::server() β€” starts the builder for an HTTP application.
  3. .routing(|r| { ... }) β€” registers routes; path segments in {braces} become extractors.
  4. .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

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 / embeddedServerChurust::server() builder
routing { … }.routing(|r| { … })
get / post / …r.get / r.post / …
install(…).install(…)
Application plugins + interceptorsPlugins into fixed phases
callCall or typed extractors
call.parameters["id"]Path(id): Path<u64>
call.receive<T>()Json(t): Json<T> (feature json)
Auth principalPrincipal<P> (feature auth)
Application attributes / DI.state(T) + State<T>
application.conf + envchurust.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}")
});
}
KtorChurust
Read call.parameters["id"] as a stringPath<u64> parses and types the segment
Manual toLongOrNull + 400Malformed path β†’ error before the handler body
Optional/nullable handling by handUse 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| {
        // …
    })
}
KtorChurust
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 orderFixed 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 })
});
}
KtorChurust
call.receive<T>()Json<T> extractor (last argument if it consumes the body)
call.respond(T) with content negotiationJson(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
        });
    })
}
KtorChurust
Named auth configs + authenticate("…") blocksInstall Auth, then require Principal<P> on the handler
call.principal<T>()Principal<T> extractor
Forgetting authenticate is a common footgunNo 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:

TopicExpectation
Ownership & lifetimesHandlers own or borrow what extractors give them; no hidden GC
ErrorsPrefer Result / IntoError / ? over silent nulls
Asyncasync/.await on tokio (via churust::tokio or your own features)
TypesPath/query/JSON failures surface as typed HTTP errors, not null
FeaturesOpt-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

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

FeaturePulls inGives you
jsonchurust-jsonJson<T> extractor/responder, content negotiation
loggingchurust-loggingCallLogging via tracing
corschurust-corsPreflight + CORS headers
authchurust-authBearer / Basic / JWT, Principal<P>
ratelimitchurust-ratelimitRateLimit, GCRA, keyed on the peer address
compressionchurust-compressionbrotli / gzip / deflate response bodies
templateschurust-templatesTemplates + Renderer (minijinja)
fullthe seven aboveThe whole plugin set
redischurust-redisRedisStore: server-side sessions, revocable
clientchurust-clientOutbound HTTP client (client-tls for HTTPS)
openapichurust-openapiOpenAPI 3.1 document from the router (implies json)
wschurust-core/wsWebSocket upgrade + WebSocket / Message
fschurust-core/fsStaticFiles, conditional GET, byte ranges
multipartchurust-core/multipartmultipart/form-data, buffered or streamed
tlschurust-core/tlsrustls-backed HTTPS
http3churust-core/http3HTTP/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:

CrateRole
churustUmbrella + prelude. Depend on this.
churust-coreEngine, routing, pipeline, extractors, config, TLS, WS, static, tests
churust-macros#[churust::main]
churust-jsonJSON + content negotiation
churust-loggingRequest logging
churust-corsCORS
churust-authAuth + Principal
churust-ratelimitRate limiting
churust-compressionResponse compression
churust-templatesHTML templates
churust-redisRedis session store
churust-clientOutbound HTTP client
churust-openapiOpenAPI generation
churust-labIncubator β€” never reaches 1.0; expect breaks

Runnable examples live under examples/:

ExampleCommandShows
hellocargo run -p helloRoutes, path/query, state
apicargo run -p apiJSON + plugins + auth
chatcargo run -p chatWebSockets
staticcargo run -p static-exampleStatic files + streamed body

Where docs live

PathAudience
book/ (this site)Users learning the framework
docs.rsAPI 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 Allow header that both 405 and OPTIONS agree 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:

PolicyBehavior
strict (default)Refuse non-canonical paths
redirect308 to the canonical form
collapseCollapse 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

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:

ExtractorPurpose
Path<T>Path parameters
Query<T>Query string
Header<T, N>Named header
State<T>Typed app state
BearerTokenRaw 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:

ExtractorPurpose
Json<T>JSON body (json feature)
Form<T>Form body
Bytes / StringRaw body
PayloadStreaming body (not capped by materializing whole upload)
Multipart / stream variantsUploads (multipart feature)
Either<A, B>One of two body shapes
CallFull 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 / Bytes
  • StatusCode
  • (StatusCode, T)
  • Json<T> (feature json)
  • Response (fully controlled)
  • Streaming via Response::stream and Body::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

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-Encoding and Content-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

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:

  1. Setup
  2. Monitoring
  3. Plugins
  4. Call
  5. 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)

PluginFeatureRole
CallLoggingloggingStructured request logs
ContentNegotiationjsonJSON errors / negotiation
CorscorsCORS + preflight
Auth (Bearer / Basic / JWT)authAuthentication
RateLimitratelimitGCRA rate limiting
CompressioncompressionResponse compression
TemplatestemplatesTemplate 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

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):

  1. Built-in defaults
  2. churust.toml
  3. Environment variables (CHURUST_*)
  4. 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

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() -> App factory 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)

PageFeatureInstall API
JSONjsonContentNegotiation, Json<T>
LoggingloggingCallLogging
CORScorsCors
AuthauthAuth::bearer / basic / jwt
Rate limitratelimitRateLimit
CompressioncompressionCompression
TemplatestemplatesTemplates + Renderer
RedisredisRedisStore
ClientclientClient
OpenAPIopenapiOpenApi

features = ["full"] enables: json, logging, cors, auth, ratelimit, compression, templates.

Transport / core features

PageFeature
WebSocketsws
Static files & streamingfs (+ always-on Body)
Multipartmultipart
TLStls
HTTP/3http3

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 tracing subscriber in main (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"] }

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

  1. Install an Auth plugin that verifies credentials and produces a principal type P.
  2. Handlers that need authentication take Principal<P>.
  3. 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

MethodPurpose
.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

MethodPurpose
.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);
}
MethodPurpose
.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

MethodPurpose
.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_agentDefaults 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 documentation
  • stale() β€” 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:

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

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-Type from 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

examples/static Β· SPA recipe

Multipart uploads

feature: multipart

Enable

churust = { version = "0.3", features = ["multipart"] }

Two APIs

TypeBehavior
MultipartBuffers the whole body
MultipartStreamReads 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

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

SettingMeaning
max_tls_handshakesConcurrent handshakes (asymmetric work)
tls_handshake_timeout_msBounds 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.

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

  1. Enable the feature and provide TLS certificates suitable for QUIC.
  2. Configure the h3 listener (bind address + server config from PEM helpers in core).
  3. 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

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.
  • ContentNegotiation keeps 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 StaticFiles supports a fallback option; if not, serve index.html from 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

ConcernSuggestion
TLSTerminate at reverse proxy or enable tls in-process
Logstracing subscriber β†’ stdout; ship to your aggregator
HealthA cheap GET /health or /ready route
Graceful stopshutdown_timeout_ms bounds drain; send SIGTERM from orchestrator
ConnectionsTune 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

FeatureCrateNotes
jsonchurust-jsonJson<T>, ContentNegotiation
loggingchurust-loggingCallLogging
corschurust-corsCors
authchurust-authAuth, Principal<P>
ratelimitchurust-ratelimitRateLimit (GCRA)
compressionchurust-compressionbrotli / gzip / deflate
templateschurust-templatesminijinja templates
full(meta)All seven plugins above

Data & docs

FeatureCrateNotes
redischurust-redisRedisStore sessions
clientchurust-clientOutbound HTTP
client-tlschurust-clientHTTPS for the client (enables client)
openapichurust-openapiOpenAPI 3.1 (enables json)

Transports

FeatureEnablesNotes
wschurust-core/wsWebSockets
fschurust-core/fsStaticFiles
multipartchurust-core/multipartMultipart parsers
tlschurust-core/tlsrustls HTTPS
http3churust-core/http3QUIC / 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

CrateDocsWhat it is
churustdocs.rsUmbrella + prelude. Depend on this. Plugins behind features.
churust-coredocs.rsEngine, routing, pipeline, Call, extractors, config, state, TLS, WebSockets, static files, test harness
churust-macrosdocs.rs#[churust::main]
churust-jsondocs.rsJson<T> + ContentNegotiation
churust-loggingdocs.rsCallLogging
churust-corsdocs.rsCors
churust-authdocs.rsAuth + Principal
churust-ratelimitdocs.rsRateLimit
churust-compressiondocs.rsCompression
churust-templatesdocs.rsTemplates + Renderer
churust-redisdocs.rsRedisStore
churust-clientdocs.rsOutbound HTTP client
churust-openapidocs.rsOpenAPI 3.1 generation
churust-labdocs.rsIncubator. 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]

KeyExampleMeaning
host"0.0.0.0"Bind address
port8080Port
max_body_bytes1048576Max request body size
request_timeout_ms30000Request timeout
keep_alive_ms75000Idle keep-alive; 0 answers and closes
max_connections25000Concurrent connections; 0 unlimited
max_tls_handshakes256Concurrent TLS handshakes
tls_handshake_timeout_ms10000Handshake (+ queue) deadline
shutdown_timeout_ms30000Bounded 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.

KeyMeaning
certPath to certificate PEM
keyPath 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

ControlBehavior
Security headersApplied on every response, including transport-written 413 / 400 / 408
Body size limitsmax_body_bytes bounds every request
Request timeoutsBound handler work
Header-read timeoutsFrom accept, not only after protocol selection (slow-loris)
Header / path-depth capsLimit parser work
Panic isolationPanicking handler β†’ 500; process keeps serving
Version bannerNot advertised
Path policyDefault 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-Length refused
  • WebSocket frame/message caps when ws is 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

ItemWhy
Actor integrationOut of framework scope
multipart/byterangesMulti-range responses; RFC 9110 allows omitting
WebSockets over HTTP/3Would need Extended CONNECT (RFC 9220); ws is HTTP/1.1 upgrade
Revocation from client-only sessionsNo server-side record β€” use churust-redis
Unlimited body via streamingStreaming 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:

CHANGELOG.md

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

  1. Read the changelog section for the target version.
  2. cargo update -p churust (or bump the pin) and rebuild with -D warnings if you match project CI.
  3. Re-run examples or your TestClient suite.
  4. Check renamed APIs (example: CORS permissive β†’ allow_any_origin_insecure in 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:

CONTRIBUTING.md

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 questionDiscussions β†’ Q&A
Propose a featureDiscussions β†’ Ideas
Report a bugIssues
Report a vulnerabilitySECURITY.md β€” privately
Code of conductCODE_OF_CONDUCT.md

Design specs live under docs/design/ (internal milestones, not product version numbers).

Support

Start here

You want toGo to
Learn the APIdocs.rs/churust
Follow a guideThis site (sidebar)
See working codeexamples/
Understand a design decisiondocs/design/
Ask a questionDiscussions β†’ Q&A
Report a bugIssues
Suggest a featureDiscussions β†’ Ideas
Report a vulnerabilitySECURITY.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.

GitHub Sponsors funds maintenance, not feature bounties. Features are decided on merit in Discussions.

Full text: SUPPORT.md.