Pull out a shared store interface

Use it in ui_main on the server to prove it works.
This commit is contained in:
Jeremy Wall 2022-08-09 17:38:04 -04:00
parent e1ba92938f
commit 2dc561e393
11 changed files with 218 additions and 19 deletions

8
Cargo.lock generated
View File

@ -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"

View File

@ -1,2 +1,2 @@
[workspace]
members = [ "recipes", "kitchen", "web" ]
members = [ "recipes", "kitchen", "web", "recipe-store"]

8
examples/categories.txt Normal file
View File

@ -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

View File

@ -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"

View File

@ -23,6 +23,7 @@ use tracing_subscriber::FmtSubscriber;
pub mod api;
mod cli;
mod store;
mod web;
fn create_app<'a>() -> clap::App<'a> {

93
kitchen/src/store.rs Normal file
View File

@ -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<P: Into<PathBuf>>(root: P) -> Self {
Self { path: root.into() }
}
}
impl RecipeStore<io::Error> for AsyncFileStore {
#[instrument(fields(path = ?self.path), skip_all)]
fn get_categories(&self) -> MaybeAsync<Result<Option<String>, 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<Result<Option<Vec<String>>, 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))
}))
}
}

View File

@ -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<Vec<String>, ParseError> {
@ -54,40 +56,54 @@ 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 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::<String>::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::<String>::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,
),
}
}

9
recipe-store/Cargo.toml Normal file
View File

@ -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" }

63
recipe-store/src/lib.rs Normal file
View File

@ -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<T>
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<Box<dyn Future<Output = T> + Send>>),
}
impl<T> MaybeAsync<T>
where
T: Send,
{
pub async fn as_async(self) -> Result<T, &'static str> {
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<T, &'static str> {
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<S, E>
where
S: RecipeStore<E>,
E: Send,
{
fn get_user_store(&self, user: String) -> S;
}
pub trait RecipeStore<E>
where
E: Send,
{
/// Get categories text unparsed.
fn get_categories(&self) -> MaybeAsync<Result<Option<String>, E>>;
/// Get list of recipe text unparsed.
fn get_recipes(&self) -> MaybeAsync<Result<Option<Vec<String>>, E>>;
}