Editor’s note: This guide to building a REST API in Rust with warp was last updated on 8 February 2023 to reflect recent changes to the Rust framework. This update also includes new sections on the benefits of warp in Rust.
Rust is a lot of folks’ favorite programming language, but it can still be hard to find a project for it or even to get a firm grasp of it. A good way to start with any language is to build something you will use daily. If your company operates microservices, it’s even easier. Rust is well-suited to replace such a service, and you could rewrite it in a matter of days.
When starting with Rust, you’ll need to learn the fundamentals. Once familiar with the syntax and basic concepts, you can start thinking about asynchronous Rust. Most modern languages have a built-in runtime that handles async tasks, such as sending off a request or waiting in the background for an answer.
In Rust, you have to choose an async runtime that works for you. Libraries usually have their own runtime. If you work on a larger project, you may want to avoid adding multiple runtimes because by choosing a single, consistent runtime, you can simplify your application architecture, and reduce the application’s complexity, and the risk of compatibility issues.
Tokio is the most production-used and proven runtime that can handle asynchronous tasks, so chances are high that your future employer already uses it. Your choices are, therefore, somewhat limited since you may need to choose a library that already has Tokio built in to create your API. For this tutorial, we’ll use warp. Depending on your previous programming experience, it may take a few days to wrap your head around it. But once you understand warp, it can be quite an elegant tool for building APIs.
Let’s get started:
Warp is a minimal and efficient web framework for building HTTP-based web services in Rust. It provides a high-level API for building HTTP servers, focusing on security, performance, and stability. Warp also includes built-in features such as support for HTTP/1 and HTTP/2, TLS encryption, asynchronous programming, and common middleware for logging, rate limiting, and routing tasks. A lot of its features are borrowed from Hyper, as warp is more like a superset of Hyper.
You’ll need to install the following libraries to follow along with this tutorial.
ReadWriteLock
for your local storageFirst, create a new project with cargo:
cargo new neat-api --bin
We’ve included warp in our Cargo.toml
so we can use it throughout our codebase:
[dependencies]
warp = "0.2"
parking_lot = "0.10.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "0.2", features = ["macros"] }
For the first test, create a simple “Hello, World!” in main.rs
, as shown below:
<
use warp::Filter; #[tokio::main] async fn main() { // GET /hello/warp => 200 OK with body "Hello, warp!" let hello = warp::path!("hello" / String) .map(|name| format!("Hello, {}!", name)); warp::serve(hello) .run(([127, 0, 0, 1], 3030)) .await; }
Filters
are a way to parse a request and match against a route we created. So when you start the server via cargo run
and point your browser to localhost:3030/hello/WHATEVER
, warp sends this request through its filters and executes the first one that is triggered.
In let hello = …
we created a new path, essentially saying that every request with the path /hello
plus a string gets handled by this method. So, we return Hello, WHATEVER
.
If we point the browser to localhost:3030/hello/new/WHATEVER
, we’ll get a 404 since we don’t have a filter for /hello/new + String
.
Let’s build a real API to demonstrate these concepts. A good model is an API for a grocery list. We want to be able to add items to the list, update the quantity, delete items, and view the whole list. Therefore, we need four different routes with the HTTP methods GET
, DELETE
, PUT
, and POST
.
With so many different routes, is it wise to create methods for each instead of handling them all in main.rs
?
In addition to routes, we need to store a state in a file or local variable. In an async environment, we have to make sure only one method can access the store at a time so there are no inconsistencies between threads.
In Rust, we have Arc
, so the compiler knows when to drop a value and a read and write lock (RwLock
). That way, no two methods on different threads are writing to the same memory.
Your store implementation should look like this:
<
use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; type Items = HashMap<String, i32>; #[derive(Debug, Deserialize, Serialize, Clone)] struct Item { name: String, quantity: i32, } #[derive(Clone)] struct Store { grocery_list: Arc<RwLock<Items>> } impl Store { fn new() -> Self { Store { grocery_list: Arc::new(RwLock::new(HashMap::new())), } } }
POST
ing an item to the listNow, we can add our first route. To add items to the list, make an HTTP POST
request to a path. Our method has to return a proper HTTP code so the caller knows whether their call was successful. Warp offers basic types via its own http
library, which we need to include as well. Add it like so:
<
use warp::{http, Filter};
The method for the POST
request looks like this:
<
async fn add_grocery_list_item( item: Item, store: Store ) -> Result<impl warp::Reply, warp::Rejection> { let r = store.grocery_list.read(); Ok(warp::reply::json(&*r)) }
The warp framework offers the option to reply with status
so we can add text plus a generic HTTP status so the caller knows whether the request was successful or if they have to try again. Now, add a new route and call the method you just created for it. Since you can expect a JSON for this, you should create a little json_body
helper function to extract the Item
out of the body
of the HTTP request.
In addition, we need to pass the store down to each method by cloning it and creating a warp filter
, which we call in the .and()
during the warp path
creation:
<
fn json_body() -> impl Filter<Extract = (Item,), Error = warp::Rejection> + Clone { // When accepting a body, we want a JSON body // (and to reject huge payloads)... warp::body::content_length_limit(1024 * 16).and(warp::body::json()) } #[tokio::main] async fn main() { let store = Store::new(); let store_filter = warp::any().map(move || store.clone()); let add_items = warp::post() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(json_body()) .and(store_filter.clone()) .and_then(add_grocery_list_item); warp::serve(add_items) .run(([127, 0, 0, 1], 3030)) .await; }
You can test the POST
call via curl
or an application such as Postman, which is now a standalone application for making HTTP requests. Start the server via cargo run
and open another terminal window or tab to execute the following curl
:
<
curl --location --request POST 'localhost:3030/v1/groceries' \ --header 'Content-Type: application/json' \ --header 'Content-Type: text/plain' \ --data-raw '{ "name": "apple", "quantity": 3 }'
You should get the text response and HTTP code as defined in your method.
GET
ting the grocery listNow, we can post a list of items to our grocery list, but we still can’t retrieve them. We need to create another route for the GET
request. Our main function will add this new route. For this new route, we don’t need to parse any JSON. Here’s the code:
#[tokio::main] async fn main() { let store = Store::new(); let store_filter = warp::any().map(move || store.clone()); let add_items = warp::post() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(json_body()) .and(store_filter.clone()) .and_then(add_grocery_list_item); let get_items = warp::get() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(store_filter.clone()) .and_then(get_grocery_list); let routes = add_items.or(get_items); warp::serve(routes) .run(([127, 0, 0, 1], 3030)) .await; }
You’ll get a taste of async Rust when you examine the data structure behind your Arc
. You’ll need to use the .read()
method to access and dereference the data. Here is how the function looks:
async fn get_grocery_list( store: Store ) -> Result<impl warp::Reply, warp::Rejection> { let result = store.grocery_list.read(); Ok(warp::reply::json(&*result)) }
Then, create a variable for the store.grocery_list.read()
, we’ll call it result
. Notice that we are returning &*result;
that’s new, right? Yes, &*result
dereferences the RwLockReadGuard
object result
to a &HashMap
, which is then passed as a reference to the warp::reply::json
function that’s been returned.
UPDATE
and DELETE
The last two missing methods are UPDATE
and DELETE
. For DELETE
, you can almost copy your add_grocery_list_item
, but instead of .insert()
, .remove()
an entry.
A special case is the UPDATE
. Here the Rust HashMap
implementation uses .insert()
as well, but it updates the value instead of creating a new entry if the key doesn’t exist. Therefore, just rename the method and call it for the POST
as well as the PUT
.
For the DELETE
method, you need to pass just the name of the item, so create a new struct and add another parse_json()
method for the new type. Rename the first parsing method and add another one.
You can simply rename your add_grocery_list_item
method to call it update_grocery_list
and call it for a warp::post()
and warp::put()
. Your complete code should look like this:
use warp::{http, Filter}; use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; use serde::{Serialize, Deserialize}; type Items = HashMap<String, i32>; #[derive(Debug, Deserialize, Serialize, Clone)] struct Id { name: String, } #[derive(Debug, Deserialize, Serialize, Clone)] struct Item { name: String, quantity: i32, } #[derive(Clone)] struct Store { grocery_list: Arc<RwLock<Items>> } impl Store { fn new() -> Self { Store { grocery_list: Arc::new(RwLock::new(HashMap::new())), } } } async fn update_grocery_list( item: Item, store: Store ) -> Result<impl warp::Reply, warp::Rejection> { store.grocery_list.write().insert(item.name, item.quantity); Ok(warp::reply::with_status( "Added items to the grocery list", http::StatusCode::CREATED, )) } async fn delete_grocery_list_item( id: Id, store: Store ) -> Result<impl warp::Reply, warp::Rejection> { store.grocery_list.write().remove(&id.name); Ok(warp::reply::with_status( "Removed item from grocery list", http::StatusCode::OK, )) } async fn get_grocery_list( store: Store ) -> Result<impl warp::Reply, warp::Rejection> { let r = store.grocery_list.read(); Ok(warp::reply::json(&*r)) } fn delete_json() -> impl Filter<Extract = (Id,), Error = warp::Rejection> + Clone { // When accepting a body, we want a JSON body // (and to reject huge payloads)... warp::body::content_length_limit(1024 * 16).and(warp::body::json()) } fn post_json() -> impl Filter<Extract = (Item,), Error = warp::Rejection> + Clone { // When accepting a body, we want a JSON body // (and to reject huge payloads)... warp::body::content_length_limit(1024 * 16).and(warp::body::json()) } #[tokio::main] async fn main() { let store = Store::new(); let store_filter = warp::any().map(move || store.clone()); let add_items = warp::post() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(post_json()) .and(store_filter.clone()) .and_then(update_grocery_list); let get_items = warp::get() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(store_filter.clone()) .and_then(get_grocery_list); let delete_item = warp::delete() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(delete_json()) .and(store_filter.clone()) .and_then(delete_grocery_list_item); let update_item = warp::put() .and(warp::path("v1")) .and(warp::path("groceries")) .and(warp::path::end()) .and(post_json()) .and(store_filter.clone()) .and_then(update_grocery_list); let routes = add_items.or(get_items).or(delete_item).or(update_item); warp::serve(routes) .run(([127, 0, 0, 1], 3030)) .await; }
After you update the code, restart the server via cargo run
and use these curls to post, update, get, and delete items.
POST
curl --location --request POST 'localhost:3030/v1/groceries' \ --header 'Content-Type: application/json' \ --header 'Content-Type: text/plain' \ --data-raw '{ "name": "apple", "quantity": 3 }'
UPDATE
curl --location --request PUT 'localhost:3030/v1/groceries' \ --header 'Content-Type: application/json' \ --header 'Content-Type: text/plain' \ --data-raw '{ "name": "apple", "quantity": 5 }'
GET
curl --location --request GET 'localhost:3030/v1/groceries' \ --header 'Content-Type: application/json' \ --header 'Content-Type: text/plain'
DELETE
curl --location --request DELETE 'localhost:3030/v1/groceries' \ --header 'Content-Type: application/json' \ --header 'Content-Type: text/plain' \ --data-raw '{ "name": "apple" }'
To summarize the steps we just covered:
/v1/groceries/{id}
When it comes to building an API in Rust, you have several library options. However, the specific requirements of your project will help guide your choice. If you decide to go with warp, here are some advantages of using it in your Rust project.
Warp is an interesting tool for building web APIs with Rust. And even though the code is far from perfect, the sample code gives us the tip of the iceberg for what is possible with warp. We could extend it and I hope you do. I’ll love to see your feedback based on what you’ve built already.
Some things you could add:
You can see how straightforward it is to create your first REST API with Rust and warp and how the Rust type system makes clear what data you’re handling and what methods are available to you. Now it’s up to you to hone your skills and optimize the code. You can find the full source code on GitHub. Feel free to clone and experiment and improve upon it.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
5 Replies to "Building a REST API in Rust with warp"
nice intro to warp, thanks!
Thanks, I was stuck but your tutorial helped me. I’m noob to rust so I would love more tutorials 🙂
Thanks for the tut. One remark though:
“Due to the nature of Rust’s ownership model, you can’t simply read and return the underlying list of groceries.”
That’s not true, the Deref trait on Mutex allows us to do this:
Ok(warp::reply::json(&*store.grocery_list.read()));
Cheers 🙂
Thanks for the tutorial.
Maybe I am missing something, but it seems to me, your curl delete example is missing the id. In fact I am missing where the id path section is defined at all for get (on a single item) and put.
sorry noob here, how can I solve this ?:
error: cannot find derive macro `Deserialize` in this scope
–> src/serve.rs:69:17
|
69 | #[derive(Debug, Deserialize, Serialize, Clone)]
| ^^^^^^^^^^^
Thanks a lot !