diff --git a/web/src/api.rs b/web/src/api.rs index 20e5a41..40e1519 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -21,7 +21,8 @@ use tracing::{debug, error, instrument, warn}; use client_api::*; use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; -use wasm_bindgen::JsValue; +use wasm_bindgen::{JsValue, UnwrapThrowExt}; +use web_sys::Storage; use crate::{app_state::AppState, js_lib}; @@ -107,14 +108,170 @@ fn token68(user: String, pass: String) -> String { base64::encode(format!("{}:{}", user, pass)) } +#[derive(Clone, Debug)] +pub struct LocalStore { + store: Storage, +} + +impl LocalStore { + pub fn new() -> Self { + Self { + store: js_lib::get_storage(), + } + } + + /// Gets user data from local storage. + pub fn get_user_data(&self) -> Option { + self.store + .get("user_data") + .map_or(None, |val| val.map(|val| from_str(&val).unwrap_or(None))) + .flatten() + } + + // Set's user data to local storage. + pub fn set_user_data(&self, data: Option<&UserData>) { + if let Some(data) = data { + self.store + .set("user_data", &to_string(data).unwrap_throw()) + .unwrap_throw(); + } else { + self.store.delete("user_data").unwrap_throw(); + } + } + + /// Gets categories from local storage. + pub fn get_categories(&self) -> Option { + self.store.get("categories").unwrap_throw() + } + + /// Set the categories to the given string. + pub fn set_categories(&self, categories: Option<&String>) { + if let Some(c) = categories { + self.store.set("categories", c).unwrap_throw(); + } else { + self.store.delete("categories").unwrap_throw() + } + } + + fn get_storage_keys(&self) -> Vec { + let mut keys = Vec::new(); + for idx in 0..self.store.length().unwrap() { + keys.push(self.store.key(idx).unwrap_throw().unwrap_throw()) + } + keys + } + + fn get_recipe_keys(&self) -> impl Iterator { + self.get_storage_keys() + .into_iter() + .filter(|k| k.starts_with("recipe:")) + } + + /// Gets all the recipes from local storage. + pub fn get_recipes(&self) -> Option> { + let mut recipe_list = Vec::new(); + for recipe_key in self.get_recipe_keys() { + if let Some(entry) = self.store.get(&recipe_key).unwrap_throw() { + match from_str(&entry) { + Ok(entry) => { + recipe_list.push(entry); + } + Err(e) => { + error!(recipe_key, err = ?e, "Failed to parse recipe entry"); + } + } + } + } + if recipe_list.is_empty() { + return None; + } + Some(recipe_list) + } + + /// Sets the set of recipes to the entries passed in. Deletes any recipes not + /// in the list. + pub fn set_all_recipes(&self, entries: &Vec) { + for recipe_key in self.get_recipe_keys() { + self.store.delete(&recipe_key).unwrap_throw(); + } + for entry in entries { + self.set_recipe_entry(entry); + } + } + + /// Set recipe entry in local storage. + pub fn set_recipe_entry(&self, entry: &RecipeEntry) { + self.store + .set( + &recipe_key(entry.recipe_id()), + &to_string(&entry).unwrap_throw(), + ) + .unwrap_throw() + } + + /// Delete recipe entry from local storage. + pub fn delete_recipe_entry(&self, recipe_key: &str) { + self.store.delete(recipe_key).unwrap_throw() + } + + /// Save working plan to local storage. + pub fn save_plan(&self, plan: &Vec<(String, i32)>) { + self.store + .set("plan", &to_string(&plan).unwrap_throw()) + .unwrap_throw(); + } + + pub fn get_plan(&self) -> Option> { + if let Some(plan) = self.store.get("plan").unwrap_throw() { + Some(from_str(&plan).unwrap_throw()) + } else { + None + } + } + + pub fn get_inventory_data( + &self, + ) -> Option<( + BTreeSet, + BTreeMap, + Vec<(String, String)>, + )> { + if let Some(inventory) = self.store.get("inventory").unwrap_throw() { + return Some(from_str(&inventory).unwrap_throw()); + } + return None; + } + + pub fn delete_inventory_data(&self) { + self.store.delete("inventory").unwrap_throw(); + } + + pub fn set_inventory_data( + &self, + inventory: ( + &BTreeSet, + &BTreeMap, + &Vec<(String, String)>, + ), + ) { + self.store + .set("inventory", &to_string(&inventory).unwrap_throw()) + .unwrap_throw(); + } +} + #[derive(Clone, Debug)] pub struct HttpStore { root: String, + local_store: LocalStore, } impl HttpStore { pub fn new(root: String) -> Self { - Self { root } + Self { + root, + local_store: LocalStore::new(), + } } pub fn v1_path(&self) -> String { @@ -143,7 +300,6 @@ impl HttpStore { debug!("attempting login request against api."); let mut path = self.v1_path(); path.push_str("/auth"); - let storage = js_lib::get_storage(); let result = reqwasm::http::Request::get(&path) .header( "Authorization", @@ -158,12 +314,7 @@ impl HttpStore { .await .expect("Unparseable authentication response") .as_success(); - storage - .set( - "user_data", - &to_string(&user_data).expect("Unable to serialize user_data"), - ) - .unwrap(); + self.local_store.set_user_data(user_data.as_ref()); return user_data; } error!(status = resp.status(), "Login was unsuccessful") @@ -177,12 +328,11 @@ impl HttpStore { pub async fn get_categories(&self) -> Result, Error> { let mut path = self.v1_path(); path.push_str("/categories"); - let storage = js_lib::get_storage(); let resp = match reqwasm::http::Request::get(&path).send().await { Ok(resp) => resp, Err(reqwasm::Error::JsError(err)) => { error!(path, ?err, "Error hitting api"); - return Ok(storage.get("categories")?); + return Ok(self.local_store.get_categories()); } Err(err) => { return Err(err)?; @@ -190,14 +340,14 @@ impl HttpStore { }; if resp.status() == 404 { debug!("Categories returned 404"); - storage.remove_item("categories")?; + self.local_store.set_categories(None); Ok(None) } else if resp.status() != 200 { Err(format!("Status: {}", resp.status()).into()) } else { debug!("We got a valid response back!"); let resp = resp.json::().await?.as_success().unwrap(); - storage.set("categories", &resp)?; + self.local_store.set_categories(Some(&resp)); Ok(Some(resp)) } } @@ -206,26 +356,16 @@ impl HttpStore { pub async fn get_recipes(&self) -> Result>, Error> { let mut path = self.v1_path(); path.push_str("/recipes"); - let storage = js_lib::get_storage(); let resp = match reqwasm::http::Request::get(&path).send().await { Ok(resp) => resp, Err(reqwasm::Error::JsError(err)) => { error!(path, ?err, "Error hitting api"); - let mut entries = Vec::new(); - for key in js_lib::get_storage_keys() { - if key.starts_with("recipe:") { - let entry = from_str(&storage.get_item(&key)?.unwrap()) - .map_err(|e| format!("{}", e))?; - entries.push(entry); - } - } - return Ok(Some(entries)); + return Ok(self.local_store.get_recipes()); } Err(err) => { return Err(err)?; } }; - let storage = js_lib::get_storage(); if resp.status() != 200 { Err(format!("Status: {}", resp.status()).into()) } else { @@ -236,12 +376,7 @@ impl HttpStore { .map_err(|e| format!("{}", e))? .as_success(); if let Some(ref entries) = entries { - for r in entries.iter() { - storage.set( - &recipe_key(r.recipe_id()), - &to_string(&r).expect("Unable to serialize recipe entries"), - )?; - } + self.local_store.set_all_recipes(&entries); } Ok(entries) } @@ -293,15 +428,11 @@ impl HttpStore { pub async fn save_recipes(&self, recipes: Vec) -> Result<(), Error> { let mut path = self.v1_path(); path.push_str("/recipes"); - let storage = js_lib::get_storage(); for r in recipes.iter() { if r.recipe_id().is_empty() { return Err("Recipe Ids can not be empty".into()); } - storage.set( - &recipe_key(r.recipe_id()), - &to_string(&r).expect("Unable to serialize recipe entries"), - )?; + self.local_store.set_recipe_entry(&r); } let serialized = to_string(&recipes).expect("Unable to serialize recipe entries"); let resp = reqwasm::http::Request::post(&path) @@ -321,8 +452,7 @@ impl HttpStore { pub async fn save_categories(&self, categories: String) -> Result<(), Error> { let mut path = self.v1_path(); path.push_str("/categories"); - let storage = js_lib::get_storage(); - storage.set("categories", &categories)?; + self.local_store.set_categories(Some(&categories)); let resp = reqwasm::http::Request::post(&path) .body(to_string(&categories).expect("Unable to encode categories as json")) .header("content-type", "application/json") @@ -360,9 +490,7 @@ impl HttpStore { pub async fn save_plan(&self, plan: Vec<(String, i32)>) -> Result<(), Error> { let mut path = self.v1_path(); path.push_str("/plan"); - let storage = js_lib::get_storage(); - let serialized_plan = to_string(&plan).expect("Unable to encode plan as json"); - storage.set("plan", &serialized_plan)?; + self.local_store.save_plan(&plan); let resp = reqwasm::http::Request::post(&path) .body(to_string(&plan).expect("Unable to encode plan as json")) .header("content-type", "application/json") @@ -380,7 +508,6 @@ impl HttpStore { let mut path = self.v1_path(); path.push_str("/plan"); let resp = reqwasm::http::Request::get(&path).send().await?; - let storage = js_lib::get_storage(); if resp.status() != 200 { Err(format!("Status: {}", resp.status()).into()) } else { @@ -391,8 +518,7 @@ impl HttpStore { .map_err(|e| format!("{}", e))? .as_success(); if let Some(ref entry) = plan { - let serialized: String = to_string(entry).map_err(|e| format!("{}", e))?; - storage.set("plan", &serialized)? + self.local_store.save_plan(&entry); } Ok(plan) } @@ -414,26 +540,9 @@ impl HttpStore { let resp = reqwasm::http::Request::get(&path).send().await?; if resp.status() != 200 { let err = Err(format!("Status: {}", resp.status()).into()); - Ok(match storage.get("inventory") { - Ok(Some(val)) => match from_str(&val) { - // TODO(jwall): Once we remove the v1 endpoint this is no longer needed. - Ok((filtered_ingredients, modified_amts)) => { - (filtered_ingredients, modified_amts, Vec::new()) - } - Err(_) => match from_str(&val) { - Ok((filtered_ingredients, modified_amts, extra_items)) => { - (filtered_ingredients, modified_amts, extra_items) - } - Err(_) => { - // Whatever is in storage is corrupted or invalid so we should delete it. - storage - .delete("inventory") - .expect("Unable to delete corrupt data in inventory cache"); - return err; - } - }, - }, - Ok(None) | Err(_) => return err, + Ok(match self.local_store.get_inventory_data() { + Some(val) => val, + None => return err, }) } else { debug!("We got a valid response back"); @@ -447,11 +556,11 @@ impl HttpStore { .map_err(|e| format!("{}", e))? .as_success() .unwrap(); - let _ = storage.set( - "inventory", - &to_string(&(&filtered_ingredients, &modified_amts)) - .expect("Failed to serialize inventory data"), - ); + self.local_store.set_inventory_data(( + &(filtered_ingredients.iter().cloned().collect()), + &(modified_amts.iter().cloned().collect()), + &extra_items, + )); Ok(( filtered_ingredients.into_iter().collect(), modified_amts.into_iter().collect(), @@ -471,13 +580,14 @@ impl HttpStore { path.push_str("/inventory"); let filtered_ingredients: Vec = filtered_ingredients.into_iter().collect(); let modified_amts: Vec<(IngredientKey, String)> = modified_amts.into_iter().collect(); + debug!("Storing inventory data in cache"); + self.local_store.set_inventory_data(( + &(filtered_ingredients.iter().cloned().collect()), + &(modified_amts.iter().cloned().collect()), + &extra_items, + )); let serialized_inventory = to_string(&(filtered_ingredients, modified_amts, extra_items)) .expect("Unable to encode plan as json"); - let storage = js_lib::get_storage(); - debug!("Storing inventory data in cache"); - storage - .set("inventory", &serialized_inventory) - .expect("Failed to cache inventory data"); debug!("Storing inventory data via API"); let resp = reqwasm::http::Request::post(&path) .body(&serialized_inventory)