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
    })
    // Named in the 401 challenge; browsers show it in the login dialog.
    .realm("admin area"),
)
}

An unauthenticated request to a Principal-protected route answers 401 with WWW-Authenticate: Basic realm="…". The realm defaults to restricted. Install Basic and Bearer together and a 401 offers both challenges, in installation order.

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

Both also bound QUIC handshakes when the http3 feature is on: a QUIC handshake is a TLS 1.3 handshake, asymmetric in the same way.

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

Which [server] knobs apply

Enabling http3 starts a second listener, and a setting that reached only the TCP one would be a bound you believed you had. These all apply here:

KeyOn the HTTP/3 listener
max_connectionsConcurrent QUIC connections
max_tls_handshakesConcurrent QUIC handshakes β€” TLS 1.3 either way
tls_handshake_timeout_msHandshake deadline, queue wait included
keep_alive_msQUIC idle timeout (see below for 0)
h2_max_concurrent_streamsRequest streams per connection (see below for 0)
h2_max_header_list_sizeh3 max_field_section_size
request_timeout_msBody read plus handler, as one budget
max_body_bytesRequest body cap

header_read_timeout_ms is not applied here. Abandoning a request whose HEADERS frame never arrives leaks its stream id inside the h3 crate, which stops the connection ever closing β€” worse than the stall it would bound. What bounds the exposure instead is h2_max_concurrent_streams per connection and max_connections overall.

Two things behave differently here, both deliberately:

  • keep_alive_ms = 0 has no QUIC meaning β€” a connection multiplexes streams and cannot close after one response β€” so the listener keeps the default 75000 ms idle bound instead.
  • h2_max_concurrent_streams = 0 means "no limit" on HTTP/2 and cannot be translated: quinn allocates one slot per advertised stream when it builds a connection, so advertising a varint maximum is a four-quintillion-iteration loop triggered by the first packet from any peer. The listener keeps the default 200 instead.
  • A request body is collected before dispatch, where the TCP path streams it. That is what lets a truncated body be refused before a handler ever runs on a fragment, which is undetectable afterwards because a truncated body is just a shorter body. The cost is one max_body_bytes per in-flight request.

serve reads these from the app. If you build a quinn::ServerConfig yourself and use serve_with_config, the transport parameters are yours β€” the per-stream deadlines still apply.

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

Performance

Churust is fast, and this page is about being precise rather than loud: what was measured, against what, on what, and what the number does not cover.

keep-alive, 4 workers, median of 9req/svs. Churustserver CPU Β΅s/reqp99
actix-web 4.14916,3641.12Γ—4.20105 Β΅s
bare hyper β€” the floor, not a framework895,0731.10Γ—4.08113 Β΅s
Churust816,986β€”4.87137 Β΅s

Linux 6.12, server pinned to CPUs 0–7 and the load generator to 8–11, 64 keep-alive connections, one server running at a time, order rotated each round. Three routes returning constants. Full method: benchmarks/README.md.

actix-web leads on all three columns. Churust is not the fastest Rust web framework on this workload and this page will say so until it is.

axum, Ktor and Go were measured on an earlier eight-worker configuration and have not been re-run against this harness; their figures live in benchmarks/results/ rather than in this table, because quoting a number measured under one configuration beneath a table built under another is exactly how the corrections further down this page became necessary.

Read this before quoting the table

Worker count moves these numbers more than most code changes do. Both servers default to one worker per core; on this machine that default sits at different distances from each framework's optimum, so a default-vs-default table measures the defaults. Everything above is at four workers for every server. run_sharded(0) picks one per core, which is a starting point rather than an answer β€” sweep it for your hardware.

Nine rounds because fewer is not enough. Three consecutive five-round runs of identical code put Churust 3.6% ahead, then 11% ahead, then 5.9% behind; the between-run spread on this machine is wider than most of what is being measured. Read the ranges in benchmarks/results/, not just the medians.

A caveat on the throughput column specifically. CPU/req Γ— req/s gives cores consumed: bare hyper 3.65, actix-web 3.85, Churust 3.98, all on four workers. Churust's workers are saturated while the other two have headroom, which raises the possibility that their throughput is bounded by the four-thread load generator rather than by the server. The CPU-per-request figures are measured directly and do not depend on saturation, so they are the ones to trust; the throughput ordering may be partly an artifact of the harness.

Sharding is a choice you make, and it is a large one. The same code on the same cores, with and without connection pinning β€” both figures from the earlier eight-worker run, kept because the comparison is the point and both sides were measured together:

build (8 workers, superseded run)req/sserver CPU Β΅s/reqp99 latency
shared runtime (App::start, the default)390,77212.94444 Β΅s
one runtime per core (App::run_sharded)757,9308.39246 Β΅s

1.94Γ— the throughput, and better tail latency too once the per-connection handoff and the connection loop's per-wake bookkeeping were removed. Pinning a connection to one runtime for its life means a request waits for that worker rather than being picked up by whichever is idle.

With pipelining the order changes. actix-web reaches 5.96M requests a second against Churust's 4.45M β€” 1.34Γ— β€” and does it on 1.14 Β΅s of CPU per request against 1.70. Pipelining is not the shape most traffic has, which is why it is not the headline, but it is where Churust's dispatch path is furthest behind.

That gap is Churust's own, and it took a floor to find out. This page used to say the difference was hyper's HTTP/1 implementation against actix-http's, reasoning that Churust's dispatch layer measures 393 ns in isolation so the remainder had to belong to the stack underneath. That was arithmetic, not a measurement, and it was wrong. benchmarks/bench-hyper now runs the comparison against hyper and tokio with no framework on top β€” same routes, same responses, same one-runtime-per-core shape β€” and hyper is not the bottleneck:

keep-alive, 4 workersreq/sserver CPU Β΅s/reqp99
actix-web902,9074.20111 Β΅s
bare hyper (the floor)902,4814.07110 Β΅s
Churust814,6904.87131 Β΅s

hyper answers a request for less CPU than actix-web does, so it was never what the gap was made of. Churust's own layer costs 0.80 Β΅s per request on top of the hyper it runs on, and that is the whole of the distance to actix-web.

The first 0.22 Β΅s of that came back from one line. tokio::time::timeout takes its future by value, and the future it was being handed is the entire request β€” the pipeline, the handler, and the Call threaded through both, 2,616 bytes of it. Every request memcpy'd all of that into the timeout wrapper. Pinning it first (std::pin::pin!) hands over a Pin<&mut _> instead, and the future stays where it was already built. Churust went from 5.10 to 4.87 Β΅s per request and 781,872 to 814,690 req/s, with the confirming run's slowest round faster than the old best one.

Where the 0.79 Β΅s goes, and what it would take to close it.

Half of it is Churust's dispatch layer and half is the engine around it:

ns/req
Churust's overhead above the floor~790
dispatch β€” routing, extraction, pipeline~400
the engine β€” respond, Call construction, timeout, EngineBody, guards~390

(Two figures have been published for dispatch, 393 ns and 672 ns, and both are right about different applications. benches/dispatch.rs builds the default server, which installs the security-headers middleware β€” a layer measuring 268–301 ns that bench-churust turns off. Measured in the wire configuration, dispatch is ~400 ns.)

First place is not reachable by making dispatch faster. Matching actix-web means shedding 790 βˆ’ 120 = 670 ns, and the entire dispatch layer is ~400. A dispatch layer that cost nothing would leave Churust at 4.47 Β΅s against actix's 4.20.

That is worth stating plainly because the obvious redesign was prototyped rather than assumed. Replacing Arc<dyn Handler> and the per-layer Pin<Box<dyn Future>> with monomorphised static dispatch measures 20 ns β€” and it inlines every handler's state machine into the request future, working against the only change this session that measurably helped. actix-web pays more vtable dispatches and more allocations per request than Churust does and is still faster. Erasure is not where the money is.

What is reachable without touching the public API is 60–110 ns (~1.7%): dropping #[async_trait] from the extractor traits, and router work β€” FxHash, inline path segments, fusing the three separate scans over the path.

And the largest single win does not show up on this page's charts at all, because it is disabled in the benchmark: the default configuration pays 268–301 ns for security headers, and on the plaintext path applies them twice β€” once in the middleware, once in the transport β€” where the second pass reserves six header slots on a map that already holds them, forcing a grow and a rehash to discover there is nothing to do. Every real deployment pays that; no benchmark here measures it.

Pipelined throughput: actix-web 5.96M, Churust 4.45M, Ktor 1.23M, Go 352k, axum 24k

axum's last place there is not about its routing β€” the keep-alive table shows that is fine. hyper answers each request in a pipelined batch with its own flush, and axum::serve exposes no way to turn the aggregation on, so a batch of 16 costs it 16 write syscalls. Churust can be asked (pipeline_flush).

Numbers move between runs, so read the ranges. The published figures are medians of five rounds with the running order rotated; the results file carries the spread for every row, and any row whose rounds differed by more than 3Γ— is flagged rather than averaged into respectability.

Every route returns a constant. This measures dispatch overhead and says nothing about an application that talks to a database.

Getting the throughput in your own application

The measured configuration is not the default, on purpose. Three knobs:

App::run_sharded β€” one runtime per core

The default App::start runs every connection on one shared work-stealing runtime. That balances perfectly, and it lets a connection's read wakeup, its handler and its write each land on a different thread β€” an atomic, a cache miss and usually a syscall per hop. run_sharded gives each worker its own single-threaded runtime and pins a connection to one for its life.

use churust::prelude::*;
fn main() -> std::io::Result<()> {
    let app = Churust::server()
        .routing(|r| { r.get("/", |_c: Call| async { "hi" }); })
        .build();
    app.run_sharded(0) // 0 = one worker per core
}

Note there is no #[churust::main]: run_sharded builds and owns its runtimes, and wrapping it in another one leaves a multi-threaded runtime idling underneath the single-threaded ones doing the work.

Measured against the shared runtime: 1.94Γ— the throughput (391k β†’ 758k), and tail latency improved as well (444 Β΅s β†’ 246 Β΅s). With per-connection affinity a worker whose connections are busy cannot borrow an idle worker's core, so a request that lands on a busy worker waits.

Choose run_sharded when throughput is the constraint and the requests are many, short and uniform. Keep start β€” the default β€” when the slowest percentile is a number anyone looks at.

pipeline_flush β€” only if your clients pipeline

#![allow(unused)]
fn main() {
use churust::prelude::*;
let _ =
Churust::server().pipeline_flush(true)
;
}

Answers a batch of pipelined requests with one flush instead of one per response. Off by default because a client that does not pipeline then waits for a flush that only comes once the connection has nothing left to read: measured on loopback with one request in flight, a median 90Β΅s instead of 56Β΅s. Turn it on only if you know the traffic pipelines.

tcp_nodelay β€” on by default, leave it

Nagle's algorithm holds a small write waiting for more to send while the peer's delayed ACK holds the acknowledgement waiting for a response; the standoff breaks on a timer. Churust disables it on every accepted connection. The only workload that wants it back is pipelining, and pipeline_flush gets the same coalescing without giving up the latency guarantee.

Where the speed-up came from

Churust before and after: 391k to 758k requests per second

None of it was in routing or extraction, which is where a profiler points first:

  1. Removing per-request atomics from shared cache lines. The request path cloned the application handle five times per request; each clone is an atomic read-modify-write on one cache line that every core is also writing to. Twelve workers sharing one application reached 1.29M req/s where twelve processes not sharing it reached 2.07M β€” the same code on the same cores, differing only in whether one integer was contended.
  2. One runtime per core with connections pinned (run_sharded), with the latency cost documented above.
  3. Dropping a per-request allocation in the extension map, plus the router, pipeline and response-body allocations listed in the changelog.
  4. TCP_NODELAY on by default, which it should always have been.

On pipelined load the multiple is far larger β€” aggregating flushes is worth 4.1Γ— on its own β€” but that is a workload most services do not have, and the 1.94Γ— above is the one to plan around.

Running it yourself

docker build -f benchmarks/Dockerfile -t churust-bench .   # from the repo root
docker run --rm churust-bench

Docker is the supported path because the kernel matters more than anything else here: on macOS the loopback saturates around 45–50k round-trips a second, below what any of these servers can answer, and every framework reports the same number. The same Churust binary measures 47k there and 691k on Linux.

The harness refuses to measure unless all five apps return byte-identical responses on every route, so a result you get is a result about dispatch and not about servers doing different work. docker run -e APPS="churust axum" … runs a subset.

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 (QUIC included)
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). It does not apply to HTTP/3 β€” see HTTP/3 for why, and for what bounds that transport instead.

Which transports a knob reaches

One setting should mean one thing on every transport this server speaks, so unless noted below a [server] key applies to HTTP/1.1, HTTP/2 and HTTP/3 alike. Two deliberate differences are worth knowing:

  • keep_alive_ms = 0 means "answer and close". HTTP/1.1 closes after the response and HTTP/2 closes once nothing is in flight. A QUIC connection multiplexes streams and cannot be closed after a single response, so there is nothing for the setting to mean there β€” the HTTP/3 listener keeps the default 75000 ms idle bound rather than never expiring, which would leave a connection holding a max_connections permit for good.
  • h2_max_concurrent_streams is named for HTTP/2 and also caps concurrent request streams per HTTP/3 connection. 0 means unlimited on HTTP/2 only: QUIC allocates against whatever cap is advertised, so HTTP/3 keeps the default.

An HTTP/3 request body is collected before the request is dispatched, where the TCP path streams it to the handler. That is what lets a body truncated mid-flight be refused without the handler running on a fragment, and the cost is one max_body_bytes per in-flight request β€” bounded by h2_max_concurrent_streams Γ— max_connections.

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