[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
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
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
}
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;
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"
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;
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)))
}
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"
While instead this one gets through:
curl "http://localhost:8000/api/users/$(uuidgen)"
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)"
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;
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
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};
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>,
}
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,
}
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();
}
}
Lot of code! To break it down:
- the first
fn
isnew()
: it takes as parameters the same fields we have inInsertableUser
, 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 auuid
version 4, and two timestamps forcreated
andupdated
(if we wanted them to coincide we could have calledUtc::now()
on a variable beforehand, and then fill in both fields with the same var.
- creates a
- the second function is
from_insertable()
: it takes anInsertableUser
and creates aUser::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 aupdate_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()
}
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),
}
}
}
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>,
}
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;
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 User
s, 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![]))
}
}
}
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())
}
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};
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,
}
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,
}
}
}
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()
}
}
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()]))
}
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())))
}
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 {
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)))
}
}
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());
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)))
}
}
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)))
}
}
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)))
}
}
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)))
}
}
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
])
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"
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"
}
Then we can test the POST
route this way:
curl -d @request_post.json -H "Content-Type: application/json" "http://localhost:8000/api/users"
As an example, this is the response I got:
{"email":"j.doe@m.com","id":"e3404b3d-0298-40a8-95bd-de642ba5d8c2","name":"John Doe"}
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"
}
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"
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"
}
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"
}
if we create a request_put.json with the following content:
{
"name": "Jane Doe",
"email": "j.doe@m.com",
"password": "123456"
}
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"
}
Similarly, with a request_changepassword.json:
{
"password": "123456",
"new_password": "qwertyuiop"
}
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"
And with a request_delete.json with the new password,
{
"password": "qwertyuiop"
}
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"
}
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!
Top comments (3)
Hi. Sorry, where's the screenshot?
Anyway, I have to check the code on the latest rocket release.. It might be a version compatibility issue. So check also the rocket version and the dependencies versions (the .lock file might help)
Thanks for putting together all this great info on Rocket! ๐
Up till version 0.7.3, rng.sample(Alphanumeric)) would produce chars. After 0.8.0 you need the char conversion you added to the code.