Separate the api datamodel into a separate crate

This commit is contained in:
Jeremy Wall 2022-12-19 17:03:30 -05:00
parent 066fa8648d
commit db63deb319
7 changed files with 280 additions and 137 deletions

10
Cargo.lock generated
View File

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

View File

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

18
api/Cargo.toml Normal file
View File

@ -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 = []

163
api/src/lib.rs Normal file
View File

@ -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<T> {
Success(T),
Err { status: u16, message: String },
NotFound,
Unauthorized,
}
impl<T> Response<T> {
pub fn error<S: Into<String>>(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<T> IntoResponse for Response<T>
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<T> From<Result<T, String>> for Response<T> {
fn from(val: Result<T, String>) -> Self {
match val {
Ok(val) => Response::Success(val),
Err(e) => Response::error(500, e),
}
}
}
impl<T> From<Result<Option<T>, String>> for Response<T>
where
T: Default,
{
fn from(val: Result<Option<T>, 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<UserData>;
impl From<UserData> for AccountResponse {
fn from(user_data: UserData) -> Self {
Response::Success(user_data)
}
}
pub type RecipeEntryResponse = Response<Vec<RecipeEntry>>;
impl From<Vec<RecipeEntry>> for RecipeEntryResponse {
fn from(entries: Vec<RecipeEntry>) -> Self {
Response::Success(entries)
}
}
pub type PlanDataResponse = Response<Vec<(String, i32)>>;
impl From<Vec<(String, i32)>> for PlanDataResponse {
fn from(plan: Vec<(String, i32)>) -> Self {
Response::Success(plan)
}
}
impl From<Option<Vec<(String, i32)>>> for PlanDataResponse {
fn from(plan: Option<Vec<(String, i32)>>) -> Self {
match plan {
Some(plan) => Response::Success(plan),
None => Response::Success(Vec::new()),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct InventoryData {
pub filtered_ingredients: Vec<IngredientKey>,
pub modified_amts: Vec<(IngredientKey, String)>,
pub extra_items: Vec<(String, String)>,
}
pub type InventoryResponse = Response<InventoryData>;
impl
From<(
Vec<IngredientKey>,
Vec<(IngredientKey, String)>,
Vec<(String, String)>,
)> for InventoryData
{
fn from(
(filtered_ingredients, modified_amts, extra_items): (
Vec<IngredientKey>,
Vec<(IngredientKey, String)>,
Vec<(String, String)>,
),
) -> Self {
InventoryData {
filtered_ingredients,
modified_amts,
extra_items,
}
}
}
impl From<InventoryData> for InventoryResponse {
fn from(inventory_data: InventoryData) -> Self {
Response::Success(inventory_data)
}
}

View File

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

View File

@ -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<UserCreds> for AccountResponse {
impl From<UserCreds> 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<Arc<storage::SqliteStore>>,
) -> (StatusCode, HeaderMap, axum::Json<AccountResponse>) {
) -> (StatusCode, HeaderMap, axum::Json<api::AccountResponse>) {
// 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))
}
}

View File

@ -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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Path(recipe_id): Path<String>,
) -> impl IntoResponse {
) -> api::Response<Option<RecipeEntry>> {
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<Arc<storage::file_store::AsyncFileStore>>,
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
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::<RecipeEntry>::new())),
Err(e) => Err(e),
}
result.into()
}
#[instrument]
@ -132,7 +126,7 @@ async fn api_categories(
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
) -> api::Response<String> {
// 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<axum::Json<String>, 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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(categories): Json<String>,
) -> impl IntoResponse {
) -> api::Response<String> {
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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(recipes): Json<Vec<RecipeEntry>>,
) -> 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<Arc<storage::SqliteStore>>,
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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Path(date): Path<chrono::NaiveDate>,
) -> impl IntoResponse {
) -> api::Response<BTreeMap<NaiveDate, Vec<(String, i32)>>> {
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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(meal_plan): Json<Vec<(String, i32)>>,
) -> 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<Arc<storage::SqliteStore>>,
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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
) -> api::Response<(Vec<IngredientKey>, 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<IngredientKey>,
modified_amts: BTreeMap<IngredientKey, String>,
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<IngredientKey>,
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
}
}