Basic Rust Web API: Part III
This is part 3 and the final part of a series of posts where I explain basic Rust concepts, the Rocket web framework, and the Serde crate. Like the previous posts, I will be going over this backend assessment, specifically part 3 and part 4 of this assessment. You can check the two previous posts: part 1 and part 2. You can find the GitHub repo here along with some tests.
In this post, I will be demonstrating how to use Rocket to:
- Make a
POST
request to add a recipe to a JSON file - Make a
PUT
request to update a recipe - Use
RwLock
to introduce mutable state
Here is the full code.
Code
// main.rs
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
use rocket::http::Status;
use rocket::serde::json::{Json, Value};
use rocket::serde::{Deserialize, Serialize};
use rocket::{Request, State};
use std::fs::{self, File};
use std::io::Write;
use std::sync::RwLock;
#[catch(404)]
fn not_found(req: &Request) -> String {
format!("could not find '{}'", req.uri())
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
struct Recipes {
name: String,
ingredients: Vec<String>,
instructions: Vec<String>,
}
struct JsonState {
json: Value,
}
type MutJsonState = RwLock<Value>;
impl JsonState {
fn new(json: Value) -> MutJsonState {
RwLock::new(json)
}
}
fn get_recipes_json(json: &State<MutJsonState>) -> Result<String, (Status, String)> {
// Lock the thread for reading
let json = json.read().unwrap();
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(),
))
}
};
Ok(recipes)
}
fn open_file(file_path: String) -> File {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.append(false)
.create(false)
.open(file_path)
.expect("unable to open");
file
}
fn add_to_vec(data: Vec<Recipes>) -> Vec<String> {
let mut all_recipe_names: Vec<String> = Vec::new();
for ele in data.iter() {
all_recipe_names.push(ele.name.to_owned());
}
all_recipe_names
}
#[get("/")]
fn index() -> &'static str {
"trunk-web-api"
}
#[get("/recipes")]
fn get_recipe_names(json: &State<MutJsonState>) -> Result<Value, (Status, String)> {
let recipes = match crate::get_recipes_json(json) {
Ok(r) => r,
Err(e) => return Err(e),
};
let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
let all_recipe_names = crate::add_to_vec(data);
let result: serde_json::Value = serde_json::json!( {
"recipeNames": all_recipe_names,
});
Ok(result)
}
#[get("/recipes/details/<name>")]
fn get_recipe_details(
json: &State<MutJsonState>,
name: &str,
) -> Result<Value, (Status, String)> {
let recipes = match crate::get_recipes_json(json) {
Ok(r) => r,
Err(e) => return Err(e),
};
let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
for ele in data.iter() {
if ele.name.to_string() == name {
let details: serde_json::Value = serde_json::json!({
"ingredients": ele.ingredients,
"numSteps": ele.instructions.len()
});
let result = serde_json::json!({ "details": details });
return Ok(result);
} else {
{}
}
}
Err((Status::BadRequest, "Name not found".to_string()))
}
#[post("/recipes", format = "json", data = "<item>")]
fn add_recipe(
json: &State<MutJsonState>,
item: Json<Recipes>
) -> Result<(), (Status, String)> {
let recipes = match crate::get_recipes_json(json) {
Ok(r) => r,
Err(e) => return Err(e),
};
let mut all_recipes: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
// Create a vector of recipe names
let all_recipe_names = crate::add_to_vec(serde_json::from_str(&recipes).unwrap_or_default());
// Check if the recipe does not exist
if !all_recipe_names.contains(&item.name) {
// Consume the json wrapper and return the item
let new_recipe = item.into_inner();
// Add the new recipe
all_recipes.push(new_recipe);
// Construct a json with "recipes" as the key
let result = serde_json::json!({ "recipes": all_recipes });
// Dereference and lock the thread for writing
*json.write().unwrap() = result.clone();
// Open the json file
let mut file = crate::open_file("data/data.json".to_string());
// Overwrite the file with our recipes
serde_json::to_writer_pretty(&mut file, &result).unwrap_or_default();
file.flush().unwrap_or_default();
Ok(())
} else {
Err((Status::BadRequest, "Recipe already exists".to_string()))
}
}
// Much of the code is the same for `add_recipe` so please look there for code explanations
// or check below for the explanation on code difference
#[put("/recipes", format = "json", data = "<item>")]
fn edit_recipe(
json: &State<MutJsonState>,
item: Json<Recipes>,
) -> Result<(), (Status, String)> {
let recipes = match crate::get_recipes_json(json) {
Ok(r) => r,
Err(e) => return Err(e),
};
let mut all_recipes: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();
let all_recipe_names = crate::add_to_vec(serde_json::from_str(&recipes).unwrap_or_default());
if all_recipe_names.contains(&item.name) {
all_recipes.retain(|x| x.name != item.name);
let new_recipe = item.into_inner();
all_recipes.push(new_recipe);
let result = serde_json::json!({ "recipes": all_recipes });
// Dereference and lock the thread for writing
*json.write().unwrap() = result.clone();
let mut file = crate::open_file("data/data.json".to_string());
serde_json::to_writer_pretty(&mut file, &result).unwrap_or_default();
file.flush().unwrap_or_default();
Ok(())
} else {
Err((Status::BadRequest, "Recipe does not exist".to_string()))
}
}
#[launch]
fn rocket() -> _ {
let rdr = crate::open_file("data/data.json".to_string());
let json: Value =
serde_json::from_reader(rdr).expect("Failed to convert rdr into serde_json::Value");
rocket::build()
.manage(JsonState::new(json))
.register("/", catchers![not_found])
.mount(
"/",
routes![
index,
get_recipe_names,
get_recipe_details,
add_recipe,
edit_recipe
],
)
}
Code Explanation
add_recipe and edit_recipe
For the most part, add_recipe
and edit_recipe
are very similar with the only difference being the logical check.
In add_recipe
, our conditional check looked like this:
// Check if the recipe does not exist
if !all_recipe_names.contains(&item.name) {
// If it doesn't, add it to our recipes
let new_recipe = item.into_inner();
...
} else {
...
}
Whereas, in edit_recipe
, our conditional check looked like this:
// Check if the recipe exists
if all_recipe_names.contains(&item.name) {
// If it does, keep all other recipes so that we can add the updated recipe
all_recipes.retain(|x| x.name != item.name);
let new_recipe = item.into_inner();
...
} else {
...
}
So, when we update a recipe, we are updating the entire json.
RwLock
Without RwLock
, the program would run fine and any edits to the json file would appear. However, they would not appear in our state without rerunning the program. To solve this issue, I introduced mutable state using RwLock
.
struct JsonState {
json: Value,
}
type MutJsonState = RwLock<Value>;
impl JsonState {
fn new(json: Value) -> MutJsonState {
RwLock::new(json)
}
}
To do this, I simply created a new struct JsonState
to create a new
function that would wrap our Value
and a new type alias MutJsonState
that is wrapped by State
. So, now we pass in JsonState::new(json)
rather than just json
into our manage
method before rocket launches and &State<Value>
becomes &State<MutJsonState>
.
Great! Now we can update the state of our application. One caveat is that this does not update the json file itself, so we still need to open the file and write in our updated state. I moved this into its own function open_file
so that it could be used prior to rocket launch and during. If anyone has a fix for this, please reach out to me through my socials or open an issue in the GitHub repo.
That concludes this series of posts on a simple REST API with the Rocket Web Framework. Again, you can find the full code on GitHub. Thank you for reading!