Basic Rust Web API: Part I
Rust is a systems programming language that boasts a speedy and safe runtime at the expense of generally slower development speed and compile time. As a Rust newbie, I originally came from a JavaScript and Node.js background. The goal of this post is to help simplify some Rust concepts that were at first tricky to someone with my background.
I would like to walk through an example program that I had worked on recently to help illustrate some important Rust concepts. The problem statement was taken from the hatchways backend practice assessment. The goal of this post is to illustrate how to:
- Read in a JSON file
- Use the Rocket web framework through examples
- Write functions that return a
Result
- Use the Serde crate to serialize and deserialize types
This is a multipart post, starting with Part 1 of the above practice assessment.
In Rust, "crates" are the equivalent of "packages" in Node.js. The crates we will be using are defined in Cargo.toml
. We will be using the Rocket crate, which allows you to easily spin up an HTTP server.
Crates used:
Cargo.toml
[package]
name = "trunk-web-app"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = {version = "0.5.0-rc.1", features = ["json"]}
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
[dependencies.rocket_contrib]
version = "0.4.10"
default-features = false
features = ["json"]
One thing that had caused all sorts of issues with being unable to derive implementations was not using specifying features = ["json"]
for rocket in Cargo.toml
. I must have overlooked this while looking through Rocket, but I personally found it difficult to find at the time.
Code
// main.rs
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
use rocket::http::Status;
use rocket::serde::json::Value;
use rocket::serde::{Deserialize, Serialize};
use rocket::{Request, State};
use std::fs::File;
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
struct Recipes {
name: String,
ingredients: Vec<String>,
instructions: Vec<String>,
}
#[catch(404)]
fn not_found(req: &Request) -> String {
format!("could not find '{}'", req.uri())
}
#[get("/")]
fn index() -> &'static str {
"trunk-web-api"
}
#[get("/recipes")]
fn get_recipe_names(json: &State<Value>) -> Result<Value, (Status, String)> {
// Create a mutable vector to hold the recipes names
let mut all_recipe_names = Vec::new();
// Try to find the value associate with "recipes" key and convert to `String`. Otherwise, return an `Error`.
let recipes = match json.get("recipes") {
Some(r) => r.to_string(),
None => {
return Err(
(Status::BadGateway,
"Could not find get top-level json \"recipes\"".to_string(),
))
}
};
// Deserialize the `String` into a vector of `Recipes`
let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
// Loop through `Vec<Recipes>` and push the value associated with "name" into the previously created mutable vector
for ele in data.iter() {
all_recipe_names.push(&ele.name);
}
// Create a JSON `Value` with a key of "recipeNames" and a value of `Vec<String>`
let result: serde_json::Value = serde_json::json!( {
"recipeNames": all_recipe_names,
});
// Return the successful result
Ok(result)
}
#[launch]
fn rocket() -> _ {
let rdr = File::open("data/data.json").expect("Failed to open data.json");
let json: Value =
serde_json::from_reader(rdr).expect("Failed to convert rdr into serde_json::Value");
rocket::build()
.manage(json)
.register("/", catchers![not_found])
.mount(
"/",
routes![
index,
recipe_names,
],
)
}
Code Explanation
Let's start with fn rocket()
.
let rdr = File::open("data/data.json").expect("Failed to open data.json");
let json: Value =
serde_json::from_reader(rdr).expect("Failed to convert rdr into serde_json::Value");
In this API example, rather than using a database to store our data, we are simply using a JSON file in our "data" directory. These first few lines will open our data.json
file in read-only mode and deserialize the File
instance into a JSON Value
. We use .expect()
to handle the error case, since panicking would be an appropriate response to failing to open and read our data store.
rocket::build()
.manage(json)
.register("/", catchers![not_found])
.mount(
"/",
routes![
index,
recipe_names,
],
)
Next, we will create our rocket instance with rocket::build()
. We will call .manage(json)
to add state to our rocket instance so that it can be used as an argument in our endpoint functions. Then, we will register our simple 404 catcher scoped to "/" with the catchers!
macro. Lastly, we will mount our routes to the "/" base path with the routes!
macro. The only route we have so far is index
and get_recipes_names
with an endpoint of "/" and "/recipes" respectively, so any other endpoint would return the 404 catcher.
Let's move on to our first endpoint fn index()
, which is simply the base path "/".
#[get("/")]
fn index() -> &'static str {
"trunk-web-api"
}
The first line #[get("/")]
is a Rocket macro to generate the route and associated metadata. This endpoint will simply display the string literal (&'static str
) "trunk-web-api" when we visit "localhost:8000" (This is the default port used in Rocket).
You can run cargo run
in your terminal, which should launch Rocket. Try visiting the url and confirm that this text is displayed.
Let's move on to fn recipe_names()
.
The get_recipes_names
function takes a json of type &State<Value>
and returns a Results<Value, (Status, String)>
, where Value
represents any valid JSON value. Using Result
, we either return an Ok()
or an Err()
.
let mut all_recipe_names = Vec::new();
First, we instantiate a new mutable Vector named all_recipe_names
to hold the names of the recipes.
let recipes = match json.get("recipes") {
Some(r) => r.to_string(),
None => {
return Err(
(Status::BadGateway,
"Could not find get top-level \"recipes\" property.".to_string(),
))
}
};
Next, we call .get()
to get the value associated with the key recipes
. Since .get()
returns an Option
, we can use the match
keyword to handle this optional value, which will either be Some(value)
or None
. Some(value)
is a tuple struct that wraps a value
of type T
, while None
represents the lack of a value. So, if "recipes" is found in the JSON, the Some(r)
match arm is resolved, where r
is of type &Value
. Then we convert r
to a String
by calling to_string()
and assign it to recipes
. If "recipes" is not found, we return an Error
, consisting of a tuple of an appropriate status and a descriptive message.
let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
Next, we deserialize recipes
into a JSON. If the deserialization fails, an empty Vec
is returned.
The Recipes
struct implements Serialize
, Deserialize
, and Debug
from serde, which generates the appropriate implementations.
for ele in data.iter() {
all_recipe_names.push(&ele.name);
}
Next, we will loop through our Vec<Recipes>
and push each borrowed element name into our vector all_recipes_names
.
let result: serde_json::Value = serde_json::json!( {
"recipeNames": all_recipe_names,
});
Ok(result)
Lastly, to properly format the response json, we create a json object where the key is recipeNames
and the value is the Vec<Recipes>
we had previously create. Then we return the resulting json with Ok(result)
.
This is not the most idiomatic Rust code to accomplish our goal, but I am content with this solution in terms of readability. Converting from a serde_json::Value
to Vec<Recipes>
was also more verbose than preferred, so if anyone has any recommendations on how to approach this, please reach out to me. Thank you for reading!