Skip to content
Rust & NativeRust y nativo

Introducing Churust: A Secure, Ktor-Style Web Framework for RustPresentamos Churust: un framework web para Rust seguro y al estilo de Ktor

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 28 July 2026Actualizado el 28 de julio de 202614 min read14 min de lectura
RustKtorWeb FrameworkBackendtokiohyperOpen SourceHTTP
Introducing Churust: A Secure, Ktor-Style Web Framework for Rust
Share:Compartir:

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 Call remains 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_compare avoids 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:

  • ETag and Last-Modified
  • Conditional requests and 304 Not Modified
  • Byte ranges with 206 Partial Content and correct 416 responses
  • 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:

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 Call completo 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_compare evita 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:

  • ETag y Last-Modified
  • Peticiones condicionales y 304 Not Modified
  • Rangos de bytes, con 206 Partial Content y respuestas 416 correctas
  • 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:

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

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…