diff --git a/Cargo.lock b/Cargo.lock index 1b992e6..ee21281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,7 @@ dependencies = [ "async-std", "clap", "csv", + "recipe-store", "recipes", "static_dir", "tracing", @@ -1156,6 +1157,13 @@ dependencies = [ "getrandom", ] +[[package]] +name = "recipe-store" +version = "0.1.0" +dependencies = [ + "recipes", +] + [[package]] name = "recipes" version = "0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 2c20466..1d2cebb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = [ "recipes", "kitchen", "web" ] \ No newline at end of file +members = [ "recipes", "kitchen", "web", "recipe-store"] \ No newline at end of file diff --git a/examples/categories.txt b/examples/categories.txt new file mode 100644 index 0000000..ed781da --- /dev/null +++ b/examples/categories.txt @@ -0,0 +1,8 @@ +Produce: onion|green pepper|Green Pepper|bell pepper|corn|potato|green onion|scallions|lettuce|fresh basil|cucumber|celery +Meat: ground beef|beef|beef tenderloin|ribeye|filet mignon|pork|chicken|chicken breast|chicken tenders|sausage|hot dogs|bacon|lamb +Dairy: milk|oat milk|butter|heavy cream|cheddar cheese|mozarella|cheddar|white american|american|swiss +Drinks: can Sprite|can Coke Zero|can ginger ale|can dr pepper|can coke|orange juice|apple juice +Dry Goods: sugar|flour|brown sugar|bag powder sugar|bag powdered sugar|baking soda|baking powder +Spices: cumin|cinnamon|garlic|clove garlic|garlic powder|paprika|basil|oregano|parsley|celery salt|salt|pepper +Cereal: oatmeal|cream of wheat + diff --git a/examples/cornbread_dressing.txt b/examples/recipes.txt/cornbread_dressing.txt similarity index 100% rename from examples/cornbread_dressing.txt rename to examples/recipes.txt/cornbread_dressing.txt diff --git a/examples/meatloaf.txt b/examples/recipes.txt/meatloaf.txt similarity index 100% rename from examples/meatloaf.txt rename to examples/recipes.txt/meatloaf.txt diff --git a/kitchen/Cargo.toml b/kitchen/Cargo.toml index 472bf92..8bd391e 100644 --- a/kitchen/Cargo.toml +++ b/kitchen/Cargo.toml @@ -10,6 +10,7 @@ edition = "2021" tracing = "0.1.35" tracing-subscriber = "0.3.14" recipes = {path = "../recipes" } +recipe-store = {path = "../recipe-store" } csv = "1.1.1" warp = "0.3.2" static_dir = "0.2.0" diff --git a/kitchen/src/main.rs b/kitchen/src/main.rs index 4c1c1cf..b9c2b17 100644 --- a/kitchen/src/main.rs +++ b/kitchen/src/main.rs @@ -23,6 +23,7 @@ use tracing_subscriber::FmtSubscriber; pub mod api; mod cli; +mod store; mod web; fn create_app<'a>() -> clap::App<'a> { diff --git a/kitchen/src/store.rs b/kitchen/src/store.rs new file mode 100644 index 0000000..018142e --- /dev/null +++ b/kitchen/src/store.rs @@ -0,0 +1,93 @@ +// Copyright 2022 Jeremy Wall (Jeremy@marzhilsltudios.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// 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 async_std::{ + io::{self, ReadExt}, + path::PathBuf, + stream::StreamExt, +}; + +use async_std::fs::{read_dir, read_to_string, DirEntry, File}; +use recipe_store::{MaybeAsync, RecipeStore}; +use tracing::{info, instrument, warn}; + +pub struct AsyncFileStore { + path: PathBuf, +} + +impl AsyncFileStore { + pub fn new>(root: P) -> Self { + Self { path: root.into() } + } +} + +impl RecipeStore for AsyncFileStore { + #[instrument(fields(path = ?self.path), skip_all)] + fn get_categories(&self) -> MaybeAsync, io::Error>> { + let mut category_path = PathBuf::new(); + category_path.push(&self.path); + category_path.push("categories.txt"); + MaybeAsync::Async(Box::pin(async move { + let category_file = match File::open(&category_path).await { + Ok(f) => f, + Err(e) => { + if let io::ErrorKind::NotFound = e.kind() { + return Ok(None); + } + return Err(e); + } + }; + let mut buf_reader = io::BufReader::new(category_file); + let mut contents = Vec::new(); + if let Err(e) = buf_reader.read_to_end(&mut contents).await { + return Err(e); + } + match String::from_utf8(contents) { + Ok(s) => Ok(Some(s)), + Err(e) => Err(io::Error::new(io::ErrorKind::Other, e)), + } + })) + } + + fn get_recipes(&self) -> MaybeAsync>, io::Error>> { + let mut recipe_path = PathBuf::new(); + recipe_path.push(&self.path); + recipe_path.push("recipes"); + MaybeAsync::Async(Box::pin(async move { + let mut entries = read_dir(&recipe_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() + && !filtered + .iter() + .any(|&s| s == entry.file_name().to_string_lossy().to_string()) + { + // add it to the entry + info!("adding recipe file {}", entry.file_name().to_string_lossy()); + let recipe_contents = read_to_string(entry.path()).await?; + entry_vec.push(recipe_contents); + } else { + warn!( + file = %entry.path().to_string_lossy(), + "skipping file not a recipe", + ); + } + } + Ok(Some(entry_vec)) + })) + } +} diff --git a/kitchen/src/web.rs b/kitchen/src/web.rs index d45e3cf..364f4ac 100644 --- a/kitchen/src/web.rs +++ b/kitchen/src/web.rs @@ -14,13 +14,15 @@ use std::net::SocketAddr; use std::path::PathBuf; -use async_std::fs::{self, read_dir, read_to_string, DirEntry}; +use async_std::fs::{read_dir, read_to_string, DirEntry}; use async_std::stream::StreamExt; +use recipe_store::*; use static_dir::static_dir; use tracing::{info, instrument, warn}; use warp::{http::StatusCode, hyper::Uri, Filter}; use crate::api::ParseError; +use crate::store; #[instrument(fields(recipe_dir=?recipe_dir_path), skip_all)] pub async fn get_recipes(recipe_dir_path: PathBuf) -> Result, ParseError> { @@ -54,40 +56,54 @@ 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(); + 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(); - info!(?dir_path, "servicing recipe api request."); - async move { - match get_recipes(dir_path).await { - Ok(recipes) => { + async { + let store = store::AsyncFileStore::new(dir_path); + let recipe_future = store.get_recipes().as_async(); + match recipe_future.await { + Ok(Ok(Some(recipes))) => { warp::reply::with_status(warp::reply::json(&recipes), StatusCode::OK) } - Err(e) => warp::reply::with_status( + Ok(Ok(None)) => warp::reply::with_status( + warp::reply::json(&Vec::::new()), + StatusCode::OK, + ), + Ok(Err(e)) => warp::reply::with_status( warp::reply::json(&format!("Error: {:?}", e)), StatusCode::INTERNAL_SERVER_ERROR, ), + 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 || { - info!(?file_path, "servicing category api request"); - let file_path = (&file_path).clone(); + let dir_path = (&recipe_dir_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) + let store = store::AsyncFileStore::new(dir_path); + match store.get_categories().as_async().await { + Ok(Ok(Some(categories))) => { + warp::reply::with_status(warp::reply::json(&categories), StatusCode::OK) } - Err(_) => warp::reply::with_status( - warp::reply::json(&"No categories found"), - StatusCode::NOT_FOUND, + Ok(Ok(None)) => warp::reply::with_status( + warp::reply::json(&Vec::::new()), + StatusCode::OK, + ), + Ok(Err(e)) => warp::reply::with_status( + warp::reply::json(&format!("Error: {:?}", e)), + StatusCode::INTERNAL_SERVER_ERROR, + ), + Err(e) => warp::reply::with_status( + warp::reply::json(&format!("Error: {}", e)), + StatusCode::INTERNAL_SERVER_ERROR, ), } } diff --git a/recipe-store/Cargo.toml b/recipe-store/Cargo.toml new file mode 100644 index 0000000..4e1a679 --- /dev/null +++ b/recipe-store/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "recipe-store" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +recipes = {path = "../recipes" } diff --git a/recipe-store/src/lib.rs b/recipe-store/src/lib.rs new file mode 100644 index 0000000..a6e05c4 --- /dev/null +++ b/recipe-store/src/lib.rs @@ -0,0 +1,63 @@ +// Copyright 2022 Jeremy Wall (Jeremy@marzhilsltudios.com) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// 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::{future::Future, pin::Pin}; + +pub enum MaybeAsync +where + T: Send, +{ + Sync(T), + // NOTE(jwall): For reasons I do not entirely understand yet + // You have to specify that this is both Future + Send because + // the compiler can't figure it out for you. + Async(Pin + Send>>), +} + +impl MaybeAsync +where + T: Send, +{ + pub async fn as_async(self) -> Result { + use MaybeAsync::{Async, Sync}; + match self { + Async(f) => Ok(f.await), + Sync(_) => Err("Something went very wrong. Attempted to use Sync as Async."), + } + } + pub fn as_sync(self) -> Result { + use MaybeAsync::{Async, Sync}; + match self { + Async(_) => Err("Something went very wrong. Attempted to use Async as Sync."), + Sync(v) => Ok(v), + } + } +} + +pub trait TenantStoreFactory +where + S: RecipeStore, + E: Send, +{ + fn get_user_store(&self, user: String) -> S; +} + +pub trait RecipeStore +where + E: Send, +{ + /// Get categories text unparsed. + fn get_categories(&self) -> MaybeAsync, E>>; + /// Get list of recipe text unparsed. + fn get_recipes(&self) -> MaybeAsync>, E>>; +}