Basic Rust Web API: Part III

Cover Image for Basic Rust Web API: Part III
Alexander Chau
Alexander Chau

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!