about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-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()
         })
     );