Davide Del Papa
Posted on November 10, 2020
[ 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
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
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/"))
}
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;
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
}
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();
}
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()));
}
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()
}
We need to update src/routes/mod.rs to point to the new file:
pub mod ping;
And also src/lib.rs:
.mount("/", routes![routes::ping::ping_fn])
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()));
}
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)
}
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;
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/"))
}
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!
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
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(),
}
}
}
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"))
}
If we test empirically the response, we see it working.
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)))
}
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")
}
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()));
}
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()));
}
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()));
}
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!
Posted on November 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.