Composing routes with filters
You are building a microservice. You need a /health endpoint, a /users POST route, and some authentication logic. You grab a framework, and suddenly you are writing fifty lines of boilerplate just to parse a query parameter. Or you pick a "simple" library and end up manually routing every byte, checking headers, and handling errors in a giant match block.
Warp takes a different path. It treats HTTP routes like Lego bricks. You snap small filters together to build complex behavior. No boilerplate. No magic. Just composition. You define what a route matches, extract the data you need, and chain the pieces until you have a handler. If a request doesn't match, the chain breaks, and Warp tries the next route.
The filter model
At the heart of Warp is the Filter trait. Think of a filter as a gatekeeper. It looks at an incoming HTTP request. If the request matches the filter's criteria, the gate opens, and the filter hands you a piece of data extracted from the request. If the request doesn't match, the gate slams shut. The request is rejected.
You chain filters together. The output of one becomes the input to the next. If every filter in the chain opens, you get all the extracted data, and your handler function runs. If any filter rejects, the whole chain stops, and Warp moves on to the next route you defined.
Filters extract values. warp::path("users") extracts nothing but validates the path. warp::path("users" / u32) extracts a u32 ID. warp::body::json() extracts a deserialized struct. When you chain them with .and(), Warp merges the extracted values into a tuple and passes it to your .map() closure.
Filters must implement Clone. Warp clones the filter chain for every request. This allows the framework to share the route definition across thousands of concurrent connections without locking. If you capture a non-cloneable value in a filter, the compiler rejects it.
Minimal example
Here is a runnable server that defines a single route. It matches GET /hello/<name>, extracts the name, and returns a JSON response.
use warp::Filter;
/// Starts the Warp server and defines a greeting route.
#[tokio::main]
async fn main() {
// Match the path "hello" followed by a String segment.
// The macro extracts the string into the closure argument.
let hello = warp::path!("hello" / String)
.and(warp::get()) // Restrict to GET requests.
.map(|id| {
// Format the extracted ID into a greeting string.
format!("Hello, {}!", id)
})
// Wrap the string in a JSON reply.
.map(|reply| warp::reply::json(&serde_json::json!({ "message": reply })));
// Add a logger to the route.
let routes = hello.with(warp::log("warp-example"));
println!("Listening on port 3030");
// Bind the routes to the address and start the async server.
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}
The community prefers the warp::path! macro for static path segments. It is faster than chaining warp::path("hello") and gives better compile-time checks. Use the macro when you know the path structure at compile time.
Filters are pure. They do not mutate state; they extract data.
Walking through a request
When a request hits GET /hello/alice, Warp starts checking your filters. warp::path!("hello" / String) matches the path. It extracts "alice" and passes it forward. warp::get() checks the method. It is GET, so it passes. The .map closure receives "alice". It formats the string. The second .map wraps it in JSON. The response goes back to the client.
If a request hits POST /hello/alice, warp::get() rejects it. The chain breaks. Warp returns a 405 Method Not Allowed automatically. You did not write error handling. Warp did it for you based on the rejection type.
Rejections are first-class citizens. Treat them as data, not exceptions.
Handling JSON and state
Real applications need to parse request bodies and share state like database pools. Warp provides filters for JSON deserialization and a pattern for injecting shared resources.
use serde::{Deserialize, Serialize};
use warp::Filter;
/// Input structure for creating a user.
#[derive(Deserialize, Debug)]
struct CreateUser {
name: String,
email: String,
}
/// Output structure for the created user.
#[derive(Serialize)]
struct UserCreated {
id: u32,
name: String,
}
/// Defines a POST route that accepts JSON and returns a user object.
fn post_user() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path("users")
.and(warp::post()) // Restrict to POST method.
.and(warp::body::json()) // Deserialize JSON body into CreateUser.
.map(|input: CreateUser| {
// Simulate database insertion and return response.
warp::reply::json(&UserCreated {
id: 42,
name: input.name,
})
})
}
Define routes as functions returning impl Filter. This keeps your main function readable and lets you compose routes without exposing the full type signature. The compiler infers the complex trait bounds.
Sharing state requires lifting it into the filter chain. You cannot capture a variable directly if it is not Clone. Use warp::any() to create a filter that always passes and extracts a value.
// Create a filter that provides the database pool.
let db_pool = Pool::new();
let db_filter = warp::any().map(move || db_pool.clone());
// Combine the state filter with the route.
let route = warp::path("data")
.and(db_filter)
.map(|pool| {
// Use the pool here.
warp::reply::json(&serde_json::json!({ "status": "ok" }))
});
The community calls this "lifting" state. You lift the state into the filter chain so every request gets a clone of the handle. Lift your state into the filter chain. Keep your handlers stateless.
Combining routes
A server usually has multiple routes. Use .or() to combine route trees. If the left side rejects, Warp tries the right side.
let health = warp::path("health").and(warp::get()).map(|| warp::reply::json(&serde_json::json!({ "status": "ok" })));
let users = post_user();
// Combine routes. If health rejects, try users.
let routes = health.or(users);
Order matters. If you put a broad filter before a specific one, the broad filter might match and reject the specific route. Put specific routes first.
Pitfalls and compiler errors
Filters must be Clone. If you capture a non-cloneable value, the compiler rejects you with E0277 (the trait bound Clone is not satisfied). Wrap shared state in Arc or Rc if you need to share it across filters.
The JSON body filter has a default size limit. If a client sends a payload larger than 16KB, Warp rejects it. You will get a 413 Payload Too Large response. Increase the limit with .max_length() if you expect big uploads.
warp::body::content_length_limit(1024 * 1024) // 1MB limit
.and(warp::body::json())
Sometimes you need custom error logic. Use .recover() to catch rejections and map them to a reply. This runs after a filter chain rejects.
let routes = post_user()
.recover(|err| async move {
// Map rejection to a custom JSON error response.
warp::reply::json(&serde_json::json!({ "error": "Something went wrong" }))
});
Check the rejection type in your recovery handler. A generic 500 error hides the real problem.
When to use Warp
Use Warp when you need a lightweight, composable API framework with minimal boilerplate. Use Warp when you want type-safe routing where the compiler checks your path parameters and method constraints. Use Warp when you are building a microservice or a JSON API and prefer functional composition over imperative routing tables. Reach for Actix-web when you need raw throughput and a more traditional middleware stack. Reach for Axum when you want a similar composable style but with deeper integration into the Tokio ecosystem and tower middleware. Reach for Rocket when you prefer a macro-heavy, convention-over-configuration approach with built-in session management.