aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:24:32 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:25:06 +0100
commite5f90f4474cb96a78080395980283e4b2ce40214 (patch)
treecaac3300795eae8e4cb1ee3c1c4bf85cd5950402
parentchore(treewide): Update (diff)
downloadserver-e5f90f4474cb96a78080395980283e4b2ce40214.zip
feat(treewide): Add recipes and user handling
Diffstat (limited to '')
-rw-r--r--Cargo.lock207
-rw-r--r--TODO.md1
-rw-r--r--crates/rocie-server/Cargo.toml3
-rw-r--r--crates/rocie-server/src/api/get/auth/mod.rs28
-rw-r--r--crates/rocie-server/src/api/get/auth/product.rs62
-rw-r--r--crates/rocie-server/src/api/get/auth/recipe.rs219
-rw-r--r--crates/rocie-server/src/api/get/auth/recipe_parent.rs108
-rw-r--r--crates/rocie-server/src/api/get/no_auth/mod.rs7
-rw-r--r--crates/rocie-server/src/api/get/no_auth/state.rs41
-rw-r--r--crates/rocie-server/src/api/set/auth/mod.rs2
-rw-r--r--crates/rocie-server/src/api/set/auth/recipe.rs21
-rw-r--r--crates/rocie-server/src/api/set/auth/recipe_parent.rs67
-rw-r--r--crates/rocie-server/src/api/set/no_auth/user.rs10
-rw-r--r--crates/rocie-server/src/main.rs35
-rw-r--r--crates/rocie-server/src/storage/migrate/sql/0->1.sql28
-rw-r--r--crates/rocie-server/src/storage/sql/get/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/get/product/mod.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/get/product_parent/mod.rs4
-rw-r--r--crates/rocie-server/src/storage/sql/get/recipe/mod.rs90
-rw-r--r--crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs85
-rw-r--r--crates/rocie-server/src/storage/sql/get/user/mod.rs39
-rw-r--r--crates/rocie-server/src/storage/sql/insert/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs4
-rw-r--r--crates/rocie-server/src/storage/sql/insert/recipe/mod.rs87
-rw-r--r--crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs113
-rw-r--r--crates/rocie-server/src/storage/sql/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/product.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/product_amount.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs383
-rw-r--r--crates/rocie-server/src/storage/sql/recipe_parent.rs31
-rw-r--r--crates/rocie-server/src/storage/sql/unit.rs2
-rw-r--r--crates/rocie-server/tests/_testenv/init.rs2
-rw-r--r--crates/rocie-server/tests/recipe_parents/mod.rs2
-rw-r--r--crates/rocie-server/tests/recipe_parents/query.rs138
-rw-r--r--crates/rocie-server/tests/recipe_parents/register.rs69
-rw-r--r--crates/rocie-server/tests/recipies/mod.rs160
-rw-r--r--crates/rocie-server/tests/tests.rs1
-rw-r--r--crates/rocie-server/tests/users/mod.rs4
38 files changed, 1920 insertions, 141 deletions
diff --git a/Cargo.lock b/Cargo.lock
index da36223..03e842c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,21 +20,6 @@ dependencies = [
]
[[package]]
-name = "actix-cors"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
-dependencies = [
- "actix-utils",
- "actix-web",
- "derive_more",
- "futures-util",
- "log",
- "once_cell",
- "smallvec",
-]
-
-[[package]]
name = "actix-http"
version = "3.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -578,6 +563,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
+name = "codesnake"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2205f7f6d3de68ecf4c291c789b3edf07b6569268abd0188819086f71ae42225"
+
+[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -638,9 +629,9 @@ dependencies = [
[[package]]
name = "cookie_store"
-version = "0.21.1"
+version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
+checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f"
dependencies = [
"cookie 0.18.1",
"document-features",
@@ -655,6 +646,32 @@ dependencies = [
]
[[package]]
+name = "cooklang"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4c4c23c94d7a3195645155ed76e9cb9ede0a549bfaf816b27c668de9237e090"
+dependencies = [
+ "bitflags",
+ "codesnake",
+ "enum-map",
+ "finl_unicode",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_yaml",
+ "smallvec",
+ "strum",
+ "syn",
+ "thiserror",
+ "toml",
+ "tracing",
+ "unicase",
+ "unicode-width",
+ "yansi",
+]
+
+[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -870,6 +887,27 @@ dependencies = [
]
[[package]]
+name = "enum-map"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+dependencies = [
+ "enum-map-derive",
+ "serde",
+]
+
+[[package]]
+name = "enum-map-derive"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -927,6 +965,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
name = "flate2"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1879,6 +1923,16 @@ dependencies = [
]
[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2043,9 +2097,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
-version = "0.12.25"
+version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -2105,19 +2159,20 @@ dependencies = [
name = "rocie-server"
version = "0.1.0"
dependencies = [
- "actix-cors",
"actix-identity",
"actix-session",
"actix-web",
"argon2",
"chrono",
"clap",
+ "cooklang",
"env_logger",
"log",
"percent-encoding",
"rocie-client",
"serde",
"serde_json",
+ "serde_yaml",
"sqlx",
"thiserror",
"tokio",
@@ -2257,6 +2312,15 @@ dependencies = [
]
[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2300,6 +2364,19 @@ dependencies = [
]
[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.12.1",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2618,6 +2695,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2783,6 +2882,47 @@ dependencies = [
]
[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap 2.12.1",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2911,6 +3051,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2927,6 +3073,12 @@ dependencies = [
]
[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[package]]
name = "url"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3389,6 +3541,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3401,6 +3562,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..72f0a9f
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1 @@
+- Explicit version argument for each API function.
diff --git a/crates/rocie-server/Cargo.toml b/crates/rocie-server/Cargo.toml
index 047e67c..3709ed1 100644
--- a/crates/rocie-server/Cargo.toml
+++ b/crates/rocie-server/Cargo.toml
@@ -31,18 +31,19 @@ rocie-client.workspace = true
tokio.workspace = true
[dependencies]
-actix-cors = "0.7.1"
actix-identity = "0.9.0"
actix-session = { version = "0.11.0", features = ["cookie-session"] }
actix-web = "4.12.1"
argon2 = "0.5.3"
chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive", "env"] }
+cooklang = "0.17.14"
env_logger = "0.11.8"
log = "0.4.29"
percent-encoding = "2.3.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
+serde_yaml = "0.9.34"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
thiserror = "2.0.17"
utoipa = { version = "5.4.0", features = ["actix_extras", "uuid"] }
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,
diff --git a/crates/rocie-server/tests/_testenv/init.rs b/crates/rocie-server/tests/_testenv/init.rs
index 37d50ff..4a169fe 100644
--- a/crates/rocie-server/tests/_testenv/init.rs
+++ b/crates/rocie-server/tests/_testenv/init.rs
@@ -21,7 +21,7 @@ use std::{
use rocie_client::{
apis::{api_set_no_auth_user_api::provision, configuration::Configuration},
- models::{LoginInfo, UserStub},
+ models::UserStub,
};
use crate::{
diff --git a/crates/rocie-server/tests/recipe_parents/mod.rs b/crates/rocie-server/tests/recipe_parents/mod.rs
new file mode 100644
index 0000000..d3799a7
--- /dev/null
+++ b/crates/rocie-server/tests/recipe_parents/mod.rs
@@ -0,0 +1,2 @@
+mod query;
+mod register;
diff --git a/crates/rocie-server/tests/recipe_parents/query.rs b/crates/rocie-server/tests/recipe_parents/query.rs
new file mode 100644
index 0000000..bcbf00b
--- /dev/null
+++ b/crates/rocie-server/tests/recipe_parents/query.rs
@@ -0,0 +1,138 @@
+use rocie_client::{
+ apis::{
+ api_get_auth_recipe_api::{
+ recipes_by_recipe_parent_id_direct, recipes_by_recipe_parent_id_indirect,
+ },
+ api_get_auth_recipe_parent_api::{recipe_parents_toplevel, recipe_parents_under},
+ api_set_auth_recipe_api::add_recipe,
+ api_set_auth_recipe_parent_api::register_recipe_parent,
+ },
+ models::{RecipeParentStub, RecipeStub},
+};
+
+use crate::{
+ testenv::init::function_name,
+ testenv::{TestEnv, log::request},
+};
+
+#[tokio::test]
+async fn test_recipe_parent_query() {
+ let env = TestEnv::new(function_name!()).await;
+
+ let parent_asia = request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: Some("Asia inspired recipes".to_owned()),
+ name: "asia".to_owned(),
+ parent: None,
+ })
+ );
+ request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: Some("Traditionally chineese recipes".to_owned()),
+ name: "china".to_owned(),
+ parent: Some(parent_asia),
+ })
+ );
+
+ request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: Some("Europeen recipes".to_owned()),
+ name: "europe".to_owned(),
+ parent: None,
+ })
+ );
+
+ assert_eq!(
+ request!(env, recipe_parents_toplevel())
+ .into_iter()
+ .map(|parent| parent.name)
+ .collect::<Vec<_>>(),
+ vec!["asia".to_owned(), "europe".to_owned(),]
+ );
+
+ assert_eq!(
+ request!(env, recipe_parents_under(parent_asia))
+ .into_iter()
+ .map(|parent| parent.name)
+ .collect::<Vec<_>>(),
+ vec!["china".to_owned()]
+ );
+}
+
+#[tokio::test]
+async fn test_recipe_parent_query_recipe() {
+ let env = TestEnv::new(function_name!()).await;
+
+ let parent_asia = request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: None,
+ name: "asia".to_owned(),
+ parent: None,
+ })
+ );
+ let parent_china = request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: None,
+ name: "china".to_owned(),
+ parent: Some(parent_asia),
+ })
+ );
+
+ request!(
+ env,
+ add_recipe(RecipeStub {
+ name: "Orange Chicken".to_owned(),
+ parent: Some(parent_china),
+ content: "Do some chicken".to_owned()
+ })
+ );
+
+ request!(
+ env,
+ add_recipe(RecipeStub {
+ name: "Beef and Broccoli Stir-Fry".to_owned(),
+ parent: Some(parent_asia),
+ content: "Do some beef and add stir-fryed broccoli".to_owned()
+ })
+ );
+
+ assert_eq!(
+ request!(env, recipes_by_recipe_parent_id_indirect(parent_china))
+ .into_iter()
+ .map(|recipe| recipe.name)
+ .collect::<Vec<_>>(),
+ vec!["Orange Chicken".to_owned()],
+ );
+
+ assert_eq!(
+ request!(env, recipes_by_recipe_parent_id_direct(parent_china))
+ .into_iter()
+ .map(|recipe| recipe.name)
+ .collect::<Vec<_>>(),
+ vec!["Orange Chicken".to_owned()],
+ );
+
+ assert_eq!(
+ request!(env, recipes_by_recipe_parent_id_indirect(parent_asia))
+ .into_iter()
+ .map(|recipe| recipe.name)
+ .collect::<Vec<_>>(),
+ vec![
+ "Beef and Broccoli Stir-Fry".to_owned(),
+ "Orange Chicken".to_owned(),
+ ],
+ );
+
+ assert_eq!(
+ request!(env, recipes_by_recipe_parent_id_direct(parent_asia))
+ .into_iter()
+ .map(|recipe| recipe.name)
+ .collect::<Vec<_>>(),
+ vec!["Beef and Broccoli Stir-Fry".to_owned()],
+ );
+}
diff --git a/crates/rocie-server/tests/recipe_parents/register.rs b/crates/rocie-server/tests/recipe_parents/register.rs
new file mode 100644
index 0000000..a113bd3
--- /dev/null
+++ b/crates/rocie-server/tests/recipe_parents/register.rs
@@ -0,0 +1,69 @@
+use rocie_client::{
+ apis::{
+ api_get_auth_recipe_api::recipe_by_id, api_set_auth_recipe_api::add_recipe,
+ api_set_auth_recipe_parent_api::register_recipe_parent,
+ },
+ models::{RecipeParentStub, RecipeStub},
+};
+
+use crate::testenv::{TestEnv, init::function_name, log::request};
+
+#[tokio::test]
+async fn test_recipe_parent_register_roundtrip() {
+ let env = TestEnv::new(function_name!()).await;
+
+ let parent_dairy = request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: Some("Dairy replacment recipes".to_owned()),
+ name: "Dairy replacements".to_owned(),
+ parent: None,
+ })
+ );
+ let parent_dairy_milk = request!(
+ env,
+ register_recipe_parent(RecipeParentStub {
+ description: Some("Milk replacment recipes".to_owned()),
+ name: "Milk replacements".to_owned(),
+ parent: Some(parent_dairy),
+ })
+ );
+
+ let recipe_soy_milk = request!(
+ env,
+ add_recipe(RecipeStub {
+ name: "Soy drink".to_owned(),
+ parent: Some(parent_dairy_milk),
+ content: "Mix soy and drink".to_owned()
+ })
+ );
+ let recipe_oat_milk = request!(
+ env,
+ add_recipe(RecipeStub {
+ name: "Oat drink".to_owned(),
+ parent: Some(parent_dairy_milk),
+ content: "Mix oat and drink".to_owned()
+ })
+ );
+
+ let recipe_vegan_cheese = request!(
+ env,
+ add_recipe(RecipeStub {
+ name: "Vegan cheese".to_owned(),
+ parent: Some(parent_dairy),
+ content: "Make cheese. Remove cheese".to_owned()
+ })
+ );
+
+ for recipe in [recipe_soy_milk, recipe_oat_milk] {
+ let recipe = request!(env, recipe_by_id(recipe));
+
+ assert_eq!(recipe.parent, Some(parent_dairy_milk));
+ }
+
+ {
+ let recipe = request!(env, recipe_by_id(recipe_vegan_cheese));
+
+ assert_eq!(recipe.parent, Some(parent_dairy));
+ }
+}
diff --git a/crates/rocie-server/tests/recipies/mod.rs b/crates/rocie-server/tests/recipies/mod.rs
index dfc8983..e59c3f6 100644
--- a/crates/rocie-server/tests/recipies/mod.rs
+++ b/crates/rocie-server/tests/recipies/mod.rs
@@ -1,24 +1,170 @@
use rocie_client::{
- apis::{api_get_auth_recipe_api::recipe_by_id, api_set_auth_recipe_api::add_recipe},
- models::RecipeStub,
+ apis::{
+ api_get_auth_recipe_api::recipe_by_id, api_set_auth_product_api::register_product,
+ api_set_auth_recipe_api::add_recipe,
+ api_set_auth_unit_property_api::register_unit_property,
+ },
+ models::{
+ Content, ContentOneOf, CooklangRecipe, Ingredient, IngredientOneOf1NotRegisteredProduct,
+ Item, ItemOneOf, ItemOneOf1, Metadata, NameAndUrl, ProductStub, RecipeStub, Section, Step,
+ UnitPropertyStub,
+ },
};
use crate::testenv::{TestEnv, init::function_name, log::request};
+#[expect(clippy::unnecessary_wraps)]
+fn name_and_url(name: &str, url: &str) -> Option<NameAndUrl> {
+ Some(NameAndUrl {
+ name: if name.is_empty() {
+ None
+ } else {
+ Some(name.to_owned())
+ },
+ url: if url.is_empty() {
+ None
+ } else {
+ Some(url.to_owned())
+ },
+ })
+}
+
#[tokio::test]
-async fn test_recipe_roundtrip() {
+async fn test_recipe_metadata() {
let env = TestEnv::new(function_name!()).await;
let recipe_id = request!(
env,
add_recipe(RecipeStub {
- path: "/asia/curry".to_owned(),
- content: "just make the curry".to_owned(),
+ content: "
+---
+author: James Connor
+description: Meaty curry with sharp anacado source and a burning d-pad.
+source: https://google.com/search?q=test
+title: Curry
+---
+"
+ .to_owned(),
+ name: "Curry".to_owned(),
+ parent: None,
})
);
let output = request!(env, recipe_by_id(recipe_id));
- assert_eq!(output.path, "/asia/curry".to_owned());
- assert_eq!(output.content, "just make the curry".to_owned());
+ assert_eq!(output.name, "Curry".to_owned());
+ assert_eq!(
+ output.content,
+ CooklangRecipe {
+ cookware: vec![],
+ ingredients: vec![],
+ metadata: Metadata {
+ author: name_and_url("James Connor", ""),
+ description: Some(
+ "Meaty curry with sharp anacado source and a burning d-pad.".to_owned()
+ ),
+ source: name_and_url("", "https://google.com/search?q=test"),
+ tags: None,
+ title: Some("Curry".to_owned())
+ },
+ sections: vec![],
+ timers: vec![]
+ }
+ );
+}
+
+#[tokio::test]
+async fn test_recipe_ingredients() {
+ let env = TestEnv::new(function_name!()).await;
+
+ let up = request!(
+ env,
+ register_unit_property(UnitPropertyStub {
+ description: None,
+ name: "mass".to_owned()
+ })
+ );
+
+ let rice_id = request!(
+ env,
+ register_product(ProductStub {
+ description: None,
+ name: "rice".to_owned(),
+ parent: None,
+ unit_property: up,
+ })
+ );
+
+ let recipe_id = request!(
+ env,
+ add_recipe(RecipeStub {
+ content: "
+---
+author: James Connor
+title: Curry
+---
+Add @rice{} and @water{200%ml} to a pot.
+"
+ .to_owned(),
+ name: "Curry".to_owned(),
+ parent: None,
+ })
+ );
+
+ let output = request!(env, recipe_by_id(recipe_id));
+
+ assert_eq!(output.name, "Curry".to_owned());
+ assert_eq!(
+ output.content.ingredients,
+ vec![
+ Ingredient::IngredientOneOf(rocie_client::models::IngredientOneOf {
+ registered_product: rocie_client::models::IngredientOneOfRegisteredProduct {
+ alias: None,
+ id: rice_id,
+ quantity: None,
+ }
+ }),
+ Ingredient::IngredientOneOf1(rocie_client::models::IngredientOneOf1 {
+ not_registered_product: IngredientOneOf1NotRegisteredProduct {
+ name: "water".to_owned(),
+ quantity: None,
+ }
+ }),
+ ]
+ );
+
+ assert_eq!(
+ output.content.sections,
+ vec![Section {
+ content: vec![Content::ContentOneOf(ContentOneOf {
+ value: Step {
+ items: vec![
+ Item::ItemOneOf(ItemOneOf {
+ r#type: rocie_client::models::item_one_of::Type::Text,
+ value: "Add ".to_owned()
+ }),
+ Item::ItemOneOf1(ItemOneOf1 {
+ r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
+ index: 0
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ r#type: rocie_client::models::item_one_of::Type::Text,
+ value: " and ".to_owned()
+ }),
+ Item::ItemOneOf1(ItemOneOf1 {
+ r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
+ index: 1
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ r#type: rocie_client::models::item_one_of::Type::Text,
+ value: " to a pot.".to_owned()
+ })
+ ],
+ number: 1
+ },
+ r#type: rocie_client::models::content_one_of::Type::Step
+ })],
+ name: None
+ }]
+ );
}
diff --git a/crates/rocie-server/tests/tests.rs b/crates/rocie-server/tests/tests.rs
index 373b573..e788712 100644
--- a/crates/rocie-server/tests/tests.rs
+++ b/crates/rocie-server/tests/tests.rs
@@ -5,6 +5,7 @@ pub(crate) use _testenv as testenv;
mod product_parents;
mod products;
+mod recipe_parents;
mod recipies;
mod units;
mod users;
diff --git a/crates/rocie-server/tests/users/mod.rs b/crates/rocie-server/tests/users/mod.rs
index 8138691..d381e8f 100644
--- a/crates/rocie-server/tests/users/mod.rs
+++ b/crates/rocie-server/tests/users/mod.rs
@@ -45,7 +45,7 @@ async fn test_register_user() {
@expect_error "The password is wrong"
env,
login(LoginInfo {
- id: user_id,
+ user_name: "me".to_owned(),
password: "hunter13".to_owned()
})
);
@@ -53,7 +53,7 @@ async fn test_register_user() {
request!(
env,
login(LoginInfo {
- id: user_id,
+ user_name: "me".to_owned(),
password: "hunter14".to_owned()
})
);