diff --git a/Cargo.lock b/Cargo.lock index 7b244eb..d25c4cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,15 @@ version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1485d4d2cc45e7b201ee3767015c96faa5904387c9d87c6efdd0fb511f12d305" +[[package]] +name = "api" +version = "0.1.0" +dependencies = [ + "axum", + "recipes", + "serde", +] + [[package]] name = "argon2" version = "0.4.1" @@ -1262,6 +1271,7 @@ dependencies = [ name = "kitchen" version = "0.2.11" dependencies = [ + "api", "argon2", "async-session", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 34751a5..073a4b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "recipes", "kitchen", "web" ] +members = [ "recipes", "kitchen", "web", "api" ] [patch.crates-io] # TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch. diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..07d80b0 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = "1.0.144" +recipes = { path = "../recipes" } + +[dependencies.axum] +version = "0.5.16" +optional = true + +[features] +server = ["axum"] +browser = [] \ No newline at end of file diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..eebde5f --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,163 @@ +// 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. +#[cfg(feature = "server")] +use axum::{ + self, + http::StatusCode, + response::{IntoResponse, Response as AxumResponse}, +}; +use serde::{Deserialize, Serialize}; + +use recipes::{IngredientKey, RecipeEntry}; + +#[derive(Serialize, Deserialize)] +pub enum Response { + Success(T), + Err { status: u16, message: String }, + NotFound, + Unauthorized, +} + +impl Response { + pub fn error>(code: u16, msg: S) -> Self { + Self::Err { + status: code, + message: msg.into(), + } + } + + pub fn success(payload: T) -> Self { + Self::Success(payload) + } +} + +#[cfg(feature = "server")] +impl IntoResponse for Response +where + T: Serialize, +{ + fn into_response(self) -> AxumResponse { + match &self { + Self::Success(_) => (StatusCode::OK, axum::Json::from(self)).into_response(), + Self::Err { status, message: _ } => { + let code = match StatusCode::from_u16(*status) { + Ok(c) => c, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (code, axum::Json::from(self)).into_response() + } + Self::NotFound => (StatusCode::NOT_FOUND, axum::Json::from(self)).into_response(), + Self::Unauthorized => { + (StatusCode::UNAUTHORIZED, axum::Json::from(self)).into_response() + } + } + } +} + +impl From> for Response { + fn from(val: Result) -> Self { + match val { + Ok(val) => Response::Success(val), + Err(e) => Response::error(500, e), + } + } +} + +impl From, String>> for Response +where + T: Default, +{ + fn from(val: Result, String>) -> Self { + match val { + Ok(Some(val)) => Response::Success(val), + Ok(None) => Response::Success(T::default()), + Err(e) => Response::error(500, e), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct UserData { + pub user_id: String, +} + +pub type AccountResponse = Response; + +impl From for AccountResponse { + fn from(user_data: UserData) -> Self { + Response::Success(user_data) + } +} + +pub type RecipeEntryResponse = Response>; + +impl From> for RecipeEntryResponse { + fn from(entries: Vec) -> Self { + Response::Success(entries) + } +} + +pub type PlanDataResponse = Response>; + +impl From> for PlanDataResponse { + fn from(plan: Vec<(String, i32)>) -> Self { + Response::Success(plan) + } +} + +impl From>> for PlanDataResponse { + fn from(plan: Option>) -> Self { + match plan { + Some(plan) => Response::Success(plan), + None => Response::Success(Vec::new()), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct InventoryData { + pub filtered_ingredients: Vec, + pub modified_amts: Vec<(IngredientKey, String)>, + pub extra_items: Vec<(String, String)>, +} + +pub type InventoryResponse = Response; + +impl + From<( + Vec, + Vec<(IngredientKey, String)>, + Vec<(String, String)>, + )> for InventoryData +{ + fn from( + (filtered_ingredients, modified_amts, extra_items): ( + Vec, + Vec<(IngredientKey, String)>, + Vec<(String, String)>, + ), + ) -> Self { + InventoryData { + filtered_ingredients, + modified_amts, + extra_items, + } + } +} + +impl From for InventoryResponse { + fn from(inventory_data: InventoryData) -> Self { + Response::Success(inventory_data) + } +} diff --git a/kitchen/Cargo.toml b/kitchen/Cargo.toml index 9ef1a10..501ef19 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" } +api = { path = "../api" } csv = "1.1.1" rust-embed="6.4.0" mime_guess = "2.0.4" diff --git a/kitchen/src/web/auth.rs b/kitchen/src/web/auth.rs index 7786ec0..cc67c03 100644 --- a/kitchen/src/web/auth.rs +++ b/kitchen/src/web/auth.rs @@ -14,6 +14,7 @@ use std::str::FromStr; use std::sync::Arc; +use api; use async_session::{Session, SessionStore}; use axum::{ extract::Extension, @@ -22,31 +23,15 @@ use axum::{ use axum_auth::AuthBasic; use cookie::{Cookie, SameSite}; use secrecy::Secret; -use serde::{Deserialize, Serialize}; use tracing::{debug, error, info, instrument}; use super::storage::{self, AuthStore, UserCreds}; -// FIXME(jwall): This needs to live in a client integration library. -#[derive(Serialize, Deserialize)] -pub enum AccountResponse { - Success { user_id: String }, - Err { message: String }, -} - -impl From for AccountResponse { +impl From for api::AccountResponse { fn from(auth: UserCreds) -> Self { - Self::Success { + Self::Success(api::UserData { user_id: auth.user_id().to_owned(), - } - } -} - -impl<'a> From<&'a str> for AccountResponse { - fn from(msg: &'a str) -> Self { - Self::Err { - message: msg.to_string(), - } + }) } } @@ -54,7 +39,7 @@ impl<'a> From<&'a str> for AccountResponse { pub async fn handler( auth: AuthBasic, Extension(session_store): Extension>, -) -> (StatusCode, HeaderMap, axum::Json) { +) -> (StatusCode, HeaderMap, axum::Json) { // NOTE(jwall): It is very important that you do **not** log the password // here. We convert the AuthBasic into UserCreds immediately to help prevent // that. Do not circumvent that protection. @@ -67,7 +52,10 @@ pub async fn handler( let mut session = Session::new(); if let Err(err) = session.insert("user_id", auth.user_id()) { error!(?err, "Unable to insert user id into session"); - let resp: AccountResponse = "Unable to insert user id into session".into(); + let resp = api::AccountResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Unable to insert user id into session", + ); return ( StatusCode::INTERNAL_SERVER_ERROR, headers, @@ -78,7 +66,10 @@ pub async fn handler( let cookie_value = match session_store.store_session(session).await { Err(err) => { error!(?err, "Unable to store session in session store"); - let resp: AccountResponse = "Unable to store session in session store".into(); + let resp = api::AccountResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Unable to store session in session store", + ); return ( StatusCode::INTERNAL_SERVER_ERROR, headers, @@ -87,7 +78,10 @@ pub async fn handler( } Ok(None) => { error!("Unable to create session cookie"); - let resp: AccountResponse = "Unable to create session cookie".into(); + let resp = api::AccountResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Unable to create session cookie", + ); return ( StatusCode::INTERNAL_SERVER_ERROR, headers, @@ -105,7 +99,10 @@ pub async fn handler( let parsed_cookie = match cookie.to_string().parse() { Err(err) => { error!(?err, "Unable to parse session cookie"); - let resp: AccountResponse = "Unable to parse session cookie".into(); + let resp = api::AccountResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Unable to parse session cookie", + ); return ( StatusCode::INTERNAL_SERVER_ERROR, headers, @@ -116,12 +113,15 @@ pub async fn handler( }; headers.insert(header::SET_COOKIE, parsed_cookie); // Respond with 200 OK - let resp: AccountResponse = auth.into(); + let resp: api::AccountResponse = auth.into(); (StatusCode::OK, headers, axum::Json::from(resp)) } else { debug!("Invalid credentials"); let headers = HeaderMap::new(); - let resp: AccountResponse = "Invalid user id or password".into(); + let resp = api::AccountResponse::error( + StatusCode::UNAUTHORIZED.as_u16(), + "Invalid user id or password", + ); (StatusCode::UNAUTHORIZED, headers, axum::Json::from(resp)) } } diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 2e54d7c..ebda686 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -23,13 +23,15 @@ use axum::{ response::{IntoResponse, Redirect, Response}, routing::{get, Router}, }; +use chrono::NaiveDate; use mime_guess; use recipes::{IngredientKey, RecipeEntry}; use rust_embed::RustEmbed; use tower::ServiceBuilder; use tower_http::trace::TraceLayer; -use tracing::{debug, error, info, instrument}; +use tracing::{debug, info, instrument}; +use api; use storage::{APIStore, AuthStore}; mod auth; @@ -83,7 +85,7 @@ async fn api_recipe_entry( Extension(app_store): Extension>, session: storage::UserIdFromSession, Path(recipe_id): Path, -) -> impl IntoResponse { +) -> api::Response> { use storage::{UserId, UserIdFromSession::*}; let result = match session { NoUserId => store @@ -95,11 +97,7 @@ async fn api_recipe_entry( .await .map_err(|e| format!("Error: {:?}", e)), }; - match result { - Ok(Some(recipes)) => (StatusCode::OK, axum::Json::from(recipes)).into_response(), - Ok(None) => (StatusCode::NOT_FOUND, axum::Json::from("")).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json::from(e)).into_response(), - } + result.into() } #[instrument] @@ -107,7 +105,7 @@ async fn api_recipes( Extension(store): Extension>, Extension(app_store): Extension>, session: storage::UserIdFromSession, -) -> impl IntoResponse { +) -> api::RecipeEntryResponse { // Select recipes based on the user-id if it exists or serve the default if it does not. use storage::{UserId, UserIdFromSession::*}; let result = match session { @@ -120,11 +118,7 @@ async fn api_recipes( .await .map_err(|e| format!("Error: {:?}", e)), }; - match result { - Ok(Some(recipes)) => Ok(axum::Json::from(recipes)), - Ok(None) => Ok(axum::Json::from(Vec::::new())), - Err(e) => Err(e), - } + result.into() } #[instrument] @@ -132,7 +126,7 @@ async fn api_categories( Extension(store): Extension>, Extension(app_store): Extension>, session: storage::UserIdFromSession, -) -> impl IntoResponse { +) -> api::Response { // Select Categories based on the user-id if it exists or serve the default if it does not. use storage::{UserId, UserIdFromSession::*}; let categories_result = match session { @@ -145,33 +139,28 @@ async fn api_categories( .await .map_err(|e| format!("Error: {:?}", e)), }; - let result: Result, String> = match categories_result { - Ok(Some(categories)) => Ok(axum::Json::from(categories)), - Ok(None) => Ok(axum::Json::from(String::new())), - Err(e) => Err(e), - }; - result + categories_result.into() } async fn api_save_categories( Extension(app_store): Extension>, session: storage::UserIdFromSession, Json(categories): Json, -) -> impl IntoResponse { +) -> api::Response { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { if let Err(e) = app_store .store_categories_for_user(id.as_str(), categories.as_str()) .await { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); + return api::Response::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + format!("{:?}", e), + ); } - (StatusCode::OK, "Successfully saved categories".to_owned()) + api::Response::success("Successfully saved categories".into()) } else { - ( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - ) + api::Response::Unauthorized } } @@ -179,43 +168,31 @@ async fn api_save_recipes( Extension(app_store): Extension>, session: storage::UserIdFromSession, Json(recipes): Json>, -) -> impl IntoResponse { +) -> api::Response<()> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { let result = app_store .store_recipes_for_user(id.as_str(), &recipes) .await; - match result.map_err(|e| format!("Error: {:?}", e)) { - Ok(val) => Ok(axum::Json::from(val)), - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), - } + result.map_err(|e| format!("Error: {:?}", e)).into() } else { - Err(( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - )) + api::Response::Unauthorized } } async fn api_plan( Extension(app_store): Extension>, session: storage::UserIdFromSession, -) -> impl IntoResponse { +) -> api::PlanDataResponse { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - match app_store + app_store .fetch_latest_meal_plan(&id) .await .map_err(|e| format!("Error: {:?}", e)) - { - Ok(val) => Ok(axum::Json::from(val)), - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), - } + .into() } else { - Err(( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - )) + api::Response::Unauthorized } } @@ -223,22 +200,16 @@ async fn api_plan_since( Extension(app_store): Extension>, session: storage::UserIdFromSession, Path(date): Path, -) -> impl IntoResponse { +) -> api::Response>> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - match app_store + app_store .fetch_meal_plans_since(&id, date) .await .map_err(|e| format!("Error: {:?}", e)) - { - Ok(val) => Ok(axum::Json::from(val)), - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), - } + .into() } else { - Err(( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - )) + api::Response::Unauthorized } } @@ -246,63 +217,53 @@ async fn api_save_plan( Extension(app_store): Extension>, session: storage::UserIdFromSession, Json(meal_plan): Json>, -) -> impl IntoResponse { +) -> api::Response<()> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - if let Err(e) = app_store + app_store .save_meal_plan(id.as_str(), &meal_plan, chrono::Local::now().date_naive()) .await - { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); - } - (StatusCode::OK, "Successfully saved mealPlan".to_owned()) + .map_err(|e| format!("{:?}", e)) + .into() } else { - ( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - ) + api::Response::Unauthorized } } async fn api_inventory_v2( Extension(app_store): Extension>, session: storage::UserIdFromSession, -) -> impl IntoResponse { +) -> api::InventoryResponse { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - match app_store.fetch_latest_inventory_data(id).await { - Ok(tpl) => Ok(axum::Json::from(tpl)), - Err(e) => { - error!(err=?e); - Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e))) - } - } + app_store + .fetch_latest_inventory_data(id) + .await + .map_err(|e| format!("{:?}", e)) + .map(|d| { + let data: api::InventoryData = d.into(); + data + }) + .into() } else { - Err(( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - )) + api::Response::Unauthorized } } async fn api_inventory( Extension(app_store): Extension>, session: storage::UserIdFromSession, -) -> impl IntoResponse { +) -> api::Response<(Vec, Vec<(IngredientKey, String)>)> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { - match app_store.fetch_latest_inventory_data(id).await { - Ok((item1, item2, _)) => Ok(axum::Json::from((item1, item2))), - Err(e) => { - error!(err=?e); - Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e))) - } - } + app_store + .fetch_latest_inventory_data(id) + .await + .map_err(|e| format!("{:?}", e)) + .map(|(filtered, modified, _)| (filtered, modified)) + .into() } else { - Err(( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - )) + api::Response::Unauthorized } } @@ -312,18 +273,12 @@ async fn save_inventory_data( filtered_ingredients: BTreeSet, modified_amts: BTreeMap, extra_items: Vec<(String, String)>, -) -> (StatusCode, String) { - if let Err(e) = app_store +) -> api::Response<()> { + app_store .save_inventory_data(id, filtered_ingredients, modified_amts, extra_items) .await - { - error!(err=?e); - return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); - } - ( - StatusCode::OK, - "Successfully saved inventory data".to_owned(), - ) + .map_err(|e| format!("{:?}", e)) + .into() } async fn api_save_inventory_v2( @@ -334,7 +289,7 @@ async fn api_save_inventory_v2( Vec<(IngredientKey, String)>, Vec<(String, String)>, )>, -) -> impl IntoResponse { +) -> api::Response<()> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { let filtered_ingredients = filtered_ingredients.into_iter().collect(); @@ -347,11 +302,9 @@ async fn api_save_inventory_v2( extra_items, ) .await + .into() } else { - ( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - ) + api::Response::Unauthorized } } @@ -362,7 +315,7 @@ async fn api_save_inventory( Vec, Vec<(IngredientKey, String)>, )>, -) -> impl IntoResponse { +) -> api::Response<()> { use storage::{UserId, UserIdFromSession::FoundUserId}; if let FoundUserId(UserId(id)) = session { let filtered_ingredients = filtered_ingredients.into_iter().collect(); @@ -375,11 +328,9 @@ async fn api_save_inventory( Vec::new(), ) .await + .into() } else { - ( - StatusCode::UNAUTHORIZED, - "You must be authorized to use this API call".to_owned(), - ) + api::Response::Unauthorized } }