DEV Community

Cover image for REST API with Rust + Warp 3: GET
Roger Torres (he/him/ele)
Roger Torres (he/him/ele)

Posted on

REST API with Rust + Warp 3: GET

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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fetch all the entries: /holodeck
  2. Fetch a single entry: /holodeck/:id
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 and hash(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(),
    })
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)) 
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In the next episode of Engaging Warp...

We will finish the implementation by implementing the PUT and DELETE methods.

🖖

Oldest comments (0)