Rocket Tutorial 02: Minimalist API

davidedelpapa

Davide Del Papa

Posted on November 10, 2020

Rocket Tutorial 02: Minimalist API

[ Photo by Adetunji Paul on Unsplash ]

In this installment we will create a minimalist CRUD API server using Rocket. Actually, we will just sketch one up, and prepare the foundations for more to come.

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

Clean up -- Refactor

As first thing to do, we will refactor the code.

This is the filesystem we will end up with after refactoring:

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── src
│   ├── lib.rs
│   ├── main.rs
│   └── routes
│       ├── echo.rs
│       └── mod.rs
├── static
│   └── abruzzo.png
└── tests
    └── basic_test.rs
Enter fullscreen mode Exit fullscreen mode

In src/ we will create the folder routes/, where our routes will go, while outside src/, alongside it, we will create a tests/ folder to hold all our tests

Let's go into all the files needed.

Alongside main,rs, inside src/ we will create a lib.rs with the following code:

#![feature(proc_macro_hygiene, decl_macro)]
#![allow(unused_attributes)]

#[macro_use] use rocket::*;
use rocket_contrib::serve::StaticFiles;
use rocket_contrib::helmet::SpaceHelmet;

mod routes;

pub fn rocket_builder() -> rocket::Rocket {
    rocket::ignite().attach(SpaceHelmet::default())
    .mount("/", routes![routes::echo::echo_fn])

    .mount("/files", StaticFiles::from("static/"))
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we refactored the fn rocket(), changing its name to rocket_builder() in order that it will not collide with the Rocket crate.

We declare a mod routes inside this file as well. Notice too that the namespace for the route of our echo function is now routes::echo::echo_fn.

In order to use the directory src/routes/ as a module, we will create a mod.rs inside it, with the following content

pub mod echo;
Enter fullscreen mode Exit fullscreen mode

Then we will create the file src/routes/echo.rs with the following content:

use rocket::*;

#[get("/echo/<echo>")]
pub fn echo_fn(echo: String) -> String {
    echo
}
Enter fullscreen mode Exit fullscreen mode

This is simply our echo_fn() factored out.

Finally our src/main.rs will be just as follows:

use rocket_tut::rocket_builder;

fn main() {

    rocket_builder().launch();
}
Enter fullscreen mode Exit fullscreen mode

Everything has been factored out, that is why it feels so bare-bones.

With cargo run we will insure everything is working properly.

Lastly, inside tests/ we will create all our tests for the server. Thus,let's start with the code we already have, that we will put inside tests/basic_test.rs:

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

#[test]
fn echo_test() {
    let client = Client::new(rocket_builder()).expect("Valid Rocket instance");
    let mut response = client.get("/echo/test_echo").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.body_string(), Some("test_echo".into()));
}
Enter fullscreen mode Exit fullscreen mode

The code for testing has been reduced as well.

With cargo test we can confirm that everything is testing properly.

The bare minimum for a usable API

We will create a CRUD server, with the capability of registering a user, and managing the user account.

We will have the following endpoints, each with its method:

GET /users/ - Get a list of all the registered users
POST /users/ - registers a new user
GET /users/ - Get info on the user with id =
PUT /users/ - Updates info of the user with id =
DELETE /users/ - Deletes the user with id =

For now we will keep it that simple.

As for the echo_fn(), we will not need it, so we will transform it from a echo to a ping function. This is useful to check if the server's up and running, in case anybody needs it.

src/routes/echo.rs will become src/routes/ping.rs, with the following content:

use rocket::*;

#[get("/ping")]
pub fn ping_fn() -> String {
    "PONG!".to_string()
}
Enter fullscreen mode Exit fullscreen mode

We need to update src/routes/mod.rs to point to the new file:

pub mod ping;
Enter fullscreen mode Exit fullscreen mode

And also src/lib.rs:

.mount("/", routes![routes::ping::ping_fn])
Enter fullscreen mode Exit fullscreen mode

Finally, the tests/basic_test.rs has to reflect the new route & test condition:

#[test]
fn ping_test() {
    let client = Client::new(rocket_builder()).expect("Valid Rocket instance");
    let mut response = client.get("/ping").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.body_string(), Some("PONG!".into()));
}
Enter fullscreen mode Exit fullscreen mode

Now both cargo test and cargo run should work as expected.

The User's routes

Let's add a user.rs inside src/routes.

We will use placeholders routes just in order to scaffold and quick-test this.

use rocket::*;

#[get("/users")]
pub fn user_list_rt() -> String {
    "List of users".to_string()
}

#[post("/users")]
pub fn new_user_rt() -> String {
    "Creation of new user".to_string()
}

#[get("/users/<id>")]
pub fn info_user_rt(id: String) -> String {
    format!("Info for user {}", id)
}

#[put("/users/<id>")]
pub fn update_user_rt(id: String) -> String {
    format!("Update info for user {}", id)
}

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

As you can see, there is a function (ending in _rt) for each route, with the agreed upon route and http method.

We need to update src/routes/mod.rs

pub mod ping;
pub mod user;
Enter fullscreen mode Exit fullscreen mode

as well as src/lib.rs:

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

As you can see, we added all the routes under the /api mount-point. This way, the GET users/, can be retrieved with: localhost:8000/api/users/1 for example.

To test this now we will go with an empirical method, checking the API manually.

In this case I am using ARC for Google Chrome, but there are many alternatives, such as Postman, Insomnia, Hoppscotch.io (formerly postwoman), etc. Even curl can be used for the purpose!

Alt Text

JSON responses

After a quick test, it is time to transform our REST API for the better, so that it can respond with JSON

First we need serde:

cargo add serde --features derive
Enter fullscreen mode Exit fullscreen mode

Now let's change the code in src/routes/user.rs. We'll add a struct to represent the JSON response.

use serde::{Deserialize, Serialize};


#[derive(Serialize, Deserialize)]
pub struct Response {
    status: String,
    message: String,
}
impl Response {
    fn ok(msg: &str) -> Self {
        Response {
            status: "Success".to_string(),
            message: msg.to_string(),
        }
    }
    fn err(msg: &str) -> Self {
        Response {
            status: "Error".to_string(),
            message: msg.to_string(),
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Now we have a Response struct that will be converted to a JSON object. We implemented two functions to return one a message with Success, and the other a message with an Error.

Now let's change accordingly the first route, fn user_list_rt():

#[get("/users")]
pub fn user_list_rt() -> Json<Response> {
    Json(Response::ok("List of users"))
}
Enter fullscreen mode Exit fullscreen mode

If we test empirically the response, we see it working.

Alt Text

Now we can now update all the routes the same way:

#[post("/users")]
pub fn new_user_rt() -> Json<Response> {
    Json(Response::ok("Creation of new user"))
}

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

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

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

Implementing the Tests

In order to implement the tests we will create a mod inside the tests/ folder, to collect all initialization logic we will need for our tests.

This will be very helpful later on as well.

For now let's create the directory tests/common/, inside which we will write the following mod.rs:

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

pub fn setup () -> Client {
    Client::new(rocket_builder()).expect("Valid Rocket instance")
}
Enter fullscreen mode Exit fullscreen mode

This will ensure we can call the init of the Rocket client from all our tests, as well as giving us the possibility to insert more needed init code for the future.

Next we will modify our tests/basic_test.rs as follows:

use rocket::http::{ContentType, Status};

mod common;

#[test]
fn ping_test() {
    let client = common::setup();
    let mut response = client.get("/ping").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.body_string(), Some("PONG!".into()));
}
Enter fullscreen mode Exit fullscreen mode

Now we can use the setup() function we defined in tests/common/mod.rs.

Going further, we can check also the first route we wrote in src/routes/user.rs, the user_list_rt() function.

The following is the code to test it:

#[test]
fn user_list_rt_test(){
    let client = common::setup();
    let mut response = client.get("/api/users").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"List of users\"}".into()));
}
Enter fullscreen mode Exit fullscreen mode

As you can see we check the success of the response, but we test as well that the response corresponds to a application/json in the Content-Type, and finally we check for the actual response as well (we construct the JSON of the body directly as a String).

Now running cargo test should verify that all the tests pass successfully.

We can finally test the whole set of routes, to ensure everything works as expected.

#[test]
fn new_user_rt_test(){
    let client = common::setup();
    let mut response = client.post("/api/users").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"Creation of new user\"}".into()));
}

#[test]
fn info_user_rt_test(){
    let client = common::setup();
    let mut response = client.get("/api/users/1").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"Info for user 1\"}".into()));
}

#[test]
fn update_user_rt_test(){
    let client = common::setup();
    let mut response = client.put("/api/users/1").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"Update info for user 1\"}".into()));
}

#[test]
fn delete_user_rt_test(){
    let client = common::setup();
    let mut response = client.delete("/api/users/1").dispatch();
    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.content_type(), Some(ContentType::JSON));
    assert_eq!(response.body_string(), Some("{\"status\":\"Success\",\"message\":\"Delete user 1\"}".into()));
}
Enter fullscreen mode Exit fullscreen mode

Conclusions

We started to stub out our CRUD server.

Of course the routes still do not work as intended, and the tests are merely a pretext, but hey!, everything works correctly, and we are setting the environment for more to come.

Stay tuned!

💖 💪 🙅 🚩
davidedelpapa
Davide Del Papa

Posted on November 10, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related