Warp #
Warp adalah web framework Rust yang dibangun di atas hyper dengan konsep inti yang unik: filter composition. Berbeda dari framework lain yang mendefinisikan route sebagai fungsi biasa, di Warp segala sesuatu adalah filter — termasuk path matching, method matching, header extraction, body parsing, dan state injection. Filter bisa dikombinasikan dengan .and() (AND logis) dan .or() (OR logis) untuk membangun route yang kompleks dari bagian-bagian yang sederhana dan reusable. Ini memberikan composability yang sangat tinggi tapi juga kurva belajar yang curam, terutama karena error dari filter composition bisa menghasilkan pesan kompilasi yang panjang. Artikel ini membahas Warp secara menyeluruh — dari filter dasar hingga rejection handling, WebSocket, dan SSE.
Instalasi #
[dependencies]
warp = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
Konsep Inti: Filter Composition #
Di Warp, route dibangun dengan merangkai filter menggunakan operator .and():
flowchart LR
A["warp::path('api')"] --> AND1[".and()"]
AND1 --> B["warp::path('pengguna')"]
B --> AND2[".and()"]
AND2 --> C["warp::get()"]
C --> AND3[".and()"]
AND3 --> D["warp::query()"]
D --> MAP[".map(handler)"]use warp::Filter;
// Setiap komponen adalah filter yang bisa dikombinasikan
let route = warp::path("api") // cocok path "api"
.and(warp::path("halo")) // AND cocok path "halo"
.and(warp::get()) // AND method GET
.map(|| "Halo dari Warp!"); // map ke handler
// Jalankan server
#[tokio::main]
async fn main() {
warp::serve(route)
.run(([127, 0, 0, 1], 3030))
.await;
}
Path dan Method Filter #
use warp::{Filter, Reply};
use serde::{Deserialize, Serialize};
// GET /
let root = warp::path::end()
.and(warp::get())
.map(|| "Selamat datang di Warp!");
// GET /halo/:nama
let halo = warp::path!("halo" / String)
.and(warp::get())
.map(|nama: String| format!("Halo, {}!", nama));
// GET /pengguna/:id — id dikonversi ke tipe u64 otomatis
let ambil = warp::path!("pengguna" / u64)
.and(warp::get())
.map(|id: u64| {
warp::reply::json(&serde_json::json!({
"id": id,
"nama": format!("Pengguna {}", id)
}))
});
// GET /pengguna/:id/artikel/:slug — multiple param
let artikel = warp::path!("pengguna" / u64 / "artikel" / String)
.and(warp::get())
.map(|user_id: u64, slug: String| {
warp::reply::json(&serde_json::json!({
"pengguna_id": user_id,
"slug": slug
}))
});
// Gabungkan semua route dengan .or()
let routes = root
.or(halo)
.or(ambil)
.or(artikel);
Query Parameter dan JSON Body #
use warp::Filter;
use serde::Deserialize;
// Query parameter
#[derive(Deserialize)]
struct ParamsCari {
q: Option<String>,
halaman: Option<u32>,
}
let cari = warp::path!("cari")
.and(warp::get())
.and(warp::query::<ParamsCari>()) // ekstrak query string
.map(|params: ParamsCari| {
warp::reply::json(&serde_json::json!({
"query": params.q,
"halaman": params.halaman.unwrap_or(1)
}))
});
// JSON body dengan validasi ukuran
#[derive(Deserialize, Serialize)]
struct InputPengguna {
nama: String,
email: String,
}
let buat = warp::path!("pengguna")
.and(warp::post())
.and(warp::body::content_length_limit(1024 * 16)) // maks 16KB
.and(warp::body::json::<InputPengguna>()) // parse JSON
.map(|body: InputPengguna| {
warp::reply::with_status(
warp::reply::json(&serde_json::json!({
"id": 1001,
"nama": body.nama,
"email": body.email
})),
warp::http::StatusCode::CREATED,
)
});
Shared State dengan warp::any()
#
State di Warp diteruskan ke handler lewat filter — biasanya dengan warp::any().map(move || state.clone()):
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
use warp::Filter;
type Database = Arc<Mutex<HashMap<u64, String>>>;
// Filter yang menyuntikkan state ke handler
fn dengan_db(db: Database) -> impl Filter<Extract = (Database,), Error = std::convert::Infallible> + Clone {
warp::any().map(move || db.clone())
}
async fn handler_daftar(db: Database) -> Result<impl warp::Reply, warp::Rejection> {
let data = db.lock().unwrap();
let daftar: Vec<&String> = data.values().collect();
Ok(warp::reply::json(&daftar))
}
async fn handler_ambil(id: u64, db: Database) -> Result<impl warp::Reply, warp::Rejection> {
let data = db.lock().unwrap();
match data.get(&id) {
Some(nilai) => Ok(warp::reply::json(&serde_json::json!({"id": id, "nilai": nilai}))),
None => Err(warp::reject::not_found()),
}
}
async fn handler_simpan(
body: serde_json::Value,
db: Database,
) -> Result<impl warp::Reply, warp::Rejection> {
let mut data = db.lock().unwrap();
let id = data.len() as u64 + 1;
data.insert(id, body["nilai"].as_str().unwrap_or("").to_string());
Ok(warp::reply::with_status(
warp::reply::json(&serde_json::json!({"id": id})),
warp::http::StatusCode::CREATED,
))
}
#[tokio::main]
async fn main() {
let db: Database = Arc::new(Mutex::new(HashMap::new()));
let daftar_route = warp::path!("item")
.and(warp::get())
.and(dengan_db(db.clone()))
.and_then(handler_daftar);
let ambil_route = warp::path!("item" / u64)
.and(warp::get())
.and(dengan_db(db.clone()))
.and_then(handler_ambil);
let simpan_route = warp::path!("item")
.and(warp::post())
.and(warp::body::json())
.and(dengan_db(db.clone()))
.and_then(handler_simpan);
let routes = daftar_route.or(ambil_route).or(simpan_route);
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}
Filter Autentikasi #
Filter sangat cocok untuk autentikasi — buat sekali, reuse di banyak route:
use warp::{Filter, Rejection};
#[derive(Debug, Clone)]
struct InfoPengguna {
id: u64,
nama: String,
peran: String,
}
// Custom rejection untuk error auth
#[derive(Debug)]
struct TokenTidakValid;
impl warp::reject::Reject for TokenTidakValid {}
#[derive(Debug)]
struct TokenTidakAda;
impl warp::reject::Reject for TokenTidakAda {}
// Filter autentikasi yang bisa di-reuse
fn filter_auth() -> impl Filter<Extract = (InfoPengguna,), Error = Rejection> + Clone {
warp::header::optional::<String>("authorization")
.and_then(|auth_header: Option<String>| async move {
let token = auth_header
.and_then(|h| h.strip_prefix("Bearer ").map(|t| t.to_string()))
.ok_or_else(|| warp::reject::custom(TokenTidakAda))?;
if !token.starts_with("valid-") {
return Err(warp::reject::custom(TokenTidakValid));
}
Ok(InfoPengguna {
id: 42,
nama: "Budi".to_string(),
peran: "admin".to_string(),
})
})
}
// Route yang butuh autentikasi — .and(filter_auth())
let profil = warp::path!("profil")
.and(warp::get())
.and(filter_auth()) // suntikkan InfoPengguna
.map(|pengguna: InfoPengguna| {
warp::reply::json(&serde_json::json!({
"id": pengguna.id,
"nama": pengguna.nama
}))
});
// Filter untuk role-based access
fn filter_admin() -> impl Filter<Extract = (InfoPengguna,), Error = Rejection> + Clone {
filter_auth().and_then(|pengguna: InfoPengguna| async move {
if pengguna.peran == "admin" {
Ok(pengguna)
} else {
Err(warp::reject::custom(TokenTidakValid))
}
})
}
Rejection Handling — Error Response Global #
use warp::{Rejection, Reply};
use std::convert::Infallible;
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
kode: u16,
pesan: String,
}
// Custom rejection
#[derive(Debug)]
struct ValidationError(String);
impl warp::reject::Reject for ValidationError {}
// Handler rejection global
async fn tangani_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
let (kode, pesan) = if err.is_not_found() {
(404u16, "Resource tidak ditemukan".to_string())
} else if let Some(_) = err.find::<TokenTidakAda>() {
(401, "Token autentikasi diperlukan".to_string())
} else if let Some(_) = err.find::<TokenTidakValid>() {
(401, "Token tidak valid atau sudah kedaluwarsa".to_string())
} else if let Some(e) = err.find::<ValidationError>() {
(422, format!("Validasi gagal: {}", e.0))
} else if let Some(_) = err.find::<warp::reject::MethodNotAllowed>() {
(405, "Method tidak diizinkan".to_string())
} else if let Some(_) = err.find::<warp::filters::body::BodyDeserializeError>() {
(400, "Format request body tidak valid".to_string())
} else {
eprintln!("Unhandled rejection: {:?}", err);
(500, "Terjadi kesalahan server".to_string())
};
let status = warp::http::StatusCode::from_u16(kode).unwrap_or(warp::http::StatusCode::INTERNAL_SERVER_ERROR);
Ok(warp::reply::with_status(
warp::reply::json(&ErrorResponse { kode, pesan }),
status,
))
}
// Pasang rejection handler ke routes
#[tokio::main]
async fn main() {
let routes = profil_route()
.or(publik_route())
.recover(tangani_rejection); // tangani semua rejection di sini
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}
fn profil_route() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::path!("profil")
.and(warp::get())
.and(filter_auth())
.map(|p: InfoPengguna| warp::reply::json(&serde_json::json!({"nama": p.nama})))
}
fn publik_route() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::path!("publik")
.and(warp::get())
.map(|| "Publik")
}
WebSocket #
use futures::{FutureExt, StreamExt};
use warp::ws::{Message, WebSocket};
use warp::Filter;
async fn tangani_ws(ws: WebSocket) {
let (mut tx, mut rx) = ws.split();
while let Some(result) = rx.next().await {
match result {
Ok(msg) if msg.is_text() => {
let teks = msg.to_str().unwrap_or("");
println!("Diterima: {}", teks);
// Echo kembali
if tx.send(Message::text(format!("Echo: {}", teks))).await.is_err() {
break;
}
}
Ok(msg) if msg.is_ping() => {
let _ = tx.send(Message::pong(msg.into_bytes())).await;
}
Ok(msg) if msg.is_close() => break,
Err(e) => {
eprintln!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
println!("WebSocket ditutup");
}
// Route WebSocket
let ws_route = warp::path("ws")
.and(warp::ws())
.map(|ws: warp::ws::Ws| {
ws.on_upgrade(|socket| tangani_ws(socket))
});
Server-Sent Events #
use futures::Stream;
use std::convert::Infallible;
use tokio::time::{interval, Duration};
use tokio_stream::wrappers::IntervalStream;
use warp::{sse::Event, Filter};
fn stream_sse() -> impl Stream<Item = Result<Event, Infallible>> {
let interval = interval(Duration::from_secs(1));
IntervalStream::new(interval).map(|_| {
Ok(Event::default()
.data(serde_json::json!({
"waktu": chrono::Utc::now().to_rfc3339(),
"random": rand::random::<u32>() % 100
}).to_string()))
})
}
let sse_route = warp::path("events")
.and(warp::get())
.map(|| {
let stream = stream_sse();
warp::sse::reply(warp::sse::keep_alive().stream(stream))
});
Testing dengan warp::test
#
#[cfg(test)]
mod tests {
use super::*;
use warp::http::StatusCode;
#[tokio::test]
async fn test_root() {
let filter = warp::path::end()
.and(warp::get())
.map(|| "OK");
let resp = warp::test::request()
.method("GET")
.path("/")
.reply(&filter)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.body(), "OK");
}
#[tokio::test]
async fn test_path_param() {
let filter = warp::path!("pengguna" / u64)
.and(warp::get())
.map(|id: u64| warp::reply::json(&serde_json::json!({"id": id})));
let resp = warp::test::request()
.method("GET")
.path("/pengguna/42")
.reply(&filter)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["id"], 42);
}
#[tokio::test]
async fn test_json_body() {
let filter = warp::path!("pengguna")
.and(warp::post())
.and(warp::body::json::<InputPengguna>())
.map(|body: InputPengguna| warp::reply::json(&body));
let resp = warp::test::request()
.method("POST")
.path("/pengguna")
.json(&InputPengguna {
nama: "Test".to_string(),
email: "[email protected]".to_string(),
})
.reply(&filter)
.await;
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn test_auth_header() {
let filter = warp::path!("profil")
.and(warp::get())
.and(filter_auth())
.map(|p: InfoPengguna| warp::reply::json(&serde_json::json!({"nama": p.nama})));
// Tanpa token
let resp = warp::test::request()
.method("GET")
.path("/profil")
.reply(&filter)
.await;
assert_eq!(resp.status(), 401);
// Dengan token valid
let resp = warp::test::request()
.method("GET")
.path("/profil")
.header("Authorization", "Bearer valid-token-abc")
.reply(&filter)
.await;
assert_eq!(resp.status(), 200);
}
}
Perbandingan Empat Framework #
| Aspek | Actix-web | Rocket | Axum | Warp |
|---|---|---|---|---|
| Paradigma routing | Macro #[get] dll. | Macro #[get] dll. | Fungsi biasa | Filter composition |
| Performa | Tertinggi | Tinggi | Sangat tinggi | Sangat tinggi |
| Kurva belajar | Sedang | Rendah | Sedang | Tinggi |
| Middleware | Actix-specific | Fairing | Tower ecosystem | Filter-based |
| Error handling | ResponseError | #[catch] | IntoResponse | Rejection |
| State injection | Data<T> | State<T> | State<T> | warp::any() |
| WebSocket | actix-ws | Tidak native | Built-in | Built-in |
| Testing | actix test utils | Client::tracked | axum-test | warp::test |
| Ekosistem | Mature | Mature | Berkembang pesat | Stabil |
Kapan memilih Warp:
✓ Filosofi filter composition cocok dengan cara berpikir tim
✓ Butuh composability tinggi dari filter yang reusable
✓ Codebase yang sudah menggunakan hyper secara langsung
✓ Proyek yang lebih fokus pada composable middleware
Kapan TIDAK memilih Warp:
✗ Tim baru atau kurva belajar menjadi prioritas
✗ Error pesan kompilasi panjang mengganggu produktivitas
✗ Butuh ekosistem middleware yang sangat kaya (Axum+Tower lebih baik)
✗ Proyek besar dengan banyak kontributor (Axum atau Actix lebih umum dikenal)
Ringkasan #
- Segala sesuatu di Warp adalah filter — path, method, header, body, state — semua direpresentasikan sebagai filter yang bisa dikombinasikan dengan
.and()dan.or().warp::path!("a" / u64 / "b")macro — cara paling ringkas mendefinisikan path dengan typed parameter. Tipe yang tidak valid otomatis menghasilkan 404..and(warp::body::json::<T>())untuk JSON body — dengancontent_length_limituntuk keamanan dari request body yang besar.dengan_db(db.clone())pattern — fungsi helper yang mengembalikan filter untuk menyuntikkan state. Ini cara idiomatis Warp untuk dependency injection.- Filter autentikasi reusable — definisikan
filter_auth()sekali, gunakan di setiap route dengan.and(filter_auth()). Tidak perlu middleware terpisah.- Custom
Rejectuntuk error typing — buat struct yang mengimplementasikanwarp::reject::Reject, tangkap denganerr.find::<TipeError>()di rejection handler..recover(tangani_rejection)untuk error handler global — satu handler untuk semua rejection dari semua route. Selalu tambahkan ini di akhir routes chain.warp::test::request()untuk testing — tidak perlu server nyata; test filter langsung dengan.reply(&filter).- Pertimbangkan Axum untuk proyek baru — Warp masih sangat baik tapi Axum dengan ekosistem Tower memberikan composability yang setara dengan API yang lebih familiar.