From Ktor to Churust

Churust deliberately reuses Ktor’s mental model so you can keep the same shape of application while writing Rust on tokio + hyper + rustls.

You think in Ktor…In Churust…
Application / embeddedServerChurust::server() builder
routing { … }.routing(|r| { … })
get / post / …r.get / r.post / …
install(…).install(…)
Application plugins + interceptorsPlugins into fixed phases
callCall or typed extractors
call.parameters["id"]Path(id): Path<u64>
call.receive<T>()Json(t): Json<T> (feature json)
Auth principalPrincipal<P> (feature auth)
Application attributes / DI.state(T) + State<T>
application.conf + envchurust.toml + CHURUST_* + DSL
testApplication { … }TestClient (in-process)

The syntax is Rust. The structure of a service — build, install, route, start — should already be familiar.


1. A minimal route

Ktor (Kotlin)

fun Application.module() {
    routing {
        get("/") {
            call.respondText("Hello from Ktor")
        }
    }
}

Churust (Rust)

use churust::prelude::*;

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .routing(|r| {
            r.get("/", |_call: Call| async { "Hello from Churust 🌀" });
        })
        .start()
        .await
}

Same idea: declare routes inside a routing block.
Rust difference: an explicit async main, and #[churust::main] (like #[tokio::main]) bootstraps the runtime Churust re-exports.


2. Path parameters

Ktor

routing {
    get("/users/{id}") {
        val id = call.parameters["id"]?.toLongOrNull()
            ?: return@get call.respond(HttpStatusCode.BadRequest)

        call.respondText("user #$id")
    }
}

Churust

#![allow(unused)]
fn main() {
r.get("/users/{id}", |Path(id): Path<u64>| async move {
    format!("user #{id}")
});
}
KtorChurust
Read call.parameters["id"] as a stringPath<u64> parses and types the segment
Manual toLongOrNull + 400Malformed path → error before the handler body
Optional/nullable handling by handUse Option<Path<…>> / optional extractors where supported

You still can take Call and dig into parts yourself; extractors are the short path for the common case.


3. Install plugins

Ktor

fun Application.module() {
    install(CallLogging)
    install(ContentNegotiation) {
        json()
    }
    install(CORS) {
        allowHost("app.example.com", schemes = listOf("https"))
    }

    routing {
        // …
    }
}

Churust

#![allow(unused)]
fn main() {
Churust::server()
    .install(CallLogging::new())
    .install(ContentNegotiation::new())
    .install(Cors::new().allow_origin("https://app.example.com"))
    .install(Compression::new())
    .install(RateLimit::per_minute(120))
    .routing(|r| {
        // …
    })
}
KtorChurust
install(Feature) inside Application.module.install(plugin) on the server builder
Features often pull in Kotlinx serialization, etc.Cargo features gate crates (json, cors, …)
Interceptor phases / plugin orderFixed pipeline phases; install order is stable within that model

Enable features in Cargo.toml the same way you would add dependencies in Gradle — then install only what you compiled in:

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

See Installation & features and Pipeline & plugins.


4. JSON body + response

Ktor

@Serializable
data class NewNote(val text: String)

@Serializable
data class Note(val id: Long, val text: String)

routing {
    post("/notes") {
        val input = call.receive<NewNote>()
        call.respond(Note(id = 1, text = input.text))
    }
}

Churust

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct NewNote { text: String }

#[derive(Serialize)]
struct Note { id: u64, text: String }

r.post("/notes", |Json(input): Json<NewNote>| async move {
    Json(Note { id: 1, text: input.text })
});
}
KtorChurust
call.receive<T>()Json<T> extractor (last argument if it consumes the body)
call.respond(T) with content negotiationJson(T) implements IntoResponse
kotlinx.serialization (typical)serde derives

5. Authentication / principal

Ktor (sketch)

install(Authentication) {
    bearer("auth") {
        authenticate { credential ->
            if (credential.token == expected) UserIdPrincipal("admin") else null
        }
    }
}

routing {
    authenticate("auth") {
        get("/me") {
            val principal = call.principal<UserIdPrincipal>()!!
            call.respondText(principal.name)
        }
    }
}

Churust

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Admin { name: String }

Churust::server()
    .install(Auth::bearer(|token: String| async move {
        (token == expected).then(|| Admin { name: "admin".into() })
    }))
    .routing(|r| {
        // Asking for Principal enforces auth (401 otherwise).
        r.get("/me", |Principal(u): Principal<Admin>| async move {
            u.name
        });
    })
}
KtorChurust
Named auth configs + authenticate("…") blocksInstall Auth, then require Principal<P> on the handler
call.principal<T>()Principal<T> extractor
Forgetting authenticate is a common footgunNo Principal → route is public; with Principal → type system enforces it

Prefer secure_compare (or a real store) over == on secrets in production. See Authentication.


6. Shared application state

Ktor

// Often: attributes, singletons, or a DI framework
val store = NoteStore()
// … put store where handlers can reach it

Churust

#![allow(unused)]
fn main() {
struct Store { notes: Mutex<Vec<Note>> }

Churust::server()
    .state(Store { notes: Mutex::new(Vec::new()) })
    .routing(|r| {
        r.get("/notes", |s: State<Store>| async move {
            // …
        });
    })
}

Typed .state(T) + State<T> replace “reach into the application container.” Use interior mutability (Mutex, channels, …) when handlers update shared data.


7. Configuration

Ktor

# application.conf
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
}

Churust

# churust.toml
[server]
host = "0.0.0.0"
port = 8080
export CHURUST_SERVER_PORT=9090
#![allow(unused)]
fn main() {
Churust::from_config()
    .port(8080) // code still wins over env/file when chained
    .routing(|r| { /* … */ })
}

Precedence: defaults < churust.toml < CHURUST_* < builder DSL. Details: State & configuration.


8. Testing

Ktor

testApplication {
    application { module() }
    val response = client.get("/ping")
    assertEquals(HttpStatusCode.OK, response.status)
}

Churust

#![allow(unused)]
fn main() {
let app = Churust::server()
    .routing(|r| {
        r.get("/ping", |_c: Call| async { "pong" });
    })
    .build();

let res = TestClient::new(app).get("/ping").send().await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text(), "pong");
}

No socket bind: the full pipeline runs in-process, same idea as testApplication. See Testing.


What stays “Rust” on purpose

Churust does not try to feel like Kotlin. It reuses Ktor’s application shape so you do not relearn “what a server is,” then leans on Rust where it matters:

TopicExpectation
Ownership & lifetimesHandlers own or borrow what extractors give them; no hidden GC
ErrorsPrefer Result / IntoError / ? over silent nulls
Asyncasync/.await on tokio (via churust::tokio or your own features)
TypesPath/query/JSON failures surface as typed HTTP errors, not null
FeaturesOpt-in Cargo features keep unused protocol surface out of the binary

You still learn Rust. You should not have to learn a fragmented web stack at the same time.


Concept map (cheat sheet)

Ktor                         Churust
────                         ───────
Application.module()    →    Churust::server()…start()
routing { }             →    .routing(|r| { })
install(Feature)        →    .install(plugin)  + Cargo feature
call                    →    Call  |  extractors
receive / respond       →    Json / IntoResponse
principal               →    Principal<P>
attributes / DI         →    .state(T) + State<T>
application.conf        →    churust.toml + CHURUST_* + DSL
testApplication         →    TestClient
embeddedServer / Netty  →    hyper (HTTP) + rustls (TLS) + tokio

Next