Roger Torres (he/him/ele)
Posted on April 14, 2021
Welcome back! Last time we saw each other I wrote:
Next in line is the
GET
method, which means we'll see parameter handling and (finally) deal with this HashSet thing.
So, "let us not waste our time in idle discourse!"
Warp 3, make it so!
The code for this part is available here.
First, I needed another dependency to help me deserialize the GET
return, so I changed the Cargo.toml
file:
serde_json = "1.0"
Then, the time came to change try_list()
. As of our last encounter, this test had only a request()
and the assert_eq!
. I added two things:
- Before the request, I manually inserted two entries into the HashSet (I could've called
POST
, but since it is already being tested elsewhere, it is ok to take this shortcut); - After the request, I deserialized the HTML body and compared its content to the data I had previously inserted.
There's a chance that a few things will appear weird, but don't worry, I will go through each one of them.
use std::collections::HashSet;
#[tokio::test]
async fn try_list() {
use std::str;
use serde_json;
let simulation1 = models::Simulation{
id: 1,
name: String::from("The Big Goodbye"),
};
let simulation2 = models::Simulation{
id: 2,
name: String::from("Bride Of Chaotica!"),
};
let db = models::new_db();
db.lock().await.insert(simulation1.clone());
db.lock().await.insert(simulation2.clone());
let api = filters::list_sims(db);
let response = request()
.method("GET")
.path("/holodeck")
.reply(&api)
.await;
let result: Vec<u8> = response.into_body().into_iter().collect();
let result = str::from_utf8(&result).unwrap();
let result: HashSet<models::Simulation> = serde_json::from_str(result).unwrap();
assert_eq!(models::get_simulation(&result, 1).unwrap(), &simulation1);
assert_eq!(models::get_simulation(&result, 2).unwrap(), &simulation2);
let response = request()
.method("GET")
.path("/holodeck/2")
.reply(&api)
.await;
let result: Vec<u8> = response.into_body().into_iter().collect();
let result = str::from_utf8(&result).unwrap();
let result: HashSet<models::Simulation> = serde_json::from_str(result).unwrap();
assert_eq!(result.len(),1);
assert_eq!(models::get_simulation(&result, 2).unwrap(), &simulation2);
}
The first thing I take as deserving an explanation is the db.lock().await.insert()
. The lock()
gives you what's inside the Arc, and in this case, it returns a Future. Why? Because we are not using std::sync::Mutex
, but tokio::sync::Mutex
, which is an Async implementation of the former. That's why we don't unwrap()
, but instead await
, as we need to suspend execution until the result of the Future is ready.
Moving on, filters::list_sims()
is now getting a parameter, which is the data it will return (which, in a real execution, would come from the HTTP body).
After the request—that remains the same—there are three lines of Bytes-handling-jibber-jabber.
Bytes is the format with which warp's RequestBuilder handles the HTML body content. It looks like a [u8] (that is, an array of the primitive u8], but it is a little bit more painful to handle. What I did with it, however, is simple. I:
- Mapped its content to a Vector of u8
- Moved the Vector's content to the slice
- Used
serde_json::from_str()
function to map it to the Simulation struct inside the HashSet.
And this is one of the reasons I wanted a HashSet. As far as I know, standard Rust doesn't allow you to create a HashMap referring to a struct of two fields; that is, you cannot do that:
\\ This code is not in the project!
struct Example {
id: u64,
name: String,
}
type Impossible = HashMap<Example>;
And without using a struct as I did with the HashMap (as well as the cool kids did with Vector here at line 205), using serde
gets... complicated (which means I have no idea how to do it).
Nonetheless, there is another reason why I wanted to stick the struct within the HashSet: it gave me the chance to implement some traits for my type.
Before diving into the traits, I would like to explain the last part of the test (which should be a different test, but the example is already too big).
The GET
method can be used in three different ways:
- Fetch all the entries:
/holodeck
- Fetch a single entry:
/holodeck/:id
- Fetch filtered entries:
/holodeck/?search=query
This last request()
using path /holodeck/2
was written to cover the second case. I did not (and will not) develop the third one.
Boldly implementing traits
If you compare the HashSet element with another, it will compare everything. That's no good if you have a key-value-pair struct. As I didn't want to use HashMap because of the aforementioned reasons, the way to go is to change this behavior, making comparisons only care about the id.
First, I brought Hash
and Hasher
, then I removed the Eq
, PartialEq
and Hash
, so I could implement them myself. And the implementation was this:
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Simulation {
pub id: u64,
pub name: String,
}
impl PartialEq for Simulation{
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Simulation {}
impl Hash for Simulation{
fn hash<H: Hasher>(&self, state: &mut H){
self.id.hash(state);
}
}
How did I know how to do it? I just followed the documentation where it says "How can I implement Eq?". Yes, Rust docs are that good.
And what about Hash? Same thing. But it is interesting to note why I did it. HashSet requires the Hash trait, and the Hash trait demands this:
k1 == k2 -> hash(k1) == hash(k2)
That means, if the values you're comparing are equal, their hashes also have to be equal, which would not hold after the implementation of PartialEq
and Eq
because both values were being hashed and compared, while the direct comparison only cared about id
.
99% chance that I am wrong, but I think it should not be an implication (→), but a biconditional (↔), because the way it stands if
k1 == k2
is false andhash(k1) == hash(k2)
is true, the implication's result is still true. But I am not a trained computer scientist and I am not sure this uses first-order logic notation. Let me know in the comments if you do.
One last addition I made below the Hash implementation was this:
pub fn get_simulation<'a>(sims: &'a HashSet<Simulation>, id: u64) -> Option<&'a Simulation>{
sims.get(&Simulation{
id,
name: String::new(),
})
}
Even though the only relevant field for comparisons is id
when using methods such as get()
we have to pass the entire struct, so I created get_simulation()
to replace it.
Ok, back to the GET
method.
Getting away with it
The functions dealing with the GET
method now have to deal with two additional information, the HashSet from where it will fetch the result and the parameter that might be used.
pub fn list_sims(db: models::Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
let db_map = warp::any()
.map(move || db.clone());
let opt = warp::path::param::<u64>()
.map(Some)
.or_else(|_| async {
// Ok(None)
Ok::<(Option<u64>,), std::convert::Infallible>((None,))
});
warp::path!("holodeck" / ..)
.and(opt)
.and(warp::path::end())
.and(db_map)
.and_then(handlers::handle_list_sims)
}
The opt
represents the optional parameter that can be sent. It gets a param, map it as an Option
(i.e., Some
). If it was not provided, the or_else()
returns a None
. The reason why there's and async
there is because or_else()
returns a TryFuture.
The path
we are actually returning includes this opt
the same way we included the db_bap
. the / ..
at the and of path!
is there to tell the macro to not add the end()
so I could add the opt
. That's why there's a manual end()
there soon after.
I didn't found this solution in the docs or in the examples. Actually, for some reason, most tutorials omit
GET
parameters. They either just list everything or use query. I found one tutorial that implemented this, but they did so by creating two filters and two handlers. It didn't felt ok, and I knew there should be a solution and that the problem was probably my searching skills; so I asked for help in warp's discord channel, and the nice gentleman jxs pointed me to the solution you saw above.
The next step was to fix the handler:
pub async fn handle_list_sims(param: u64, db: models::Db) -> Result<impl warp::Reply, Infallible> {
let mut result = db.lock().await.clone();
if param > 0 {
result.retain(|k| k.id == param);
};
Ok(warp::reply::json(&result))
}
It is no longer a Matthew McConaughey handler, but still very simple. I am using retain
instead of a get_simulation()
because it returns a HashSet (and get would give me a models::Simulation
), which is exactly what the handler must return.
$ 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
In the next episode of Engaging Warp...
We will finish the implementation by implementing the PUT
and DELETE
methods.
🖖
Posted on April 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.