Roger Torres (he/him/ele)
Posted on April 14, 2021
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);
}
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);
}
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"]}
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()))
}
}
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; regardingMutex
, I could have usedRwLock
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())
}
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)
}
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))
}
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
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.
🖖
Posted on April 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.