diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-02-15 22:24:32 +0100 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-02-15 22:25:06 +0100 |
| commit | e5f90f4474cb96a78080395980283e4b2ce40214 (patch) | |
| tree | caac3300795eae8e4cb1ee3c1c4bf85cd5950402 /crates/rocie-server/src | |
| parent | chore(treewide): Update (diff) | |
| download | server-e5f90f4474cb96a78080395980283e4b2ce40214.zip | |
feat(treewide): Add recipes and user handling
Diffstat (limited to 'crates/rocie-server/src')
28 files changed, 1364 insertions, 110 deletions
diff --git a/crates/rocie-server/src/api/get/auth/mod.rs b/crates/rocie-server/src/api/get/auth/mod.rs index c51f6a7..0821222 100644 --- a/crates/rocie-server/src/api/get/auth/mod.rs +++ b/crates/rocie-server/src/api/get/auth/mod.rs @@ -1,9 +1,12 @@ use actix_web::web; +use log::info; +use percent_encoding::percent_decode_str; pub(crate) mod inventory; pub(crate) mod product; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; @@ -17,11 +20,19 @@ pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { .service(product::products_by_product_parent_id_indirect) .service(product::products_in_storage) .service(product::products_registered) + .service(product::products_without_product_parent) .service(product_parent::product_parents) .service(product_parent::product_parents_toplevel) .service(product_parent::product_parents_under) + .service(recipe::recipe_by_name) .service(recipe::recipe_by_id) .service(recipe::recipes) + .service(recipe::recipes_by_recipe_parent_id_direct) + .service(recipe::recipes_by_recipe_parent_id_indirect) + .service(recipe::recipes_without_recipe_parent) + .service(recipe_parent::recipe_parents) + .service(recipe_parent::recipe_parents_toplevel) + .service(recipe_parent::recipe_parents_under) .service(unit::unit_by_id) .service(unit::units) .service(unit::units_by_property_id) @@ -30,3 +41,20 @@ pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { .service(user::users) .service(user::user_by_id); } + +/// A String, that is not url-decoded on parse. +struct UrlEncodedString(String); + +impl UrlEncodedString { + /// Percent de-encode a given string + fn percent_decode(&self) -> Result<String, std::str::Utf8Error> { + percent_decode_str(self.0.replace('+', "%20").as_str()) + .decode_utf8() + .map(|s| s.to_string()) + .inspect(|s| info!("Decoded `{}` as `{s}`", self.0)) + } + + fn from_str(inner: &str) -> Self { + Self(inner.to_owned()) + } +} diff --git a/crates/rocie-server/src/api/get/auth/product.rs b/crates/rocie-server/src/api/get/auth/product.rs index 1a1e31d..7e32a0b 100644 --- a/crates/rocie-server/src/api/get/auth/product.rs +++ b/crates/rocie-server/src/api/get/auth/product.rs @@ -1,33 +1,13 @@ use actix_identity::Identity; use actix_web::{HttpRequest, HttpResponse, Responder, Result, get, web}; -use log::info; -use percent_encoding::percent_decode_str; use crate::{ - app::App, - storage::sql::{ + api::get::auth::UrlEncodedString, app::App, storage::sql::{ product::{Product, ProductId, ProductIdStub}, product_amount::ProductAmount, product_parent::{ProductParent, ProductParentId, ProductParentIdStub}, - }, -}; - -/// A String, that is not url-decoded on parse. -struct UrlEncodedString(String); - -impl UrlEncodedString { - /// Percent de-encode a given string - fn percent_decode(&self) -> Result<String, std::str::Utf8Error> { - percent_decode_str(self.0.replace('+', "%20").as_str()) - .decode_utf8() - .map(|s| s.to_string()) - .inspect(|s| info!("Decoded `{}` as `{s}`", self.0)) - } - - fn from_str(inner: &str) -> Self { - Self(inner.to_owned()) } -} +}; /// Get Product by id #[utoipa::path( @@ -118,7 +98,7 @@ pub(crate) async fn product_by_name( ); let name = name.percent_decode()?; - match Product::from_name(&app, name).await? { + match Product::from_name(&app, name.as_str()).await? { Some(product) => Ok(HttpResponse::Ok().json(product)), None => Ok(HttpResponse::NotFound().finish()), @@ -360,3 +340,39 @@ pub(crate) async fn products_by_product_parent_id_direct( Ok(HttpResponse::NotFound().finish()) } } + +/// Get Products by it's absents of a product parent +/// +/// This will only return products without a product parent associated with it +#[utoipa::path( + responses( + ( + status = OK, + description = "Products found from database", + body = Vec<Product> + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), +)] +#[get("/product/without-product-parent")] +pub(crate) async fn products_without_product_parent( + app: web::Data<App>, + _user: Identity, +) -> Result<impl Responder> { + let all = { + let base = Product::get_all(&app).await?; + base.into_iter() + .filter(|r| r.parent.is_none()) + .collect::<Vec<_>>() + }; + + Ok(HttpResponse::Ok().json(all)) +} diff --git a/crates/rocie-server/src/api/get/auth/recipe.rs b/crates/rocie-server/src/api/get/auth/recipe.rs index cb80597..e3032b9 100644 --- a/crates/rocie-server/src/api/get/auth/recipe.rs +++ b/crates/rocie-server/src/api/get/auth/recipe.rs @@ -1,11 +1,66 @@ use actix_identity::Identity; -use actix_web::{HttpResponse, Responder, error::Result, get, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, error::Result, get, web}; use crate::{ + api::get::auth::UrlEncodedString, app::App, - storage::sql::recipe::{Recipe, RecipeId, RecipeIdStub}, + storage::sql::{ + recipe::{Recipe, RecipeId, RecipeIdStub}, + recipe_parent::{RecipeParent, RecipeParentId, RecipeParentIdStub}, + }, }; +/// Get an recipe by it's name. +#[utoipa::path( + responses( + ( + status = OK, + description = "Recipe found in database and fetched", + body = Recipe, + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = NOT_FOUND, + description = "Recipe not found in database" + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), + params( + ( + "name" = String, + description = "Recipe name" + ), + ) +)] +#[get("/recipe/by-name/{name}")] +pub(crate) async fn recipe_by_name( + app: web::Data<App>, + req: HttpRequest, + name: web::Path<String>, + _user: Identity, +) -> Result<impl Responder> { + drop(name); + + let name = UrlEncodedString::from_str( + req.path() + .strip_prefix("/recipe/by-name/") + .expect("Will always exists"), + ); + let name = name.percent_decode()?; + + match Recipe::from_name(&app, name).await? { + Some(recipe) => Ok(HttpResponse::Ok().json(recipe)), + None => Ok(HttpResponse::NotFound().finish()), + } +} + /// Get an recipe by it's id. #[utoipa::path( responses( @@ -41,9 +96,7 @@ pub(crate) async fn recipe_by_id( id: web::Path<RecipeIdStub>, _user: Identity, ) -> Result<impl Responder> { - let id = id.into_inner(); - - match Recipe::from_id(&app, id.into()).await? { + match Recipe::from_id(&app, id.into_inner().into()).await? { Some(recipe) => Ok(HttpResponse::Ok().json(recipe)), None => Ok(HttpResponse::NotFound().finish()), } @@ -55,7 +108,7 @@ pub(crate) async fn recipe_by_id( ( status = OK, description = "All recipes found in database and fetched", - body = Recipe, + body = Vec<Recipe>, ), ( status = UNAUTHORIZED, @@ -74,3 +127,157 @@ pub(crate) async fn recipes(app: web::Data<App>, _user: Identity) -> Result<impl Ok(HttpResponse::Ok().json(all)) } + +/// Get Recipes by it's recipe parent id +/// +/// This will also return all recipes below this recipe parent id +#[utoipa::path( + responses( + ( + status = OK, + description = "Recipes found from database", + body = Vec<Recipe> + ), + ( + status = NOT_FOUND, + description = "Recipe parent id not found in database" + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), + params( + ( + "id" = RecipeParentId, + description = "Recipe parent id" + ), + ) +)] +#[get("/recipe/by-recipe-parent-id-indirect/{id}")] +pub(crate) async fn recipes_by_recipe_parent_id_indirect( + app: web::Data<App>, + id: web::Path<RecipeParentIdStub>, + _user: Identity, +) -> Result<impl Responder> { + let id = id.into_inner(); + + if let Some(parent) = RecipeParent::from_id(&app, id.into()).await? { + async fn collect_recipes(app: &App, parent: RecipeParent) -> Result<Vec<Recipe>> { + let mut all = Recipe::get_all(app) + .await? + .into_iter() + .filter(|prod| prod.parent.is_some_and(|val| val == parent.id)) + .collect::<Vec<_>>(); + + if let Some(child) = RecipeParent::get_all(app) + .await? + .into_iter() + .find(|pp| pp.parent.is_some_and(|id| id == parent.id)) + { + all.extend(Box::pin(collect_recipes(app, child)).await?); + } + + Ok(all) + } + + let all = collect_recipes(&app, parent).await?; + + Ok(HttpResponse::Ok().json(all)) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +/// Get Recipes by it's recipe parent id +/// +/// This will only return recipes directly associated with this recipe parent id +#[utoipa::path( + responses( + ( + status = OK, + description = "Recipes found from database", + body = Vec<Recipe> + ), + ( + status = NOT_FOUND, + description = "Recipe parent id not found in database" + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), + params( + ( + "id" = RecipeParentId, + description = "Recipe parent id" + ), + ) +)] +#[get("/recipe/by-recipe-parent-id-direct/{id}")] +pub(crate) async fn recipes_by_recipe_parent_id_direct( + app: web::Data<App>, + id: web::Path<RecipeParentIdStub>, + _user: Identity, +) -> Result<impl Responder> { + let id = id.into_inner(); + + if let Some(parent) = RecipeParent::from_id(&app, id.into()).await? { + let all = Recipe::get_all(&app) + .await? + .into_iter() + .filter(|prod| prod.parent.is_some_and(|val| val == parent.id)) + .collect::<Vec<_>>(); + + Ok(HttpResponse::Ok().json(all)) + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +/// Get Recipes by it's absents of a recipe parent +/// +/// This will only return recipes without a recipe parent associated with it +#[utoipa::path( + responses( + ( + status = OK, + description = "Recipes found from database", + body = Vec<Recipe> + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), +)] +#[get("/recipe/without-recipe-parent")] +pub(crate) async fn recipes_without_recipe_parent( + app: web::Data<App>, + _user: Identity, +) -> Result<impl Responder> { + let all = { + let base = Recipe::get_all(&app).await?; + base.into_iter() + .filter(|r| r.parent.is_none()) + .collect::<Vec<_>>() + }; + + Ok(HttpResponse::Ok().json(all)) +} diff --git a/crates/rocie-server/src/api/get/auth/recipe_parent.rs b/crates/rocie-server/src/api/get/auth/recipe_parent.rs new file mode 100644 index 0000000..d54082b --- /dev/null +++ b/crates/rocie-server/src/api/get/auth/recipe_parent.rs @@ -0,0 +1,108 @@ +use actix_identity::Identity; +use actix_web::{HttpResponse, Responder, error::Result, get, web}; + +use crate::{ + app::App, + storage::sql::recipe_parent::{RecipeParent, RecipeParentId, RecipeParentIdStub}, +}; + +/// Return all registered recipe parents +#[utoipa::path( + responses( + ( + status = OK, + description = "All parents found", + body = Vec<RecipeParent> + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), +)] +#[get("/recipe_parents/")] +pub(crate) async fn recipe_parents(app: web::Data<App>, _user: Identity) -> Result<impl Responder> { + let all: Vec<RecipeParent> = RecipeParent::get_all(&app).await?; + + Ok(HttpResponse::Ok().json(all)) +} + +/// Return all registered recipe parents, that have no parents themselves +#[utoipa::path( + responses( + ( + status = OK, + description = "All parents found", + body = Vec<RecipeParent> + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), +)] +#[get("/recipe_parents_toplevel/")] +pub(crate) async fn recipe_parents_toplevel( + app: web::Data<App>, + _user: Identity, +) -> Result<impl Responder> { + let all: Vec<RecipeParent> = RecipeParent::get_all(&app) + .await? + .into_iter() + .filter(|parent| parent.parent.is_none()) + .collect(); + + Ok(HttpResponse::Ok().json(all)) +} + +/// Return all parents, that have this parent as parent +#[utoipa::path( + responses( + ( + status = OK, + description = "All parents found", + body = Vec<RecipeParent> + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), + params( + ( + "id" = RecipeParentId, + description = "Recipe parent id" + ), + ), +)] +#[get("/recipe_parents_under/{id}")] +pub(crate) async fn recipe_parents_under( + app: web::Data<App>, + id: web::Path<RecipeParentIdStub>, + _user: Identity, +) -> Result<impl Responder> { + let id = id.into_inner().into(); + + let all: Vec<_> = RecipeParent::get_all(&app) + .await? + .into_iter() + .filter(|parent| parent.parent.is_some_and(|found| found == id)) + .collect(); + + Ok(HttpResponse::Ok().json(all)) +} diff --git a/crates/rocie-server/src/api/get/no_auth/mod.rs b/crates/rocie-server/src/api/get/no_auth/mod.rs index 38a041c..5274b4c 100644 --- a/crates/rocie-server/src/api/get/no_auth/mod.rs +++ b/crates/rocie-server/src/api/get/no_auth/mod.rs @@ -1,3 +1,8 @@ use actix_web::web; -pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) {} +pub(crate) mod state; + +pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { + cfg.service(state::is_logged_in) + .service(state::can_be_provisioned); +} diff --git a/crates/rocie-server/src/api/get/no_auth/state.rs b/crates/rocie-server/src/api/get/no_auth/state.rs new file mode 100644 index 0000000..31cbfa5 --- /dev/null +++ b/crates/rocie-server/src/api/get/no_auth/state.rs @@ -0,0 +1,41 @@ +use actix_identity::Identity; +use actix_web::{HttpResponse, Responder, Result, get, web}; + +use crate::{app::App, storage::sql::user::User}; + +/// Check if you are logged in +#[utoipa::path( + responses( + ( + status = OK, + description = "User login state checked", + body = bool + ) + ) +)] +#[get("/is-logged-in")] +pub(crate) async fn is_logged_in(user: Option<Identity>) -> impl Responder { + HttpResponse::Ok().json(user.is_some()) +} + +/// Check if the server can be provisioned +#[utoipa::path( + responses( + ( + status = OK, + description = "Provisioning state checked", + body = bool + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ) +)] +#[get("/can-be-provisioned")] +pub(crate) async fn can_be_provisioned(app: web::Data<App>) -> Result<impl Responder> { + let users = User::get_all(&app).await?; + + Ok(HttpResponse::Ok().json(users.is_empty())) +} diff --git a/crates/rocie-server/src/api/set/auth/mod.rs b/crates/rocie-server/src/api/set/auth/mod.rs index 4e733a9..6379f22 100644 --- a/crates/rocie-server/src/api/set/auth/mod.rs +++ b/crates/rocie-server/src/api/set/auth/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; @@ -13,6 +14,7 @@ pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { .service(product::associate_barcode) .service(product_parent::register_product_parent) .service(recipe::add_recipe) + .service(recipe_parent::register_recipe_parent) .service(unit::register_unit) .service(unit_property::register_unit_property) .service(barcode::consume_barcode) diff --git a/crates/rocie-server/src/api/set/auth/recipe.rs b/crates/rocie-server/src/api/set/auth/recipe.rs index 43a034e..b9f930d 100644 --- a/crates/rocie-server/src/api/set/auth/recipe.rs +++ b/crates/rocie-server/src/api/set/auth/recipe.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use actix_identity::Identity; use actix_web::{HttpResponse, Responder, error::Result, post, web}; use serde::Deserialize; @@ -10,14 +8,18 @@ use crate::{ storage::sql::{ insert::Operations, recipe::{Recipe, RecipeId}, + recipe_parent::RecipeParentId, }, }; #[derive(Deserialize, ToSchema)] struct RecipeStub { - /// The path the recipe should have - #[schema(value_type = String)] - path: PathBuf, + /// The globally unique name of this recipe + name: String, + + /// The optional parent of this recipe. + #[schema(nullable = false)] + parent: Option<RecipeParentId>, /// The content of this recipe, in cooklang format content: String, @@ -52,7 +54,14 @@ pub(crate) async fn add_recipe( let stub = stub.into_inner(); let mut ops = Operations::new("add recipe parent"); - let recipe = Recipe::new(stub.path, stub.content, &mut ops); + let recipe = Recipe::new( + &app, + stub.name, + stub.parent, + stub.content, + &mut ops, + ) + .await?; ops.apply(&app).await?; diff --git a/crates/rocie-server/src/api/set/auth/recipe_parent.rs b/crates/rocie-server/src/api/set/auth/recipe_parent.rs new file mode 100644 index 0000000..e020dd3 --- /dev/null +++ b/crates/rocie-server/src/api/set/auth/recipe_parent.rs @@ -0,0 +1,67 @@ +use actix_identity::Identity; +use actix_web::{HttpResponse, Responder, Result, post, web}; +use serde::Deserialize; +use utoipa::ToSchema; + +use crate::{ + app::App, + storage::sql::{ + insert::Operations, + recipe_parent::{RecipeParent, RecipeParentId}, + }, +}; + +#[derive(Deserialize, ToSchema)] +struct RecipeParentStub { + /// The name of the recipe parent + name: String, + + /// A description. + #[schema(nullable = false)] + description: Option<String>, + + /// A parent of this recipe parent, otherwise the parent will be the root of the parent tree. + #[schema(nullable = false)] + parent: Option<RecipeParentId>, +} + +/// Register a product parent +#[utoipa::path( + responses( + ( + status = OK, + description = "Recipe parent successfully registered in database", + body = RecipeParentId, + ), + ( + status = UNAUTHORIZED, + description = "You did not login before calling this endpoint", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String, + ) + ), + request_body = RecipeParentStub, +)] +#[post("/recipe_parent/new")] +pub(crate) async fn register_recipe_parent( + app: web::Data<App>, + parent_stub: web::Json<RecipeParentStub>, + _user: Identity, +) -> Result<impl Responder> { + let parent_stub = parent_stub.into_inner(); + let mut ops = Operations::new("register recipe parent"); + + let parent = RecipeParent::register( + parent_stub.name, + parent_stub.description, + parent_stub.parent, + &mut ops, + ); + + ops.apply(&app).await?; + + Ok(HttpResponse::Ok().json(parent.id)) +} diff --git a/crates/rocie-server/src/api/set/no_auth/user.rs b/crates/rocie-server/src/api/set/no_auth/user.rs index 7acb482..7ca865c 100644 --- a/crates/rocie-server/src/api/set/no_auth/user.rs +++ b/crates/rocie-server/src/api/set/no_auth/user.rs @@ -14,8 +14,8 @@ use crate::{ #[derive(ToSchema, Deserialize, Serialize)] struct LoginInfo { - /// The id of the user. - id: UserId, + /// The user name of the user. + user_name: String, /// The password of the user. password: String, @@ -30,7 +30,7 @@ struct LoginInfo { ), ( status = NOT_FOUND, - description = "User id not found" + description = "User name not found" ), ( status = FORBIDDEN, @@ -52,9 +52,9 @@ async fn login( ) -> Result<impl Responder> { let info = info.into_inner(); - if let Some(user) = User::from_id(&app, info.id).await? { + if let Some(user) = User::from_name(&app, info.user_name).await? { if user.password_hash.verify(&info.password) { - Identity::login(&request.extensions(), info.id.to_string())?; + Identity::login(&request.extensions(), user.id.to_string())?; Ok(HttpResponse::Ok().finish()) } else { Ok(HttpResponse::Forbidden().finish()) diff --git a/crates/rocie-server/src/main.rs b/crates/rocie-server/src/main.rs index caa210d..5f3b0ff 100644 --- a/crates/rocie-server/src/main.rs +++ b/crates/rocie-server/src/main.rs @@ -1,4 +1,3 @@ -use actix_cors::Cors; use actix_web::{ App, HttpServer, cookie::{Key, SameSite}, @@ -27,35 +26,47 @@ async fn main() -> Result<(), std::io::Error> { #[derive(OpenApi)] #[openapi( paths( + api::get::auth::inventory::amount_by_id, api::get::auth::product::product_by_id, api::get::auth::product::product_by_name, api::get::auth::product::product_suggestion_by_name, - api::get::auth::product::products_registered, - api::get::auth::product::products_in_storage, - api::get::auth::product::products_by_product_parent_id_indirect, api::get::auth::product::products_by_product_parent_id_direct, + api::get::auth::product::products_by_product_parent_id_indirect, + api::get::auth::product::products_in_storage, + api::get::auth::product::products_registered, + api::get::auth::product::products_without_product_parent, api::get::auth::product_parent::product_parents, api::get::auth::product_parent::product_parents_toplevel, api::get::auth::product_parent::product_parents_under, + api::get::auth::recipe::recipe_by_name, api::get::auth::recipe::recipe_by_id, api::get::auth::recipe::recipes, + api::get::auth::recipe::recipes_by_recipe_parent_id_direct, + api::get::auth::recipe::recipes_by_recipe_parent_id_indirect, + api::get::auth::recipe::recipes_without_recipe_parent, + api::get::auth::recipe_parent::recipe_parents, + api::get::auth::recipe_parent::recipe_parents_toplevel, + api::get::auth::recipe_parent::recipe_parents_under, + api::get::auth::unit::unit_by_id, api::get::auth::unit::units, api::get::auth::unit::units_by_property_id, - api::get::auth::unit::unit_by_id, - api::get::auth::unit_property::unit_property_by_id, api::get::auth::unit_property::unit_properties, - api::get::auth::inventory::amount_by_id, - api::get::auth::user::users, + api::get::auth::unit_property::unit_property_by_id, api::get::auth::user::user_by_id, + api::get::auth::user::users, // - api::set::auth::product::register_product, + api::get::no_auth::state::is_logged_in, + api::get::no_auth::state::can_be_provisioned, + // + api::set::auth::barcode::buy_barcode, + api::set::auth::barcode::consume_barcode, api::set::auth::product::associate_barcode, + api::set::auth::product::register_product, api::set::auth::product_parent::register_product_parent, api::set::auth::recipe::add_recipe, + api::set::auth::recipe_parent::register_recipe_parent, api::set::auth::unit::register_unit, api::set::auth::unit_property::register_unit_property, - api::set::auth::barcode::buy_barcode, - api::set::auth::barcode::consume_barcode, api::set::auth::user::register_user, // api::set::no_auth::user::login, @@ -90,8 +101,6 @@ async fn main() -> Result<(), std::io::Error> { let srv = HttpServer::new(move || { App::new() - // TODO: Remove before an actual deploy <2025-09-26> - .wrap(Cors::permissive()) .wrap(Logger::new( r#"%a "%r" -> %s %b ("%{Referer}i" "%{User-Agent}i" %T s)"#, )) diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql index e3dd879..ba44c68 100644 --- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql +++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql @@ -14,17 +14,29 @@ CREATE TABLE version ( valid_to INTEGER UNIQUE CHECK (valid_to > valid_from) ) STRICT; --- Encodes the tree structure of the products. --- A parent cannot be a product, but can have parents on it's own. +-- Encodes the tree structure of the product parents. +-- A product parent cannot be a product, but can have parents on it's own. -- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> -CREATE TABLE parents ( +CREATE TABLE product_parents ( id TEXT UNIQUE NOT NULL PRIMARY KEY, parent TEXT DEFAULT NULL CHECK ( id IS NOT parent ), name TEXT UNIQUE NOT NULL, description TEXT, - FOREIGN KEY(parent) REFERENCES parents(id) + FOREIGN KEY(parent) REFERENCES product_parents(id) +) STRICT; + +-- Encodes the tree structure of the recipe parents. +-- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> +CREATE TABLE recipe_parents ( + id TEXT UNIQUE NOT NULL PRIMARY KEY, + parent TEXT DEFAULT NULL CHECK ( + id IS NOT parent + ), + name TEXT UNIQUE NOT NULL, + description TEXT, + FOREIGN KEY(parent) REFERENCES recipe_parents(id) ) STRICT; -- Stores the registered users. @@ -71,7 +83,7 @@ CREATE TABLE products ( description TEXT, parent TEXT DEFAULT NULL, unit_property TEXT NOT NULL, - FOREIGN KEY(parent) REFERENCES parents(id), + FOREIGN KEY(parent) REFERENCES product_parents(id), FOREIGN KEY(unit_property) REFERENCES unit_properties(id) ) STRICT; @@ -123,8 +135,10 @@ CREATE TABLE unit_properties ( CREATE TABLE recipies ( id TEXT UNIQUE NOT NULL PRIMARY KEY, - path TEXT UNIQUE NOT NULL, - content TEXT NOT NULL + name TEXT UNIQUE NOT NULL, + parent TEXT, + content TEXT NOT NULL, + FOREIGN KEY(parent) REFERENCES recipe_parents(id) ) STRICT; -- Encodes unit conversions: diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs index 92b34aa..a6ee0e1 100644 --- a/crates/rocie-server/src/storage/sql/get/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod product_amount; pub(crate) mod product_parent; +pub(crate) mod recipe_parent; pub(crate) mod recipe; pub(crate) mod unit; pub(crate) mod unit_property; diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs index 915da81..3d8b6e6 100644 --- a/crates/rocie-server/src/storage/sql/get/product/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs @@ -66,7 +66,7 @@ impl Product { } } - pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> { + pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_id::Error> { let record = query!( " SELECT name, id, unit_property, description, parent diff --git a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs index 5b85b62..243ae1e 100644 --- a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs @@ -10,7 +10,7 @@ impl ProductParent { let records = query!( " SELECT id, parent, name, description - FROM parents + FROM product_parents " ) .fetch_all(&app.db) @@ -40,7 +40,7 @@ impl ProductParent { let record = query!( " SELECT parent, name, description - FROM parents + FROM product_parents WHERE id = ? ", id diff --git a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs index 9d6dc79..f433541 100644 --- a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs @@ -1,15 +1,38 @@ use crate::{ app::App, - storage::sql::recipe::{Recipe, RecipeId}, + storage::sql::{ + recipe::{CooklangRecipe, Recipe, RecipeId}, + recipe_parent::RecipeParentId, + }, }; use sqlx::query; +pub(crate) mod parse { + use crate::storage::sql::recipe::conversion; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to convert from cooklang recipe to our own struct")] + Conversion(#[from] conversion::Error), + } +} + +async fn recipe_from_content(app: &App, content: &str) -> Result<CooklangRecipe, parse::Error> { + // NOTE: We can ignore warnings here, as we should already have handled them at the recipe + // insert point. <2026-01-31> + let (output, _warnings) = cooklang::parse(content) + .into_result() + .expect("The values in the db should always be valid, as we checked before inserting them"); + + Ok(CooklangRecipe::from(app, output).await?) +} + impl Recipe { pub(crate) async fn from_id(app: &App, id: RecipeId) -> Result<Option<Self>, from_id::Error> { let record = query!( " - SELECT content, path + SELECT name, parent, content FROM recipies WHERE id = ? ", @@ -21,11 +44,33 @@ impl Recipe { if let Some(record) = record { Ok(Some(Self { id, - path: record - .path - .parse() - .expect("Was a path before, should still be one"), - content: record.content, + content: recipe_from_content(app, &record.content).await?, + name: record.name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), + })) + } else { + Ok(None) + } + } + + pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> { + let record = query!( + " + SELECT id, parent, content + FROM recipies + WHERE name = ? +", + name + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + id: RecipeId::from_db(&record.id), + content: recipe_from_content(app, &record.content).await?, + name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), })) } else { Ok(None) @@ -35,31 +80,39 @@ impl Recipe { pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> { let records = query!( " - SELECT id, content, path + SELECT id, name, parent, content FROM recipies ", ) .fetch_all(&app.db) .await?; - Ok(records - .into_iter() - .map(|record| Self { + let mut output = vec![]; + for record in records { + output.push(Self { id: RecipeId::from_db(&record.id), - path: record.path.parse().expect("Is still valid"), - content: record.content, - }) - .collect()) + content: recipe_from_content(app, &record.content).await?, + name: record.name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), + }); + } + + Ok(output) } } pub(crate) mod from_id { use actix_web::ResponseError; + use crate::storage::sql::get::recipe::parse; + #[derive(thiserror::Error, Debug)] pub(crate) enum Error { - #[error("Failed to execute the sql query")] + #[error("Failed to execute the sql query: `{0}`")] SqlError(#[from] sqlx::Error), + + #[error("Failed to parse the recipe content as cooklang recipe: `{0}`")] + RecipeParse(#[from] parse::Error), } impl ResponseError for Error {} @@ -68,10 +121,15 @@ pub(crate) mod from_id { pub(crate) mod get_all { use actix_web::ResponseError; + use crate::storage::sql::get::recipe::parse; + #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to execute the sql query")] SqlError(#[from] sqlx::Error), + + #[error("Failed to parse the recipe content as cooklang recipe")] + RecipeParse(#[from] parse::Error), } impl ResponseError for Error {} diff --git a/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs new file mode 100644 index 0000000..d53e853 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs @@ -0,0 +1,85 @@ +use crate::{ + app::App, + storage::sql::{ + recipe_parent::{RecipeParent, RecipeParentId}, + }, +}; + +use sqlx::query; + +impl RecipeParent { + pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> { + let records = query!( + " + SELECT id, parent, name, description + FROM recipe_parents +" + ) + .fetch_all(&app.db) + .await?; + + let mut all = Vec::with_capacity(records.len()); + for record in records { + let parent = Self { + id: RecipeParentId::from_db(&record.id), + parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)), + name: record.name, + description: record.description, + }; + + all.push(parent); + } + + Ok(all) + } + + pub(crate) async fn from_id( + app: &App, + id: RecipeParentId, + ) -> Result<Option<Self>, from_id::Error> { + let record = query!( + " + SELECT parent, name, description + FROM recipe_parents + WHERE id = ? +", + id + ) + .fetch_optional(&app.db) + .await?; + + match record { + Some(record) => Ok(Some(Self { + id, + parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)), + name: record.name, + description: record.description, + })), + None => Ok(None), + } + } +} + +pub(crate) mod from_id { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} + +pub(crate) mod get_all { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/get/user/mod.rs b/crates/rocie-server/src/storage/sql/get/user/mod.rs index e36c6cf..e09ef67 100644 --- a/crates/rocie-server/src/storage/sql/get/user/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/user/mod.rs @@ -50,6 +50,33 @@ impl User { Ok(None) } } + + pub(crate) async fn from_name( + app: &App, + name: String, + ) -> Result<Option<Self>, from_name::Error> { + let record = query!( + " + SELECT id, name, password_hash, description + FROM users + WHERE name = ? +", + name + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + name: record.name, + description: record.description, + id: UserId::from_db(&record.id), + password_hash: PasswordHash::from_db(record.password_hash), + })) + } else { + Ok(None) + } + } } pub(crate) mod get_all { @@ -75,3 +102,15 @@ pub(crate) mod from_id { impl ResponseError for Error {} } + +pub(crate) mod from_name { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs index 673cbdd..b92a88c 100644 --- a/crates/rocie-server/src/storage/sql/insert/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; diff --git a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs index 644778f..72fb564 100644 --- a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs @@ -31,7 +31,7 @@ impl Transactionable for Operation { } => { query!( " - INSERT INTO parents (id, name, description, parent) + INSERT INTO product_parents (id, name, description, parent) VALUES (?,?,?,?) ", id, @@ -56,7 +56,7 @@ impl Transactionable for Operation { } => { query!( " - DELETE FROM products + DELETE FROM product_parents WHERE id = ? AND name = ? AND description = ? AND parent = ?; ", id, diff --git a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs index b223bfe..b60874f 100644 --- a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs @@ -1,19 +1,23 @@ -use std::path::PathBuf; - +use cooklang::{Converter, CooklangParser, Extensions}; use serde::{Deserialize, Serialize}; use sqlx::query; use uuid::Uuid; -use crate::storage::sql::{ - insert::{Operations, Transactionable}, - recipe::{Recipe, RecipeId}, +use crate::{ + app::App, + storage::sql::{ + insert::{Operations, Transactionable}, + recipe::{CooklangRecipe, Recipe, RecipeId}, + recipe_parent::RecipeParentId, + }, }; #[derive(Debug, Deserialize, Serialize)] pub(crate) enum Operation { New { id: RecipeId, - path: PathBuf, + name: String, + parent: Option<RecipeParentId>, content: String, }, } @@ -24,16 +28,20 @@ impl Transactionable for Operation { async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> { match self { - Operation::New { id, path, content } => { - let path = path.display().to_string(); - + Operation::New { + id, + name, + parent, + content, + } => { query!( " - INSERT INTO recipies (id, path, content) - VALUES (?, ?, ?) + INSERT INTO recipies (id, name, parent, content) + VALUES (?, ?, ?, ?) ", id, - path, + name, + parent, content, ) .execute(txn) @@ -45,16 +53,20 @@ impl Transactionable for Operation { async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { match self { - Operation::New { id, path, content } => { - let path = path.display().to_string(); - + Operation::New { + id, + name, + parent, + content, + } => { query!( " DELETE FROM recipies - WHERE id = ? AND path = ? AND content = ? + WHERE id = ? AND name = ? AND parent = ? AND content = ? ", id, - path, + name, + parent, content ) .execute(txn) @@ -79,17 +91,50 @@ pub(crate) mod apply { SqlError(#[from] sqlx::Error), } } +pub(crate) mod new { + use actix_web::ResponseError; + + use crate::storage::sql::recipe::conversion; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to parse the recipe contents as cooklang: `{0}`")] + RecipeParse(#[from] cooklang::error::SourceReport), + + #[error("Failed to convert the cooklang recipe to our struct: `{0}`")] + RecipeConvert(#[from] conversion::Error), + } + + impl ResponseError for Error {} +} impl Recipe { - pub(crate) fn new(path: PathBuf, content: String, ops: &mut Operations<Operation>) -> Self { + pub(crate) async fn new( + app: &App, + name: String, + parent: Option<RecipeParentId>, + content: String, + ops: &mut Operations<Operation>, + ) -> Result<Self, new::Error> { let id = RecipeId::from(Uuid::new_v4()); + let parser = CooklangParser::new(Extensions::empty(), Converter::bundled()); + + // TODO: Somehow return the warnings <2026-01-31> + let (recipe, _warnings) = parser.parse(&content).into_result()?; + ops.push(Operation::New { id, - path: path.clone(), - content: content.clone(), + content, + name: name.clone(), + parent, }); - Self { id, path, content } + Ok(Self { + id, + name, + parent, + content: CooklangRecipe::from(app, recipe).await?, + }) } } diff --git a/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs new file mode 100644 index 0000000..95bc6f1 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use sqlx::query; +use uuid::Uuid; + +use crate::storage::sql::{ + insert::{Operations, Transactionable}, + recipe_parent::{RecipeParent, RecipeParentId}, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum Operation { + RegisterRecipeParent { + id: RecipeParentId, + name: String, + description: Option<String>, + parent: Option<RecipeParentId>, + }, +} + +impl Transactionable for Operation { + type ApplyError = apply::Error; + type UndoError = undo::Error; + + async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> { + match self { + Operation::RegisterRecipeParent { + id, + name, + description, + parent, + } => { + query!( + " + INSERT INTO recipe_parents (id, name, description, parent) + VALUES (?,?,?,?) +", + id, + name, + description, + parent + ) + .execute(txn) + .await?; + } + } + Ok(()) + } + + async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { + match self { + Operation::RegisterRecipeParent { + id, + name, + description, + parent, + } => { + query!( + " + DELETE FROM recipe_parents + WHERE id = ? AND name = ? AND description = ? AND parent = ?; +", + id, + name, + description, + parent, + ) + .execute(txn) + .await?; + } + } + Ok(()) + } +} + +pub(crate) mod undo { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute undo sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} +pub(crate) mod apply { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute apply sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} + +impl RecipeParent { + pub(crate) fn register( + name: String, + description: Option<String>, + parent: Option<RecipeParentId>, + ops: &mut Operations<Operation>, + ) -> Self { + let id = RecipeParentId::from(Uuid::new_v4()); + + ops.push(Operation::RegisterRecipeParent { + id, + name: name.clone(), + description: description.clone(), + parent, + }); + + Self { + id, + name, + description, + parent, + } + } +} diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs index 315c251..c37e68f 100644 --- a/crates/rocie-server/src/storage/sql/mod.rs +++ b/crates/rocie-server/src/storage/sql/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod product; pub(crate) mod product_amount; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs index 00c79d3..c2c32ec 100644 --- a/crates/rocie-server/src/storage/sql/product.rs +++ b/crates/rocie-server/src/storage/sql/product.rs @@ -17,6 +17,7 @@ pub(crate) struct Product { /// The parent this product has. /// /// This is effectively it's anchor in the product DAG. + /// None means, that it has no parents and as such is in the toplevel. #[schema(nullable = false)] pub(crate) parent: Option<ProductParentId>, diff --git a/crates/rocie-server/src/storage/sql/product_amount.rs b/crates/rocie-server/src/storage/sql/product_amount.rs index 0f19afc..dafe43a 100644 --- a/crates/rocie-server/src/storage/sql/product_amount.rs +++ b/crates/rocie-server/src/storage/sql/product_amount.rs @@ -3,7 +3,7 @@ use utoipa::ToSchema; use crate::storage::sql::{product::ProductId, unit::UnitAmount}; -#[derive(Clone, ToSchema, Serialize, Deserialize)] +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize, PartialEq)] pub(crate) struct ProductAmount { pub(crate) product_id: ProductId, pub(crate) amount: UnitAmount, diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs index 835d98b..1fc3b56 100644 --- a/crates/rocie-server/src/storage/sql/recipe.rs +++ b/crates/rocie-server/src/storage/sql/recipe.rs @@ -1,18 +1,391 @@ -use std::path::PathBuf; +#![expect(clippy::unused_async)] use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::storage::sql::mk_id; +use crate::{ + app::App, + storage::sql::{ + mk_id, + product::{Product, ProductId}, + product_amount::ProductAmount, + recipe_parent::RecipeParentId, + unit::UnitAmount, + }, +}; +macro_rules! for_in { + ($value:expr, |$name:ident| $closoure:expr) => {{ + let fun = async |$name| $closoure; + + let mut output = Vec::with_capacity($value.len()); + for $name in $value { + output.push(fun($name).await?); + } + output + }}; +} + +/// An recipe. +/// +/// These are transparently expressed in cooklang. #[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] pub(crate) struct Recipe { + /// The unique id of this recipe. pub(crate) id: RecipeId, - #[schema(value_type = String)] - pub(crate) path: PathBuf, + /// The name of the recipe. + /// + /// This should be globally unique, to make searching easier for the user. + pub(crate) name: String, + + /// The parent this recipe has. + /// + /// This is effectively it's anchor in the recipe DAG. + /// None means, that it has no parents and as such is in the toplevel. + #[schema(nullable = false)] + pub(crate) parent: Option<RecipeParentId>, - pub(crate) content: String, + /// The actual content of this recipe. + pub(crate) content: CooklangRecipe, } mk_id!(RecipeId and RecipeIdStub); + +/// A complete recipe +/// +/// The recipes do not have a name. You give it externally or maybe use +/// some metadata key. +/// +/// The recipe returned from parsing is a [`ScalableRecipe`]. +/// +/// The difference between [`ScalableRecipe`] and [`ScaledRecipe`] is in the +/// values of the quantities of ingredients, cookware and timers. The parser +/// returns [`ScalableValue`]s and after scaling, these are converted to regular +/// [`Value`]s. +#[derive(ToSchema, Debug, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct CooklangRecipe { + /// Metadata as read from preamble + pub(crate) metadata: Metadata, + + /// Each of the sections + /// + /// If no sections declared, a section without name + /// is the default. + pub(crate) sections: Vec<Section>, + + /// All the ingredients + pub(crate) ingredients: Vec<Ingredient>, + + /// All the cookware + pub(crate) cookware: Vec<Cookware>, + + /// All the timers + pub(crate) timers: Vec<Timer>, +} + +/// A section holding steps +#[derive(Debug, ToSchema, Default, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Section { + /// Name of the section + #[schema(nullable = false)] + pub(crate) name: Option<String>, + + /// Content inside + pub(crate) content: Vec<Content>, +} + +/// Each type of content inside a section +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub(crate) enum Content { + /// A step + Step(Step), + + /// A paragraph of just text, no instructions + Text(String), +} + +/// A step holding step [`Item`]s +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Step { + /// [`Item`]s inside + pub(crate) items: Vec<Item>, + + /// Step number + /// + /// The step numbers start at 1 in each section and increase with non + /// text step. + pub(crate) number: u32, +} + +/// A step item +/// +/// Except for [`Item::Text`], the value is the index where the item is located +/// in it's corresponding [`Vec`] in the [`Recipe`]. +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum Item { + /// Just plain text + Text { + value: String, + }, + Ingredient { + index: usize, + }, + Cookware { + index: usize, + }, + Timer { + index: usize, + }, + InlineQuantity { + index: usize, + }, +} + +/// A recipe ingredient +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) enum Ingredient { + /// This ingredient is a registered product. + RegisteredProduct { + id: ProductId, + + /// Alias + #[schema(nullable = false)] + alias: Option<String>, + + /// Quantity + #[schema(nullable = false)] + quantity: Option<ProductAmount>, + }, + + /// This ingredient is a not yet registered product. + NotRegisteredProduct { + name: String, + + /// Quantity + #[schema(nullable = false)] + quantity: Option<UnitAmount>, + }, + + /// This ingredient is a reference to another recipe. + RecipeReference { id: RecipeId }, +} + +/// A recipe cookware item +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Cookware { + /// Name + pub(crate) name: String, + + /// Alias + #[schema(nullable = false)] + pub(crate) alias: Option<String>, + + /// Amount needed + /// + /// Note that this is a value, not a quantity, so it doesn't have units. + #[schema(nullable = false)] + pub(crate) quantity: Option<usize>, + + /// Note + #[schema(nullable = false)] + pub(crate) note: Option<String>, +} + +/// A recipe timer +/// +/// If created from parsing, at least one of the fields is guaranteed to be +/// [`Some`]. +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Timer { + /// Name + #[schema(nullable = false)] + pub(crate) name: Option<String>, + + /// Time quantity + pub(crate) quantity: UnitAmount, +} + +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Metadata { + #[schema(nullable = false)] + title: Option<String>, + + #[schema(nullable = false)] + description: Option<String>, + + #[schema(nullable = false)] + tags: Option<Vec<String>>, + + #[schema(nullable = false)] + author: Option<NameAndUrl>, + + #[schema(nullable = false)] + source: Option<NameAndUrl>, + // time: Option<serde_yaml::Value>, + // prep_time: Option<serde_yaml::Value>, + // cook_time: Option<serde_yaml::Value>, + // servings: Option<serde_yaml::Value>, + // difficulty: Option<serde_yaml::Value>, + // cuisine: Option<serde_yaml::Value>, + // diet: Option<serde_yaml::Value>, + // images: Option<serde_yaml::Value>, + // locale: Option<serde_yaml::Value>, + + // other: serde_yaml::Mapping, +} + +pub(crate) mod conversion { + use crate::storage::sql::get::product::from_id; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to get a product by id: `{0}`")] + ProductAccess(#[from] from_id::Error), + } +} + +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct NameAndUrl { + #[schema(nullable = false)] + name: Option<String>, + + #[schema(nullable = false)] + url: Option<String>, +} +impl NameAndUrl { + async fn from(value: cooklang::metadata::NameAndUrl) -> Result<Self, conversion::Error> { + Ok(Self { + name: value.name().map(|v| v.to_owned()), + url: value.url().map(|v| v.to_owned()), + }) + } +} + +impl CooklangRecipe { + pub(crate) async fn from( + app: &App, + value: cooklang::Recipe, + ) -> Result<Self, conversion::Error> { + Ok(Self { + metadata: Metadata::from(value.metadata).await?, + sections: for_in!(value.sections, |s| Section::from(s).await), + ingredients: for_in!(value.ingredients, |i| Ingredient::from(app, i).await), + cookware: for_in!(value.cookware, |c| Cookware::from(c).await), + timers: for_in!(value.timers, |t| Timer::from(t).await), + }) + } +} + +impl Metadata { + async fn from(value: cooklang::Metadata) -> Result<Self, conversion::Error> { + let author = if let Some(author) = value.author() { + Some(NameAndUrl::from(author).await?) + } else { + None + }; + let source = if let Some(source) = value.source() { + Some(NameAndUrl::from(source).await?) + } else { + None + }; + + Ok(Self { + title: value.title().map(str::to_owned), + description: value.description().map(str::to_owned), + tags: value + .tags() + .map(|vec| vec.into_iter().map(|c| c.to_string()).collect()), + author, + source, + // time: value.time(&Converter::bundled()).map(|t| t.total()), + // prep_time: todo!(), + // cook_time: todo!(), + // servings: todo!(), + // difficulty: todo!(), + // cuisine: todo!(), + // diet: todo!(), + // images: todo!(), + // locale: todo!(), + // other: value.map_filtered()., + }) + } +} + +impl Section { + async fn from(value: cooklang::Section) -> Result<Self, conversion::Error> { + Ok(Self { + name: value.name, + content: for_in!(value.content, |c| Content::from(c).await), + }) + } +} +impl Content { + async fn from(value: cooklang::Content) -> Result<Self, conversion::Error> { + match value { + cooklang::Content::Step(step) => Ok(Self::Step(Step::from(step).await?)), + cooklang::Content::Text(text) => Ok(Self::Text(text)), + } + } +} +impl Step { + async fn from(value: cooklang::Step) -> Result<Self, conversion::Error> { + Ok(Self { + items: for_in!(value.items, |item| Item::from(item).await), + number: value.number, + }) + } +} +impl Item { + async fn from(value: cooklang::Item) -> Result<Self, conversion::Error> { + match value { + cooklang::Item::Text { value } => Ok(Self::Text { value }), + cooklang::Item::Ingredient { index } => Ok(Self::Ingredient { index }), + cooklang::Item::Cookware { index } => Ok(Self::Cookware { index }), + cooklang::Item::Timer { index } => Ok(Self::Timer { index }), + cooklang::Item::InlineQuantity { index } => Ok(Self::InlineQuantity { index }), + } + } +} +impl Ingredient { + async fn from(app: &App, value: cooklang::Ingredient) -> Result<Self, conversion::Error> { + if value.name.starts_with('/') { + Ok(Self::RecipeReference { id: todo!() }) + } else if let Some(product) = Product::from_name(&app, value.name.as_str()).await? { + Ok(Self::RegisteredProduct { + id: product.id, + alias: value.alias, + quantity: None, + }) + } else { + Ok(Self::NotRegisteredProduct { + name: value.name, + quantity: None, + }) + } + } +} +impl Cookware { + async fn from(value: cooklang::Cookware) -> Result<Self, conversion::Error> { + Ok(Self { + name: value.name, + alias: value.alias, + quantity: value.quantity.map(|q| match q.value() { + cooklang::Value::Number(number) => number.value() as usize, + cooklang::Value::Range { start, end } => todo!(), + cooklang::Value::Text(_) => todo!(), + }), + note: value.note, + }) + } +} +impl Timer { + async fn from(value: cooklang::Timer) -> Result<Self, conversion::Error> { + Ok(Self { + name: value.name, + quantity: todo!(), + }) + } +} diff --git a/crates/rocie-server/src/storage/sql/recipe_parent.rs b/crates/rocie-server/src/storage/sql/recipe_parent.rs new file mode 100644 index 0000000..6225a4b --- /dev/null +++ b/crates/rocie-server/src/storage/sql/recipe_parent.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::storage::sql::mk_id; + +/// The grouping system for recipes. +/// +/// Every recipe can have a related parent, and every parent can have a parent themselves. +/// As such, the recipe list constructs a DAG. +#[derive(Clone, ToSchema, Serialize, Deserialize)] +pub(crate) struct RecipeParent { + /// The id of the recipe parent. + pub(crate) id: RecipeParentId, + + /// The optional id of the parent of this recipe parent. + /// + /// This must not form a cycle. + #[schema(nullable = false)] + pub(crate) parent: Option<RecipeParentId>, + + /// The name of the recipe parent. + /// + /// This should be globally unique, to make searching easier for the user. + pub(crate) name: String, + + /// An optional description of this recipe parent. + #[schema(nullable = false)] + pub(super) description: Option<String>, +} + +mk_id!(RecipeParentId and RecipeParentIdStub); diff --git a/crates/rocie-server/src/storage/sql/unit.rs b/crates/rocie-server/src/storage/sql/unit.rs index 8bbfe60..dc16e4c 100644 --- a/crates/rocie-server/src/storage/sql/unit.rs +++ b/crates/rocie-server/src/storage/sql/unit.rs @@ -41,7 +41,7 @@ pub(crate) struct Unit { pub(crate) unit_property: UnitPropertyId, } -#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub(crate) struct UnitAmount { #[schema(minimum = 0)] pub(crate) value: u32, |
