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