REST API with Rust + Warp 2: POST

rogertorres

Roger Torres (he/him/ele)

Posted on April 14, 2021

REST API with Rust + Warp 2: POST

Glad to see you back! In this second part, we'll build the first functional method of our API: POST.


Warp 2. Engage!

The code for this part is available here.

The POST sends a request to the server to insert some data. This data is sent in JSON. This program's job is then to parse (deserialize) the JSON and store this information (in memory only—I will not deal with ORM in this series; maybe in another one).

The holodeck produces simulations (ignore this statement if you're not into Star Trek). With that in mind, I followed the KISS principle and made it a key-value pair of "simulation id" and "simulation name".

First, once I knew what I was supposed to be listing, I renamed the previous list() to list_sims() and handle_list() to handle_list_sims().

Then, I created a try_create() test function, pretty similar to the previous test: it sends the method (POST this time) to the same/holodeck path, using a new (still-to-be-coded) filter and expects a good answer (because what could go wrong in the holodeck?).

// Add "models" in the already existing line "use super::filters;":
use super::{filters,models};


#[tokio::test]
async fn try_create() {
    let db = models::new_db();
    let api = filters::post_sim(db);

    let response = request()
        .method("POST")
        .path("/holodeck")
        .json(&models::Simulation{
            id: 1,
            name: String::from("The Big Goodbye")
        })
        .reply(&api)
        .await;

    assert_eq!(response.status(), StatusCode::CREATED);
}
Enter fullscreen mode Exit fullscreen mode

What can go wrong (among other things) is conflict due to duplicated entries; so I created a test for that as well:

#[tokio::test]
async fn try_create_duplicates() {
    let db = models::new_db();
    let api = filters::post_sim(db);

    let response = request()
        .method("POST")
        .path("/holodeck")
        .json(&models::Simulation{
            id: 1,
            name: String::from("Bride Of Chaotica!")
        })
        .reply(&api)
        .await;

    assert_eq!(response.status(), StatusCode::CREATED);

    let response = request()
        .method("POST")
        .path("/holodeck")
        .json(&models::Simulation{
            id: 1,
            name: String::from("Bride Of Chaotica!")
        })
        .reply(&api)
        .await;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
Enter fullscreen mode Exit fullscreen mode

The Simulation struct that's being passed as JSON is still to be defined (which will require a use super::models here in the tests mod—be aware of these use statements; hopefully I haven't forgotten to mention any).

Before coding post_sim(), I needed three things: [1] types to handle and persist the data (just in memory), [2] a JSON body, and [3] a way to bundle it all up.

Starting with [1]: I first needed the serde crate to handle (de)serialization, so I added this line under [dependencies] in Cargo.toml

serde = { version = "1", features = ["derive"]}
Enter fullscreen mode Exit fullscreen mode

With that settled I went back to lib.rs and created a mod with a type and a struct that can automagically (de)serialize the data that will come as JSON, thanks to the serde macros. Ah, and there's also the omnipresent new() function (which is not inside an implementation as the manuals usually tell you to do because I used type for Db, as I didn't want to nest it within a struct).

mod models {
    use serde::{Deserialize, Serialize};
    use std::collections::HashSet;
    use std::sync::Arc;
    use tokio::sync::Mutex;

    #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]
    pub struct Simulation {
        pub id: u64,
        pub name: String,
    }

    pub type Db = Arc<Mutex<HashSet<Simulation>>>;

    pub fn new_db() -> Db {
        Arc::new(Mutex::new(HashSet::new()))
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, why did I chose these smart pointers?

  • Short answer: because the cool kids did so.
  • Smart answer: I don't think I had a good alternative for Arc, as I needed a thread-safe reference to the HashSet; regarding Mutex, I could have used RwLock instead, to allow concurrent reads (Mutex make you hold the lock for both read and write alike), but it didn't seem necessary given the context.

But what about what is inside the Mutex? "Why a HashSet?", you ask. Well, I agree that a HashMap feels like the obvious choice, and I also agree that a Vector would be the easier one to implement, but I chose HashSet because it allowed me to gain an important benefit from HashMap alongside some of the Vector advantages; but the actual explanation is something that I'm keeping for the second part of this series, so you'll have to trust me on this one.

[2]: The JSON body function was just copied from the aforementioned cool kids' example and placed inside the filters mod. It accepts any JSON body, insofar it ain't too big.

// This line already exists; I just added "models"
use super::{handlers, models}; 

fn json_body() -> impl Filter<Extract = (models::Simulation,), Error = warp::Rejection> + Clone {
    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}
Enter fullscreen mode Exit fullscreen mode

To bundle it all up [3], we need another new function under filters. This function, post_sim(), will receive a JSON body and a Db (our Arc<Mutex<HashSet>>), and then send both to the handler handle_create_sim().

pub fn post_sim(db: models::Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    let db_map = warp::any()
        .map(move || db.clone());

    warp::path!("holodeck")
        .and(warp::post())
        .and(json_body())
        .and(db_map)
        .and_then(handlers::handle_create_sim)
}
Enter fullscreen mode Exit fullscreen mode

The line that might be harder to grasp here is the let db_map one. The warp::any is a catch all; that is, it is a filter that filters nothing. So all we are doing here is making sure our Db is "wrapped" in a Filter, so we can stick it into the .and(db_map) you see up there.

The handle_create_sim() that is triggered by the and_then goes inside the handlers mod looks like this:

// Add this "use" below the others
use super::models;

pub async fn handle_create_sim(sim: models::Simulation, db: models::Db) -> Result<impl warp::Reply, Infallible> {
    let mut map = db.lock().await;

    if let Some(result) = map.get(&sim){
        return Ok(warp::reply::with_status(
            format!("Simulation #{} already exists under the name {}", result.id, result.name), 
            StatusCode::BAD_REQUEST,
        ));
    }

    map.insert(sim.clone());
    Ok(warp::reply::with_status(format!("Simulation #{} created", sim.id), StatusCode::CREATED))
}
Enter fullscreen mode Exit fullscreen mode

It takes the JSON (sim: Simulation) and the HashSet (db: Db), returns an error if the entry is already there, or inserts the JSON data into the HashSet and return success otherwise.

Here having a HashMap would clearly be the better solution, as it would allow us to compare keys. We'll solve this in part 2.

I could've just used Ok(StatusCode::CREATED) and Ok(StatusCode::BAD_REQUEST) instead of wrapping it inside a warp::reply::with_status, but since I am not going to handle the errors, I thought it was the least I could do.

$ cargo test

running 3 tests
test tests::try_create ... ok
test tests::try_create_duplicates ... ok
test tests::try_list ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Enter fullscreen mode Exit fullscreen mode

Alright, alright, alright.


In the next episode of Engaging Warp...

Next in line is the GET method, which means we'll see parameter handling and (finally) deal with this HashSet thing.

🖖

💖 💪 🙅 🚩
rogertorres
Roger Torres (he/him/ele)

Posted on April 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related