The first time you open a new web framework, you should not have to relearn what a server is.
If you already know Ktor, you know the feeling I wanted from Churust: create an application, install the capabilities you need, describe your routes in one readable block, and get on with building the product. If you do not know Ktor, the same API is still small enough to learn in an afternoon.
That is Churust: an open-source Rust web framework designed to be simple, secure, and easy to learn.
It gives you Ktor’s developer experience on Rust’s async stack. The application engine runs on tokio, HTTP is handled by hyper, and optional TLS is provided by rustls. Churust does not try to replace those proven foundations. It gives them a cohesive, friendly API.
And yes, the name is exactly what it sounds like: churro plus Rust.
Key Takeaways
- Churust is shaped like Ktor: a server builder, a nested routing DSL,
install(plugin), and a fixed request pipeline.- A working server needs one dependency.
#[churust::main]uses Churust’s re-exported tokio runtime.- Typed extractors make handlers short and explicit, while the full
Callremains available whenever you want lower-level control.- Security is part of the default configuration: headers, body and connection limits, timeouts, strict path handling, safe cookie defaults, and panic isolation are already on.
- JSON, logging, CORS, authentication, compression, rate limiting, templates, sessions, Redis, WebSockets, static files, multipart, an HTTP client, OpenAPI, TLS, HTTP/2, and HTTP/3 are all part of the first-party feature set.
- Churust 0.3 is backed by a large regression suite, strict CI, weekly dependency checks, public design documents, a detailed changelog, and a private vulnerability-reporting process.
- The full user guide covers quick start, fundamentals, every plugin, and recipes. The guide is separate from the API docs on docs.rs.
Why I built Churust
I write a lot of Kotlin, and I write Rust where its safety and performance genuinely pay off. That combination has taken me from Rust-powered mobile libraries to servers that need to speak to them.
Every time I started one of those services, I found myself missing Ktor.
The part I missed was Ktor’s shape:
- Configuration reads like configuration.
- Routes live together and are easy to scan.
- Cross-cutting behaviour is installed as a plugin.
- Plugins run in predictable phases.
- Common server capabilities feel like one framework instead of a shopping list.
Rust already has an excellent networking ecosystem. What I wanted was a framework that made that ecosystem feel immediately familiar to a Kotlin developer, without hiding Rust’s type system or pretending async work is simpler than it is.
So I built Churust.
The goal is to remove the unnecessary learning curve around the server itself. You still get Rust’s ownership model, enums, traits, explicit errors, and compile-time guarantees. You just do not need to invent your application’s basic shape before writing its first route.
Your first Churust server
Start with one dependency:
[dependencies]
churust = "0.3"
Then write the server:
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
}
Run it and call both routes:
cargo run
curl http://localhost:8080/
curl http://localhost:8080/users/42
There is no separate tokio dependency in that project. Churust re-exports the runtime it uses, and #[churust::main] expands against that re-export. You can add tokio yourself if you need extra runtime features, but you do not need to solve a version-matching problem just to print “Hello.”
Make the first step obvious, then let the application grow without changing its mental model.
If Ktor feels familiar, Churust will too
Here is a small Ktor route:
fun Application.module() {
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respondText("user #$id")
}
}
}
And here is the Churust version:
Churust::server()
.routing(|r| {
r.get("/users/{id}", |Path(id): Path<u64>| async move {
format!("user #{id}")
});
})
The syntax is Rust. The route is inside routing, the path parameter belongs to the handler, and invalid input is rejected before the handler body runs.
Plugins follow the same idea:
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| {
// Your routes
})
If you have used install(ContentNegotiation) or install(Authentication) in Ktor, there is very little to translate. If you have not, the builder still reads from top to bottom: configure the server, install behaviour, declare routes, start.
A real JSON API
A framework earns its place when the application needs state, JSON, authentication, logging, CORS, compression, and rate limiting at the same time.
Enable the complete plugin set:
[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Then build the API:
use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Clone, Serialize)]
struct Note {
id: u64,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
#[derive(Clone)]
struct AdminUser {
name: String,
}
struct Store {
notes: Mutex<Vec<Note>>,
}
#[churust::main]
async fn main() -> std::io::Result<()> {
let admin_token =
std::env::var("ADMIN_TOKEN").expect("ADMIN_TOKEN must be set");
Churust::server()
.state(Store {
notes: Mutex::new(Vec::new()),
})
.install(CallLogging::new())
.install(ContentNegotiation::new())
.install(Cors::new().allow_origin("https://app.example.com"))
.install(Compression::new())
.install(RateLimit::per_minute(120).burst(20))
.install(Auth::bearer(move |token: String| {
let expected = admin_token.clone();
async move {
churust::secure_compare(
token.as_bytes(),
expected.as_bytes(),
)
.then(|| AdminUser {
name: "admin".into(),
})
}
}))
.routing(|r| {
r.get("/notes", |store: State<Store>| async move {
Json(store.notes.lock().unwrap().clone())
});
r.post(
"/notes",
|Principal(admin): Principal<AdminUser>,
store: State<Store>,
Json(input): Json<NewNote>| async move {
let mut notes = store.notes.lock().unwrap();
let note = Note {
id: notes.len() as u64 + 1,
text: input.text,
};
notes.push(note.clone());
println!("{} created note #{}", admin.name, note.id);
(StatusCode::CREATED, Json(note))
},
);
})
.start()
.await
}
There is a lot happening here, but very little framework ceremony:
.state(Store)registers typed application state.State<Store>retrieves it in a handler.Json<NewNote>validates and deserializes the request body.- Returning
Json(note)serializes the response. Principal<AdminUser>makes the route private. Without a valid principal, the handler never runs.secure_compareavoids a timing-sensitive comparison for a request-supplied secret.- Logging, CORS, compression, and rate limiting stay outside the business logic.
Security checks and operational behaviour should be difficult to forget and easy to find.
The complete feature set
Churust keeps its core lean through Cargo features, but the capabilities are first-party and versioned together. Default features are empty, so you only compile what the service uses.
| Feature | What it gives you |
|---|---|
| Core | Server engine, routing, typed extractors, call-style handlers, responses, errors, cookies, signed sessions, identity, typed state, layered configuration, security defaults, HTTP/1.1, HTTP/2, streaming bodies, graceful shutdown, and TestClient. |
json |
Json<T> requests and responses plus the ContentNegotiation plugin. |
logging |
CallLogging, request IDs, x-request-id, and W3C traceparent continuation. |
cors |
Restrictive CORS configuration, preflight handling, and response headers. |
auth |
Bearer, Basic, and JWT authentication plus Principal<P> authorization. |
ratelimit |
GCRA rate limiting with exact Retry-After, peer-based keys, custom keys, and route-scoped use. |
compression |
Brotli, gzip, and deflate response compression, including streamed responses. |
templates |
minijinja templates parsed at startup and HTML-escaped regardless of filename. |
full |
The seven installable plugins above: JSON, logging, CORS, auth, rate limiting, compression, and templates. |
redis |
Revocable server-side sessions through RedisStore. |
client |
A pooled outbound HTTP client with request timeouts, bounded response bodies, and controlled redirects. |
client-tls |
HTTPS support for the outbound client. |
openapi |
OpenAPI 3.1 generated from the router, with explicit operation descriptions and drift checks. |
ws |
WebSocket upgrades, messages, and bidirectional sockets. |
fs |
Secure static-file serving, conditional requests, byte ranges, and opt-in directory listings. |
multipart |
Buffered Multipart and incremental MultipartStream uploads. |
tls |
HTTPS through rustls. |
http3 |
HTTP/3 over QUIC on a separate listener, including Alt-Svc advertisement from the TCP server. |
tower in churust-core |
An adapter for using a tower::Service as Churust middleware. |
The core also handles the small pieces that otherwise spread across every service. HEAD and OPTIONS are automatic, and 405 Method Not Allowed uses the same sorted Allow value as OPTIONS. on_error gives the application one place to render its own 4xx and 5xx pages, including router failures. Custom application errors can implement IntoError, which lets ? work in a handler without exposing the error’s Display text to the client. The peer address is available for audit logs and trusted-proxy-aware policies.
All fourteen Churust crates release in lockstep with the same version. In normal application code, you depend on the umbrella churust crate and select features there. That keeps the framework, plugins, client, and integrations aligned in one lockfile.
For a feature-rich HTTPS service, the dependency can still be one line:
churust = {
version = "0.3",
features = [
"full",
"redis",
"client-tls",
"openapi",
"ws",
"fs",
"multipart",
"tls",
"http3",
],
}
Typed handlers without losing control
Churust supports two handler styles, and they work together.
The high-level style uses extractors:
r.get(
"/teams/{team_id}/members/{member_id}",
|Path((team_id, member_id)): Path<(u64, u64)>,
Query(filters): Query<MemberFilters>,
store: State<Store>| async move {
// team_id, member_id, filters, and store are already typed
},
);
Request-head extractors such as Path<T>, Query<T>, headers, state, bearer tokens, and principals can appear in any position. A body-consuming extractor such as Json<T>, Form<T>, String, Payload, Multipart, or the full Call must come last.
That rule exists because a request body is a one-shot stream, and Churust enforces it at compile time. Version 0.3 also adds custom compiler diagnostics so an invalid handler signature explains the rule instead of leaving you with an opaque trait error.
Optional input is explicit:
r.get("/search", |query: Option<Query<Filters>>| async move {
match query {
Some(Query(filters)) => format!("filtering by {}", filters.tag),
None => "showing everything".to_string(),
}
});
Absent input becomes None. Malformed input remains an error. A broken query does not quietly turn into an unfiltered request.
And when extractors are not the right tool, take the complete call:
r.post("/uppercase", |mut call: Call| async move {
let body = call.receive_text().await?;
Ok::<_, Error>(Response::text(body.to_uppercase()))
});
Plugins run in a predictable pipeline
Churust uses five named phases:
Setup → Monitoring → Plugins → Call → Fallback
A plugin registers its middleware in the phase where it belongs. Logging stays in Monitoring; CORS, authentication, JSON, compression, and similar behaviour live in Plugins.
Execution order follows the named phase. The model is deterministic, easy to explain, and familiar to Ktor developers.
Middleware can also be scoped to part of the route tree:
.routing(|r| {
r.get("/health", |_call: Call| async { "ok" });
r.route("/api", |api| {
api.intercept(RateLimit::per_minute(60));
api.get("/profile", |user: Principal<User>| async move {
Json(user.0)
});
});
})
Route guards can distinguish routes by host, header, or a custom predicate, and per-route body limits let an upload endpoint accept more than the rest of the application without raising the global ceiling.
Secure by default means real defaults
“Secure by default” means what the server does when you forget to configure something.
This is the default posture in Churust 0.3:
| Default | Value | Why it matters |
|---|---|---|
| Security headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer |
Reduces MIME sniffing, clickjacking, and referrer leakage. |
| Request body limit | 1 MiB | Prevents an unbounded body from becoming an easy memory-exhaustion path. |
| Request timeout | 30 seconds | Stops a request from holding resources forever. |
| Header-read timeout | 10 seconds | Bounds slow-loris clients, including peers that connect and send nothing. |
| Header count | 100 | Bounds hostile or accidental header growth. |
| Path depth | 64 segments | Prevents pathological router work. |
| Concurrent connections | 25,000 | Puts a deliberate ceiling on tasks and file descriptors. |
| TLS handshakes | 256 with a 10-second deadline | Separately limits the more expensive handshake path. |
| Idle keep-alive | 75 seconds | Closes connections that go quiet. |
| WebSocket limits | 1 MiB per frame, 4 MiB per message | Bounds fragmented-message reassembly. |
| Path handling | PathPolicy::Strict |
Refuses repeated-slash aliases such as //admin. |
| Cookies | HttpOnly, SameSite=Lax, Path=/ |
Safer behaviour without repeating the same flags everywhere. |
| Handler panic | Isolated as a 500 response |
One bad request does not crash the server. |
| Version banner | Not sent | Avoids unnecessary implementation disclosure. |
Those protections also apply to transport-generated errors such as 400, 408, and 413, not only responses that reached a route.
Churust also refuses ambiguous request framing. A request containing both Transfer-Encoding and Content-Length is rejected and its connection is closed instead of guessing which length is correct. Duplicate routes and missing static roots fail during startup. Static-file paths are checked against traversal and symlink escapes. Directory listings are off unless you enable them, and filenames are escaped when you do.
TLS is opt-in because a framework should never pretend plaintext is encrypted. Once you enable the tls feature and provide a certificate and key, rustls handles the connection and ALPN negotiates HTTP/2 where the client supports it.
Authentication and sessions that are hard to forget
Authentication inserts your principal into the request. Authorization happens when a handler asks for it:
r.get(
"/account",
|Principal(user): Principal<User>| async move {
Json(user)
},
);
If authentication did not produce a User, that handler does not run. The requirement is visible in the function signature instead of hidden inside the body.
For session-backed applications, signed cookie sessions are available in core:
let session_key =
std::env::var("SESSION_KEY").expect("SESSION_KEY must be set");
Churust::server()
.install(
Sessions::cookie(session_key)
.secure(true)
.max_age(60 * 60 * 24),
)
.install(Identities::new())
Cookie sessions use HMAC-SHA256 signatures and constant-time verification. They are signed, not encrypted, so they are a good fit for small, non-secret session state.
When you need server-side state and immediate revocation, switch the store to Redis:
let store = RedisStore::connect("redis://127.0.0.1/").await?;
let app = Churust::server()
.install(Sessions::with_store(store.ttl(3600)))
.install(Identities::new())
.build();
The browser keeps an opaque identifier while the session lives in Redis. Logout deletes the server-side record, and a failed revocation is reported as an error instead of returning a successful logout while the session remains valid.
The identity layer includes login, logout, session rotation, an absolute login deadline, and an idle-visit deadline.
Streaming, files, WebSockets, and modern HTTP
Churust supports buffered bodies when they are convenient and streaming bodies when size matters.
Payload reads a request incrementally. MultipartStream does the same for file uploads, so memory usage does not grow with the entire upload. Streaming does not bypass the body limit; raise the limit deliberately on the upload route.
Response bodies can also stay streamed, including when compression is enabled. Static files support:
ETagandLast-Modified- Conditional requests and
304 Not Modified - Byte ranges with
206 Partial Contentand correct416responses - Optional index files
- Opt-in, escaped directory listings
- Protection against
.., absolute paths, encoded separators, and symlink escapes
WebSockets use a typed upgrade extractor:
use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};
r.get("/echo", |ws: WebSocketUpgrade| async move {
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if matches!(message, Message::Close)
|| socket.send(message).await.is_err()
{
break;
}
}
})
});
The normal TCP engine supports HTTP/1.1 and HTTP/2. HTTP/3 is available behind http3 and runs over QUIC on its own UDP listener. The same App can serve both transports, and advertise_http3 adds the Alt-Svc header that tells compatible clients where to find it.
Configuration that grows with the service
Small applications can configure everything in Rust. Deployed services can layer a file and environment variables underneath the same builder.
# 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"
[tls]
cert = "cert.pem"
key = "key.pem"
Load it with Churust::from_config(). Environment variables such as CHURUST_SERVER_PORT=9090 override the file, and values set in the Rust builder override both.
The order is:
secure defaults < churust.toml < CHURUST_* environment < Rust builder
That works well locally, in containers, and in environments where deployment configuration should stay outside the binary.
Churust can bind several addresses, use Unix domain sockets, limit concurrent connections and TLS handshakes, tune HTTP/2 streams and headers, and perform a bounded graceful shutdown that drains in-flight work.
The first-party client and OpenAPI support
A server usually calls another server. With the client-tls feature, the Churust client uses pooled connections and supports HTTP and HTTPS:
use churust::client::Client;
let client = Client::new()
.max_response_bytes(2 * 1024 * 1024)
.max_redirects(5);
let response = client
.get("https://api.example.com/health")
.send()
.await?;
println!("{}", response.text()?);
The request timeout covers the entire redirect chain, response bodies are bounded, credentials are stripped on cross-origin redirects, and an HTTPS request cannot be silently redirected down to HTTP.
OpenAPI support starts from the router because the router already knows which paths and methods exist:
use churust::openapi::{OpenApi, Operation};
let builder = Churust::server().routing(|r| {
r.get("/users/{id}", |_call: Call| async { "a user" });
});
let api = OpenApi::new("Users API", "1.0.0")
.from_routes(builder.routes())
.operation(
Method::GET,
"/users/{id}",
Operation::new()
.summary("Fetch one user")
.response(200, "The user")
.response(404, "No such user"),
);
let app = builder
.routing(|r| api.mount(r, "/openapi.json"))
.build();
Paths and parameters come from the real router. Descriptions and schemas remain explicit, and drift checks can fail a test when the routes and the document stop agreeing.
Testing is part of the developer experience
You do not need to bind a port to test an application. TestClient runs requests through the complete pipeline in process:
let app = Churust::server()
.routing(|r| {
r.get("/ping", |_call: Call| async { "pong" });
})
.build();
let client = TestClient::new(app);
let response = client.get("/ping").send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text(), "pong");
That makes route, extractor, plugin, error-page, and security behaviour fast to test without port conflicts or startup sleeps.
Version 0.3 grew the all-features workspace from 219 listed tests in 0.2 to 754 in 0.3. Those tests cover the failures that only appear under load or at protocol boundaries: graceful shutdown, idle keep-alive, path normalization, request framing, conditional file requests, compression metadata, Redis revocation, redirects, and more.
What well maintained means for Churust
Maintenance is the process around the code.
Every push and pull request runs:
- Formatting checks
- Clippy with warnings treated as errors
- Default-feature and all-feature matrices
- The full workspace tests
- Plugin-specific tests
- Example builds
- Documentation builds with warnings denied
- A check that every crate is present in the release list
CI pins Rust 1.96, the documented minimum supported Rust version. A separate workflow runs every Monday against the newest semver-compatible dependency versions, so upstream changes are discovered deliberately instead of waiting for the next manual lockfile update.
The changelog explains behaviour changes and the reason behind them. Design documents record the trade-offs. All fourteen crates are released together. Security reports have a private disclosure process with published response targets.
Churust is pre-1.0, so minor releases may include API changes while the public surface settles. That is not an excuse for surprise: changes are documented, tested, and released across the whole workspace together.
Getting started
The step-by-step guide lives at davthecoder.github.io/Churust: quick start, extractors, plugins, recipes, and reference. Use docs.rs/churust when you need the full type API.
For the smallest server:
[dependencies]
churust = "0.3"
For the complete plugin set plus WebSockets, static files, and TLS:
[dependencies]
churust = {
version = "0.3",
features = ["full", "ws", "fs", "tls"],
}
The repository includes four runnable examples:
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo run -p hello
cargo run -p api
cargo run -p chat
cargo run -p static-example
Start with hello, move to api to see plugins and typed authentication, use chat for WebSockets, and open static-example for file serving and streamed responses.
The main links are:
- User guide (GitHub Pages)
- Churust on GitHub
- Churust on crates.io
- API documentation on docs.rs
- Portfolio case study
- Questions and ideas in GitHub Discussions
- Bug reports in GitHub Issues
Frequently Asked Questions
Is Churust secure?
Churust is designed with a secure default posture and documents the exact limits it enables. It builds on hyper, tokio, and rustls for the protocol, runtime, and TLS layers, then adds framework-level limits and checks around them.
No framework can make an application automatically secure. You still need proper secret storage, authorization rules, dependency updates, TLS configuration, and application-specific validation. Churust makes the common safe choices the starting point instead of an optional hardening guide.
Why are features opt-in?
Because an API server does not need templates, a static site does not need JWT, and an internal service may not need WebSockets or HTTP/3. Opt-in features keep compile times, dependencies, and protocol surface aligned with the application you are actually building.
Install churust = "0.3", follow the user guide, build one route, and tell me where the experience can become even better. The framework is open source, actively maintained, and ready for developers who want Rust on the server without giving up a clear, approachable API.
Happy coding!
David Cruz
davthecoder.com
La primera vez que abres un framework web nuevo, no deberías tener que volver a aprender qué es un servidor.
Si ya conoces Ktor, conoces la sensación que quería de Churust: crear una aplicación, instalar las capacidades que necesitas, describir tus rutas en un bloque legible y seguir construyendo el producto. Si no conoces Ktor, la misma API sigue siendo lo bastante pequeña como para aprenderla en una tarde.
Eso es Churust: un framework web de código abierto para Rust, diseñado para ser sencillo, seguro y fácil de aprender.
Te da la experiencia de desarrollo de Ktor sobre la pila asíncrona de Rust. El motor de la aplicación corre sobre tokio, HTTP lo gestiona hyper y el TLS opcional lo aporta rustls. Churust no intenta sustituir esos cimientos ya probados. Les da una API coherente y cercana.
Y sí, el nombre es exactamente lo que parece: churro más Rust.
Ideas clave
- Churust está formado como Ktor: un constructor de servidor, un DSL de rutas anidadas,
install(plugin)y un pipeline de petición fijo.- Un servidor que funciona necesita una sola dependencia.
#[churust::main]usa el runtime de tokio reexportado por Churust.- Los extractores tipados hacen los manejadores cortos y explícitos, mientras el
Callcompleto sigue disponible siempre que quieras un control de más bajo nivel.- La seguridad forma parte de la configuración por defecto: cabeceras, límites de cuerpo y de conexión, tiempos de espera, tratamiento estricto de rutas, valores seguros por defecto en las cookies y aislamiento de los panic ya están activos.
- JSON, registro, CORS, autenticación, compresión, limitación de tasa, plantillas, sesiones, Redis, WebSockets, ficheros estáticos, multipart, un cliente HTTP, OpenAPI, TLS, HTTP/2 y HTTP/3 forman parte del conjunto de funciones de primera mano.
- Churust 0.3 se apoya en una suite de regresión grande, una CI estricta, comprobaciones semanales de dependencias, documentos de diseño públicos, un registro de cambios detallado y un proceso privado de notificación de vulnerabilidades.
- La guía de uso completa cubre el arranque rápido, los fundamentos, cada plugin y recetas, aparte de la documentación de la API en docs.rs.
Por qué construí Churust
Escribo mucho Kotlin, y escribo Rust allí donde su seguridad y su rendimiento compensan de verdad. Esa combinación me ha llevado de bibliotecas móviles impulsadas por Rust a servidores que tienen que hablar con ellas.
Cada vez que arrancaba uno de esos servicios, echaba de menos Ktor.
La parte que echaba de menos era la forma de Ktor:
- La configuración se lee como configuración.
- Las rutas viven juntas y son fáciles de recorrer con la vista.
- El comportamiento transversal se instala como un plugin.
- Los plugins corren en fases previsibles.
- Las capacidades habituales de un servidor se sienten como un solo framework, no como una lista de la compra.
Rust ya tiene un ecosistema de red excelente. Lo que yo quería era un framework que hiciera que ese ecosistema resultara familiar de inmediato a quien desarrolla en Kotlin, sin esconder el sistema de tipos de Rust ni fingir que el trabajo asíncrono es más simple de lo que es.
Así que construí Churust.
El objetivo es quitar la curva de aprendizaje innecesaria que rodea al servidor en sí. Sigues teniendo el modelo de propiedad de Rust, los enums, los traits, los errores explícitos y las garantías en tiempo de compilación. Lo que no necesitas es inventar la forma básica de tu aplicación antes de escribir su primera ruta.
Tu primer servidor con Churust
Empieza con una sola dependencia:
[dependencies]
churust = "0.3"
Luego escribe el servidor:
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
}
Ejecútalo y llama a las dos rutas:
cargo run
curl http://localhost:8080/
curl http://localhost:8080/users/42
En ese proyecto no hay una dependencia aparte de tokio. Churust reexporta el runtime que usa, y #[churust::main] se expande contra esa reexportación. Puedes añadir tokio tú mismo si necesitas funciones extra del runtime, pero no tienes que resolver un problema de coincidencia de versiones solo para imprimir «Hello».
Haz obvio el primer paso y luego deja que la aplicación crezca sin cambiar su modelo mental.
Si Ktor te resulta familiar, Churust también
Aquí va una ruta pequeña de Ktor:
fun Application.module() {
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
call.respondText("user #$id")
}
}
}
Y aquí va la versión de Churust:
Churust::server()
.routing(|r| {
r.get("/users/{id}", |Path(id): Path<u64>| async move {
format!("user #{id}")
});
})
La sintaxis es Rust. La ruta está dentro de routing, el parámetro de ruta pertenece al manejador, y la entrada no válida se rechaza antes de que se ejecute el cuerpo del manejador.
Los plugins siguen la misma idea:
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| {
// Your routes
})
Si has usado install(ContentNegotiation) o install(Authentication) en Ktor, hay muy poco que traducir. Si no, el constructor se sigue leyendo de arriba abajo: configura el servidor, instala el comportamiento, declara las rutas, arranca.
Una API JSON de verdad
Un framework se gana su sitio cuando la aplicación necesita estado, JSON, autenticación, registro, CORS, compresión y limitación de tasa al mismo tiempo.
Activa el conjunto completo de plugins:
[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Luego construye la API:
use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Clone, Serialize)]
struct Note {
id: u64,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
#[derive(Clone)]
struct AdminUser {
name: String,
}
struct Store {
notes: Mutex<Vec<Note>>,
}
#[churust::main]
async fn main() -> std::io::Result<()> {
let admin_token =
std::env::var("ADMIN_TOKEN").expect("ADMIN_TOKEN must be set");
Churust::server()
.state(Store {
notes: Mutex::new(Vec::new()),
})
.install(CallLogging::new())
.install(ContentNegotiation::new())
.install(Cors::new().allow_origin("https://app.example.com"))
.install(Compression::new())
.install(RateLimit::per_minute(120).burst(20))
.install(Auth::bearer(move |token: String| {
let expected = admin_token.clone();
async move {
churust::secure_compare(
token.as_bytes(),
expected.as_bytes(),
)
.then(|| AdminUser {
name: "admin".into(),
})
}
}))
.routing(|r| {
r.get("/notes", |store: State<Store>| async move {
Json(store.notes.lock().unwrap().clone())
});
r.post(
"/notes",
|Principal(admin): Principal<AdminUser>,
store: State<Store>,
Json(input): Json<NewNote>| async move {
let mut notes = store.notes.lock().unwrap();
let note = Note {
id: notes.len() as u64 + 1,
text: input.text,
};
notes.push(note.clone());
println!("{} created note #{}", admin.name, note.id);
(StatusCode::CREATED, Json(note))
},
);
})
.start()
.await
}
Aquí pasa mucho, pero hay muy poca ceremonia de framework:
.state(Store)registra estado tipado de la aplicación.State<Store>lo recupera en un manejador.Json<NewNote>valida y deserializa el cuerpo de la petición.- Devolver
Json(note)serializa la respuesta. Principal<AdminUser>hace privada la ruta. Sin un principal válido, el manejador no se ejecuta nunca.secure_compareevita una comparación sensible al tiempo para un secreto aportado por la petición.- El registro, CORS, la compresión y la limitación de tasa se quedan fuera de la lógica de negocio.
Las comprobaciones de seguridad y el comportamiento operativo deberían ser difíciles de olvidar y fáciles de encontrar.
El conjunto completo de funciones
Churust mantiene el núcleo delgado mediante features de Cargo, pero las capacidades son de primera mano y se versionan juntas. Las features por defecto están vacías, así que solo compilas lo que el servicio usa.
| Feature | Qué te aporta |
|---|---|
| Core | Motor del servidor, enrutado, extractores tipados, manejadores al estilo de call, respuestas, errores, cookies, sesiones firmadas, identidad, estado tipado, configuración por capas, valores seguros por defecto, HTTP/1.1, HTTP/2, cuerpos en streaming, cierre ordenado y TestClient. |
json |
Peticiones y respuestas Json<T>, más el plugin ContentNegotiation. |
logging |
CallLogging, identificadores de petición, x-request-id y continuación de traceparent de W3C. |
cors |
Configuración restrictiva de CORS, gestión del preflight y cabeceras de respuesta. |
auth |
Autenticación Bearer, Basic y JWT, más autorización con Principal<P>. |
ratelimit |
Limitación de tasa GCRA, con Retry-After exacto, claves basadas en el par, claves personalizadas y uso acotado a la ruta. |
compression |
Compresión de respuesta Brotli, gzip y deflate, incluidas las respuestas en streaming. |
templates |
Plantillas de minijinja analizadas al arrancar y escapadas como HTML, sea cual sea el nombre del fichero. |
full |
Los siete plugins instalables de arriba: JSON, registro, CORS, autenticación, limitación de tasa, compresión y plantillas. |
redis |
Sesiones revocables en el lado del servidor, mediante RedisStore. |
client |
Un cliente HTTP de salida con pool, tiempos de espera de petición, cuerpos de respuesta acotados y redirecciones controladas. |
client-tls |
Soporte de HTTPS para el cliente de salida. |
openapi |
OpenAPI 3.1 generado desde el enrutador, con descripciones explícitas de operación y comprobaciones de deriva. |
ws |
Actualizaciones a WebSocket, mensajes y sockets bidireccionales. |
fs |
Servicio seguro de ficheros estáticos, peticiones condicionales, rangos de bytes y listados de directorio opcionales. |
multipart |
Subidas con Multipart en búfer y con MultipartStream incremental. |
tls |
HTTPS mediante rustls. |
http3 |
HTTP/3 sobre QUIC en un listener aparte, incluido el anuncio de Alt-Svc desde el servidor TCP. |
tower en churust-core |
Un adaptador para usar un tower::Service como middleware de Churust. |
El núcleo también se encarga de las piezas pequeñas que, si no, se esparcen por cada servicio. HEAD y OPTIONS son automáticos, y 405 Method Not Allowed usa el mismo valor Allow ordenado que OPTIONS. on_error da a la aplicación un único sitio para pintar sus propias páginas 4xx y 5xx, incluidos los fallos del enrutador. Los errores propios de la aplicación pueden implementar IntoError, lo que permite que ? funcione en un manejador sin exponer al cliente el texto Display del error. La dirección del par está disponible para registros de auditoría y para políticas que tienen en cuenta el proxy de confianza.
Las catorce crates de Churust se publican a la par, con la misma versión. En el código normal de una aplicación, dependes de la crate paraguas churust y seleccionas ahí las features. Eso mantiene alineados, en un solo lockfile, el framework, los plugins, el cliente y las integraciones.
Para un servicio HTTPS rico en funciones, la dependencia puede seguir siendo una sola línea:
churust = {
version = "0.3",
features = [
"full",
"redis",
"client-tls",
"openapi",
"ws",
"fs",
"multipart",
"tls",
"http3",
],
}
Manejadores tipados sin perder el control
Churust admite dos estilos de manejador, y funcionan juntos.
El estilo de alto nivel usa extractores:
r.get(
"/teams/{team_id}/members/{member_id}",
|Path((team_id, member_id)): Path<(u64, u64)>,
Query(filters): Query<MemberFilters>,
store: State<Store>| async move {
// team_id, member_id, filters, and store are already typed
},
);
Los extractores de la cabecera de la petición, como Path<T>, Query<T>, cabeceras, estado, tokens bearer y principals, pueden aparecer en cualquier posición. Un extractor que consume el cuerpo, como Json<T>, Form<T>, String, Payload, Multipart o el Call completo, tiene que ir el último.
Esa regla existe porque el cuerpo de una petición es un stream de un solo uso, y Churust lo impone en tiempo de compilación. La versión 0.3 añade además diagnósticos propios del compilador, de modo que una firma de manejador no válida explica la regla, en vez de dejarte con un error de trait opaco.
La entrada opcional es explícita:
r.get("/search", |query: Option<Query<Filters>>| async move {
match query {
Some(Query(filters)) => format!("filtering by {}", filters.tag),
None => "showing everything".to_string(),
}
});
La entrada ausente se convierte en None. La entrada mal formada sigue siendo un error. Una query rota no se convierte, en silencio, en una petición sin filtros.
Y, cuando los extractores no son la herramienta adecuada, toma la llamada completa:
r.post("/uppercase", |mut call: Call| async move {
let body = call.receive_text().await?;
Ok::<_, Error>(Response::text(body.to_uppercase()))
});
Los plugins corren en un pipeline previsible
Churust usa cinco fases con nombre:
Setup → Monitoring → Plugins → Call → Fallback
Un plugin registra su middleware en la fase a la que pertenece. El registro se queda en Monitoring; CORS, la autenticación, JSON, la compresión y el comportamiento similar viven en Plugins.
El orden de ejecución sigue la fase con nombre. El modelo es determinista, fácil de explicar y familiar para quien desarrolla con Ktor.
El middleware también se puede acotar a una parte del árbol de rutas:
.routing(|r| {
r.get("/health", |_call: Call| async { "ok" });
r.route("/api", |api| {
api.intercept(RateLimit::per_minute(60));
api.get("/profile", |user: Principal<User>| async move {
Json(user.0)
});
});
})
Los guardas de ruta pueden distinguir rutas por host, por cabecera o por un predicado propio, y los límites de cuerpo por ruta dejan que un endpoint de subida acepte más que el resto de la aplicación, sin subir el techo global.
Seguro por defecto significa valores por defecto de verdad
«Seguro por defecto» es lo que hace el servidor cuando se te olvida configurar algo.
Esta es la postura por defecto en Churust 0.3:
| Valor por defecto | Valor | Por qué importa |
|---|---|---|
| Cabeceras de seguridad | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer |
Reduce el rastreo de tipos MIME, el secuestro de clics y la filtración del referrer. |
| Límite del cuerpo de la petición | 1 MiB | Impide que un cuerpo sin cota se convierta en un camino fácil de agotar la memoria. |
| Tiempo de espera de la petición | 30 segundos | Impide que una petición retenga recursos para siempre. |
| Tiempo de espera de lectura de cabeceras | 10 segundos | Acota a los clientes slow-loris, incluidos los pares que se conectan y no envían nada. |
| Número de cabeceras | 100 | Acota el crecimiento hostil o accidental de cabeceras. |
| Profundidad de la ruta | 64 segmentos | Impide trabajo patológico del enrutador. |
| Conexiones concurrentes | 25.000 | Pone un techo deliberado a las tareas y a los descriptores de fichero. |
| Handshakes de TLS | 256, con un plazo de 10 segundos | Limita aparte el camino del handshake, que es más caro. |
| Keep-alive en reposo | 75 segundos | Cierra las conexiones que se quedan en silencio. |
| Límites de WebSocket | 1 MiB por trama, 4 MiB por mensaje | Acota el reensamblado de mensajes fragmentados. |
| Tratamiento de la ruta | PathPolicy::Strict |
Rechaza alias de barras repetidas, como //admin. |
| Cookies | HttpOnly, SameSite=Lax, Path=/ |
Un comportamiento más seguro, sin repetir las mismas marcas en todas partes. |
| Panic de un manejador | Aislado como respuesta 500 |
Una petición mala no tumba el servidor. |
| Cartel de versión | No se envía | Evita revelar la implementación sin necesidad. |
Esas protecciones también se aplican a los errores generados por el transporte, como 400, 408 y 413, no solo a las respuestas que llegaron a una ruta.
Churust también rechaza el encuadre ambiguo de la petición. Una petición que contiene a la vez Transfer-Encoding y Content-Length se rechaza, y su conexión se cierra, en vez de adivinar qué longitud es la correcta. Las rutas duplicadas y las raíces estáticas que faltan fallan durante el arranque. Las rutas de ficheros estáticos se comprueban contra el atravesado de directorios y los escapes por enlace simbólico. Los listados de directorio están apagados salvo que los actives, y los nombres de fichero se escapan cuando lo haces.
TLS es optativo porque un framework no debería fingir nunca que el texto plano está cifrado. Cuando activas la feature tls y aportas un certificado y una clave, rustls gestiona la conexión y ALPN negocia HTTP/2 allí donde el cliente lo soporta.
Autenticación y sesiones difíciles de olvidar
La autenticación inserta tu principal en la petición. La autorización ocurre cuando un manejador la pide:
r.get(
"/account",
|Principal(user): Principal<User>| async move {
Json(user)
},
);
Si la autenticación no produjo un User, ese manejador no se ejecuta. El requisito está a la vista en la firma de la función, en vez de escondido dentro del cuerpo.
Para las aplicaciones respaldadas por sesión, las sesiones en cookie firmada están disponibles en el núcleo:
let session_key =
std::env::var("SESSION_KEY").expect("SESSION_KEY must be set");
Churust::server()
.install(
Sessions::cookie(session_key)
.secure(true)
.max_age(60 * 60 * 24),
)
.install(Identities::new())
Las sesiones en cookie usan firmas HMAC-SHA256 y verificación en tiempo constante. Están firmadas, no cifradas, así que encajan bien con un estado de sesión pequeño y no secreto.
Cuando necesitas estado en el lado del servidor y revocación inmediata, cambia el almacén a Redis:
let store = RedisStore::connect("redis://127.0.0.1/").await?;
let app = Churust::server()
.install(Sessions::with_store(store.ttl(3600)))
.install(Identities::new())
.build();
El navegador guarda un identificador opaco, mientras la sesión vive en Redis. Cerrar la sesión borra el registro del lado del servidor, y una revocación fallida se informa como un error, en vez de devolver un cierre de sesión con éxito mientras la sesión sigue siendo válida.
La capa de identidad incluye inicio de sesión, cierre de sesión, rotación de sesión, un plazo absoluto de login y un plazo de visita en reposo.
Streaming, ficheros, WebSockets y HTTP moderno
Churust admite cuerpos en búfer cuando convienen, y cuerpos en streaming cuando el tamaño importa.
Payload lee una petición de forma incremental. MultipartStream hace lo mismo con las subidas de ficheros, de modo que el uso de memoria no crece con la subida entera. El streaming no se salta el límite del cuerpo: sube el límite de forma deliberada en la ruta de subida.
Los cuerpos de respuesta también pueden quedarse en streaming, incluso cuando la compresión está activada. Los ficheros estáticos admiten:
ETagyLast-Modified- Peticiones condicionales y
304 Not Modified - Rangos de bytes, con
206 Partial Contenty respuestas416correctas - Ficheros índice opcionales
- Listados de directorio opcionales y escapados
- Protección contra
.., rutas absolutas, separadores codificados y escapes por enlace simbólico
Los WebSockets usan un extractor de actualización tipado:
use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};
r.get("/echo", |ws: WebSocketUpgrade| async move {
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if matches!(message, Message::Close)
|| socket.send(message).await.is_err()
{
break;
}
}
})
});
El motor TCP normal admite HTTP/1.1 y HTTP/2. HTTP/3 está disponible detrás de http3 y corre sobre QUIC en su propio listener UDP. La misma App puede servir los dos transportes, y advertise_http3 añade la cabecera Alt-Svc que indica a los clientes compatibles dónde encontrarlo.
Configuración que crece con el servicio
Las aplicaciones pequeñas pueden configurarlo todo en Rust. Los servicios desplegados pueden poner por debajo del mismo constructor un fichero y variables de entorno.
# 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"
[tls]
cert = "cert.pem"
key = "key.pem"
Cárgalo con Churust::from_config(). Las variables de entorno, como CHURUST_SERVER_PORT=9090, pisan el fichero, y los valores puestos en el constructor de Rust pisan a los dos.
El orden es:
secure defaults < churust.toml < CHURUST_* environment < Rust builder
Eso funciona bien en local, en contenedores y en entornos donde la configuración del despliegue debería quedarse fuera del binario.
Churust puede enlazar varias direcciones, usar sockets de dominio Unix, limitar las conexiones concurrentes y los handshakes de TLS, afinar los streams y las cabeceras de HTTP/2, y hacer un cierre ordenado y acotado que drena el trabajo en curso.
El cliente de primera mano y el soporte de OpenAPI
Un servidor suele llamar a otro servidor. Con la feature client-tls, el cliente de Churust usa conexiones con pool y admite HTTP y HTTPS:
use churust::client::Client;
let client = Client::new()
.max_response_bytes(2 * 1024 * 1024)
.max_redirects(5);
let response = client
.get("https://api.example.com/health")
.send()
.await?;
println!("{}", response.text()?);
El tiempo de espera de la petición cubre toda la cadena de redirecciones, los cuerpos de respuesta están acotados, las credenciales se retiran en las redirecciones entre orígenes, y una petición HTTPS no puede redirigirse en silencio hacia HTTP.
El soporte de OpenAPI arranca desde el enrutador, porque el enrutador ya sabe qué rutas y qué métodos existen:
use churust::openapi::{OpenApi, Operation};
let builder = Churust::server().routing(|r| {
r.get("/users/{id}", |_call: Call| async { "a user" });
});
let api = OpenApi::new("Users API", "1.0.0")
.from_routes(builder.routes())
.operation(
Method::GET,
"/users/{id}",
Operation::new()
.summary("Fetch one user")
.response(200, "The user")
.response(404, "No such user"),
);
let app = builder
.routing(|r| api.mount(r, "/openapi.json"))
.build();
Las rutas y los parámetros salen del enrutador real. Las descripciones y los esquemas siguen siendo explícitos, y las comprobaciones de deriva pueden hacer fallar un test cuando las rutas y el documento dejan de coincidir.
Probar forma parte de la experiencia de desarrollo
No necesitas enlazar un puerto para probar una aplicación. TestClient hace pasar las peticiones por el pipeline completo, dentro del proceso:
let app = Churust::server()
.routing(|r| {
r.get("/ping", |_call: Call| async { "pong" });
})
.build();
let client = TestClient::new(app);
let response = client.get("/ping").send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text(), "pong");
Eso hace que el comportamiento de rutas, extractores, plugins, páginas de error y seguridad se pruebe rápido, sin conflictos de puerto ni esperas de arranque.
La versión 0.3 hizo crecer el workspace de todas las features, de 219 tests listados en la 0.2 a 754 en la 0.3. Esos tests cubren los fallos que solo aparecen bajo carga o en los límites del protocolo: cierre ordenado, keep-alive en reposo, normalización de rutas, encuadre de la petición, peticiones condicionales de ficheros, metadatos de compresión, revocación en Redis, redirecciones y más.
Qué significa bien mantenido para Churust
El mantenimiento es el proceso que rodea al código.
Cada push y cada pull request ejecuta:
- Comprobaciones de formato
- Clippy, con los avisos tratados como errores
- Matrices de features por defecto y de todas las features
- Los tests completos del workspace
- Tests específicos de plugins
- Compilaciones de los ejemplos
- Compilaciones de la documentación, con los avisos denegados
- Una comprobación de que cada crate está presente en la lista de publicación
La CI fija Rust 1.96, la versión mínima de Rust soportada y documentada. Un flujo aparte corre cada lunes contra las versiones de dependencia compatibles con semver más nuevas, de modo que los cambios de aguas arriba se descubren a propósito, en vez de esperar a la siguiente actualización manual del lockfile.
El registro de cambios explica los cambios de comportamiento y el motivo que hay detrás. Los documentos de diseño registran las compensaciones. Las catorce crates se publican juntas. Los informes de seguridad tienen un proceso de divulgación privada, con objetivos de respuesta publicados.
Churust es anterior a la 1.0, así que las versiones menores pueden incluir cambios de API mientras la superficie pública se asienta. Eso no es una excusa para la sorpresa: los cambios se documentan, se prueban y se publican juntos en todo el workspace.
Primeros pasos
La guía paso a paso vive en davthecoder.github.io/Churust: arranque rápido, extractores, plugins, recetas y referencia. Usa docs.rs/churust cuando necesites la API de tipos completa.
Para el servidor más pequeño:
[dependencies]
churust = "0.3"
Para el conjunto completo de plugins, más WebSockets, ficheros estáticos y TLS:
[dependencies]
churust = {
version = "0.3",
features = ["full", "ws", "fs", "tls"],
}
El repositorio incluye cuatro ejemplos que se pueden ejecutar:
git clone https://github.com/davthecoder/Churust.git
cd Churust
cargo run -p hello
cargo run -p api
cargo run -p chat
cargo run -p static-example
Empieza por hello, pasa a api para ver plugins y autenticación tipada, usa chat para WebSockets y abre static-example para el servicio de ficheros y las respuestas en streaming.
Los enlaces principales son:
- Guía de uso (GitHub Pages)
- Churust en GitHub
- Churust en crates.io
- Documentación de la API en docs.rs
- Caso de estudio del portfolio
- Preguntas e ideas en GitHub Discussions
- Informes de fallos en GitHub Issues
Preguntas frecuentes
¿Churust es seguro?
Churust está diseñado con una postura segura por defecto, y documenta los límites exactos que activa. Se apoya en hyper, tokio y rustls para las capas de protocolo, runtime y TLS, y luego añade alrededor límites y comprobaciones a nivel de framework.
Ningún framework puede hacer que una aplicación sea segura de forma automática. Sigues necesitando un almacenamiento adecuado de secretos, reglas de autorización, actualizaciones de dependencias, configuración de TLS y validación específica de la aplicación. Churust hace que las elecciones seguras habituales sean el punto de partida, en vez de una guía opcional de endurecimiento.
¿Por qué las features son optativas?
Porque un servidor de API no necesita plantillas, un sitio estático no necesita JWT y un servicio interno puede no necesitar WebSockets ni HTTP/3. Las features optativas mantienen los tiempos de compilación, las dependencias y la superficie de protocolo alineados con la aplicación que de verdad estás construyendo.
Instala churust = "0.3", sigue la guía de uso, construye una ruta y dime dónde la experiencia puede ser todavía mejor. El framework es de código abierto, se mantiene de forma activa y está listo para quien quiera Rust en el servidor sin renunciar a una API clara y cercana.
¡A programar!
David Cruz
davthecoder.com
Loading comments…Cargando comentarios…