Written by Bastian Gruber✏️
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 get started with any language is to build something you will use every day. 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 you first get started with Rust, you’ll need to learn the fundamentals. Once you’re familiar with the syntax and basic concepts, you can start thinking about asynchronous Rust. Most modern languages have a build 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 a 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.
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.
Setting up your project
To follow along with this tutorial, you’ll need to install the following libraries.
- warp for creating the API
- Tokio to run an asynchronous server
- Serde to help serialize incoming JSON
- parking_lot to create a ReadWriteLock for your local storage
First, 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 a first test, create a simple “Hello, World!” in main.rs
.
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
.
Building the API
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
.
Creating local storage
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 at a time can access the store 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())),
}
}
}
POSTing an item to the list
Now 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.
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> {
store.grocery_list.write().insert(item.name, item.quantity);
Ok(warp::reply::with_status(
"Added items to the grocery list",
http::StatusCode::CREATED,
))
}
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.
GETting the grocery list
Now 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.
#[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 .read()
and then .iter()
over the data inside the RwLock
, so create a new variable to return to the caller. Due to the nature of Rust’s ownership model, you can’t simply read and return the underlying list of groceries. Here is the method:
async fn get_grocery_list(
store: Store
) -> Result<impl warp::Reply, warp::Rejection> {
let mut result = HashMap::new();
let r = store.grocery_list.read();
for (key,value) in r.iter() {
result.insert(key, value);
}
Ok(warp::reply::json(
&result
))
}
Create a variable for the store.grocery_list.read()
. Then, iterate over the HashMap and write every key/value pair into a new one, which you’ll return via warp::reply::json()
.
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 mut result = HashMap::new();
let r = store.grocery_list.read();
for (key,value) in r.iter() {
result.insert(key, value);
}
Ok(warp::reply::json(
&result
))
}
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;
}
Testing curls
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"
}'
Final thoughts
The code is far from perfect. For example, we could optimize the return message as well as the return HTTP codes. We would need to implement proper error handling in case we pass the wrong JSON format to the server. The code would also need to be tested.
To summarize the steps we just covered:
- Create an ID for each item so you can update and delete via
/v1/groceries/{id}
- Add a 404 route
- Add error handling for malformatted JSON
- Adjust the return messages for each route
- Add tests for each route
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.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post Creating a REST API in Rust with warp appeared first on LogRocket Blog.
Top comments (0)