Basic Rust Web API: Part II

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

This is part 2 of the development of a simple Rust Web API with the Rocket framework. We will be working on part 2 of this backend assessment. If you haven't already, you can check out part 1 here.

Here is the full code snippet from the previous post with the addition of get_recipe_details and some modifications.

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>,
}

fn get_recipes_json(json: &State<Value>) -> Result<String, (Status, String)> {
    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 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
}

#[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)> {
    // Call get_recipes_json to convert our JSON `Value` into a `String`, otherwise returns an `Error`
    let recipes = match crate::get_recipes_json(json) {
        Ok(r) => r,
        Err(e) => return Err(e),
    };

    // Deserialize the `String` into a vector of `Recipes`
    let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();

    // Call add_to_vec to convert the Vec<Recipes> into a form to construct a JSON `Value`
    let all_recipe_names = crate::add_to_vec(data);

    // 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)
}

// The dynamic value "name" is wrapper in <> and is passed as an argument to our function
#[get("/recipes/details/<name>")]
fn get_recipe_details(json: &State<Value>, name: &str) -> Result<Value, (Status, String)> {
    // Call get_recipes_json to convert our JSON `Value` into a `String`, otherwise returns an `Error`
    let recipes = match crate::get_recipes_json(json) {
        Ok(r) => r,
        Err(e) => return Err(e),
    };

    // Deserialize the `String` into a vector of `Recipes`
    let data: Vec<Recipes> = serde_json::from_str(&recipes).unwrap_or_default();

    // Loop through the `Vec<Recipes>`, using an if-else to check for a name match
    for ele in data.iter() {
        if ele.name.to_string() == name {
            // If we get a hit, construct the JSON `Value`. We will call `.len()` on the instructions to get the number of steps needed for the recipe
            let details: serde_json::Value = serde_json::json!({
              "ingredients": ele.ingredients,
              "numSteps": ele.instructions.len()
            });
            // Store this in our `result` with the key `details` as specified in our problem statement, then return the successful result
            let result = serde_json::json!({ "details": details });
            return Ok(result);
        } else {
            // If we don't get a hit, do nothing
            {}
        }
    }
    // If we reach this point, the name could not be found in our JSON and an appropriate response is returned 
    Err((Status::BadRequest, "Name not found".to_string()))
}

#[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,
                get_recipe_names,
                get_recipe_details,
            ],
        )
}

Code Explanation

You might notice some changes in get_recipe_names. For the sake of reducing repitition of code, we moved two code blocks to their own functions:

  1. get_recipes_json - extract the recipes array from the JSON file and return it as a String. We can now use this function in our new get_recipe_details function.
  2. add_to_vec - converts a Vec<Recipes> to JSON literal such that we can use it to construct a serde_json::Value. This will be useful in our next functions.

Our previous function get_recipe_names and get_recipe_details share some similarities, so if you are interested in a line by line breakdown, please check out my (previous post)[https://www.alextheproxy.com/posts/post-1]. Instead, I will be explaining the for-loop.

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()))

In order to loop through the Vec<Recipes>, we must call .iter() on data, which returns an Iter or iterator. Next, we want to check if the "name" in our Recipes struct matches our dynamic name argument value. To accomplish this, we must convert the the "name" value into a String by calling .to_string() on it.

Most of the time, we will not be finding a match, so we can just do a no-op using {}. If we do find a match, we want to construct a JSON value using the json! macro, where the "ingredients" key is associated with our vector of ingredients (which is of type Vec<String>) and the "numSteps" key is associated with the number of items in our vector of instructions (also of type Vec<String>).

Afterwards, we can construct our result as a JSON value with a key of "details" and a value of our previously created Value. Then, we return the successful result. Note that you must use return in this case rather than omitting a semicolon because we are returned early.

If the loop through the entire vector without a hit, an Error is returned, which is of type tuple consisting of a rocket::http::Status code and a descriptive String as indicated by our function header.

Thank you for reading!