From 9a9d5c5880095adeb43a045dca638243c8f946e4 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Sat, 6 Sep 2025 10:31:40 +0200 Subject: feat: Provide basic API frame --- crates/rocie-cli/Cargo.toml | 38 +++ crates/rocie-cli/src/cli.rs | 63 +++++ crates/rocie-cli/src/main.rs | 83 ++++++ crates/rocie-client/.gitignore | 3 + crates/rocie-client/.openapi-generator-ignore | 23 ++ crates/rocie-client/.openapi-generator/FILES | 20 ++ crates/rocie-client/.openapi-generator/VERSION | 1 + crates/rocie-client/.travis.yml | 1 + crates/rocie-client/Cargo.toml | 16 ++ crates/rocie-client/README.md | 52 ++++ crates/rocie-client/docs/ApiGetApi.md | 63 +++++ crates/rocie-client/docs/ApiSetApi.md | 67 +++++ crates/rocie-client/docs/BarCode.md | 11 + crates/rocie-client/docs/Barcode.md | 12 + crates/rocie-client/docs/Product.md | 14 + crates/rocie-client/docs/ProductOneOf.md | 11 + crates/rocie-client/docs/ProductOneOf1.md | 11 + crates/rocie-client/docs/ProductStub.md | 13 + crates/rocie-client/docs/UnitAmount.md | 12 + crates/rocie-client/git_push.sh | 57 ++++ crates/rocie-client/src/apis/api_get_api.rs | 103 +++++++ crates/rocie-client/src/apis/api_set_api.rs | 96 +++++++ crates/rocie-client/src/apis/configuration.rs | 51 ++++ crates/rocie-client/src/apis/mod.rs | 117 ++++++++ crates/rocie-client/src/lib.rs | 11 + crates/rocie-client/src/models/bar_code.rs | 27 ++ crates/rocie-client/src/models/barcode.rs | 30 ++ crates/rocie-client/src/models/mod.rs | 8 + crates/rocie-client/src/models/product.rs | 36 +++ crates/rocie-client/src/models/product_one_of.rs | 27 ++ crates/rocie-client/src/models/product_one_of_1.rs | 27 ++ crates/rocie-client/src/models/product_stub.rs | 33 +++ crates/rocie-client/src/models/unit_amount.rs | 30 ++ crates/rocie-server/Cargo.toml | 45 +++ crates/rocie-server/src/api/get.rs | 48 ++++ crates/rocie-server/src/api/mod.rs | 2 + crates/rocie-server/src/api/set.rs | 99 +++++++ crates/rocie-server/src/app.rs | 57 ++++ crates/rocie-server/src/cli.rs | 16 ++ crates/rocie-server/src/error.rs | 1 + crates/rocie-server/src/main.rs | 77 +++++ crates/rocie-server/src/storage/migrate/mod.rs | 313 +++++++++++++++++++++ .../rocie-server/src/storage/migrate/sql/0->1.sql | 62 ++++ crates/rocie-server/src/storage/mod.rs | 3 + crates/rocie-server/src/storage/sql/get/mod.rs | 1 + .../src/storage/sql/get/product/mod.rs | 81 ++++++ crates/rocie-server/src/storage/sql/insert/mod.rs | 144 ++++++++++ .../src/storage/sql/insert/product/mod.rs | 160 +++++++++++ crates/rocie-server/src/storage/sql/mod.rs | 5 + crates/rocie-server/src/storage/sql/product.rs | 70 +++++ crates/rocie-server/src/storage/txn_log.rs | 63 +++++ crates/rocie/Cargo.toml | 39 --- 52 files changed, 2414 insertions(+), 39 deletions(-) create mode 100644 crates/rocie-cli/Cargo.toml create mode 100644 crates/rocie-cli/src/cli.rs create mode 100644 crates/rocie-cli/src/main.rs create mode 100644 crates/rocie-client/.gitignore create mode 100644 crates/rocie-client/.openapi-generator-ignore create mode 100644 crates/rocie-client/.openapi-generator/FILES create mode 100644 crates/rocie-client/.openapi-generator/VERSION create mode 100644 crates/rocie-client/.travis.yml create mode 100644 crates/rocie-client/Cargo.toml create mode 100644 crates/rocie-client/README.md create mode 100644 crates/rocie-client/docs/ApiGetApi.md create mode 100644 crates/rocie-client/docs/ApiSetApi.md create mode 100644 crates/rocie-client/docs/BarCode.md create mode 100644 crates/rocie-client/docs/Barcode.md create mode 100644 crates/rocie-client/docs/Product.md create mode 100644 crates/rocie-client/docs/ProductOneOf.md create mode 100644 crates/rocie-client/docs/ProductOneOf1.md create mode 100644 crates/rocie-client/docs/ProductStub.md create mode 100644 crates/rocie-client/docs/UnitAmount.md create mode 100644 crates/rocie-client/git_push.sh create mode 100644 crates/rocie-client/src/apis/api_get_api.rs create mode 100644 crates/rocie-client/src/apis/api_set_api.rs create mode 100644 crates/rocie-client/src/apis/configuration.rs create mode 100644 crates/rocie-client/src/apis/mod.rs create mode 100644 crates/rocie-client/src/lib.rs create mode 100644 crates/rocie-client/src/models/bar_code.rs create mode 100644 crates/rocie-client/src/models/barcode.rs create mode 100644 crates/rocie-client/src/models/mod.rs create mode 100644 crates/rocie-client/src/models/product.rs create mode 100644 crates/rocie-client/src/models/product_one_of.rs create mode 100644 crates/rocie-client/src/models/product_one_of_1.rs create mode 100644 crates/rocie-client/src/models/product_stub.rs create mode 100644 crates/rocie-client/src/models/unit_amount.rs create mode 100644 crates/rocie-server/Cargo.toml create mode 100644 crates/rocie-server/src/api/get.rs create mode 100644 crates/rocie-server/src/api/mod.rs create mode 100644 crates/rocie-server/src/api/set.rs create mode 100644 crates/rocie-server/src/app.rs create mode 100644 crates/rocie-server/src/cli.rs create mode 100644 crates/rocie-server/src/error.rs create mode 100644 crates/rocie-server/src/main.rs create mode 100644 crates/rocie-server/src/storage/migrate/mod.rs create mode 100644 crates/rocie-server/src/storage/migrate/sql/0->1.sql create mode 100644 crates/rocie-server/src/storage/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/get/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/get/product/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/insert/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/insert/product/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/mod.rs create mode 100644 crates/rocie-server/src/storage/sql/product.rs create mode 100644 crates/rocie-server/src/storage/txn_log.rs delete mode 100644 crates/rocie/Cargo.toml (limited to 'crates') diff --git a/crates/rocie-cli/Cargo.toml b/crates/rocie-cli/Cargo.toml new file mode 100644 index 0000000..e68a7b4 --- /dev/null +++ b/crates/rocie-cli/Cargo.toml @@ -0,0 +1,38 @@ +# rocie - An enterprise grocery management system +# +# Copyright (C) 2024 Benedikt Peetz +# Copyright (C) 2025 Benedikt Peetz +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of Rocie. +# +# You should have received a copy of the License along with this program. +# If not, see . + +[package] +name = "rocie-cli" +keywords = [] +categories = [] +default-run = "rocie-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.99" +clap = { version = "4.5.47", features = ["derive"] } +rocie-client.workspace = true +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +uuid = "1.18.1" + + +[package.metadata.docs.rs] +all-features = true diff --git a/crates/rocie-cli/src/cli.rs b/crates/rocie-cli/src/cli.rs new file mode 100644 index 0000000..ac220d5 --- /dev/null +++ b/crates/rocie-cli/src/cli.rs @@ -0,0 +1,63 @@ +use clap::{Parser, Subcommand}; +use uuid::Uuid; + +#[derive(Parser)] +pub(crate) struct CliArgs { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand)] +pub(crate) enum Command { + /// Deal with products + Product { + #[command(subcommand)] + command: ProductCommand, + }, +} + +#[derive(Subcommand)] +pub(crate) enum ProductCommand { + /// Register a new product + Register { + /// The name of new the product + #[arg(short, long)] + name: String, + + /// Optional description of the new the product + #[arg(short, long)] + description: Option, + + /// Optional parent of the new the product + #[arg(short, long)] + parent: Option, + }, + + AssociateBarcode { + /// The id of the product to associated the barcode with + #[arg(short, long)] + product_id: Uuid, + + /// The barcode number + #[arg(short, long)] + barcode_number: u32, + + /// The quantity of amount, this barcode signifies + #[arg(short = 'v', long)] + amount_value: u32, + + /// The unit the amount value is in + #[arg(short = 'u', long)] + amount_unit: String, + }, + + /// Get a already registered product by id + Get { + /// The id of the product + #[arg(short, long)] + id: Uuid, + }, + + /// List all available products + List {}, +} diff --git a/crates/rocie-cli/src/main.rs b/crates/rocie-cli/src/main.rs new file mode 100644 index 0000000..f0192d4 --- /dev/null +++ b/crates/rocie-cli/src/main.rs @@ -0,0 +1,83 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use rocie_client::{ + apis::{ + api_get_api::{product_by_id, products}, + api_set_api::{associate_barcode, register_product}, + configuration::Configuration, + }, + models::{Barcode, UnitAmount}, +}; + +use crate::cli::{CliArgs, Command, ProductCommand}; + +mod cli; + +#[tokio::main] +async fn main() -> Result<()> { + let args = CliArgs::parse(); + + let mut config = Configuration::new(); + "http://127.0.0.1:8080".clone_into(&mut config.base_path); + + match args.command { + Command::Product { command } => { + match command { + ProductCommand::Register { + name, + description, + parent, + } => { + let new_id = register_product( + &config, + rocie_client::models::ProductStub { + description: Some(description), // TODO: Fix + name, + parent, + }, + ) + .await + .context("Failed to register new product")?; + + println!("Registered new product with id: {new_id}"); + } + ProductCommand::Get { id } => { + let product = product_by_id(&config, id.to_string().as_str()) + .await + .with_context(|| format!("Failed to get product with id: {id}"))?; + + println!("{product:#?}"); + } + ProductCommand::AssociateBarcode { + product_id, + barcode_number, + amount_value, + amount_unit, + } => associate_barcode( + &config, + product_id.to_string().as_str(), + Barcode { + amount: Box::new(UnitAmount { + unit: amount_unit, + value: amount_value as i32, + }), + id: barcode_number as i32, + }, + ) + .await + .context("Failed to associated barcode")?, + ProductCommand::List {} => { + let all = products(&config) + .await + .context("Failed to get all products")?; + + for product in all { + println!("{}: {}", product.name, product.id); + } + } + } + } + } + + Ok(()) +} diff --git a/crates/rocie-client/.gitignore b/crates/rocie-client/.gitignore new file mode 100644 index 0000000..6aa1064 --- /dev/null +++ b/crates/rocie-client/.gitignore @@ -0,0 +1,3 @@ +/target/ +**/*.rs.bk +Cargo.lock diff --git a/crates/rocie-client/.openapi-generator-ignore b/crates/rocie-client/.openapi-generator-ignore new file mode 100644 index 0000000..b0147e0 --- /dev/null +++ b/crates/rocie-client/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +Cargo.toml + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/crates/rocie-client/.openapi-generator/FILES b/crates/rocie-client/.openapi-generator/FILES new file mode 100644 index 0000000..f9b70e6 --- /dev/null +++ b/crates/rocie-client/.openapi-generator/FILES @@ -0,0 +1,20 @@ +.gitignore +.travis.yml +README.md +docs/ApiGetApi.md +docs/ApiSetApi.md +docs/Barcode.md +docs/Product.md +docs/ProductStub.md +docs/UnitAmount.md +git_push.sh +src/apis/api_get_api.rs +src/apis/api_set_api.rs +src/apis/configuration.rs +src/apis/mod.rs +src/lib.rs +src/models/barcode.rs +src/models/mod.rs +src/models/product.rs +src/models/product_stub.rs +src/models/unit_amount.rs diff --git a/crates/rocie-client/.openapi-generator/VERSION b/crates/rocie-client/.openapi-generator/VERSION new file mode 100644 index 0000000..e465da4 --- /dev/null +++ b/crates/rocie-client/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.14.0 diff --git a/crates/rocie-client/.travis.yml b/crates/rocie-client/.travis.yml new file mode 100644 index 0000000..22761ba --- /dev/null +++ b/crates/rocie-client/.travis.yml @@ -0,0 +1 @@ +language: rust diff --git a/crates/rocie-client/Cargo.toml b/crates/rocie-client/Cargo.toml new file mode 100644 index 0000000..96e0e24 --- /dev/null +++ b/crates/rocie-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rocie-client" +version = "0.1.0" +authors = ["benedikt.peetz@b-peetz.de"] +description = "An enterprise grocery management system" +license = "GPL-3.0-or-later" +edition = "2024" + +[dependencies] +serde = { version = "^1.0", features = ["derive"] } +serde_with = { version = "^3.8", default-features = false, features = ["base64", "std", "macros"] } +serde_json = "^1.0" +serde_repr = "^0.1" +url = "^2.5" +uuid = { version = "^1.8", features = ["serde", "v4"] } +reqwest = { version = "^0.12", default-features = false, features = ["json", "multipart"] } diff --git a/crates/rocie-client/README.md b/crates/rocie-client/README.md new file mode 100644 index 0000000..7649325 --- /dev/null +++ b/crates/rocie-client/README.md @@ -0,0 +1,52 @@ +# Rust API client for openapi + +An enterprise grocery management system + + +## Overview + +This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client. + +- API version: 0.1.0 +- Package version: 0.1.0 +- Generator version: 7.14.0 +- Build package: `org.openapitools.codegen.languages.RustClientCodegen` + +## Installation + +Put the package under your project folder in a directory named `openapi` and add the following to `Cargo.toml` under `[dependencies]`: + +``` +openapi = { path = "./openapi" } +``` + +## Documentation for API Endpoints + +All URIs are relative to *http://localhost* + +Class | Method | HTTP request | Description +------------ | ------------- | ------------- | ------------- +*ApiGetApi* | [**product_by_id**](docs/ApiGetApi.md#product_by_id) | **GET** /product/{id} | Get Product by id +*ApiGetApi* | [**products**](docs/ApiGetApi.md#products) | **GET** /products/ | Return all registered products +*ApiSetApi* | [**associate_barcode**](docs/ApiSetApi.md#associate_barcode) | **POST** /product/{id}/associate | Associate a barcode with a product +*ApiSetApi* | [**register_product**](docs/ApiSetApi.md#register_product) | **POST** /product/new | Register a product + + +## Documentation For Models + + - [Barcode](docs/Barcode.md) + - [Product](docs/Product.md) + - [ProductStub](docs/ProductStub.md) + - [UnitAmount](docs/UnitAmount.md) + + +To get access to the crate's generated documentation, use: + +``` +cargo doc --open +``` + +## Author + +benedikt.peetz@b-peetz.de + diff --git a/crates/rocie-client/docs/ApiGetApi.md b/crates/rocie-client/docs/ApiGetApi.md new file mode 100644 index 0000000..f2df94c --- /dev/null +++ b/crates/rocie-client/docs/ApiGetApi.md @@ -0,0 +1,63 @@ +# \ApiGetApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**product_by_id**](ApiGetApi.md#product_by_id) | **GET** /product/{id} | Get Product by id +[**products**](ApiGetApi.md#products) | **GET** /products/ | Return all registered products + + + +## product_by_id + +> models::Product product_by_id(id) +Get Product by id + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**id** | **uuid::Uuid** | Product id | [required] | + +### Return type + +[**models::Product**](Product.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## products + +> Vec products() +Return all registered products + +### Parameters + +This endpoint does not need any parameter. + +### Return type + +[**Vec**](Product.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/crates/rocie-client/docs/ApiSetApi.md b/crates/rocie-client/docs/ApiSetApi.md new file mode 100644 index 0000000..87f88b8 --- /dev/null +++ b/crates/rocie-client/docs/ApiSetApi.md @@ -0,0 +1,67 @@ +# \ApiSetApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**associate_barcode**](ApiSetApi.md#associate_barcode) | **POST** /product/{id}/associate | Associate a barcode with a product +[**register_product**](ApiSetApi.md#register_product) | **POST** /product/new | Register a product + + + +## associate_barcode + +> associate_barcode(id, barcode) +Associate a barcode with a product + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**id** | **uuid::Uuid** | The id of the product to associated the barcode with | [required] | +**barcode** | [**Barcode**](Barcode.md) | | [required] | + +### Return type + + (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## register_product + +> uuid::Uuid register_product(product_stub) +Register a product + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**product_stub** | [**ProductStub**](ProductStub.md) | | [required] | + +### Return type + +[**uuid::Uuid**](uuid::Uuid.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/crates/rocie-client/docs/BarCode.md b/crates/rocie-client/docs/BarCode.md new file mode 100644 index 0000000..8c58cb3 --- /dev/null +++ b/crates/rocie-client/docs/BarCode.md @@ -0,0 +1,11 @@ +# BarCode + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**id** | **i64** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/Barcode.md b/crates/rocie-client/docs/Barcode.md new file mode 100644 index 0000000..7e6f4fb --- /dev/null +++ b/crates/rocie-client/docs/Barcode.md @@ -0,0 +1,12 @@ +# Barcode + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**amount** | [**models::UnitAmount**](UnitAmount.md) | | +**id** | **i32** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/Product.md b/crates/rocie-client/docs/Product.md new file mode 100644 index 0000000..3995429 --- /dev/null +++ b/crates/rocie-client/docs/Product.md @@ -0,0 +1,14 @@ +# Product + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**associated_bar_codes** | [**Vec**](Barcode.md) | | +**description** | Option<**String**> | | [optional] +**id** | [**uuid::Uuid**](uuid::Uuid.md) | | +**name** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/ProductOneOf.md b/crates/rocie-client/docs/ProductOneOf.md new file mode 100644 index 0000000..1ab44aa --- /dev/null +++ b/crates/rocie-client/docs/ProductOneOf.md @@ -0,0 +1,11 @@ +# ProductOneOf + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**mass** | **i64** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/ProductOneOf1.md b/crates/rocie-client/docs/ProductOneOf1.md new file mode 100644 index 0000000..f395a25 --- /dev/null +++ b/crates/rocie-client/docs/ProductOneOf1.md @@ -0,0 +1,11 @@ +# ProductOneOf1 + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**volume** | **i64** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/ProductStub.md b/crates/rocie-client/docs/ProductStub.md new file mode 100644 index 0000000..d0b0db8 --- /dev/null +++ b/crates/rocie-client/docs/ProductStub.md @@ -0,0 +1,13 @@ +# ProductStub + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**description** | Option<**String**> | | [optional] +**name** | **String** | | +**parent** | Option<[**uuid::Uuid**](uuid::Uuid.md)> | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/docs/UnitAmount.md b/crates/rocie-client/docs/UnitAmount.md new file mode 100644 index 0000000..39b2264 --- /dev/null +++ b/crates/rocie-client/docs/UnitAmount.md @@ -0,0 +1,12 @@ +# UnitAmount + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**unit** | **String** | | +**value** | **i32** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/crates/rocie-client/git_push.sh b/crates/rocie-client/git_push.sh new file mode 100644 index 0000000..f53a75d --- /dev/null +++ b/crates/rocie-client/git_push.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ +# +# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" + +git_user_id=$1 +git_repo_id=$2 +release_note=$3 +git_host=$4 + +if [ "$git_host" = "" ]; then + git_host="github.com" + echo "[INFO] No command line input provided. Set \$git_host to $git_host" +fi + +if [ "$git_user_id" = "" ]; then + git_user_id="GIT_USER_ID" + echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" +fi + +if [ "$git_repo_id" = "" ]; then + git_repo_id="GIT_REPO_ID" + echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" +fi + +if [ "$release_note" = "" ]; then + release_note="Minor update" + echo "[INFO] No command line input provided. Set \$release_note to $release_note" +fi + +# Initialize the local directory as a Git repository +git init + +# Adds the files in the local repository and stages them for commit. +git add . + +# Commits the tracked changes and prepares them to be pushed to a remote repository. +git commit -m "$release_note" + +# Sets the new remote +git_remote=$(git remote) +if [ "$git_remote" = "" ]; then # git remote not defined + + if [ "$GIT_TOKEN" = "" ]; then + echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." + git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git + else + git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git + fi + +fi + +git pull origin master + +# Pushes (Forces) the changes in the local repository up to the remote repository +echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" +git push origin master 2>&1 | grep -v 'To https' diff --git a/crates/rocie-client/src/apis/api_get_api.rs b/crates/rocie-client/src/apis/api_get_api.rs new file mode 100644 index 0000000..3217717 --- /dev/null +++ b/crates/rocie-client/src/apis/api_get_api.rs @@ -0,0 +1,103 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + + +use reqwest; +use serde::{Deserialize, Serialize, de::Error as _}; +use crate::{apis::ResponseContent, models}; +use super::{Error, configuration, ContentType}; + + +/// struct for typed errors of method [`product_by_id`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ProductByIdError { + Status404(), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`products`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ProductsError { + UnknownValue(serde_json::Value), +} + + +pub async fn product_by_id(configuration: &configuration::Configuration, id: &str) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_id = id; + + let uri_str = format!("{}/product/{id}", configuration.base_path, id=crate::apis::urlencode(p_id)); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Product`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Product`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + +pub async fn products(configuration: &configuration::Configuration, ) -> Result, Error> { + + let uri_str = format!("{}/products/", configuration.base_path); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `Vec<models::Product>`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `Vec<models::Product>`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + diff --git a/crates/rocie-client/src/apis/api_set_api.rs b/crates/rocie-client/src/apis/api_set_api.rs new file mode 100644 index 0000000..7c0c414 --- /dev/null +++ b/crates/rocie-client/src/apis/api_set_api.rs @@ -0,0 +1,96 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + + +use reqwest; +use serde::{Deserialize, Serialize, de::Error as _}; +use crate::{apis::ResponseContent, models}; +use super::{Error, configuration, ContentType}; + + +/// struct for typed errors of method [`associate_barcode`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AssociateBarcodeError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`register_product`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RegisterProductError { + UnknownValue(serde_json::Value), +} + + +pub async fn associate_barcode(configuration: &configuration::Configuration, id: &str, barcode: models::Barcode) -> Result<(), Error> { + // add a prefix to parameters to efficiently prevent name collisions + let p_id = id; + let p_barcode = barcode; + + let uri_str = format!("{}/product/{id}/associate", configuration.base_path, id=crate::apis::urlencode(p_id)); + let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + req_builder = req_builder.json(&p_barcode); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + if !status.is_client_error() && !status.is_server_error() { + Ok(()) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + +pub async fn register_product(configuration: &configuration::Configuration, product_stub: models::ProductStub) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_product_stub = product_stub; + + let uri_str = format!("{}/product/new", configuration.base_path); + let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + req_builder = req_builder.json(&p_product_stub); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `uuid::Uuid`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `uuid::Uuid`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + diff --git a/crates/rocie-client/src/apis/configuration.rs b/crates/rocie-client/src/apis/configuration.rs new file mode 100644 index 0000000..a4fbb93 --- /dev/null +++ b/crates/rocie-client/src/apis/configuration.rs @@ -0,0 +1,51 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + + + +#[derive(Debug, Clone)] +pub struct Configuration { + pub base_path: String, + pub user_agent: Option, + pub client: reqwest::Client, + pub basic_auth: Option, + pub oauth_access_token: Option, + pub bearer_access_token: Option, + pub api_key: Option, +} + +pub type BasicAuth = (String, Option); + +#[derive(Debug, Clone)] +pub struct ApiKey { + pub prefix: Option, + pub key: String, +} + + +impl Configuration { + pub fn new() -> Configuration { + Configuration::default() + } +} + +impl Default for Configuration { + fn default() -> Self { + Configuration { + base_path: "http://localhost".to_owned(), + user_agent: Some("OpenAPI-Generator/0.1.0/rust".to_owned()), + client: reqwest::Client::new(), + basic_auth: None, + oauth_access_token: None, + bearer_access_token: None, + api_key: None, + } + } +} diff --git a/crates/rocie-client/src/apis/mod.rs b/crates/rocie-client/src/apis/mod.rs new file mode 100644 index 0000000..c3f990f --- /dev/null +++ b/crates/rocie-client/src/apis/mod.rs @@ -0,0 +1,117 @@ +use std::error; +use std::fmt; + +#[derive(Debug, Clone)] +pub struct ResponseContent { + pub status: reqwest::StatusCode, + pub content: String, + pub entity: Option, +} + +#[derive(Debug)] +pub enum Error { + Reqwest(reqwest::Error), + Serde(serde_json::Error), + Io(std::io::Error), + ResponseError(ResponseContent), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (module, e) = match self { + Error::Reqwest(e) => ("reqwest", e.to_string()), + Error::Serde(e) => ("serde", e.to_string()), + Error::Io(e) => ("IO", e.to_string()), + Error::ResponseError(e) => ("response", format!("status code {}", e.status)), + }; + write!(f, "error in {}: {}", module, e) + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + Some(match self { + Error::Reqwest(e) => e, + Error::Serde(e) => e, + Error::Io(e) => e, + Error::ResponseError(_) => return None, + }) + } +} + +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Error::Reqwest(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Serde(e) + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +pub fn urlencode>(s: T) -> String { + ::url::form_urlencoded::byte_serialize(s.as_ref().as_bytes()).collect() +} + +pub fn parse_deep_object(prefix: &str, value: &serde_json::Value) -> Vec<(String, String)> { + if let serde_json::Value::Object(object) = value { + let mut params = vec![]; + + for (key, value) in object { + match value { + serde_json::Value::Object(_) => params.append(&mut parse_deep_object( + &format!("{}[{}]", prefix, key), + value, + )), + serde_json::Value::Array(array) => { + for (i, value) in array.iter().enumerate() { + params.append(&mut parse_deep_object( + &format!("{}[{}][{}]", prefix, key, i), + value, + )); + } + }, + serde_json::Value::String(s) => params.push((format!("{}[{}]", prefix, key), s.clone())), + _ => params.push((format!("{}[{}]", prefix, key), value.to_string())), + } + } + + return params; + } + + unimplemented!("Only objects are supported with style=deepObject") +} + +/// Internal use only +/// A content type supported by this client. +#[allow(dead_code)] +enum ContentType { + Json, + Text, + Unsupported(String) +} + +impl From<&str> for ContentType { + fn from(content_type: &str) -> Self { + if content_type.starts_with("application") && content_type.contains("json") { + return Self::Json; + } else if content_type.starts_with("text/plain") { + return Self::Text; + } else { + return Self::Unsupported(content_type.to_string()); + } + } +} + +pub mod api_get_api; +pub mod api_set_api; + +pub mod configuration; diff --git a/crates/rocie-client/src/lib.rs b/crates/rocie-client/src/lib.rs new file mode 100644 index 0000000..e152062 --- /dev/null +++ b/crates/rocie-client/src/lib.rs @@ -0,0 +1,11 @@ +#![allow(unused_imports)] +#![allow(clippy::too_many_arguments)] + +extern crate serde_repr; +extern crate serde; +extern crate serde_json; +extern crate url; +extern crate reqwest; + +pub mod apis; +pub mod models; diff --git a/crates/rocie-client/src/models/bar_code.rs b/crates/rocie-client/src/models/bar_code.rs new file mode 100644 index 0000000..c8123a2 --- /dev/null +++ b/crates/rocie-client/src/models/bar_code.rs @@ -0,0 +1,27 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct BarCode { + #[serde(rename = "id")] + pub id: i64, +} + +impl BarCode { + pub fn new(id: i64) -> BarCode { + BarCode { + id, + } + } +} + diff --git a/crates/rocie-client/src/models/barcode.rs b/crates/rocie-client/src/models/barcode.rs new file mode 100644 index 0000000..7690be2 --- /dev/null +++ b/crates/rocie-client/src/models/barcode.rs @@ -0,0 +1,30 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct Barcode { + #[serde(rename = "amount")] + pub amount: Box, + #[serde(rename = "id")] + pub id: i32, +} + +impl Barcode { + pub fn new(amount: models::UnitAmount, id: i32) -> Barcode { + Barcode { + amount: Box::new(amount), + id, + } + } +} + diff --git a/crates/rocie-client/src/models/mod.rs b/crates/rocie-client/src/models/mod.rs new file mode 100644 index 0000000..6c96c01 --- /dev/null +++ b/crates/rocie-client/src/models/mod.rs @@ -0,0 +1,8 @@ +pub mod barcode; +pub use self::barcode::Barcode; +pub mod product; +pub use self::product::Product; +pub mod product_stub; +pub use self::product_stub::ProductStub; +pub mod unit_amount; +pub use self::unit_amount::UnitAmount; diff --git a/crates/rocie-client/src/models/product.rs b/crates/rocie-client/src/models/product.rs new file mode 100644 index 0000000..acdc6c6 --- /dev/null +++ b/crates/rocie-client/src/models/product.rs @@ -0,0 +1,36 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct Product { + #[serde(rename = "associated_bar_codes")] + pub associated_bar_codes: Vec, + #[serde(rename = "description", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "id")] + pub id: uuid::Uuid, + #[serde(rename = "name")] + pub name: String, +} + +impl Product { + pub fn new(associated_bar_codes: Vec, id: uuid::Uuid, name: String) -> Product { + Product { + associated_bar_codes, + description: None, + id, + name, + } + } +} + diff --git a/crates/rocie-client/src/models/product_one_of.rs b/crates/rocie-client/src/models/product_one_of.rs new file mode 100644 index 0000000..399c06b --- /dev/null +++ b/crates/rocie-client/src/models/product_one_of.rs @@ -0,0 +1,27 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProductOneOf { + #[serde(rename = "Mass")] + pub mass: i64, +} + +impl ProductOneOf { + pub fn new(mass: i64) -> ProductOneOf { + ProductOneOf { + mass, + } + } +} + diff --git a/crates/rocie-client/src/models/product_one_of_1.rs b/crates/rocie-client/src/models/product_one_of_1.rs new file mode 100644 index 0000000..58e033f --- /dev/null +++ b/crates/rocie-client/src/models/product_one_of_1.rs @@ -0,0 +1,27 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProductOneOf1 { + #[serde(rename = "Volume")] + pub volume: i64, +} + +impl ProductOneOf1 { + pub fn new(volume: i64) -> ProductOneOf1 { + ProductOneOf1 { + volume, + } + } +} + diff --git a/crates/rocie-client/src/models/product_stub.rs b/crates/rocie-client/src/models/product_stub.rs new file mode 100644 index 0000000..3849c18 --- /dev/null +++ b/crates/rocie-client/src/models/product_stub.rs @@ -0,0 +1,33 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProductStub { + #[serde(rename = "description", default, with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "parent", skip_serializing_if = "Option::is_none")] + pub parent: Option, +} + +impl ProductStub { + pub fn new(name: String) -> ProductStub { + ProductStub { + description: None, + name, + parent: None, + } + } +} + diff --git a/crates/rocie-client/src/models/unit_amount.rs b/crates/rocie-client/src/models/unit_amount.rs new file mode 100644 index 0000000..038adb0 --- /dev/null +++ b/crates/rocie-client/src/models/unit_amount.rs @@ -0,0 +1,30 @@ +/* + * rocie-server + * + * An enterprise grocery management system + * + * The version of the OpenAPI document: 0.1.0 + * Contact: benedikt.peetz@b-peetz.de + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UnitAmount { + #[serde(rename = "unit")] + pub unit: String, + #[serde(rename = "value")] + pub value: i32, +} + +impl UnitAmount { + pub fn new(unit: String, value: i32) -> UnitAmount { + UnitAmount { + unit, + value, + } + } +} + diff --git a/crates/rocie-server/Cargo.toml b/crates/rocie-server/Cargo.toml new file mode 100644 index 0000000..93dbcd4 --- /dev/null +++ b/crates/rocie-server/Cargo.toml @@ -0,0 +1,45 @@ +# rocie - An enterprise grocery management system +# +# Copyright (C) 2024 Benedikt Peetz +# Copyright (C) 2025 Benedikt Peetz +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of Rocie. +# +# You should have received a copy of the License along with this program. +# If not, see . + +[package] +name = "rocie-server" +keywords = [] +categories = [] +default-run = "rocie-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description.workspace = true +publish = false + +[lints] +workspace = true + +[dev-dependencies] + +[dependencies] +actix-web = "4.11.0" +chrono = "0.4.41" +clap = { version = "4.5.45", features = ["derive"] } +env_logger = "0.11.8" +log = "0.4.27" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.143" +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } +thiserror = "2.0.16" +utoipa = { version = "5.4.0", features = ["actix_extras", "uuid"] } +uuid = { version = "1.18.1", features = ["v4", "serde"] } + +[package.metadata.docs.rs] +all-features = true diff --git a/crates/rocie-server/src/api/get.rs b/crates/rocie-server/src/api/get.rs new file mode 100644 index 0000000..94015cf --- /dev/null +++ b/crates/rocie-server/src/api/get.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, Responder, Result, get, web}; + +use crate::{ + app::App, + storage::sql::product::{Product, ProductId}, +}; + +pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { + cfg.service(product_by_id).service(products); +} + +/// Get Product by id +#[utoipa::path( + responses( + (status = OK, description = "Product found from database", body = Product), + (status = NOT_FOUND, description = "Product not found in database"), + (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String) + ), + params( + ("id" = ProductId, description = "Product id" ), + ) +)] +#[get("/product/{id}")] +pub(crate) async fn product_by_id( + app: web::Data, + id: web::Path, +) -> Result { + let id = id.into_inner(); + + match Product::from_id(&app, id).await? { + Some(product) => Ok(HttpResponse::Ok().json(product)), + None => Ok(HttpResponse::NotFound().finish()), + } +} + +/// Return all registered products +#[utoipa::path( + responses( + (status = OK, description = "All products founds", body = Vec), + (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String) + ), +)] +#[get("/products/")] +pub(crate) async fn products(app: web::Data) -> Result { + let all = Product::get_all(&app).await?; + + Ok(HttpResponse::Ok().json(all)) +} diff --git a/crates/rocie-server/src/api/mod.rs b/crates/rocie-server/src/api/mod.rs new file mode 100644 index 0000000..c573122 --- /dev/null +++ b/crates/rocie-server/src/api/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod get; +pub(crate) mod set; diff --git a/crates/rocie-server/src/api/set.rs b/crates/rocie-server/src/api/set.rs new file mode 100644 index 0000000..0a6af1b --- /dev/null +++ b/crates/rocie-server/src/api/set.rs @@ -0,0 +1,99 @@ +use actix_web::{HttpResponse, Responder, Result, post, web}; +use serde::Deserialize; +use utoipa::ToSchema; + +use crate::{ + app::App, + storage::sql::{ + insert::Operations, + product::{Barcode, Product, ProductId}, + }, +}; + +#[derive(Deserialize, ToSchema)] +struct ProductStub { + name: String, + description: Option, + parent: Option, +} + +pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { + cfg.service(register_product).service(associate_barcode); +} + +/// Register a product +#[utoipa::path( + responses( + ( + status = 200, + description = "Product successfully registered in database", + body = ProductId, + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String, + ) + ), + request_body = ProductStub, +)] +#[post("/product/new")] +pub(crate) async fn register_product( + app: web::Data, + product_stub: web::Json, +) -> Result { + let mut ops = Operations::new("register product"); + + let product = Product::register( + product_stub.name.clone(), + product_stub.description.clone(), + product_stub.parent, + &mut ops, + ); + + ops.apply(&app).await?; + + Ok(HttpResponse::Ok().json(product.id)) +} + +/// Associate a barcode with a product +#[utoipa::path( + responses( + ( + status = OK, + description = "Barcode successfully associated with product", + ), + ( + status = NOT_FOUND, + description = "Product id not found in database", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String, + ) + ), + params ( + ("id" = ProductId, description = "The id of the product to associated the barcode with"), + ), + request_body = Barcode, +)] +#[post("/product/{id}/associate")] +pub(crate) async fn associate_barcode( + app: web::Data, + id: web::Path, + barcode: web::Json, +) -> Result { + let mut ops = Operations::new("associated barcode with product"); + + match Product::from_id(&app, id.into_inner()).await? { + Some(product) => { + product.associate_barcode(barcode.into_inner(), &mut ops); + + ops.apply(&app).await?; + + Ok(HttpResponse::Ok().finish()) + } + None => Ok(HttpResponse::NotFound().finish()), + } +} diff --git a/crates/rocie-server/src/app.rs b/crates/rocie-server/src/app.rs new file mode 100644 index 0000000..ab8f764 --- /dev/null +++ b/crates/rocie-server/src/app.rs @@ -0,0 +1,57 @@ +use std::{env, path::PathBuf}; + +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; + +use crate::storage::migrate::migrate_db; + +#[derive(Clone)] +pub(crate) struct App { + pub(crate) db: SqlitePool, +} + +impl App { + pub(crate) async fn new() -> Result { + let db_path: PathBuf = PathBuf::from(env::var("ROCIE_DB_PATH")?); + + let db = { + let options = SqliteConnectOptions::new() + .filename(&db_path) + .optimize_on_close(true, None) + .create_if_missing(true); + + SqlitePool::connect_with(options).await.map_err(|err| { + app_create::Error::DbConnectionFailed { + inner: err, + db_path, + } + })? + }; + + let me = Self { db }; + + migrate_db(&me).await?; + + Ok(me) + } +} + +pub(crate) mod app_create { + use std::{env, path::PathBuf}; + + use crate::storage::migrate::migrate_db; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("The `ROCIE_DB_PATH` variable is not accessible: {0}")] + MissingDbVariable(#[from] env::VarError), + + #[error("Failed to connect to the sqlite database at `{db_path}`, because: {inner}")] + DbConnectionFailed { + inner: sqlx::Error, + db_path: PathBuf, + }, + + #[error("Failed to migrate db to the current version")] + MigrateDb(#[from] migrate_db::Error), + } +} diff --git a/crates/rocie-server/src/cli.rs b/crates/rocie-server/src/cli.rs new file mode 100644 index 0000000..5961ab7 --- /dev/null +++ b/crates/rocie-server/src/cli.rs @@ -0,0 +1,16 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +pub(crate) struct CliArgs { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand)] +pub(crate) enum Command { + /// Serve the server on the default ports. + Serve, + + /// Print the `OpenAPI` API documentation to stdout. + OpenApi, +} diff --git a/crates/rocie-server/src/error.rs b/crates/rocie-server/src/error.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/rocie-server/src/error.rs @@ -0,0 +1 @@ + diff --git a/crates/rocie-server/src/main.rs b/crates/rocie-server/src/main.rs new file mode 100644 index 0000000..453a2dc --- /dev/null +++ b/crates/rocie-server/src/main.rs @@ -0,0 +1,77 @@ +use actix_web::{App, HttpServer, middleware::Logger, web::Data}; +use clap::Parser; +use utoipa::OpenApi; + +use crate::cli::{CliArgs, Command}; + +mod api; +mod app; +mod cli; +mod storage; + +#[actix_web::main] +#[expect( + clippy::needless_for_each, + reason = "utoipa generates this, we can't change it" +)] +async fn main() -> Result<(), std::io::Error> { + #[derive(OpenApi)] + #[openapi( + paths( + api::get::product_by_id, + api::get::products, + api::set::register_product, + api::set::associate_barcode + ), + // security( + // (), + // ("my_auth" = ["read:items", "edit:items"]), + // ("token_jwt" = []) + // ), + )] + struct ApiDoc; + + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let args = CliArgs::parse(); + + match args.command { + Command::Serve => { + let host = "127.0.0.1"; + let port = 8080; + let data = Data::new( + app::App::new() + .await + .map_err(|err| std::io::Error::other(main::Error::AppInit(err)))?, + ); + + eprintln!("Serving at http://{host}:{port}"); + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .app_data(Data::clone(&data)) + .configure(api::get::register_paths) + .configure(api::set::register_paths) + }) + .bind((host, port))? + .run() + .await + } + Command::OpenApi => { + let openapi = ApiDoc::openapi(); + println!("{}", openapi.to_pretty_json().expect("Comp-time constant")); + Ok(()) + } + } +} + +pub(crate) mod main { + use crate::app::app_create; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to initialize shared application state: {0}")] + AppInit(#[from] app_create::Error), + } +} diff --git a/crates/rocie-server/src/storage/migrate/mod.rs b/crates/rocie-server/src/storage/migrate/mod.rs new file mode 100644 index 0000000..3fdc400 --- /dev/null +++ b/crates/rocie-server/src/storage/migrate/mod.rs @@ -0,0 +1,313 @@ +use std::{ + fmt::Display, + time::{SystemTime, UNIX_EPOCH}, +}; + +use chrono::TimeDelta; +use log::{debug, info}; +use sqlx::{Sqlite, SqlitePool, Transaction, query}; + +use crate::app::App; + +macro_rules! make_upgrade { + ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => { + let mut tx = $app + .db + .begin() + .await + .map_err(|err| update::Error::TxnStart(err))?; + debug!("Migrating: {} -> {}", $old_version, $new_version); + + sqlx::raw_sql(include_str!($sql_name)) + .execute(&mut *tx) + .await + .map_err(|err| update::Error::SqlUpdate(err))?; + + set_db_version( + &mut tx, + if $old_version == Self::Empty { + // There is no previous version we would need to remove + None + } else { + Some($old_version) + }, + $new_version, + ) + .await + .map_err(|err| update::Error::SetDbVersion { + err, + new_version: $new_version, + })?; + + tx.commit() + .await + .map_err(|err| update::Error::TxnCommit(err))?; + + // NOTE: This is needed, so that sqlite "sees" our changes to the table + // without having to reconnect. <2025-02-18> + query!("VACUUM") + .execute(&$app.db) + .await + .map_err(|err| update::Error::SqlVacuum(err))?; + + Box::pin($new_version.update($app)) + .await + .map_err(|err| update::Error::NextUpdate { + err: Box::new(err), + new_version: $new_version, + })?; + + Ok(()) + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub(crate) enum DbVersion { + /// The database is not yet initialized. + Empty, + + /// Introduced: 2025-09-02. + One, +} +const CURRENT_VERSION: DbVersion = DbVersion::One; + +async fn set_db_version( + tx: &mut Transaction<'_, Sqlite>, + old_version: Option, + new_version: DbVersion, +) -> Result<(), db_version_set::Error> { + let valid_from = get_current_date(); + + if let Some(old_version) = old_version { + let valid_to = valid_from + 1; + let old_version = old_version.as_sql_integer(); + + query!( + "UPDATE version SET valid_to = ? WHERE namespace = 'rocie' AND number = ?;", + valid_to, + old_version + ) + .execute(&mut *(*tx)) + .await?; + } + + let version = new_version.as_sql_integer(); + + query!( + "INSERT INTO version (namespace, number, valid_from, valid_to) VALUES ('rocie', ?, ?, NULL);", + version, + valid_from + ) + .execute(&mut *(*tx)) + .await?; + + Ok(()) +} + +pub(crate) mod db_version_set { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to perform database action: {0}")] + DbError(#[from] sqlx::Error), + } +} + +impl DbVersion { + fn as_sql_integer(self) -> i32 { + match self { + DbVersion::One => 1, + + DbVersion::Empty => unreachable!("A empty version does not have an associated integer"), + } + } + + fn from_db(number: i64, namespace: &str) -> Result { + match (number, namespace) { + (1, "rocie") => Ok(DbVersion::One), + (number, namespace) => Err(db_version_parse::Error::UnkownVersion { + namespace: namespace.to_owned(), + number, + }), + } + } + + /// Try to update the database from version [`self`] to the [`CURRENT_VERSION`]. + /// + /// Each update is atomic, so if this function fails you are still guaranteed to have a + /// database at version `get_version`. + #[allow(clippy::too_many_lines)] + async fn update(self, app: &App) -> Result<(), update::Error> { + match self { + Self::Empty => { + make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"} + } + + // This is the current_version + Self::One => { + assert_eq!(self, CURRENT_VERSION); + assert_eq!(self, get_version(app).await?); + Ok(()) + } + } + } +} +impl Display for DbVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // It is a unit only enum, thus we can simply use the Debug formatting + ::fmt(self, f) + } +} +pub(crate) mod update { + use crate::storage::migrate::{DbVersion, db_version_set, get_db_version}; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to determine final database version: {0}")] + GetVersion(#[from] get_db_version::Error), + + #[error("Failed to set the db to version {new_version}: {err}")] + SetDbVersion { + err: db_version_set::Error, + new_version: DbVersion, + }, + + #[error("Failed to vacuum sql database after update: {0}")] + SqlVacuum(sqlx::Error), + + #[error("Failed to execute the sql update script: {0}")] + SqlUpdate(sqlx::Error), + + #[error("Failed to start the update transaction: {0}")] + TxnStart(sqlx::Error), + #[error("Failed to commit the update transaction: {0}")] + TxnCommit(sqlx::Error), + + #[error("Failed to perform the next chained update (to ver {new_version}): {err}")] + NextUpdate { + err: Box, + new_version: DbVersion, + }, + } +} +pub(crate) mod db_version_parse { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Db version is {number}, but got unknown namespace: '{namespace}'")] + UnkownVersion { namespace: String, number: i64 }, + } +} + +/// Returns the current data as UNIX time stamp. +fn get_current_date() -> i64 { + let start = SystemTime::now(); + let seconds_since_epoch: TimeDelta = TimeDelta::from_std( + start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"), + ) + .expect("Time does not go backwards"); + + // All database dates should be after the UNIX_EPOCH (and thus positiv) + seconds_since_epoch.num_milliseconds() +} + +/// Return the current database version. +/// +/// # Panics +/// Only if internal assertions fail. +pub(crate) async fn get_version(app: &App) -> Result { + get_version_db(&app.db).await +} + +/// Return the current database version. +/// +/// In contrast to the [`get_version`] function, this function does not +/// a fully instantiated [`App`], a database connection suffices. +/// +/// # Panics +/// Only if internal assertions fail. +pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result { + let version_table_exists = { + let query = query!( + " + SELECT 1 as result + FROM sqlite_master + WHERE type = 'table' + AND name = 'version' + " + ) + .fetch_optional(pool) + .await + .map_err(|err| get_db_version::Error::VersionTableExistance(err))?; + + if let Some(output) = query { + assert_eq!(output.result, 1); + true + } else { + false + } + }; + + if !version_table_exists { + return Ok(DbVersion::Empty); + } + + let current_version = query!( + " + SELECT namespace, number + FROM version + WHERE valid_to IS NULL; + " + ) + .fetch_one(pool) + .await + .map_err(|err| get_db_version::Error::VersionNumberFetch(err))?; + + Ok(DbVersion::from_db( + current_version.number, + current_version.namespace.as_str(), + )?) +} + +pub(crate) mod get_db_version { + use crate::storage::migrate::db_version_parse; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to fetch the version number from db: {0}")] + VersionNumberFetch(sqlx::Error), + + #[error("Failed to check for existance of the `version` table: {0}")] + VersionTableExistance(sqlx::Error), + + #[error("Failed to parse the db version: {0}")] + VersionParse(#[from] db_version_parse::Error), + } +} + +pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> { + let current_version = get_version(app).await?; + + if current_version == CURRENT_VERSION { + return Ok(()); + } + + info!("Migrate database from version '{current_version}' to version '{CURRENT_VERSION}'"); + + current_version.update(app).await?; + + Ok(()) +} + +pub(crate) mod migrate_db { + use crate::storage::migrate::{get_db_version, update}; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to determine the database version: {0}")] + GetVersion(#[from] get_db_version::Error), + + #[error("Failed to update the database: {0}")] + Upadate(#[from] update::Error), + } +} diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql new file mode 100644 index 0000000..13bc1cb --- /dev/null +++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql @@ -0,0 +1,62 @@ +-- All tables should be declared STRICT, as I actually like to have types checking (and a +-- db that doesn't lie to me). + +CREATE TABLE version ( + -- The `namespace` is only useful, if other tools ever build on this database + namespace TEXT NOT NULL, + + -- The version. + number INTEGER UNIQUE NOT NULL PRIMARY KEY, + + -- The validity of this version as UNIX time stamp + valid_from INTEGER NOT NULL CHECK (valid_from < valid_to), + -- If set to `NULL`, represents the current 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. +-- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> +CREATE TABLE parents ( + id TEXT UNIQUE NOT NULL PRIMARY KEY, + parent TEXT DEFAULT NULL CHECK (id IS NOT parent), + FOREIGN KEY(parent) REFERENCES parents(id) +) STRICT; + +CREATE TABLE products ( + id TEXT UNIQUE NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + parent TEXT DEFAULT NULL, + FOREIGN KEY(parent) REFERENCES parents(id) +) STRICT; + +CREATE TABLE barcodes ( + id INTEGER UNIQUE NOT NULL PRIMARY KEY, + product_id TEXT NOT NULL, + amount INTEGER NOT NULL, + unit TEXT NOT NULL, + FOREIGN KEY(product_id) REFERENCES products(id), + FOREIGN KEY(unit) REFERENCES units(name) +) STRICT; + +CREATE TABLE units ( + name TEXT UNIQUE NOT NULL PRIMARY KEY +) STRICT; + +-- Encodes unit conversions: +-- {factor} {from_unit} = 1 {to_unit} +-- E.g.: 1000 g = 1 kg +CREATE TABLE unit_conversions ( + from_unit TEXT NOT NULL, + to_unit TEXT NOT NULL, + factor REAL NOT NULL, + FOREIGN KEY(from_unit) REFERENCES units(name), + FOREIGN KEY(to_unit) REFERENCES units(name) +) STRICT; + +-- Log of all the applied operations to this db. +CREATE TABLE txn_log ( + timestamp INTEGER NOT NULL, + operation TEXT NOT NULL +) STRICT; diff --git a/crates/rocie-server/src/storage/mod.rs b/crates/rocie-server/src/storage/mod.rs new file mode 100644 index 0000000..509d15d --- /dev/null +++ b/crates/rocie-server/src/storage/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod migrate; +pub(crate) mod txn_log; +pub(crate) mod sql; diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs new file mode 100644 index 0000000..2268e85 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/mod.rs @@ -0,0 +1 @@ +pub(crate) mod product; diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs new file mode 100644 index 0000000..bcc3e32 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs @@ -0,0 +1,81 @@ +use crate::{ + app::App, + storage::sql::product::{Product, ProductId}, +}; + +use sqlx::query; + +impl Product { + pub(crate) async fn from_id(app: &App, id: ProductId) -> Result, from_id::Error> { + let record = query!( + " + SELECT name, description, parent + FROM products + WHERE id = ? +", + id + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + id, + name: record.name, + description: record.description, + associated_bar_codes: vec![], // todo + })) + } else { + Ok(None) + } + } + + pub(crate) async fn get_all(app: &App) -> Result, get_all::Error> { + let records = query!( + " + SELECT id, name, description, parent + FROM products +" + ) + .fetch_all(&app.db) + .await?; + + Ok(records + .into_iter() + .map(|record| { + Self { + id: ProductId::from_db(&record.id), + name: record.name, + description: record.description, + associated_bar_codes: vec![], // todo + } + }) + .collect()) + } +} + +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/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs new file mode 100644 index 0000000..99f1e71 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/mod.rs @@ -0,0 +1,144 @@ +use std::{fmt::Display, mem}; + +use crate::app::App; + +use chrono::Utc; +use log::{debug, trace}; +use serde::{Serialize, de::DeserializeOwned}; +use sqlx::{SqliteConnection, query}; + +pub(crate) mod product; + +pub(crate) trait Transactionable: + Sized + std::fmt::Debug + Serialize + DeserializeOwned +{ + type ApplyError: std::error::Error + Display; + type UndoError: std::error::Error + Display; + + /// Apply this transaction. + /// + /// This should change the db state. + async fn apply(self, txn: &mut SqliteConnection) -> Result<(), Self::ApplyError>; + + /// Undo this transaction. + /// + /// This should return the db to the state it was in before this transaction. + async fn undo(self, txn: &mut SqliteConnection) -> Result<(), Self::UndoError>; +} + +#[derive(Debug)] +pub(crate) struct Operations { + name: &'static str, + ops: Vec, +} + +impl Default for Operations { + fn default() -> Self { + Self::new("") + } +} + +impl Operations { + #[must_use] + pub(crate) fn new(name: &'static str) -> Self { + Self { + name, + ops: Vec::new(), + } + } + + pub(crate) async fn apply(mut self, app: &App) -> Result<(), apply::Error> { + let ops = mem::take(&mut self.ops); + + if ops.is_empty() { + return Ok(()); + } + + trace!("Begin commit of {}", self.name); + let mut txn = app.db.begin().await?; + + for op in ops { + trace!("Commiting operation: {op:?}"); + add_operation_to_txn_log(&op, &mut txn).await?; + op.apply(&mut txn) + .await + .map_err(|err| apply::Error::InnerApply(err))?; + } + + txn.commit().await?; + trace!("End commit of {}", self.name); + + Ok(()) + } + + pub(crate) fn push(&mut self, op: O) { + self.ops.push(op); + } +} + +pub(crate) mod apply { + use actix_web::ResponseError; + + use crate::storage::sql::insert::{Transactionable, add_operations_to_txn_log}; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute sql statments")] + Sql(#[from] sqlx::Error), + + #[error("Failed to append operations to the txn log: {0}")] + TxnLogAppend(#[from] add_operations_to_txn_log::Error), + + #[error("Failed to apply one of the operations: {0}")] + InnerApply(::ApplyError), + } + + impl ResponseError for Error { + // TODO(@bpeetz): Actually do something with this. <2025-09-05> + } +} + +impl Drop for Operations { + fn drop(&mut self) { + assert!( + self.ops.is_empty(), + "Trying to drop uncommitted operations (name: {}) ({:#?}). This is a bug.", + self.name, + self.ops + ); + } +} + +async fn add_operation_to_txn_log( + operation: &O, + txn: &mut SqliteConnection, +) -> Result<(), add_operations_to_txn_log::Error> { + debug!("Adding operation to txn log: {operation:?}"); + + let now = Utc::now().timestamp(); + let operation = serde_json::to_string(&operation).expect("should be serializable"); + + query!( + r#" + INSERT INTO txn_log ( + timestamp, + operation + ) + VALUES (?, ?); + "#, + now, + operation, + ) + .execute(txn) + .await?; + + Ok(()) +} + +pub(crate) mod add_operations_to_txn_log { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute sql statments")] + SqlError(#[from] sqlx::Error), + } +} diff --git a/crates/rocie-server/src/storage/sql/insert/product/mod.rs b/crates/rocie-server/src/storage/sql/insert/product/mod.rs new file mode 100644 index 0000000..562e809 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/product/mod.rs @@ -0,0 +1,160 @@ +use serde::{Deserialize, Serialize}; +use sqlx::query; +use uuid::Uuid; + +use crate::storage::sql::{ + insert::{Operations, Transactionable}, + product::{Barcode, Product, ProductId}, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum Operation { + RegisterProduct { + id: ProductId, + name: String, + description: Option, + parent: Option, + }, + AssociateBarcode { + id: ProductId, + barcode: Barcode, + }, +} + +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::RegisterProduct { + id, + name, + description, + parent, + } => { + query!( + " + INSERT INTO products (id, name, description, parent) + VALUES (?,?,?,?) +", + id, + name, + description, + parent + ) + .execute(txn) + .await?; + } + Operation::AssociateBarcode { id, barcode } => { + let barcode_id = i64::from(barcode.id); + let barcode_amount_value = i64::from(barcode.amount.value); + let barcode_amount_unit = barcode.amount.unit; + + query!( + " + INSERT INTO barcodes (id, product_id, amount, unit) + VALUES (?,?,?,?) +", + barcode_id, + id, + barcode_amount_value, + barcode_amount_unit, + ) + .execute(txn) + .await?; + } + } + Ok(()) + } + + async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { + match self { + Operation::RegisterProduct { + id, + name, + description, + parent, + } => { + query!( + " + DELETE FROM products + WHERE id = ? AND name = ? AND description = ? AND parent = ?; +", + id, + name, + description, + parent + ) + .execute(txn) + .await?; + } + Operation::AssociateBarcode { id, barcode } => { + let barcode_id = i64::from(barcode.id); + let barcode_amount_value = i64::from(barcode.amount.value); + let barcode_amount_unit = barcode.amount.unit; + + query!( + " + DELETE FROM barcodes + WHERE id = ? AND product_id = ? AND amount = ? AND unit = ?; +", + barcode_id, + id, + barcode_amount_value, + barcode_amount_unit + ) + .execute(txn) + .await?; + } + } + Ok(()) + } +} + +pub(crate) mod undo { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute sql statments")] + SqlError(#[from] sqlx::Error), + } +} +pub(crate) mod apply { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute sql statments")] + SqlError(#[from] sqlx::Error), + } +} + +impl Product { + pub(crate) fn register( + name: String, + description: Option, + parent: Option, + ops: &mut Operations, + ) -> Self { + let id = ProductId::from(Uuid::new_v4()); + + ops.push(Operation::RegisterProduct { + id, + name: name.clone(), + description: description.clone(), + parent, + }); + + Self { + id, + name, + description, + associated_bar_codes: vec![], + } + } + + pub(crate) fn associate_barcode(&self, barcode: Barcode, ops: &mut Operations) { + ops.push(Operation::AssociateBarcode { + id: self.id, + barcode, + }) + } +} diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs new file mode 100644 index 0000000..871ae3b --- /dev/null +++ b/crates/rocie-server/src/storage/sql/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod get; +pub(crate) mod insert; + +// Types +pub(crate) mod product; diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs new file mode 100644 index 0000000..e0216dd --- /dev/null +++ b/crates/rocie-server/src/storage/sql/product.rs @@ -0,0 +1,70 @@ +use std::{fmt::Display, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use sqlx::{Database, Encode, Type}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Clone, ToSchema, Serialize, Deserialize)] +pub(crate) struct Product { + pub(crate) id: ProductId, + pub(super) name: String, + pub(super) description: Option, + pub(super) associated_bar_codes: Vec, +} + +#[derive( + Deserialize, Serialize, Debug, Default, ToSchema, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, +)] +pub(crate) struct ProductId(Uuid); + +impl ProductId { + pub(crate) fn from_db(id: &str) -> ProductId { + Self(Uuid::from_str(id).expect("We put an uuid into the db, it should also go out again")) + } +} + +impl Display for ProductId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ProductId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl<'q, DB: Database> Encode<'q, DB> for ProductId +where + String: Encode<'q, DB>, +{ + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let inner = self.0.to_string(); + Encode::::encode_by_ref(&inner, buf) + } +} +impl Type for ProductId +where + String: Type, +{ + fn type_info() -> DB::TypeInfo { + >::type_info() + } +} + +#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Barcode { + pub(crate) id: u32, + pub(crate) amount: UnitAmount, +} + +#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct UnitAmount { + pub(crate) value: u32, + pub(crate) unit: String, +} diff --git a/crates/rocie-server/src/storage/txn_log.rs b/crates/rocie-server/src/storage/txn_log.rs new file mode 100644 index 0000000..d07b514 --- /dev/null +++ b/crates/rocie-server/src/storage/txn_log.rs @@ -0,0 +1,63 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see . + +use std::fmt::Display; + +use chrono::{DateTime, Utc}; + +use crate::storage::sql::insert::Transactionable; + +pub(crate) struct TxnLog { + inner: Vec<(TimeStamp, O)>, +} + +impl TxnLog { + pub(crate) fn new(inner: Vec<(TimeStamp, O)>) -> Self { + Self { inner } + } + pub(crate) fn inner(&self) -> &[(TimeStamp, O)] { + &self.inner + } +} + +/// An UNIX time stamp. +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct TimeStamp { + value: i64, +} +impl TimeStamp { + /// Return the seconds since the UNIX epoch for this [`TimeStamp`]. + #[must_use] + pub(crate) fn as_secs(self) -> i64 { + self.value + } + + /// Construct a [`TimeStamp`] from a count of seconds since the UNIX epoch. + #[must_use] + pub(crate) fn from_secs(value: i64) -> Self { + Self { value } + } + + /// Construct a [`TimeStamp`] from the current time. + #[must_use] + pub(crate) fn from_now() -> Self { + Self { + value: Utc::now().timestamp(), + } + } +} +impl Display for TimeStamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + DateTime::from_timestamp(self.value, 0) + .expect("The timestamps should always be valid") + .format("%Y-%m-%d") + .fmt(f) + } +} diff --git a/crates/rocie/Cargo.toml b/crates/rocie/Cargo.toml deleted file mode 100644 index a0ae4aa..0000000 --- a/crates/rocie/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -# rocie - An enterprise grocery management system -# -# Copyright (C) 2024 Benedikt Peetz -# Copyright (C) 2025 Benedikt Peetz -# SPDX-License-Identifier: GPL-3.0-or-later -# -# This file is part of Rocie. -# -# You should have received a copy of the License along with this program. -# If not, see . - -[package] -name = "rocie" -keywords = [] -categories = [] -default-run = "rocie" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -rust-version.workspace = true -description.workspace = true -publish = false - -[dependencies] - -[[bin]] -name = "rocie" -doc = false -path = "src/main.rs" - -[lints] -workspace = true - -[dev-dependencies] - -[package.metadata.docs.rs] -all-features = true -- cgit 1.4.1