From b37995edb501cd84f8d136b3b1b4dbb9096acd5d Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Tue, 26 Apr 2022 23:17:17 -0400 Subject: [PATCH] Serve category data via api and load it relates to issue #6 --- kitchen/src/web.rs | 74 +++++++++++++++++------ recipes/src/parse.rs | 84 +++++++++++++++++++++++++++ recipes/src/test.rs | 48 ++++++++++++++- web/src/components/recipe_selector.rs | 2 +- web/src/service.rs | 79 +++++++++++++++++++++---- web/src/typings.rs | 2 +- web/src/web.rs | 2 +- 7 files changed, 256 insertions(+), 35 deletions(-) diff --git a/kitchen/src/web.rs b/kitchen/src/web.rs index 6b89e75..bcec864 100644 --- a/kitchen/src/web.rs +++ b/kitchen/src/web.rs @@ -1,4 +1,3 @@ -use std::net::SocketAddr; // Copyright 2022 Jeremy Wall // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,9 +11,10 @@ use std::net::SocketAddr; // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +use std::net::SocketAddr; use std::path::PathBuf; -use async_std::fs::{read_dir, read_to_string, DirEntry}; +use async_std::fs::{self, read_dir, read_to_string, DirEntry}; use async_std::stream::StreamExt; use static_dir::static_dir; use warp::{http::StatusCode, hyper::Uri, Filter}; @@ -24,12 +24,25 @@ use crate::api::ParseError; pub async fn get_recipes(recipe_dir_path: PathBuf) -> Result, ParseError> { let mut entries = read_dir(recipe_dir_path).await?; let mut entry_vec = Vec::new(); + // Special files that we ignore when fetching recipes + let filtered = vec!["menu.txt", "categories.txt"]; while let Some(res) = entries.next().await { let entry: DirEntry = res?; - if entry.file_type().await?.is_dir() || entry.file_name().to_string_lossy() != "menu.txt" { + + if !entry.file_type().await?.is_dir() + && !filtered + .iter() + .any(|&s| s == entry.file_name().to_string_lossy().to_string()) + { // add it to the entry + eprintln!("adding recipe file {}", entry.file_name().to_string_lossy()); let recipe_contents = read_to_string(entry.path()).await?; entry_vec.push(recipe_contents); + } else { + eprintln!( + "skipping file {} not a recipe", + entry.path().to_string_lossy() + ); } } Ok(entry_vec) @@ -38,24 +51,47 @@ pub async fn get_recipes(recipe_dir_path: PathBuf) -> Result, ParseE pub async fn ui_main(recipe_dir_path: PathBuf, listen_socket: SocketAddr) { let root = warp::path::end().map(|| warp::redirect::found(Uri::from_static("/ui"))); let ui = warp::path("ui").and(static_dir!("../web/dist")); + let dir_path = (&recipe_dir_path).clone(); + + // recipes api path route + let recipe_path = warp::path("recipes").then(move || { + let dir_path = (&dir_path).clone(); + eprintln!("servicing recipe api request."); + async move { + match get_recipes(dir_path).await { + Ok(recipes) => { + warp::reply::with_status(warp::reply::json(&recipes), StatusCode::OK) + } + Err(e) => warp::reply::with_status( + warp::reply::json(&format!("Error: {:?}", e)), + StatusCode::INTERNAL_SERVER_ERROR, + ), + } + } + }); + + // categories api path route + let mut file_path = (&recipe_dir_path).clone(); + file_path.push("categories.txt"); + let categories_path = warp::path("categories").then(move || { + eprintln!("servicing category api request"); + let file_path = (&file_path).clone(); + async move { + match fs::metadata(&file_path).await { + Ok(_) => { + let content = read_to_string(&file_path).await.unwrap(); + warp::reply::with_status(warp::reply::json(&content), StatusCode::OK) + } + Err(_) => warp::reply::with_status( + warp::reply::json(&"No categories found"), + StatusCode::NOT_FOUND, + ), + } + } + }); let api = warp::path("api") .and(warp::path("v1")) - .and(warp::path("recipes")) - .then(move || { - let recipe_dir_path = (&recipe_dir_path).clone(); - eprintln!("servicing api request."); - async move { - match get_recipes(recipe_dir_path).await { - Ok(recipes) => { - warp::reply::with_status(warp::reply::json(&recipes), StatusCode::OK) - } - Err(e) => warp::reply::with_status( - warp::reply::json(&format!("Error: {:?}", e)), - StatusCode::INTERNAL_SERVER_ERROR, - ), - } - } - }); + .and(recipe_path.or(categories_path)); let routes = root.or(ui).or(api).with(warp::log("access log")); diff --git a/recipes/src/parse.rs b/recipes/src/parse.rs index bf95e5d..3f71e4b 100644 --- a/recipes/src/parse.rs +++ b/recipes/src/parse.rs @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::BTreeMap; use std::str::FromStr; use std::time::Duration; @@ -34,6 +35,89 @@ pub fn as_recipe(i: &str) -> std::result::Result { } } +pub fn as_categories(i: &str) -> std::result::Result, String> { + match categories(StrIter::new(i)) { + Result::Abort(e) | Result::Fail(e) => Err(format!("Parse Failure: {:?}", e)), + Result::Incomplete(_) => Err(format!("Incomplete categories list can not parse")), + Result::Complete(_, c) => Ok(c), + } +} + +make_fn!( + pub categories>, + do_each!( + first_category => category_line, + rest => repeat!(category_line), + ({ + let mut out = BTreeMap::new(); + let mut op = |(cat, mut ingredients): (String, Vec)| { + for i in ingredients.drain(0..) { + out.insert(i, cat.clone()); + } + }; + op(first_category); + for line in rest.iter() { + op(line.clone()); + } + out + }) + ) +); + +make_fn!( + category_line)>, + do_each!( + cat => until!(text_token!(":")), + _ => text_token!(":"), + ingredients => cat_ingredients_list, + _ => either!( + discard!(eoi), + discard!(text_token!("\n")) + ), + ((cat.trim().to_owned(), ingredients)) + ) +); + +make_fn!( + pub cat_ingredients_list>, + do_each!( + first_ingredient => must!(cat_ingredient), + rest => repeat!(cat_ingredient), + ({ + let mut ingredients = vec![first_ingredient]; + ingredients.extend(rest); + ingredients + }) + ) +); + +make_fn!( + pub cat_ingredient, + do_each!( + _ => peek!(not!( + either!( + discard!(text_token!("|")), + discard!(eoi), + discard!(text_token!("\n")) + ) + )), + ingredient => until!( + either!( + discard!(text_token!("|")), + discard!(eoi), + discard!(text_token!("\n")) + ) + ), + _ => either!( + discard!(text_token!("|")), + discard!(eoi), + discard!(text_token!("\n")) + ), + (ingredient.trim().to_owned()) + ) + +); + make_fn!( pub recipe, do_each!( diff --git a/recipes/src/test.rs b/recipes/src/test.rs index b19941f..3ec56e8 100644 --- a/recipes/src/test.rs +++ b/recipes/src/test.rs @@ -77,7 +77,6 @@ macro_rules! assert_normalize { #[test] fn test_volume_normalize() { assert_normalize!(Tbsp, into_tsp, "not a tablespoon after normalize call"); - assert_normalize!(Floz, into_tbsp, "not a floz after normalize call"); assert_normalize!(Cup, into_floz, "not a cup after normalize call"); assert_normalize!(Pint, into_cup, "not a pint after normalize call"); assert_normalize!(Qrt, into_pint, "not a qrt after normalize call"); @@ -506,3 +505,50 @@ step: "; } } } + +#[test] +fn test_category_line_happy_path() { + let line = "Produce: onion|green pepper|bell pepper|corn|potato|green onion|scallions|lettuce"; + match parse::as_categories(line) { + Ok(map) => { + assert_eq!(map.len(), 8); + + assert!( + map.contains_key("onion"), + "map does not contain onion {:?}", + map + ); + } + Err(e) => { + assert!(false, "{:?}", e); + } + } +} + +#[test] +fn test_category_single_ingredient_happy_paths() { + let ingredients = vec!["foo", "foo\n", "foo|"]; + for ingredient in ingredients { + match parse::cat_ingredient(StrIter::new(ingredient)) { + ParseResult::Complete(_itr, _i) => { + // yay we pass + } + res => { + assert!(false, "{:?}", res); + } + } + } +} + +#[test] +fn test_ingredients_list_happy_path() { + let line = "onion|green pepper|bell pepper|corn|potato|green onion|scallions|lettuce|"; + match parse::cat_ingredients_list(StrIter::new(line)) { + ParseResult::Complete(_itr, i) => { + assert_eq!(i.len(), 8); + } + res => { + assert!(false, "{:?}", res); + } + } +} diff --git a/web/src/components/recipe_selector.rs b/web/src/components/recipe_selector.rs index 86a5e42..c4eb3fb 100644 --- a/web/src/components/recipe_selector.rs +++ b/web/src/components/recipe_selector.rs @@ -32,7 +32,7 @@ pub fn recipe_selector() -> View { spawn_local_in_scope(cloned!((app_service) => { let mut app_service = app_service.clone(); async move { - if let Err(e) = app_service.refresh_recipes().await { + if let Err(e) = app_service.refresh().await { console_error!("{}", e); }; } diff --git a/web/src/service.rs b/web/src/service.rs index fb9fc84..d7f37ae 100644 --- a/web/src/service.rs +++ b/web/src/service.rs @@ -25,6 +25,7 @@ use recipes::{parse, Ingredient, IngredientAccumulator, IngredientKey, Recipe}; pub struct AppService { recipes: Signal)>>, staples: Signal>, + category_map: Signal>>, menu_list: Signal>, } @@ -33,6 +34,7 @@ impl AppService { Self { recipes: Signal::new(Vec::new()), staples: Signal::new(None), + category_map: Signal::new(None), menu_list: Signal::new(BTreeMap::new()), } } @@ -57,16 +59,63 @@ impl AppService { } } - pub async fn synchronize_recipes() -> Result<(), String> { + async fn fetch_categories_http() -> Result, String> { + let resp = match http::Request::get("/api/v1/categories").send().await { + Ok(resp) => resp, + Err(e) => return Err(format!("Error: {}", e)), + }; + if resp.status() == 404 { + console_debug!("Categories returned 404"); + return Ok(None); + } else if resp.status() != 200 { + return Err(format!("Status: {}", resp.status())); + } else { + console_debug!("We got a valid response back!"); + return Ok(Some(resp.text().await.map_err(|e| format!("{}", e))?)); + } + } + + async fn synchronize() -> Result<(), String> { console_log!("Synchronizing Recipes"); let storage = Self::get_storage()?.unwrap(); let recipes = Self::fetch_recipes_http().await?; storage .set_item("recipes", &recipes) .map_err(|e| format!("{:?}", e))?; + console_log!("Synchronizing categories"); + match Self::fetch_categories_http().await { + Ok(Some(categories_content)) => { + storage + .set_item("categories", &categories_content) + .map_err(|e| format!("{:?}", e))?; + } + Ok(None) => { + console_error!("There is no category file"); + } + Err(e) => { + console_error!("{}", e); + } + } Ok(()) } + pub fn fetch_categories_from_storage() -> Result>, String> { + let storage = Self::get_storage()?.unwrap(); + match storage + .get_item("categories") + .map_err(|e| format!("{:?}", e))? + { + Some(s) => match parse::as_categories(&s) { + Ok(categories) => Ok(Some(categories)), + Err(e) => { + console_debug!("Error parsing categories {}", e); + Err(format!("Error parsing categories {}", e)) + } + }, + None => Ok(None), + } + } + pub fn fetch_recipes_from_storage( ) -> Result<(Option, Option>), String> { let storage = Self::get_storage()?.unwrap(); @@ -100,23 +149,25 @@ impl AppService { } } - pub async fn fetch_recipes() -> Result<(Option, Option>), String> { - if let (staples, Some(recipes)) = Self::fetch_recipes_from_storage()? { - return Ok((staples, Some(recipes))); - } else { - console_debug!("No recipes in cache synchronizing from api"); - // Try to synchronize first - Self::synchronize_recipes().await?; - Ok(Self::fetch_recipes_from_storage()?) - } + async fn fetch_recipes() -> Result<(Option, Option>), String> { + Ok(Self::fetch_recipes_from_storage()?) } - pub async fn refresh_recipes(&mut self) -> Result<(), String> { - Self::synchronize_recipes().await?; + async fn fetch_categories() -> Result>, String> { + Ok(Self::fetch_categories_from_storage()?) + } + + pub async fn refresh(&mut self) -> Result<(), String> { + Self::synchronize().await?; + console_debug!("refreshing recipes"); if let (staples, Some(r)) = Self::fetch_recipes().await? { self.set_recipes(r); self.staples.set(staples); } + console_debug!("refreshing categories"); + if let Some(categories) = Self::fetch_categories().await? { + self.set_categories(categories); + } Ok(()) } @@ -170,4 +221,8 @@ impl AppService { .collect(), ); } + + pub fn set_categories(&mut self, categories: BTreeMap) { + self.category_map.set(Some(categories)); + } } diff --git a/web/src/typings.rs b/web/src/typings.rs index 456fc61..6f419f4 100644 --- a/web/src/typings.rs +++ b/web/src/typings.rs @@ -65,7 +65,7 @@ macro_rules! console_error { { use crate::typings::error; error(&format_args!($($t)*).to_string()); - }else if cfg!(feature="ssr") { + } else if cfg!(feature="ssr") { print!("ERROR: "); println!($($t)*); }; diff --git a/web/src/web.rs b/web/src/web.rs index d81c62f..cf13435 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -56,7 +56,7 @@ pub fn ui() -> View { spawn_local_in_scope(cloned!((page_state, view) => { let mut app_service = app_service.clone(); async move { - match AppService::fetch_recipes().await { + match AppService::fetch_recipes_from_storage() { Ok((_, Some(recipes))) => { app_service.set_recipes(recipes); }