Warp #

Warp is a Rust web framework built on hyper with a unique core concept: filter composition. Unlike other frameworks that define routes as plain functions, in Warp everything is a filter — including path matching, method matching, header extraction, body parsing, and state injection. Filters can be combined with .and() (logical AND) and .or() (logical OR) to build complex routes from simple, reusable parts. This provides very high composability but also a steep learning curve, especially since errors from filter composition can produce long compilation messages. This article covers Warp thoroughly — from basic filters to rejection handling, WebSockets, and SSE.

Installation #

[dependencies]
warp = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"

Core Concept: Filter Composition #

In Warp, routes are built by chaining filters with the .and() operator:

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;

// Every component is a filter that can be combined
let route = warp::path("api")        // match the path "api"
    .and(warp::path("halo"))         // AND match the path "halo"
    .and(warp::get())                // AND method GET
    .map(|| "Hello from Warp!");     // map to a handler

// Run the server
#[tokio::main]
async fn main() {
    warp::serve(route)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

Path and Method Filters #

use warp::{Filter, Reply};
use serde::{Deserialize, Serialize};

// GET /
let root = warp::path::end()
    .and(warp::get())
    .map(|| "Welcome to Warp!");

// GET /halo/:nama
let halo = warp::path!("halo" / String)
    .and(warp::get())
    .map(|nama: String| format!("Hello, {}!", nama));

// GET /pengguna/:id  — id is automatically converted to u64
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 params
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
        }))
    });

// Combine all routes with .or()
let routes = root
    .or(halo)
    .or(ambil)
    .or(artikel);

Query Parameters and JSON Bodies #

use warp::Filter;
use serde::Deserialize;

// Query parameters
#[derive(Deserialize)]
struct ParamsCari {
    q: Option<String>,
    halaman: Option<u32>,
}

let cari = warp::path!("cari")
    .and(warp::get())
    .and(warp::query::<ParamsCari>())  // extract the query string
    .map(|params: ParamsCari| {
        warp::reply::json(&serde_json::json!({
            "query": params.q,
            "halaman": params.halaman.unwrap_or(1)
        }))
    });

// JSON body with a size limit
#[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))  // max 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 with warp::any() #

State in Warp is passed to handlers through filters — usually with 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>>>;

// A filter that injects state into handlers
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;
}

Authentication Filters #

Filters are great for authentication — build once, reuse in many routes:

use warp::{Filter, Rejection};

#[derive(Debug, Clone)]
struct InfoPengguna {
    id: u64,
    nama: String,
    peran: String,
}

// Custom rejection for auth errors
#[derive(Debug)]
struct TokenTidakValid;
impl warp::reject::Reject for TokenTidakValid {}

#[derive(Debug)]
struct TokenTidakAda;
impl warp::reject::Reject for TokenTidakAda {}

// A reusable authentication filter
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(),
            })
        })
}

// A route that requires authentication — .and(filter_auth())
let profil = warp::path!("profil")
    .and(warp::get())
    .and(filter_auth())               // inject InfoPengguna
    .map(|pengguna: InfoPengguna| {
        warp::reply::json(&serde_json::json!({
            "id": pengguna.id,
            "nama": pengguna.nama
        }))
    });

// A filter for 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 — Global Error Responses #

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 {}

// Global rejection handler
async fn tangani_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
    let (kode, pesan) = if err.is_not_found() {
        (404u16, "Resource not found".to_string())
    } else if let Some(_) = err.find::<TokenTidakAda>() {
        (401, "Authentication token required".to_string())
    } else if let Some(_) = err.find::<TokenTidakValid>() {
        (401, "Token is invalid or expired".to_string())
    } else if let Some(e) = err.find::<ValidationError>() {
        (422, format!("Validation failed: {}", e.0))
    } else if let Some(_) = err.find::<warp::reject::MethodNotAllowed>() {
        (405, "Method not allowed".to_string())
    } else if let Some(_) = err.find::<warp::filters::body::BodyDeserializeError>() {
        (400, "Invalid request body format".to_string())
    } else {
        eprintln!("Unhandled rejection: {:?}", err);
        (500, "A server error occurred".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,
    ))
}

// Attach the rejection handler to the routes
#[tokio::main]
async fn main() {
    let routes = profil_route()
        .or(publik_route())
        .recover(tangani_rejection);  // handle all rejections here

    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(|| "Public")
}

WebSockets #

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!("Received: {}", teks);

                // Echo back
                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 closed");
}

// WebSocket route
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 with 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})));

        // Without a token
        let resp = warp::test::request()
            .method("GET")
            .path("/profil")
            .reply(&filter)
            .await;
        assert_eq!(resp.status(), 401);

        // With a valid token
        let resp = warp::test::request()
            .method("GET")
            .path("/profil")
            .header("Authorization", "Bearer valid-token-abc")
            .reply(&filter)
            .await;
        assert_eq!(resp.status(), 200);
    }
}

Comparing the Four Frameworks #

AspectActix-webRocketAxumWarp
Routing paradigm#[get] macros etc.#[get] macros etc.Plain functionsFilter composition
PerformanceHighestHighVery highVery high
Learning curveModerateLowModerateHigh
MiddlewareActix-specificFairingsTower ecosystemFilter-based
Error handlingResponseError#[catch]IntoResponseRejection
State injectionData<T>State<T>State<T>warp::any()
WebSocketsactix-wsNot nativeBuilt-inBuilt-in
Testingactix test utilsClient::trackedaxum-testwarp::test
EcosystemMatureMatureRapidly growingStable
Choose Warp when:
  ✓ The filter composition philosophy matches how the team thinks
  ✓ Need high composability from reusable filters
  ✓ A codebase that already uses hyper directly
  ✓ Projects focused on composable middleware

Do NOT choose Warp when:
  ✗ A new team or a short learning curve is a priority
  ✗ Long compilation error messages hinder productivity
  ✗ Need a very rich middleware ecosystem (Axum+Tower is better)
  ✗ Large projects with many contributors (Axum or Actix are more widely known)

Summary #

  • Everything in Warp is a filter — path, method, header, body, state — all represented as filters that can be combined with .and() and .or().
  • The warp::path!("a" / u64 / "b") macro — the most concise way to define paths with typed parameters. Invalid types automatically produce 404.
  • .and(warp::body::json::<T>()) for JSON bodies — with content_length_limit for safety against large request bodies.
  • The dengan_db(db.clone()) pattern — a helper function returning a filter to inject state. This is Warp’s idiomatic dependency injection.
  • Reusable authentication filters — define filter_auth() once, use it in every route with .and(filter_auth()). No separate middleware needed.
  • Custom Reject for error typing — create structs implementing warp::reject::Reject, catch them with err.find::<TipeError>() in the rejection handler.
  • .recover(tangani_rejection) for a global error handler — one handler for all rejections from all routes. Always add this at the end of the routes chain.
  • warp::test::request() for testing — no real server needed; test filters directly with .reply(&filter).
  • Consider Axum for new projects — Warp is still excellent, but Axum with the Tower ecosystem offers equal composability with a more familiar API.

← Previous: Axum   Next: Diesel →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact