Serve category data via api and load it

relates to issue #6
This commit is contained in:
Jeremy Wall 2022-04-26 23:17:17 -04:00
parent d8540e6e7f
commit b37995edb5
7 changed files with 256 additions and 35 deletions

View File

@ -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<Vec<String>, 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,14 +51,14 @@ pub async fn get_recipes(recipe_dir_path: PathBuf) -> Result<Vec<String>, 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 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.");
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(recipe_dir_path).await {
match get_recipes(dir_path).await {
Ok(recipes) => {
warp::reply::with_status(warp::reply::json(&recipes), StatusCode::OK)
}
@ -57,6 +70,29 @@ pub async fn ui_main(recipe_dir_path: PathBuf, listen_socket: SocketAddr) {
}
});
// 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(recipe_path.or(categories_path));
let routes = root.or(ui).or(api).with(warp::log("access log"));
warp::serve(routes).run(listen_socket).await;

View File

@ -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<Recipe, String> {
}
}
pub fn as_categories(i: &str) -> std::result::Result<BTreeMap<String, String>, 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<StrIter, BTreeMap<String, String>>,
do_each!(
first_category => category_line,
rest => repeat!(category_line),
({
let mut out = BTreeMap::new();
let mut op = |(cat, mut ingredients): (String, Vec<String>)| {
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<StrIter, (String, Vec<String>)>,
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<StrIter, Vec<String>>,
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<StrIter, String>,
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<StrIter, Recipe>,
do_each!(

View File

@ -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);
}
}
}

View File

@ -32,7 +32,7 @@ pub fn recipe_selector() -> View<G> {
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);
};
}

View File

@ -25,6 +25,7 @@ use recipes::{parse, Ingredient, IngredientAccumulator, IngredientKey, Recipe};
pub struct AppService {
recipes: Signal<Vec<(usize, Signal<Recipe>)>>,
staples: Signal<Option<Recipe>>,
category_map: Signal<Option<BTreeMap<String, String>>>,
menu_list: Signal<BTreeMap<usize, usize>>,
}
@ -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<Option<String>, 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<Option<BTreeMap<String, String>>, 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<Recipe>, Option<Vec<(usize, Recipe)>>), String> {
let storage = Self::get_storage()?.unwrap();
@ -100,23 +149,25 @@ impl AppService {
}
}
pub async fn fetch_recipes() -> Result<(Option<Recipe>, Option<Vec<(usize, Recipe)>>), 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?;
async fn fetch_recipes() -> Result<(Option<Recipe>, Option<Vec<(usize, Recipe)>>), String> {
Ok(Self::fetch_recipes_from_storage()?)
}
async fn fetch_categories() -> Result<Option<BTreeMap<String, String>>, String> {
Ok(Self::fetch_categories_from_storage()?)
}
pub async fn refresh_recipes(&mut self) -> Result<(), String> {
Self::synchronize_recipes().await?;
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<String, String>) {
self.category_map.set(Some(categories));
}
}

View File

@ -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)*);
};

View File

@ -56,7 +56,7 @@ pub fn ui() -> View<G> {
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);
}