loading...
Cover image for Rocket Tutorial 03: Proper routing

Rocket Tutorial 03: Proper routing

davidedelpapa profile image Davide Del Papa ・15 min read

[Photo by Denys Nevozhai on Unsplash, modified (cropped)]

We will continue to explore Rocket's capabilities. This time we are getting closer to the real deal (sort of...)

The code for this tutorial can be found in this repository: github.com/davidedelpapa/rocket-tut, and has been tagged for your convenience:

git clone https://github.com/davidedelpapa/rocket-tut.git
cd rocket-tut
git checkout tags/tut3
Enter fullscreen mode Exit fullscreen mode

Today will talk about or routes... So allow me a little detour first.

Improve Tests using a Single Common Client

If there's one thing we need to do about our tests, that we didn't do last time, is to create only an instance of the Rocket client.

This thing has been bugging me, we should have got it right the first time around! So we will immediately correct it.

First we need to add lazy_static as a dev-dependency, that is a dependency only used for tests or benches.

cargo add lazy_static --dev
Enter fullscreen mode Exit fullscreen mode

Now we can correct the code inside tests/common/mod.rs:

use crate::lazy_static::lazy_static;

use rocket::local::Client;
use rocket_tut::rocket_builder;

pub fn setup () -> &'static Client {
    lazy_static! {
        static ref CLIENT: Client = Client::new(rocket_builder()).expect("Valid Rocket instance");
    }
    &*CLIENT
}
Enter fullscreen mode Exit fullscreen mode

Remember, in order to use the lazy_static crate inside a mod we need to add it at the root as well, in tests/basic_test.rs:

use lazy_static;
Enter fullscreen mode Exit fullscreen mode

Now with cargo test we should see a big improvement time-wise, because there is just one Rocket Client that gets created only once, and used in all the tests. Before this, each test would create its own Client slowing down the testing process, and consuming CPU resources.

Now, back to the theme of today's installment!

Using Proper UUIDs

So far we have just created toy routes. It is time to get real(er).

The first thing we will improve is the enforcing of the use of proper UUID to refer to users inside the API.

We will need the uuid crate, and the corresponding rocket_contrib uuid features

cargo add rocket_contrib --features "helmet uuid"
cargo add uuid --features "serde v4"
Enter fullscreen mode Exit fullscreen mode

Remember, to add more than one feature to a crate we need to specify a space-separated list inside double quotes for the flag --features as shown above.

Now in src/routes/user.rs we will add the handling of uuids. First the use

use rocket::*;
use rocket_contrib::json::Json;
use serde::{Deserialize, Serialize};
use rocket_contrib::uuid::Uuid;
Enter fullscreen mode Exit fullscreen mode

Then in info_user_rt, update_user_rt, and delete_user_rt we will manage the proper uuid type:

#[get("/users/<id>")]
pub fn info_user_rt(id: Uuid) -> Json<Response> {
    Json(Response::ok(&* format!("Info for user {}", id)))
}

#[put("/users/<id>")]
pub fn update_user_rt(id: Uuid) -> Json<Response> {
    Json(Response::ok(&* format!("Update info for user {}", id)))
}

#[delete("/users/<id>")]
pub fn delete_user_rt(id: Uuid) -> Json<Response> {
    Json(Response::ok(&* format!("Delete user {}", id)))
}
Enter fullscreen mode Exit fullscreen mode

In this way, only proper uuid's will be accepted as <id>.

For example the following gives a 404 error page:

curl "http://localhost:8000/api/users/1"
Enter fullscreen mode Exit fullscreen mode

While instead this one gets through:

curl "http://localhost:8000/api/users/$(uuidgen)"
Enter fullscreen mode Exit fullscreen mode

NB: we use the command uuidgen. It should be present in any distribution by now, otherwise we can replace it with

curl "http://localhost:8000/api/users/$(cat /proc/sys/kernel/random/uuid)"
Enter fullscreen mode Exit fullscreen mode

Handling users the easy way

To keep things easy for this part we will keep the users in memory, not interfacing with any database.

Still, we need some structs to handle the users. So, we will create the folder data/ inside src/.
In this folder we will add two files src/data/mod.rs to handle the module, and src/data/db.rs where we will keep our virtual users database.

Inside mod.rs we will just add the following:

pub mod db;
Enter fullscreen mode Exit fullscreen mode

While the meatiest part is in db.rs Before editing that, we will need to install 3 new crates: rand for random generation, chrono to handle timestamps, and rust-argon2 as our trusty hashing utility.

cargo add rand rust-argon2
cargo add chrono --features serde
Enter fullscreen mode Exit fullscreen mode

We added to chrono the feature to integrate with serde.

Let's import now everything we need in src/data/db.rs

use serde::{Deserialize, Serialize};
use uuid::Uuid;
use argon2;
use rand::{thread_rng, Rng};
use rand::distributions::Alphanumeric;
use chrono::{DateTime, Utc};
Enter fullscreen mode Exit fullscreen mode

We are using serde to serialize and deserialize our structs, we are using uuid to handle the id, we are using argon2 to hash the passwords, and rand to obtain a random alphanumerical salt; chrono of course is for timestamps.

The first of our structs is User:

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
    #[serde(rename = "_id")]
    pub id: Uuid,
    pub name: String,
    pub email: String,
    pub hashed_password: String,
    pub salt: String,
    pub created: DateTime<Utc>,
    pub updated: DateTime<Utc>,
}
Enter fullscreen mode Exit fullscreen mode

I got a little ahead renaming id to _id just for use with mongodb, more on that later on (another tutorial on this).

We are not saving passwords here, in accordance with EU laws, and common sense. We save the hash of the password + a unique salt for each user, for added security. Please notice that we are going to receive the passwords in clear though, so a man-in-the-middle attack would be the easiet way to defeat all hash and salts... Therefore, all this security is useless if the communication channel can be compromised. In production we need to use always the https protocol.

As you can see we have also a created and updated fields, useful for stats on users.

In order for our APIs to work, we need also few other structs. The first of which is InsertableUser:

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InsertableUser {
    pub name: String,
    pub email: String,
    pub password: String,
}
Enter fullscreen mode Exit fullscreen mode

In this way, we can easily insert only few needed fields in our User, and do the rest through code. Let's see the impl for User:

impl User {
    pub fn new(name: String, email: String, password: String) -> Self {
        let salt: String = thread_rng()
            .sample_iter(&Alphanumeric)
            .take(20)
            .collect();
        let hashed_password = hash_password(&password, &salt);

        User {
            id: Uuid::new_v4(),
            name,
            email,
            hashed_password,
            salt,
            created: Utc::now(),
            updated: Utc::now(),
        }
    }
    pub fn from_insertable(insertable: InsertableUser) -> Self {
        User::new(insertable.name, insertable.email, insertable.password)
    }
    pub fn match_password(&self, password: &String) -> bool {
        argon2::verify_encoded(&self.hashed_password, password.as_bytes()).unwrap()
    }
    pub fn update_password(&mut self, password: &String) {
        self.hashed_password = hash_password(password, &self.salt);
        self.updated = Utc::now();
    }
    pub fn update_user(&mut self, name: &String, email: &String) {
        self.name = name.to_string();
        self.email = email.to_string();
        self.updated = Utc::now();
    }
}
Enter fullscreen mode Exit fullscreen mode

Lot of code! To break it down:

  • the first fn is new(): it takes as parameters the same fields we have in InsertableUser, and calculates all the rest:
    • creates a salt out of 20 random alphanumeric characters
    • creates the password's hash out of the real password and the salt with a helper function (more on that later on)
    • fills in all the fields of User generating a uuid version 4, and two timestamps for created and updated (if we wanted them to coincide we could have called Utc::now() on a variable beforehand, and then fill in both fields with the same var.
  • the second function is from_insertable(): it takes an InsertableUser and creates a User::new() out of it
  • we have a match_password() to check if the provided password matches with the hash saved;
  • we have also a update_password() to change the user password, and a update_user() to update the rest of the info

The following is the hash_password() which is very simple, and there's not much about it:

fn hash_password(password: &String, salt: &String) -> String {
    let config = argon2::Config::default();
    argon2::hash_encoded(password.as_bytes(), salt.as_bytes(), &config).unwrap()
}
Enter fullscreen mode Exit fullscreen mode

To round off our DB, we have also a ResponseUser struct, that is used to send info on the selected User back to thrhough the API:

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseUser {
    pub id: String,
    pub name: String,
    pub email: String,
}
impl ResponseUser{
    pub fn from_user(user: &User)-> Self {
        ResponseUser{
            id: user.id.to_string(),
            name: format!("{}", user.name),
            email: format!("{}", user.email),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, instead of a new() we have a from_user() because indeed we need to construct one such object only in answering through the API: we just need to mask out the fields we need to keep secret. Think about it: if for any request we would send back all the info we have saved for our User we would be giving out also salt and hashed_password: not good at all!

We have a last utility struct, that we will use in order to receive the password or an old password plus a new one:

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserPassword {
    pub password: String,
    pub new_password: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

We are set to go with all our structs.
The only doubt we can have: where do we store our Users?

We said we were keeping everything in memory for the time being, without saving data anywhere, so let's create a state in our server to keep all user info.

Let's modify our src/lib.rs:

use std::sync::{Arc, Mutex};
pub mod data;
Enter fullscreen mode Exit fullscreen mode

Well, yes, we could use a simpler method... but we need to lock the state in each API call in whch is needed in order to give a minimum ACID guarantee. I'm well aware that in a real world scenario this would be a major bottleneck, but... in a real world scenario we would be using a proper DB; for now this is still a core concepts guide, we didn't get yet to the real world experience.

Now we need to declare a State; we do so by creatng a struct which contains a Vec of our Users, wrapped in a Mutex, wrapped in a Arc.

pub struct Users {
    pub db: Arc<Mutex<Vec<data::db::User>>>
}
impl Users {
    pub fn new()-> Self {
        Users {
            db: Arc::new(Mutex::new(vec![]))
        } 
    }
}
Enter fullscreen mode Exit fullscreen mode

We'll see soon fearless concurrency in action(TM), don't worry! In the meanwhile, check out the rocket_builder():

pub fn rocket_builder() -> rocket::Rocket {
    rocket::ignite().attach(SpaceHelmet::default())
    .mount("/", routes![routes::ping::ping_fn])
    .mount("/api", routes![
        routes::user::user_list_rt,
        routes::user::new_user_rt,
        routes::user::info_user_rt,
        routes::user::update_user_rt,
        routes::user::delete_user_rt
    ])
    .mount("/files", StaticFiles::from("static/"))
    .manage(Users::new())
}
Enter fullscreen mode Exit fullscreen mode

Yes, in reality we just added a manage() method, passing a new Users state to it. And that is really all that there is to do on our part: Rocket'll do the rest of the job.

Our new User routes

Finally let's see how we need to update the src/routes/user.rs in order to use our new state and all our new data structures.

use rocket::*;
use rocket_contrib::json::{Json, JsonValue};
use rocket_contrib::json;
use rocket_contrib::uuid::Uuid;
use rocket::State;
use rocket::response;
use rocket::http::{ContentType, Status};
use rocket::response::{Responder, Response};
use crate::Users;
use crate::data::db::{User, InsertableUser, ResponseUser, UserPassword};
Enter fullscreen mode Exit fullscreen mode

Yes, we need many use imports. Apart from our custom structures in data::db, from rocket_contrib we are going to need the Json and Json values, as well as rocket_contrib::json, which will allow us to use the json! macro.

From rocket itself we will need to manage the State, but also various response utilities: we are going to create a custom responder!

In fact, we will change Response to ApiResponse and impl a responder for it.

#[derive(Debug)]
pub struct ApiResponse {
    status: Status,
    message: JsonValue,
}
Enter fullscreen mode Exit fullscreen mode

It is very similar to the old Response, but we are not returning two String, but a JsonValue. We will use the status field to return a real http status. In fact, before this we were returning always a 400 status of success, but with an error inside.

Check now the impl:

impl ApiResponse {
    pub fn ok(message: JsonValue) -> Self {
        ApiResponse {
            status: Status::Ok,
            message: message,
        }
    }
    pub fn err(message: JsonValue) -> Self {
        ApiResponse {
            status: Status::InternalServerError,
            message: message,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Ok, for the err() we are simply reporting a InternalServerError, that is 500. We could make other functions, notably for unauthorized.
However, this is a further improvement, which you are free to pursue on your own.

Now, to make our ApiResponse into a rocket::response::Responder, we need to impl it this way:

impl<'r> Responder<'r> for ApiResponse {
    fn respond_to(self, req: &Request) -> response::Result<'r> {
        Response::build_from(self.message.respond_to(&req).unwrap())
            .status(self.status)
            .header(ContentType::JSON)
            .ok()
    }
}
Enter fullscreen mode Exit fullscreen mode

This way we will just return ApiResponse from our route functions, and rocket will do the rest, setting the correct status and content header.

For example, take a look at the new fn user_list_rt():

#[get("/users")]
pub fn user_list_rt(userdb: State<Users>) -> ApiResponse {
    let v = userdb.db.lock().unwrap();
    let users = &*v;
    ApiResponse::ok(json!([users.len()]))
}
Enter fullscreen mode Exit fullscreen mode

We get the State<Users> as the route function parameter.

Then, we need to get a lock() on its Mutex, and get a reference to its content. Once we have a reference to the Vec<User>, we can return its lenght transforming [] into the corresponding JSON vector, trhough the json! macro. We return it building our ApiResponse struct through the ok() method we implemented earlier. Easy, isn't it?

What about inserting a new user?

#[post("/users", format = "json", data = "<user>")]
pub fn new_user_rt(userdb: State<Users>, user: Json<InsertableUser>) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    users.push(User::from_insertable((*user).clone()));
    ApiResponse::ok(json!(ResponseUser::from_user(&users.last().unwrap())))
}
Enter fullscreen mode Exit fullscreen mode

Here we need a mutable reference to the inner Vec<User>, with &mut, so that we can modify it, adding a User with the Vec::push().

In order to add a new user we make use of use the User::from_insertable, giving to it the IsertableUser we got in the body of the PUT request, as specified in the function decorator and parameters:

#[post("/users", format = "json", data = "<user>")]
pub fn new_user_rt(userdb: State<Users>, user: Json<InsertableUser>) -> ApiResponse {
Enter fullscreen mode Exit fullscreen mode

Remember to set format = "json" for it to work properly, and specify the type of the data (the data in the body of the request) as Json<InsertableUser>.

Next, for the GET with <id>, we need the following:

#[get("/users/<id>")]
pub fn info_user_rt(userdb: State<Users>, id: Uuid) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    let pos = users.iter().position(|x| x.id.to_string() == id.to_string());
    match pos {
        Some(p) => ApiResponse::ok(json!(ResponseUser::from_user(&v[p]))),
        None => ApiResponse::err(json!(format!("id {} not found",  id)))
    }
}
Enter fullscreen mode Exit fullscreen mode

We iterate over the vector, mapping it with the position() method, which returns an iterator with the position of the element(s) where the closure returns true.

let pos = users.iter().position(|x| x.id.to_string() == id.to_string());
Enter fullscreen mode Exit fullscreen mode

We match against the result, so that if an element is not found, we can return an error. If found, we return a ResponseUser::from_user() with the User we found at the v[p] position.

Similarly for all other routes that involve the <id> parameter in the request, each with its quirks.

For example, for the PUT request (update username or email), in order to update the user we request an InsertableUser, because we need approval by matching the password:

#[put("/users/<id>", format = "json", data = "<user>")]
pub fn update_user_rt(userdb: State<Users>, user: Json<InsertableUser>, id: Uuid) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    let pos = users.iter().position(|x| x.id.to_string() == id.to_string());
    match pos {
        Some(p) => {
            if v[p].match_password(&user.password) {
                v[p].update_user(&user.name, &user.email);
                ApiResponse::ok(json!(ResponseUser::from_user(&v[p])))
            }
            else { ApiResponse::err(json!("user not authenticated")) }
        },
        None => ApiResponse::err(json!(format!("id {} not found",  id)))
    }
}
Enter fullscreen mode Exit fullscreen mode

For the DELETE, we instead ask for a UserPassword to get a password for authorization.

#[delete("/users/<id>", format = "json", data = "<user>")]
pub fn delete_user_rt(userdb: State<Users>, user: Json<UserPassword>, id: Uuid) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    let pos = users.iter().position(|x| x.id.to_string() == id.to_string());
    match pos {
        Some(p) => {
            if v[p].match_password(&user.password) {
                let u = v[p].clone();
                v.remove(p);
                ApiResponse::ok(json!(ResponseUser::from_user(&u)))
            }
            else { ApiResponse::err(json!("user not authenticated")) }
        },
        None => ApiResponse::err(json!(format!("id {} not found",  id)))
    }
}
Enter fullscreen mode Exit fullscreen mode

Of course in this case we remove() the element present at the position where we found the id.

Improving the API

Speacking of UserPassword, we added an optional field for a new password, to update the user password if needed. So let's write a route for that.

We will use a PATCH request to the same users/<id> trusted route.

Why PATCH for one thing, PUT for another? Well we need two ways to update the user: name and email, or the password. We could make use of a single route, with many guard controls, etc. But since we have two http requests (PUT and PATCH) that more or less do the same job of updating data, I prefer to keep two different functions, and have therefore two routes. And I often use PUT and PATCH interchangeably. We started with PUT for name and email, we'll use PATCH for the password. Feel free to improve over it as you see fit.

#[patch("/users/<id>", format = "json", data = "<user>")]
pub fn patch_user_rt(userdb: State<Users>, user: Json<UserPassword>, id: Uuid) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    let pos = users.iter().position(|x| x.id.to_string() == id.to_string());
    match pos {
        Some(p) => {
            if v[p].match_password(&user.password) {
                match &user.new_password {
                    Some(passw) => {
                        v[p].update_password(&passw);
                        ApiResponse::ok(json!("Password updated"))
                    },
                    None => ApiResponse::err(json!("Password not provided"))
                }
            }
            else { ApiResponse::err(json!("user not authenticated")) }
        },
        None => ApiResponse::err(json!(format!("id {} not found",  id)))
    }
}
Enter fullscreen mode Exit fullscreen mode

In this way, we can update our passwords.

We need also another route, an utility to get the id from the user info. in fact, people do not remember a UUID: I can barely remember username and password of the most used services...

We could either use the name or the email to extrapolate the id. However, the name does not need to be unique, while the email is unique by definition.

Besides, in order to restore passwords or data, it is more useful to keep an official email to send the restore info to. In fact, in many services the name is optional, while the email is used as identifier.

So let's proceed with the email; we will use again the GET request:

#[get("/users/<email>", rank = 2)]
pub fn id_user_rt(userdb: State<Users>, email: String) -> ApiResponse {
    let mut v = userdb.db.lock().unwrap();
    let users = &mut *v;
    let pos = users.iter().position(|x| x.email == email);
    match pos {
        Some(p) => ApiResponse::ok(json!(ResponseUser::from_user(&v[p]))),
        None => ApiResponse::err(json!(format!("user {} not found",  email)))
    }
}
Enter fullscreen mode Exit fullscreen mode

You might wonder how can we distinguish from calling the right GET /users/..., when we have both a /users/<id> and a /users/<email>.

Well, rocket has two similar routes, it attempts first to use one, then the other if the first fails. We do not specify anything special with email, we reduce it to a String. However, while a uuid could be confused with a String, the contrary is not possible: there are various checks to ensure that a string corresponds to a UUID (it cannot be confused with a email!). With rank=2 we insure that the generic String is the second GET route it is used. In this way, only if the check to the UUID fails, we know that the data is most probably an email, not a UUID.

That is, if /users/<id> fails, then /users/<email> will be called only after the matching with a Uuid fails.

Notice that we return a ResponseUser containing the id, name, and email, for convenience.

We need to update Rocket's mount() method in src/lib.rs in order to use the last two routes:

.mount("/api", routes![
    routes::user::user_list_rt,
    routes::user::new_user_rt,
    routes::user::info_user_rt,
    routes::user::update_user_rt,
    routes::user::delete_user_rt,
    routes::user::patch_user_rt, // new
    routes::user::id_user_rt // new
])
Enter fullscreen mode Exit fullscreen mode

Quick tests

Right now the tests/basic_test.rs tests are no longer valid. To make it work we have some more work to do... but we will tend to this job in the next tutorial.

For now, let's use our postman, ARC, ... or the trusty old curl.

curl "http://localhost:8000/api/users"
Enter fullscreen mode Exit fullscreen mode

That should get you a [0] response

If we create a file called request_post.json, with the following content:

{
    "name": "John Doe",
    "email": "j.doe@m.com",
    "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

Then we can test the POST route this way:

curl -d @request_post.json -H "Content-Type: application/json" "http://localhost:8000/api/users"
Enter fullscreen mode Exit fullscreen mode

As an example, this is the response I got:

{"email":"j.doe@m.com","id":"e3404b3d-0298-40a8-95bd-de642ba5d8c2","name":"John Doe"}
Enter fullscreen mode Exit fullscreen mode

If we have jq installed (sudo apt-get install jq -y) we can get it pretty-printed:

curl -s -d @request_post.json -H "Content-Type: application/json" "http://localhost:8000/api/users" | jq

{
  "email": "j.doe@m.com",
  "id": "4cc7dcf7-37e1-4d58-abd2-ab8b13837c86",
  "name": "John Doe"
}
Enter fullscreen mode Exit fullscreen mode

And of course, if we just need to inser and get only the id for later usage, we can:

curl -s -d @request_post.json -H "Content-Type: application/json" "http://localhost:8000/api/users" | jq '.id'
"fa6fb75b-7f90-4347-b627-afa2c5c5d9b5"
Enter fullscreen mode Exit fullscreen mode

Great! Let's get the complete info now:

curl -s "http://localhost:8000/api/users/fa6fb75b-7f90-4347-b627-afa2c5c5d9b5" | jq

{
  "email": "j.doe@m.com",
  "id": "fa6fb75b-7f90-4347-b627-afa2c5c5d9b5",
  "name": "John Doe"
}
Enter fullscreen mode Exit fullscreen mode

We could get it also using the email:

curl -s "http://localhost:8000/api/users/j.doe@m.com" | jq

{
  "email": "j.doe@m.com",
  "id": "fa6fb75b-7f90-4347-b627-afa2c5c5d9b5",
  "name": "John Doe"
}
Enter fullscreen mode Exit fullscreen mode

if we create a request_put.json with the following content:

{
    "name": "Jane Doe",
    "email": "j.doe@m.com",
    "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

we can udate the info on our user:

curl -s -d @request_put.json -H "Content-Type: application/json" -X PUT "http://localhost:8000/api/users/fa6fb75b-7f90-4347-b627-afa2c5c5d9b5" | jq

{
  "email": "j.doe@m.com",
  "id": "fa6fb75b-7f90-4347-b627-afa2c5c5d9b5",
  "name": "Jane Doe"
}
Enter fullscreen mode Exit fullscreen mode

Similarly, with a request_changepassword.json:

{
    "password": "123456",
    "new_password": "qwertyuiop"
}
Enter fullscreen mode Exit fullscreen mode

we can change the user's password

curl -s -d @request_changepassword.json -H "Content-Type: application/json" -X PATCH "http://localhost:8000/api/users/fa6fb75b-7f90-4347-b627-afa2c5c5d9b5" | jq

"Password updated"
Enter fullscreen mode Exit fullscreen mode

And with a request_delete.json with the new password,

{
    "password": "qwertyuiop"
}
Enter fullscreen mode Exit fullscreen mode

we can delete the user:

curl -s -d @request_delete.json -H "Content-Type: application/json" -X DELETE "http://localhost:8000/api/users/fa6fb75b-7f90-4347-b627-afa2c5c5d9b5" | jq

{
  "email": "j.doe@m.com",
  "id": "fa6fb75b-7f90-4347-b627-afa2c5c5d9b5",
  "name": "Jane Doe"
}
Enter fullscreen mode Exit fullscreen mode

I think we can be quite satisfied by the results

Conclusions

For today that's all folks! Hope it has been useful.
Next time we will see to it that the tests are updated, before exploring ways to make our API more useful in a real project.

See you soon!

Discussion

pic
Editor guide
Collapse
terkwood profile image
Felix Terkhorn

Thanks for putting together all this great info on Rocket! 🚀