Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Churust logo — a churro spiral inside a gear

Churust 🌀

Churro + Rust — a backend web framework inspired by Kotlin's Ktor.
Simple, secure, robust, easy to learn.

crates.io docs.rs User guide CI MSRV 1.96 MIT

Guides: User documentation (quick start, plugins, recipes) · API: docs.rs/churust

Churust gives you Ktor's developer experience in Rust: an application engine, a routing DSL, an install(plugin) system, and a phased interceptor pipeline — built on a battle-tested async stack (tokio + hyper + rustls). Churust owns the ergonomic layer; it does not reinvent HTTP parsing or TLS.

use churust::prelude::*;

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .port(8080)
        .routing(|r| {
            r.get("/", |_call: Call| async { "Hello from Churust 🌀" });
            r.get("/users/{id}", |Path(id): Path<u64>| async move {
                format!("user #{id}")
            });
        })
        .start()
        .await
}

Install

[dependencies]
churust = "0.3"

That is the whole dependency list. Churust re-exports the runtime it is built on as churust::tokio, and #[churust::main] uses that re-export, so no separate tokio entry is needed. Add one only if you want a tokio feature Churust does not enable — Cargo unifies the two.

Plugins and transports are opt-in features on the umbrella crate — depend on churust and enable what you need rather than pulling in the plugin crates directly:

churust = { version = "0.3", features = ["full", "ws", "fs", "tls"] }
Feature Pulls in Gives you
json churust-json Json<T> extractor/responder, content negotiation
logging churust-logging CallLogging via tracing
cors churust-cors preflight + CORS headers
auth churust-auth Bearer/Basic/JWT, Principal<P>
ratelimit churust-ratelimit RateLimit, GCRA, keyed on the peer address (IPv6 by prefix)
compression churust-compression brotli / gzip / deflate response bodies
templates churust-templates Templates + Renderer, minijinja, HTML-escaped whatever the template is named
full the seven above the whole plugin set
redis churust-redis RedisStore: server-side sessions, revocable
client churust-client outbound HTTP client with transparent gzip/deflate decode (client-tls for HTTPS)
openapi churust-openapi an OpenAPI 3.1 document from the router
ws churust-core/ws WebSocket upgrade + WebSocket/Message
fs churust-core/fs StaticFiles, conditional GET, byte ranges
multipart churust-core/multipart multipart/form-data, buffered or streamed
tls churust-core/tls rustls-backed HTTPS
http3 churust-core/http3 HTTP/3 over QUIC (implies tls)

Default features are empty, so a plain churust = "0.3" compiles the core engine and nothing else.

Every Churust crate is released in lockstep on one version number, so churust-core, churust-json, and the rest always match the umbrella version.

Features

  • Hybrid handlers — write call-style (|call: Call|) or with typed extractors (Path<T> — including tuples and structs — Query<T>, Form<T>, Header<T, N>, State<T>, Json<T>, Payload, Either<A, B>, BearerToken, Principal<P>) — mix freely in one handler. Wrap any of them in Option<T> when the input is optional; absent is None, malformed is still an error.
  • install(plugin) — Ktor-style plugins composed over a named pipeline (Setup → Monitoring → Plugins → Call → Fallback) for deterministic order, plus intercept for middleware scoped to part of the route tree.
  • Route guards — several routes may share a path when guard::header, guard::host or a custom predicate distinguishes them.
  • Your own error pageson_error renders any 4xx/5xx, including routing failures.
  • Request correlation — a request id on every log line, echoed as x-request-id, continuing an inbound W3C traceparent.
  • Built-in plugins — JSON content negotiation, request logging, CORS, and authentication (Bearer / Basic / JWT).
  • Cookies and sessions — a cookie primitive with safe defaults (HttpOnly, SameSite=Lax), and HMAC-SHA256 signed cookie sessions.
  • Streaming request bodiesPayload reads an upload incrementally, so it is not capped by memory.
  • Peer addresscall.peer_addr() for rate limiting and audit logs.
  • Deployment knobs — idle keep-alive, listen backlog, connection cap, TLS handshake cap and a deadline that bounds queueing for it as well as the handshake itself, HTTP/2 stream and header limits, a bounded shutdown grace period that actually drains, several bind addresses, and Unix domain sockets that refuse to unlink a live one.
  • File uploadsMultipart buffers the whole body; MultipartStream reads fields and their content incrementally, so memory stops scaling with upload size. Both behind the multipart feature.
  • Typed app state / DI.state(T) then extract State<T>.
  • Layered config — defaults < churust.toml < env (CHURUST_*) < code DSL.
  • Secure by default — security headers on every response, including the 413s, 400s and 408s a transport writes for itself without reaching a handler; body-size limits; request and header-read timeouts, the latter covering a connection from the moment it is accepted rather than from the moment a protocol is chosen, so a peer that connects and says nothing is still bounded (slow-loris); header and path-depth caps; WebSocket frame and message caps; panic isolation (a panicking handler returns 500, never crashes the server); no version banner; opt-in rustls TLS.
  • HTTP/1.1, HTTP/2 and HTTP/3 — h2 over TLS via ALPN, h2c by prior knowledge in plaintext, negotiated per connection; h3 over QUIC on its own listener behind the http3 feature, with advertise_http3 emitting the Alt-Svc header clients need in order to try it.
  • Response compression — brotli, gzip and deflate, negotiated from Accept-Encoding, streaming bodies included (feature compression).
  • Rate limiting — GCRA, so bursts are smoothed and Retry-After is exact rather than estimated. IPv6 peers are bucketed by network prefix, because a subscriber is handed a whole /64 and limiting each address in it separately limits nobody (feature ratelimit).
  • Templating — minijinja, parsed at startup, HTML-escaped whatever the template is called, since the rendered output is served as HTML either way (feature templates).
  • Server-side sessions — Redis-backed, so logging out actually revokes, and a revocation that could not be carried out is an error rather than a cheerful 200 over a session that is still live (feature redis).
  • An HTTP client — pooled, bounded, on the same hyper the server runs on; transparent gzip/deflate decode with a decompression-bomb ceiling (feature client).
  • OpenAPI 3.1 — paths and parameters from the router, prose and schemas from you, with drift reported in both directions (feature openapi).
  • Login and logout — an identity layer over sessions with both an absolute and an idle deadline.
  • Correct HTTP — automatic HEAD and OPTIONS (including OPTIONS *), one Allow header that both 405 and OPTIONS agree on, conditional GET (ETag/Last-Modified/304) and byte ranges (206/416) for static files.
  • One URL per resourcePathPolicy decides what happens to //a and /a//b: refuse (default), 308 to the canonical form, or collapse. Silent collapsing makes prefix-based auth checks bypassable and cache identity ambiguous.
  • Ambiguity is refused, not guessed — a request carrying both Transfer-Encoding and Content-Length never keeps its connection, whichever order the two headers arrive in, so no leftover byte becomes the next request behind a proxy that read the length differently. This is why hyper 1.11 is a floor rather than a preference: earlier versions delete the Content-Length while parsing and leave the connection open, and no check above hyper can see what was deleted. A repeated query key on a scalar field is an error rather than a silent first-or-last-wins.
  • Borrow the ecosystem — the tower feature runs any tower::Service as middleware, so tower-http's layers work without Churust reimplementing them.
  • Errors that carry — implement IntoError on your own error type and ? works in handlers, without leaking its Display text to clients.
  • Hard to misuse — a missing static root or a duplicate route fails at startup rather than silently at runtime; secure_compare for secrets.
  • Fast, in-process testsTestClient drives the full pipeline without binding a socket.

Workspace layout

Crate Docs What it is
churust docs.rs Umbrella crate + prelude. Depend on this. Plugins behind features.
churust-core docs.rs Engine, routing, pipeline, Call, extractors, config, state, TLS, WebSockets, static files, test harness.
churust-macros docs.rs #[churust::main].
churust-json docs.rs Json<T> extractor/responder + ContentNegotiation plugin (feature json).
churust-logging docs.rs CallLogging plugin via tracing (feature logging).
churust-cors docs.rs Cors plugin — preflight + headers (feature cors).
churust-auth docs.rs Auth (Bearer/Basic/JWT) + Principal<P> (feature auth).
churust-ratelimit docs.rs RateLimit plugin, GCRA (feature ratelimit).
churust-compression docs.rs Compression plugin — brotli/gzip/deflate (feature compression).
churust-templates docs.rs Templates + Renderer over minijinja (feature templates).
churust-redis docs.rs RedisStore, a revocable server-side SessionStore (feature redis).
churust-client docs.rs Outbound HTTP client (feature client, HTTPS via client-tls).
churust-openapi docs.rs OpenAPI 3.1 document generation (feature openapi).
churust-lab docs.rs Incubator. Never reaches 1.0; expect breaking changes on most releases.

Runnable examples:

Example Run it Shows
examples/hello cargo run -p hello Minimal server, path params.
examples/api cargo run -p api JSON CRUD with all four plugins + auth-gated routes.
examples/chat cargo run -p chat WebSocket echo endpoint and a broadcast room.
examples/static cargo run -p static-example StaticFiles plus a streamed response body.

A fuller example

use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Clone, Serialize, Deserialize)]
struct Note { id: u64, text: String }
#[derive(Deserialize)]
struct NewNote { text: String }
#[derive(Clone)]
struct Admin { name: String }
struct Store { notes: Mutex<Vec<Note>> }

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .state(Store { notes: Mutex::new(Vec::new()) })
        .install(CallLogging::new())
        .install(ContentNegotiation::new())  // renders errors as JSON
        .install(Cors::new().allow_origin("http://localhost:3000"))
        .install(Auth::bearer(|token: String| async move {
            (token == "admin-token").then(|| Admin { name: "admin".into() })
        }))
        .routing(|r| {
            r.get("/notes", |s: State<Store>| async move {
                Json(s.notes.lock().unwrap().clone())
            });
            // Asking for `Principal<Admin>` enforces auth (401 otherwise).
            r.post("/notes", |Principal(_a): Principal<Admin>,
                              s: State<Store>,
                              Json(input): Json<NewNote>| async move {
                let mut notes = s.notes.lock().unwrap();
                let note = Note { id: notes.len() as u64 + 1, text: input.text };
                notes.push(note.clone());
                (StatusCode::CREATED, Json(note))
            });
        })
        .start()
        .await
}
$ curl localhost:8080/notes
[]
$ curl -X POST localhost:8080/notes -d '{"text":"hi"}'
{"error":"authentication required","status":401}
$ curl -X POST localhost:8080/notes -H 'authorization: Bearer admin-token' \
       -H 'content-type: application/json' -d '{"text":"hi"}'
{"id":1,"text":"hi"}

WebSockets (feature ws)

Opt in with features = ["ws"]. A handler takes the WebSocketUpgrade extractor and calls on_upgrade to switch the request into a bidirectional socket (a plain GET to the route is rejected with 426 Upgrade Required):

use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};

r.get("/echo", |ws: WebSocketUpgrade| async move {
    ws.on_upgrade(|mut sock| async move {
        while let Some(Ok(msg)) = sock.recv().await {
            if matches!(msg, Message::Close) || sock.send(msg).await.is_err() {
                break;
            }
        }
    })
});
churust = { version = "0.3", features = ["ws"] }

See examples/chat for an echo endpoint plus a broadcast room.

Static files & streaming (feature fs)

Response bodies are a Body — either buffered bytes or a lazy stream — so large or dynamic payloads never have to be fully materialized in memory. Body is always available; StaticFiles is behind the opt-in fs feature.

use churust::prelude::*;        // brings StaticFiles into scope under `fs`
use churust::Body;

r.get(
    "/{path...}",
    StaticFiles::dir("./public").index("index.html").handler(),
);
r.get("/numbers", |_c: Call| async {
    let chunks = futures_util::stream::iter(
        (1..=5).map(|i| Ok::<_, std::io::Error>(bytes::Bytes::from(format!("{i}\n")))),
    );
    Response::stream("text/plain", Body::from_stream(chunks))
});

StaticFiles detects the Content-Type from the file extension, serves an optional index file for directories, rejects path traversal (.., absolute paths, symlink escapes) with 404, and streams the file in chunks. See examples/static.

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

Core concepts

Handlers & extractors

A handler is an async closure/fn returning anything that implements IntoResponse. Arguments are extractors:

  • FromCallParts (borrow the request head) — any position: Path<T>, Query<T>, Header<T, N>, State<T>, BearerToken, Principal<P>.
  • FromCall (consume the body) — last argument only: Json<T>, Form<T>, Bytes, String, Payload, Multipart, Either<A, B>, Call.

The split is enforced by the compiler, not by convention: the body is a one-shot stream, so two body-consuming arguments do not compile. Every FromCallParts is also usable last.

Option<T> works for any extractor implementing OptionalFromCallParts (Query, Path, Header, BearerToken). Absent yields None; malformed is still an error — so a typo'd query string does not quietly become a default.

Call itself is the call-style base case (|call: Call| ...).

Pipeline & plugins

A Plugin registers Middleware into a Phase. Middleware own the Call, may mutate it, call next.run(call), and post-process the Response (the onion model). Phase order is fixed and deterministic regardless of install order.

Configuration

# churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000          # idle connections; 0 answers and closes
max_connections = 25000        # connections served at once; 0 is unlimited
max_tls_handshakes = 256       # much smaller: a handshake is asymmetric work
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000    # bounded drain, so exit is never held hostage
path_policy = "strict"         # strict | redirect | collapse

[tls]            # requires the `tls` feature
cert = "cert.pem"
key  = "key.pem"

Churust::from_config() loads churust.toml + CHURUST_* env vars; chained DSL setters override. Example: CHURUST_SERVER_PORT=9090.

Testing

use churust::TestClient;

let app = build_app();
let client = TestClient::new(app);
let res = client.get("/users/1").send().await;
assert_eq!(res.status(), StatusCode::OK);

Development

Requires Rust 1.96+ (the MSRV, and what CI pins).

git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo test --workspace

Warnings are errors in CI. Before opening a PR, run the full gate — the exact command list is in CONTRIBUTING.md, and it covers fmt, the clippy feature matrix, the test feature matrix, the examples, and a docs build.

Status

Published on crates.io — the badge above carries the current version. Pre-1.0, so the API is settling rather than settled: expect breaking changes in minor releases until 1.0.

Everything documented above works today:

  • Core — routing, hybrid extractors, the four plugins, named phases, layered config, typed state, timeouts, TLS, #[churust::main]
  • WebSockets — opt-in ws feature
  • Streaming bodies — the always-on Body type
  • Static filesStaticFiles, opt-in fs feature

Also working, each behind its own feature: response compression, rate limiting, templating, HTTP/3 over QUIC, Redis-backed sessions, a login/logout layer, an outbound HTTP client, OpenAPI generation, and a streaming multipart parser. Several of those were listed as deliberate non-goals until 2026-07-25; the production-readiness audit records what the original objection was and what the implementation does about it, because "we changed our mind" is more useful to a reader than a silently edited list.

Still not supported, on purpose. Actor integration. multipart/byteranges for multi-range requests, which RFC 9110 permits omitting. WebSockets over HTTP/3, which upgrade through Extended CONNECT (RFC 9220) rather than the HTTP/1.1 handshake the ws feature implements. Revocation from a client-side session store, which has no server-side record to withdraw — that is what churust-redis is for. And max_body_bytes still bounds every request: streaming changed what an upload costs in memory, not what it is allowed to weigh.

Those are tracked and ordered in docs/design/2026-07-25-roadmap-to-parity.md, which also says which are deliberate scope choices and which are simply not built yet. Want one sooner? Make the case in Discussions.

Design specs live in docs/design/ and implementation plans in docs/plans/. Those documents label work as v1 / v2.0 / v2.1 — those are internal build milestones, not published versions.

Contributing

Contributions are welcome — read CONTRIBUTING.md first. The short version: Churust keeps a narrow scope, tests come first, and anything optional is feature-gated so default builds never change.

Ask a question Discussions → Q&A
Propose a feature Discussions → Ideas
Report a bug Issues
Report a vulnerability SECURITY.md — privately, never a public issue
Get help SUPPORT.md
Cut a release (maintainers) RELEASING.md

Everyone taking part is held to the Code of Conduct.

Sponsor

Churust is built and maintained in spare time. If it saves you some, you can support the work through GitHub Sponsors.

Sponsorship funds maintenance — issue triage, security response, keeping up with tokio/hyper/rustls releases — not feature bounties. Features are decided on merit in Discussions, and that stays true regardless of who is sponsoring.

License

MIT — see LICENSE.

About

Rust Backend framework

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Sponsor this project

Packages

Contributors

Languages