├── crates ├── libs │ ├── lib-web │ │ ├── src │ │ │ ├── utils │ │ │ │ ├── mod.rs │ │ │ │ └── token.rs │ │ │ ├── routes │ │ │ │ ├── mod.rs │ │ │ │ └── routes_static.rs │ │ │ ├── handlers │ │ │ │ ├── mod.rs │ │ │ │ ├── handlers_login.rs │ │ │ │ └── handlers_rpc.rs │ │ │ ├── middleware │ │ │ │ ├── mod.rs │ │ │ │ ├── mw_req_stamp.rs │ │ │ │ ├── mw_res_map.rs │ │ │ │ └── mw_auth.rs │ │ │ ├── lib.rs │ │ │ ├── log │ │ │ │ └── mod.rs │ │ │ └── error.rs │ │ └── Cargo.toml │ ├── lib-auth │ │ ├── src │ │ │ ├── lib.rs │ │ │ ├── pwd │ │ │ │ ├── scheme │ │ │ │ │ ├── error.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── scheme_01.rs │ │ │ │ │ └── scheme_02.rs │ │ │ │ ├── error.rs │ │ │ │ └── mod.rs │ │ │ ├── token │ │ │ │ ├── error.rs │ │ │ │ └── mod.rs │ │ │ └── config.rs │ │ └── Cargo.toml │ ├── lib-rpc-core │ │ ├── src │ │ │ ├── utils │ │ │ │ ├── mod.rs │ │ │ │ └── macro_utils.rs │ │ │ ├── lib.rs │ │ │ ├── prelude.rs │ │ │ ├── rpc_result.rs │ │ │ ├── rpc_params.rs │ │ │ └── error.rs │ │ └── Cargo.toml │ ├── lib-core │ │ ├── src │ │ │ ├── model │ │ │ │ ├── acs │ │ │ │ │ └── mod.rs │ │ │ │ ├── modql_utils.rs │ │ │ │ ├── store │ │ │ │ │ ├── dbx │ │ │ │ │ │ ├── error.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── conv_user.rs │ │ │ │ ├── base │ │ │ │ │ ├── utils.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── macro_utils.rs │ │ │ │ │ └── crud_fns.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── error.rs │ │ │ │ ├── conv_msg.rs │ │ │ │ ├── user.rs │ │ │ │ ├── conv.rs │ │ │ │ └── agent.rs │ │ │ ├── lib.rs │ │ │ ├── ctx │ │ │ │ ├── error.rs │ │ │ │ └── mod.rs │ │ │ ├── config.rs │ │ │ └── _dev_utils │ │ │ │ ├── dev_db.rs │ │ │ │ └── mod.rs │ │ └── Cargo.toml │ └── lib-utils │ │ ├── Cargo.toml │ │ └── src │ │ ├── lib.rs │ │ ├── b64.rs │ │ ├── envs.rs │ │ └── time.rs ├── services │ └── web-server │ │ ├── src │ │ ├── web │ │ │ ├── mod.rs │ │ │ ├── rpcs │ │ │ │ ├── mod.rs │ │ │ │ ├── agent_rpc.rs │ │ │ │ └── conv_rpc.rs │ │ │ ├── routes_login.rs │ │ │ └── routes_rpc.rs │ │ ├── error.rs │ │ ├── config.rs │ │ └── main.rs │ │ ├── Cargo.toml │ │ └── examples │ │ └── quick_dev.rs └── tools │ └── gen-key │ ├── Cargo.toml │ └── src │ └── main.rs ├── rustfmt.toml ├── web-folder └── index.html ├── sql └── dev_initial │ ├── 00-recreate-db.sql │ ├── 02-dev-seed.sql │ └── 01-create-schema.sql ├── LICENSE-MIT ├── .cargo └── config.toml ├── .gitignore ├── Cargo.toml ├── README.md └── LICENSE-APACHE /crates/libs/lib-web/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod token; -------------------------------------------------------------------------------- /crates/libs/lib-web/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod routes_static; -------------------------------------------------------------------------------- /crates/libs/lib-web/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handlers_login; 2 | pub mod handlers_rpc; -------------------------------------------------------------------------------- /crates/libs/lib-web/src/middleware/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod mw_auth; 2 | pub mod mw_req_stamp; 3 | pub mod mw_res_map; -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | pub mod pwd; 3 | pub mod token; 4 | 5 | use config::auth_config; 6 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod macro_utils; 4 | 5 | // endregion: --- Modules 6 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | pub mod routes_login; 3 | pub mod routes_rpc; 4 | pub mod rpcs; 5 | 6 | // endregion: --- Modules 7 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/acs/mod.rs: -------------------------------------------------------------------------------- 1 | //! TO BE IMPLEMENTED 2 | //! 3 | //! `acs` Access Control System based on PBAC (Privilege Based Access Control) 4 | //! 5 | //! (more to come) 6 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | 3 | pub use error::Error; 4 | 5 | pub mod handlers; 6 | pub mod log; 7 | pub mod middleware; 8 | pub mod routes; 9 | pub mod utils; 10 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # rustfmt doc - https://rust-lang.github.io/rustfmt/ 2 | 3 | hard_tabs = true 4 | edition = "2021" 5 | 6 | # For recording 7 | max_width = 85 8 | # chain_width = 40 9 | # array_width = 40 -------------------------------------------------------------------------------- /crates/libs/lib-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod ctx; 3 | pub mod model; 4 | 5 | // #[cfg(test)] // Commented during early development. 6 | pub mod _dev_utils; 7 | 8 | use config::core_config; 9 | -------------------------------------------------------------------------------- /web-folder/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AWESOME-APP Web 7 | 8 | 9 | 10 | Hello World! 11 | 12 | 13 | -------------------------------------------------------------------------------- /crates/tools/gen-key/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gen-key" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # -- App Crates 8 | lib-utils = { path = "../../libs/lib-utils"} 9 | # -- Others 10 | rand = "0.8" 11 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/modql_utils.rs: -------------------------------------------------------------------------------- 1 | use time::serde::rfc3339; 2 | 3 | pub fn time_to_sea_value( 4 | json_value: serde_json::Value, 5 | ) -> modql::filter::SeaResult { 6 | Ok(rfc3339::deserialize(json_value)?.into()) 7 | } 8 | -------------------------------------------------------------------------------- /crates/libs/lib-utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib-utils" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | doctest = false 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | base64 = "0.22" 14 | time = { workspace = true } 15 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | mod rpc_params; 5 | mod rpc_result; 6 | mod utils; 7 | 8 | pub use self::error::{Error, Result}; 9 | pub use rpc_params::*; 10 | 11 | pub mod prelude; 12 | 13 | // endregion: --- Modules 14 | -------------------------------------------------------------------------------- /crates/libs/lib-utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The utils module is designed to export independent sub-modules to the application code. 2 | //! 3 | //! Note: Even if the util sub-modules consist of a single file, they contain their own errors 4 | //! for improved compartmentalization. 5 | //! 6 | 7 | pub mod b64; 8 | pub mod envs; 9 | pub mod time; 10 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/rpcs/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | pub mod agent_rpc; 4 | pub mod conv_rpc; 5 | 6 | use rpc_router::{Router, RouterBuilder}; 7 | 8 | // endregion: --- Modules 9 | 10 | pub fn all_rpc_router_builder() -> RouterBuilder { 11 | Router::builder() 12 | .extend(agent_rpc::rpc_router_builder()) 13 | .extend(conv_rpc::rpc_router_builder()) 14 | } 15 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/routes_login.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::post; 2 | use axum::Router; 3 | use lib_core::model::ModelManager; 4 | use lib_web::handlers::handlers_login; 5 | 6 | pub fn routes(mm: ModelManager) -> Router { 7 | Router::new() 8 | .route("/api/login", post(handlers_login::api_login_handler)) 9 | .route("/api/logoff", post(handlers_login::api_logoff_handler)) 10 | .with_state(mm) 11 | } 12 | -------------------------------------------------------------------------------- /sql/dev_initial/00-recreate-db.sql: -------------------------------------------------------------------------------- 1 | -- DEV ONLY - Brute Force DROP DB (for local dev and unit test) 2 | SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE 3 | usename = 'app_user' OR datname = 'app_db'; 4 | DROP DATABASE IF EXISTS app_db; 5 | DROP USER IF EXISTS app_user; 6 | 7 | -- DEV ONLY - Dev only password (for local dev and unit test). 8 | CREATE USER app_user PASSWORD 'dev_only_pwd'; 9 | CREATE DATABASE app_db owner app_user ENCODING = 'UTF-8'; -------------------------------------------------------------------------------- /crates/tools/gen-key/src/main.rs: -------------------------------------------------------------------------------- 1 | pub type Result = core::result::Result; 2 | pub type Error = Box; // Ok for tools. 3 | 4 | use lib_utils::b64::b64u_encode; 5 | use rand::RngCore; 6 | 7 | fn main() -> Result<()> { 8 | let mut key = [0u8; 64]; // 512 bits = 64 bytes 9 | rand::thread_rng().fill_bytes(&mut key); 10 | println!("\nGenerated key from rand::thread_rng():\n{key:?}"); 11 | 12 | let b64u = b64u_encode(key); 13 | println!("\nKey b64u encoded:\n{b64u}"); 14 | 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! This is a prelude for all .._rpc modules to avoid redundant imports. 2 | //! NOTE: This is only for the `rpcs` module and sub-modules. 3 | 4 | pub use crate::generate_common_rpc_fns; 5 | pub use crate::rpc_result::DataRpcResult; 6 | pub use crate::Result; 7 | pub use crate::{ParamsForCreate, ParamsForUpdate, ParamsIded, ParamsList}; 8 | pub use lib_core::ctx::Ctx; 9 | pub use lib_core::model::ModelManager; 10 | pub use paste::paste; 11 | pub use rpc_router::{router_builder, RouterBuilder}; 12 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/ctx/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | CtxCannotNewRootCtx, 8 | } 9 | 10 | // region: --- Error Boilerplate 11 | impl core::fmt::Display for Error { 12 | fn fmt( 13 | &self, 14 | fmt: &mut core::fmt::Formatter, 15 | ) -> core::result::Result<(), core::fmt::Error> { 16 | write!(fmt, "{self:?}") 17 | } 18 | } 19 | 20 | impl std::error::Error for Error {} 21 | // endregion: --- Error Boilerplate 22 | -------------------------------------------------------------------------------- /sql/dev_initial/02-dev-seed.sql: -------------------------------------------------------------------------------- 1 | -- root user (at id = 0) 2 | INSERT INTO "user" 3 | (id, typ, username, cid, ctime, mid, mtime) VALUES 4 | (0, 'Sys', 'root', 0, now(), 0, now()); 5 | 6 | -- User demo1 7 | INSERT INTO "user" 8 | (username, cid, ctime, mid, mtime) VALUES 9 | ('demo1', 0, now(), 0, now()); 10 | 11 | -- Agent mock-01 (with 'parrot' model) (id: 100) 12 | INSERT INTO "agent" 13 | (id, owner_id, name, cid, ctime, mid, mtime) VALUES 14 | (100, 0, 'mock-01', 0, now(), 0, now()); 15 | 16 | -------------------------------------------------------------------------------- /crates/services/web-server/src/error.rs: -------------------------------------------------------------------------------- 1 | use derive_more::From; 2 | use lib_core::model; 3 | 4 | pub type Result = core::result::Result; 5 | 6 | #[derive(Debug, From)] 7 | pub enum Error { 8 | // -- Modules 9 | #[from] 10 | Model(model::Error), 11 | } 12 | 13 | // region: --- Error Boilerplate 14 | impl core::fmt::Display for Error { 15 | fn fmt( 16 | &self, 17 | fmt: &mut core::fmt::Formatter, 18 | ) -> core::result::Result<(), core::fmt::Error> { 19 | write!(fmt, "{self:?}") 20 | } 21 | } 22 | 23 | impl std::error::Error for Error {} 24 | // endregion: --- Error Boilerplate 25 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/scheme/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | Key, 8 | Salt, 9 | Hash, 10 | PwdValidate, 11 | SchemeNotFound(String), 12 | } 13 | 14 | // region: --- Error Boilerplate 15 | impl core::fmt::Display for Error { 16 | fn fmt( 17 | &self, 18 | fmt: &mut core::fmt::Formatter, 19 | ) -> core::result::Result<(), core::fmt::Error> { 20 | write!(fmt, "{self:?}") 21 | } 22 | } 23 | 24 | impl std::error::Error for Error {} 25 | // endregion: --- Error Boilerplate 26 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/rpcs/agent_rpc.rs: -------------------------------------------------------------------------------- 1 | use lib_rpc_core::prelude::*; 2 | use lib_core::model::agent::{ 3 | Agent, AgentBmc, AgentFilter, AgentForCreate, AgentForUpdate, 4 | }; 5 | 6 | pub fn rpc_router_builder() -> RouterBuilder { 7 | router_builder!( 8 | // Same as RpcRouter::new().add... 9 | create_agent, 10 | get_agent, 11 | list_agents, 12 | update_agent, 13 | delete_agent, 14 | ) 15 | } 16 | 17 | generate_common_rpc_fns!( 18 | Bmc: AgentBmc, 19 | Entity: Agent, 20 | ForCreate: AgentForCreate, 21 | ForUpdate: AgentForUpdate, 22 | Filter: AgentFilter, 23 | Suffix: agent 24 | ); 25 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/routes/routes_static.rs: -------------------------------------------------------------------------------- 1 | use axum::handler::HandlerWithoutStateExt; 2 | use axum::http::StatusCode; 3 | use axum::routing::{any_service, MethodRouter}; 4 | use tower_http::services::ServeDir; 5 | 6 | // Note: Here we can just return a MethodRouter rather than a full Router 7 | // since ServeDir is a service. 8 | pub fn serve_dir(web_folder: &'static String) -> MethodRouter { 9 | async fn handle_404() -> (StatusCode, &'static str) { 10 | (StatusCode::NOT_FOUND, "Resource not found.") 11 | } 12 | 13 | any_service( 14 | ServeDir::new(web_folder) 15 | .not_found_service(handle_404.into_service()), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /crates/services/web-server/src/config.rs: -------------------------------------------------------------------------------- 1 | use lib_utils::envs::get_env; 2 | use std::sync::OnceLock; 3 | 4 | pub fn web_config() -> &'static WebConfig { 5 | static INSTANCE: OnceLock = OnceLock::new(); 6 | 7 | INSTANCE.get_or_init(|| { 8 | WebConfig::load_from_env().unwrap_or_else(|ex| { 9 | panic!("FATAL - WHILE LOADING CONF - Cause: {ex:?}") 10 | }) 11 | }) 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | pub struct WebConfig { 16 | pub WEB_FOLDER: String, 17 | } 18 | 19 | impl WebConfig { 20 | fn load_from_env() -> lib_utils::envs::Result { 21 | Ok(WebConfig { 22 | WEB_FOLDER: get_env("SERVICE_WEB_FOLDER")?, 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/token/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | HmacFailNewFromSlice, 8 | 9 | InvalidFormat, 10 | CannotDecodeIdent, 11 | CannotDecodeExp, 12 | SignatureNotMatching, 13 | ExpNotIso, 14 | Expired, 15 | } 16 | 17 | // region: --- Error Boilerplate 18 | impl core::fmt::Display for Error { 19 | fn fmt( 20 | &self, 21 | fmt: &mut core::fmt::Formatter, 22 | ) -> core::result::Result<(), core::fmt::Error> { 23 | write!(fmt, "{self:?}") 24 | } 25 | } 26 | 27 | impl std::error::Error for Error {} 28 | // endregion: --- Error Boilerplate 29 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib-rpc-core" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | doctest = false 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | # -- App Libs 14 | lib-core = { path = "../../libs/lib-core", features = ["with-rpc"] } 15 | # -- Async 16 | tokio = { version = "1", features = ["full"] } 17 | futures = "0.3" 18 | # -- Json 19 | serde = { version = "1", features = ["derive"] } 20 | serde_json = "1" 21 | serde_with = { workspace = true } 22 | # -- Data 23 | modql = { workspace = true } 24 | # -- Rpc 25 | rpc-router = { workspace = true } 26 | # -- Others 27 | paste = "1" 28 | derive_more = { workspace = true } 29 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib-auth" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | doctest = false 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | # -- App Libs 14 | lib-utils = { path = "../../libs/lib-utils"} 15 | # -- Async 16 | tokio = { version = "1", features = ["full"] } 17 | # -- Json 18 | serde = { version = "1", features = ["derive"] } 19 | # -- Hashing (pwd-scheme01 & Token) 20 | hmac = "0.12" 21 | sha2 = "0.10" 22 | blake3 = "1.5.5" 23 | # -- Hashing (pwd-scheme02) 24 | argon2 = {version="0.5", features=["std"]} 25 | # -- Others 26 | uuid = {version = "1", features = ["v4","fast-rng",]} 27 | lazy-regex = "3" 28 | derive_more = { workspace = true } 29 | enum_dispatch = "0.3" 30 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/error.rs: -------------------------------------------------------------------------------- 1 | use crate::pwd::scheme; 2 | use derive_more::From; 3 | use serde::Serialize; 4 | 5 | pub type Result = core::result::Result; 6 | 7 | #[derive(Debug, Serialize, From)] 8 | pub enum Error { 9 | PwdWithSchemeFailedParse, 10 | 11 | FailSpawnBlockForValidate, 12 | FailSpawnBlockForHash, 13 | 14 | // -- Modules 15 | #[from] 16 | Scheme(scheme::Error), 17 | } 18 | 19 | // region: --- Error Boilerplate 20 | impl core::fmt::Display for Error { 21 | fn fmt( 22 | &self, 23 | fmt: &mut core::fmt::Formatter, 24 | ) -> core::result::Result<(), core::fmt::Error> { 25 | write!(fmt, "{self:?}") 26 | } 27 | } 28 | 29 | impl std::error::Error for Error {} 30 | // endregion: --- Error Boilerplate 31 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/routes_rpc.rs: -------------------------------------------------------------------------------- 1 | use crate::web::rpcs::all_rpc_router_builder; 2 | use axum::routing::post; 3 | use axum::Router; 4 | use lib_core::model::ModelManager; 5 | use lib_web::handlers::handlers_rpc; 6 | 7 | /// Build the Axum router for '/api/rpc' 8 | /// Note: This will build the `rpc-router::Router` that will be used by the 9 | /// rpc_axum_handler 10 | pub fn routes(mm: ModelManager) -> Router { 11 | // Build the combined Rpc Router (from `rpc-router` crate) 12 | let rpc_router = all_rpc_router_builder() 13 | // Add the common resources for all rpc calls 14 | .append_resource(mm) 15 | .build(); 16 | 17 | // Build the Axum Router for '/rpc' 18 | Router::new() 19 | .route("/rpc", post(handlers_rpc::rpc_axum_handler)) 20 | .with_state(rpc_router) 21 | } 22 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/config.rs: -------------------------------------------------------------------------------- 1 | use lib_utils::envs::get_env; 2 | use std::sync::OnceLock; 3 | 4 | pub fn core_config() -> &'static CoreConfig { 5 | static INSTANCE: OnceLock = OnceLock::new(); 6 | 7 | INSTANCE.get_or_init(|| { 8 | CoreConfig::load_from_env().unwrap_or_else(|ex| { 9 | panic!("FATAL - WHILE LOADING CONF - Cause: {ex:?}") 10 | }) 11 | }) 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | pub struct CoreConfig { 16 | // -- Db 17 | pub DB_URL: String, 18 | 19 | // -- Web 20 | pub WEB_FOLDER: String, 21 | } 22 | 23 | impl CoreConfig { 24 | fn load_from_env() -> lib_utils::envs::Result { 25 | Ok(CoreConfig { 26 | // -- Db 27 | DB_URL: get_env("SERVICE_DB_URL")?, 28 | 29 | // -- Web 30 | WEB_FOLDER: get_env("SERVICE_WEB_FOLDER")?, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/store/dbx/error.rs: -------------------------------------------------------------------------------- 1 | use derive_more::From; 2 | use serde::Serialize; 3 | use serde_with::{serde_as, DisplayFromStr}; 4 | 5 | pub type Result = core::result::Result; 6 | 7 | #[serde_as] 8 | #[derive(Debug, Serialize, From)] 9 | pub enum Error { 10 | TxnCantCommitNoOpenTxn, 11 | CannotBeginTxnWithTxnFalse, 12 | CannotCommitTxnWithTxnFalse, 13 | NoTxn, 14 | 15 | // -- Externals 16 | #[from] 17 | Sqlx(#[serde_as(as = "DisplayFromStr")] sqlx::Error), 18 | } 19 | 20 | // region: --- Error Boilerplate 21 | 22 | impl core::fmt::Display for Error { 23 | fn fmt( 24 | &self, 25 | fmt: &mut core::fmt::Formatter, 26 | ) -> core::result::Result<(), core::fmt::Error> { 27 | write!(fmt, "{self:?}") 28 | } 29 | } 30 | 31 | impl std::error::Error for Error {} 32 | 33 | // endregion: --- Error Boilerplate 34 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/utils/token.rs: -------------------------------------------------------------------------------- 1 | 2 | pub use crate::error::ClientError; 3 | pub use crate::error::{Error, Result}; 4 | use lib_auth::token::generate_web_token; 5 | use tower_cookies::{Cookie, Cookies}; 6 | use uuid::Uuid; 7 | 8 | // endregion: --- Modules 9 | 10 | pub(crate) const AUTH_TOKEN: &str = "auth-token"; 11 | 12 | pub(crate) fn set_token_cookie(cookies: &Cookies, user: &str, salt: Uuid) -> Result<()> { 13 | let token = generate_web_token(user, salt)?; 14 | 15 | let mut cookie = Cookie::new(AUTH_TOKEN, token.to_string()); 16 | cookie.set_http_only(true); 17 | cookie.set_path("/"); 18 | 19 | cookies.add(cookie); 20 | 21 | Ok(()) 22 | } 23 | 24 | pub(crate) fn remove_token_cookie(cookies: &Cookies) -> Result<()> { 25 | let mut cookie = Cookie::from(AUTH_TOKEN); 26 | cookie.set_path("/"); 27 | 28 | cookies.remove(cookie); 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/rpc_result.rs: -------------------------------------------------------------------------------- 1 | //! The `lib_rpc::response` module normalizes the JSON-RPC `.result` format for various 2 | //! JSON-RPC APIs. 3 | //! 4 | //! The primary type is the simple DataRpcResult, which contains only a `data` property. 5 | //! 6 | //! Notes: 7 | //! - In the future, we may introduce types like `DataRpcResult` that include metadata 8 | //! about the returned list data (e.g., pagination information). 9 | //! - Although the struct is named with `Result`, it is not a typical Rust result. Instead, 10 | //! it represents the `.result` property of a JSON-RPC response. 11 | //! 12 | 13 | use serde::Serialize; 14 | 15 | #[derive(Serialize)] 16 | pub struct DataRpcResult 17 | where 18 | T: Serialize, 19 | { 20 | data: T, 21 | } 22 | 23 | impl From for DataRpcResult 24 | where 25 | T: Serialize, 26 | { 27 | fn from(val: T) -> Self { 28 | Self { data: val } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/config.rs: -------------------------------------------------------------------------------- 1 | use lib_utils::envs::{get_env_b64u_as_u8s, get_env_parse}; 2 | use std::sync::OnceLock; 3 | 4 | pub fn auth_config() -> &'static AuthConfig { 5 | static INSTANCE: OnceLock = OnceLock::new(); 6 | 7 | INSTANCE.get_or_init(|| { 8 | AuthConfig::load_from_env().unwrap_or_else(|ex| { 9 | panic!("FATAL - WHILE LOADING CONF - Cause: {ex:?}") 10 | }) 11 | }) 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | pub struct AuthConfig { 16 | // -- Crypt 17 | pub PWD_KEY: Vec, 18 | 19 | pub TOKEN_KEY: Vec, 20 | pub TOKEN_DURATION_SEC: f64, 21 | } 22 | 23 | impl AuthConfig { 24 | fn load_from_env() -> lib_utils::envs::Result { 25 | Ok(AuthConfig { 26 | // -- Crypt 27 | PWD_KEY: get_env_b64u_as_u8s("SERVICE_PWD_KEY")?, 28 | 29 | TOKEN_KEY: get_env_b64u_as_u8s("SERVICE_TOKEN_KEY")?, 30 | TOKEN_DURATION_SEC: get_env_parse("SERVICE_TOKEN_DURATION_SEC")?, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/libs/lib-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib-core" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | doctest = false 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [features] 13 | with-rpc = ["rpc-router"] 14 | 15 | [dependencies] 16 | # -- App Libs 17 | lib-utils = { path = "../../libs/lib-utils"} 18 | lib-auth = { path = "../../libs/lib-auth"} 19 | # -- Async 20 | tokio = { version = "1", features = ["full"] } 21 | # -- Json 22 | serde = { version = "1", features = ["derive"] } 23 | serde_json = "1" 24 | serde_with = { workspace = true } 25 | # -- Data 26 | sqlx = { workspace = true } 27 | sea-query = { workspace = true } 28 | sea-query-binder = { workspace = true } 29 | modql = { workspace = true } 30 | # -- Tracing 31 | tracing = "0.1" 32 | # -- Others 33 | uuid = {version = "1", features = ["v4","fast-rng",]} 34 | time = { workspace = true } 35 | derive_more = { workspace = true } 36 | 37 | # -- Feature: with-rpc 38 | rpc-router = { workspace = true, optional = true } 39 | 40 | [dev-dependencies] 41 | serial_test = "3" -------------------------------------------------------------------------------- /crates/libs/lib-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib-web" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # -- App Libs 8 | lib-utils = { path = "../../libs/lib-utils"} 9 | lib-rpc-core = { path = "../../libs/lib-rpc-core"} 10 | lib-auth = { path = "../../libs/lib-auth"} 11 | lib-core = { path = "../../libs/lib-core"} 12 | 13 | # -- Async 14 | tokio = { version = "1", features = ["full"] } 15 | # -- Json 16 | serde = { version = "1", features = ["derive"] } 17 | serde_json = "1" 18 | serde_with = { workspace = true } 19 | # -- Web 20 | axum = { workspace = true } 21 | tower-http = { workspace = true } 22 | tower-cookies = { workspace = true } 23 | # -- Tracing 24 | tracing = "0.1" 25 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 26 | # -- Rpc 27 | rpc-router = { workspace = true } 28 | # -- Others 29 | time = { workspace = true } 30 | uuid = {version = "1", features = ["v4","fast-rng",]} 31 | strum_macros = "0.26" 32 | derive_more = { workspace = true } 33 | 34 | [lints] 35 | workspace = true 36 | -------------------------------------------------------------------------------- /crates/services/web-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web-server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # -- App Libs 8 | lib-utils = { path = "../../libs/lib-utils"} 9 | lib-rpc-core = { path = "../../libs/lib-rpc-core"} 10 | lib-auth = { path = "../../libs/lib-auth"} 11 | lib-core = { path = "../../libs/lib-core"} 12 | lib-web = { path = "../../libs/lib-web"} 13 | # -- Async 14 | tokio = { version = "1", features = ["full"] } 15 | # -- Json 16 | serde = { version = "1", features = ["derive"] } 17 | serde_json = "1" 18 | serde_with = { workspace = true } 19 | # -- Web 20 | axum = { workspace = true } 21 | tower-http = { workspace = true } 22 | tower-cookies = { workspace = true } 23 | # -- Tracing 24 | tracing = "0.1" 25 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 26 | # -- Rpc 27 | rpc-router = { workspace = true } 28 | # -- Others 29 | time = { workspace = true } 30 | uuid = {version = "1", features = ["v4","fast-rng",]} 31 | strum_macros = "0.26" 32 | derive_more = { workspace = true } 33 | 34 | [dev-dependencies] 35 | httpc-test = "0.1" 36 | -------------------------------------------------------------------------------- /crates/libs/lib-utils/src/b64.rs: -------------------------------------------------------------------------------- 1 | use base64::engine::{general_purpose, Engine}; 2 | 3 | pub fn b64u_encode(content: impl AsRef<[u8]>) -> String { 4 | general_purpose::URL_SAFE_NO_PAD.encode(content) 5 | } 6 | 7 | pub fn b64u_decode(b64u: &str) -> Result> { 8 | general_purpose::URL_SAFE_NO_PAD 9 | .decode(b64u) 10 | .map_err(|_| Error::FailToB64uDecode) 11 | } 12 | 13 | pub fn b64u_decode_to_string(b64u: &str) -> Result { 14 | b64u_decode(b64u) 15 | .ok() 16 | .and_then(|r| String::from_utf8(r).ok()) 17 | .ok_or(Error::FailToB64uDecode) 18 | } 19 | 20 | // region: --- Error 21 | 22 | pub type Result = core::result::Result; 23 | 24 | #[derive(Debug)] 25 | pub enum Error { 26 | FailToB64uDecode, 27 | } 28 | 29 | // region: --- Error Boilerplate 30 | impl core::fmt::Display for Error { 31 | fn fmt( 32 | &self, 33 | fmt: &mut core::fmt::Formatter, 34 | ) -> core::result::Result<(), core::fmt::Error> { 35 | write!(fmt, "{self:?}") 36 | } 37 | } 38 | 39 | impl std::error::Error for Error {} 40 | // endregion: --- Error Boilerplate 41 | 42 | // endregion: --- Error 43 | -------------------------------------------------------------------------------- /crates/libs/lib-utils/src/envs.rs: -------------------------------------------------------------------------------- 1 | use crate::b64::b64u_decode; 2 | use std::env; 3 | use std::str::FromStr; 4 | 5 | pub fn get_env(name: &'static str) -> Result { 6 | env::var(name).map_err(|_| Error::MissingEnv(name)) 7 | } 8 | 9 | pub fn get_env_parse(name: &'static str) -> Result { 10 | let val = get_env(name)?; 11 | val.parse::().map_err(|_| Error::WrongFormat(name)) 12 | } 13 | 14 | pub fn get_env_b64u_as_u8s(name: &'static str) -> Result> { 15 | b64u_decode(&get_env(name)?).map_err(|_| Error::WrongFormat(name)) 16 | } 17 | 18 | // region: --- Error 19 | pub type Result = core::result::Result; 20 | 21 | #[derive(Debug)] 22 | pub enum Error { 23 | MissingEnv(&'static str), 24 | WrongFormat(&'static str), 25 | } 26 | 27 | // region: --- Error Boilerplate 28 | impl core::fmt::Display for Error { 29 | fn fmt( 30 | &self, 31 | fmt: &mut core::fmt::Formatter, 32 | ) -> core::result::Result<(), core::fmt::Error> { 33 | write!(fmt, "{self:?}") 34 | } 35 | } 36 | 37 | impl std::error::Error for Error {} 38 | // endregion: --- Error Boilerplate 39 | 40 | // endregion: --- Error 41 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Jeremy Chone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/scheme/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | mod scheme_01; 5 | mod scheme_02; 6 | 7 | pub use self::error::{Error, Result}; 8 | 9 | use crate::pwd::ContentToHash; 10 | use enum_dispatch::enum_dispatch; 11 | 12 | // endregion: --- Modules 13 | 14 | pub const DEFAULT_SCHEME: &str = "02"; 15 | 16 | #[derive(Debug)] 17 | pub enum SchemeStatus { 18 | Ok, // The pwd uses the latest scheme. All good. 19 | Outdated, // The pwd uses an old scheme. 20 | } 21 | 22 | #[enum_dispatch] 23 | pub trait Scheme { 24 | fn hash(&self, to_hash: &ContentToHash) -> Result; 25 | 26 | fn validate(&self, to_hash: &ContentToHash, pwd_ref: &str) -> Result<()>; 27 | } 28 | 29 | #[enum_dispatch(Scheme)] 30 | pub enum SchemeDispatcher { 31 | Scheme01(scheme_01::Scheme01), 32 | Scheme02(scheme_02::Scheme02), 33 | } 34 | 35 | pub fn get_scheme(scheme_name: &str) -> Result { 36 | match scheme_name { 37 | "01" => Ok(SchemeDispatcher::Scheme01(scheme_01::Scheme01)), 38 | "02" => Ok(SchemeDispatcher::Scheme02(scheme_02::Scheme02)), 39 | _ => Err(Error::SchemeNotFound(scheme_name.to_string())), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Cargo config file. 2 | # See: https://doc.rust-lang.org/cargo/reference/config.html 3 | 4 | # Environments variables set for all `cargo ...` commands. 5 | [env] 6 | 7 | # Scope down tracing, to filter out external lib tracing. 8 | RUST_LOG="web_server=debug,lib_core=debug,lib_web=debug,lib_auth=debug,lib_utils=debug" 9 | 10 | # -- Service Environment Variables 11 | # IMPORTANT: 12 | # For cargo commands only. 13 | # For deployed env, should be managed by container 14 | # (e.g., Kubernetes). 15 | 16 | ## -- Secrets 17 | # Keys and passwords below are for localhost dev ONLY. 18 | # e.g., "welcome" type of passwords. 19 | # i.e., Encryption not needed. 20 | 21 | SERVICE_DB_URL="postgres://app_user:dev_only_pwd@localhost/app_db" 22 | 23 | SERVICE_PWD_KEY="CKUGFOD9_2Qf6Pn3ZFRYgPYb8ht4vKqEG9PGMXTB7497bT0367DjoaD6ydFnEVaIRda0kKeBZVCT5Hb62m2sCA" 24 | 25 | SERVICE_TOKEN_KEY="9FoHBmkyxbgu_xFoQK7e0jz3RMNVJWgfvbVn712FBNH9LLaAWS3CS6Zpcg6RveiObvCUb6a2z-uAiLjhLh2igw" 26 | SERVICE_TOKEN_DURATION_SEC="1800" # 30 minutes 27 | 28 | ## -- ConfigMap 29 | 30 | # This will be relative to Cargo.toml 31 | # In deployed images, probably use absolute path. 32 | SERVICE_WEB_FOLDER="web-folder/" -------------------------------------------------------------------------------- /crates/libs/lib-core/src/ctx/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | 5 | pub use self::error::{Error, Result}; 6 | 7 | // endregion: --- Modules 8 | 9 | #[cfg_attr(feature = "with-rpc", derive(rpc_router::RpcResource))] 10 | #[derive(Clone, Debug)] 11 | pub struct Ctx { 12 | user_id: i64, 13 | 14 | /// Note: For the future ACS (Access Control System) 15 | conv_id: Option, 16 | } 17 | 18 | // Constructors. 19 | impl Ctx { 20 | pub fn root_ctx() -> Self { 21 | Ctx { 22 | user_id: 0, 23 | conv_id: None, 24 | } 25 | } 26 | 27 | pub fn new(user_id: i64) -> Result { 28 | if user_id == 0 { 29 | Err(Error::CtxCannotNewRootCtx) 30 | } else { 31 | Ok(Self { 32 | user_id, 33 | conv_id: None, 34 | }) 35 | } 36 | } 37 | 38 | /// Note: For the future ACS (Access Control System) 39 | pub fn add_conv_id(&self, conv_id: i64) -> Ctx { 40 | let mut ctx = self.clone(); 41 | ctx.conv_id = Some(conv_id); 42 | ctx 43 | } 44 | } 45 | 46 | // Property Accessors. 47 | impl Ctx { 48 | pub fn user_id(&self) -> i64 { 49 | self.user_id 50 | } 51 | 52 | //. /// Note: For the future ACS (Access Control System) 53 | pub fn conv_id(&self) -> Option { 54 | self.conv_id 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crates/libs/lib-utils/src/time.rs: -------------------------------------------------------------------------------- 1 | use time::{Duration, OffsetDateTime}; 2 | 3 | pub use time::format_description::well_known::Rfc3339; 4 | 5 | pub fn now_utc() -> OffsetDateTime { 6 | OffsetDateTime::now_utc() 7 | } 8 | 9 | pub fn format_time(time: OffsetDateTime) -> String { 10 | time.format(&Rfc3339).unwrap() // TODO: need to check if safe. 11 | } 12 | 13 | pub fn now_utc_plus_sec_str(sec: f64) -> String { 14 | let new_time = now_utc() + Duration::seconds_f64(sec); 15 | format_time(new_time) 16 | } 17 | 18 | pub fn parse_utc(moment: &str) -> Result { 19 | OffsetDateTime::parse(moment, &Rfc3339) 20 | .map_err(|_| Error::FailToDateParse(moment.to_string())) 21 | } 22 | 23 | // region: --- Error 24 | 25 | pub type Result = core::result::Result; 26 | 27 | #[derive(Debug)] 28 | pub enum Error { 29 | FailToDateParse(String), 30 | } 31 | 32 | // region: --- Error Boilerplate 33 | impl core::fmt::Display for Error { 34 | fn fmt( 35 | &self, 36 | fmt: &mut core::fmt::Formatter, 37 | ) -> core::result::Result<(), core::fmt::Error> { 38 | write!(fmt, "{self:?}") 39 | } 40 | } 41 | 42 | impl std::error::Error for Error {} 43 | // endregion: --- Error Boilerplate 44 | 45 | // endregion: --- Error 46 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/conv_user.rs: -------------------------------------------------------------------------------- 1 | use crate::model::base::DbBmc; 2 | use lib_utils::time::Rfc3339; 3 | use modql::field::Fields; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_with::serde_as; 6 | use sqlx::FromRow; 7 | use time::OffsetDateTime; 8 | 9 | // region: --- Types 10 | 11 | #[serde_as] 12 | #[derive(Debug, Clone, Fields, FromRow, Serialize)] 13 | pub struct ConvUser { 14 | pub id: i64, 15 | 16 | // -- FK 17 | pub conv_id: i64, 18 | pub user_id: i64, 19 | 20 | // -- Timestamps 21 | // creator user_id and time 22 | pub cid: i64, 23 | #[serde_as(as = "Rfc3339")] 24 | pub ctime: OffsetDateTime, 25 | // last modifier user_id and time 26 | pub mid: i64, 27 | #[serde_as(as = "Rfc3339")] 28 | pub mtime: OffsetDateTime, 29 | } 30 | 31 | #[derive(Fields, Deserialize)] 32 | pub struct ConvUserForCreate { 33 | pub conv_id: i64, 34 | pub user_id: i64, 35 | } 36 | 37 | // endregion: --- Types 38 | 39 | // region: --- ConvUser 40 | 41 | pub struct ConvUserBmc; 42 | 43 | impl DbBmc for ConvUserBmc { 44 | const TABLE: &'static str = "conv_user"; 45 | } 46 | 47 | // Note: This is not implemented yet. It will likely be similar to `ConvMsg`, meaning it will be 48 | // managed by the `ConvBmc` container entity. 49 | 50 | // endregion: --- ConvUser 51 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/middleware/mw_req_stamp.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use axum::body::Body; 3 | use axum::extract::FromRequestParts; 4 | use axum::http::request::Parts; 5 | use axum::http::Request; 6 | use axum::middleware::Next; 7 | use axum::response::Response; 8 | use lib_utils::time::now_utc; 9 | use time::OffsetDateTime; 10 | use tracing::debug; 11 | use uuid::Uuid; 12 | 13 | #[derive(Debug, Clone)] 14 | pub struct ReqStamp { 15 | pub uuid: Uuid, 16 | pub time_in: OffsetDateTime, 17 | } 18 | 19 | pub async fn mw_req_stamp_resolver( 20 | mut req: Request, 21 | next: Next, 22 | ) -> Result { 23 | debug!("{:<12} - mw_req_stamp_resolver", "MIDDLEWARE"); 24 | 25 | let time_in = now_utc(); 26 | let uuid = Uuid::new_v4(); 27 | 28 | req.extensions_mut().insert(ReqStamp { uuid, time_in }); 29 | 30 | Ok(next.run(req).await) 31 | } 32 | 33 | // region: --- ReqStamp Extractor 34 | impl FromRequestParts for ReqStamp { 35 | type Rejection = Error; 36 | 37 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 38 | debug!("{:<12} - ReqStamp", "EXTRACTOR"); 39 | 40 | parts 41 | .extensions 42 | .get::() 43 | .cloned() 44 | .ok_or(Error::ReqStampNotInReqExt) 45 | } 46 | } 47 | // endregion: --- ReqStamp Extractor 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- Base 2 | .* 3 | !.gitignore 4 | 5 | _* 6 | # '_' in src dir, ok. 7 | !**/src/**/_* 8 | 9 | *.lock 10 | *.lockb 11 | *.log 12 | 13 | # -- Rust 14 | target/ 15 | # !Cargo.lock # commented by default 16 | !.cargo/ 17 | 18 | # -- Devai 19 | # By default ignore all devai 20 | *.devai 21 | 22 | # Uncomment beow to allow commiting .devai/custom .devai 23 | # Only allow .devai/custom and .devai/Config.toml 24 | # Note: Here the starting `/` will just include the top .devai. 25 | # Remove the starting `/` to include all .devai/custom even if their in a sub dir 26 | # !/.devai/ 27 | # .devai/* 28 | # !.devai/custom/ 29 | # !.devai/custom/** 30 | # !.devai/Config.toml 31 | 32 | # -- Safety net 33 | 34 | dist/ 35 | out/ 36 | 37 | # Doc Files 38 | *.pdf 39 | *.docx 40 | *.xlsx 41 | *.pptx 42 | *.doc 43 | *.xls 44 | *.ppt 45 | *.page 46 | 47 | # Data Files 48 | *.db3 49 | *.parquet 50 | *.map 51 | *.zip 52 | *.gz 53 | *.tar 54 | *.tgz 55 | *.vsix 56 | 57 | # Videos 58 | *.mov 59 | *.mp4 60 | *.webm 61 | *.ogg 62 | *.avi 63 | 64 | # Images 65 | *.icns 66 | *.ico 67 | *.jpeg 68 | *.jpg 69 | *.png 70 | *.bmp 71 | 72 | # -- Nodejs 73 | node_modules/ 74 | !.mocharc.yaml 75 | report.*.json 76 | 77 | # -- Python 78 | __pycache__/ 79 | 80 | 81 | # -- others 82 | # Allows .env (make sure only dev info) 83 | # !.env # Commented by default 84 | 85 | # Allow vscode 86 | # !.vscode # Commented by default 87 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/rpcs/conv_rpc.rs: -------------------------------------------------------------------------------- 1 | use lib_core::model::conv::{ 2 | Conv, ConvBmc, ConvFilter, ConvForCreate, ConvForUpdate, 3 | }; 4 | use lib_core::model::conv_msg::{ConvMsg, ConvMsgForCreate}; 5 | use lib_rpc_core::prelude::*; 6 | 7 | pub fn rpc_router_builder() -> RouterBuilder { 8 | router_builder!( 9 | // Same as RpcRouter::new().add... 10 | create_conv, 11 | get_conv, 12 | list_convs, 13 | update_conv, 14 | delete_conv, 15 | add_conv_msg, 16 | ) 17 | } 18 | 19 | generate_common_rpc_fns!( 20 | Bmc: ConvBmc, 21 | Entity: Conv, 22 | ForCreate: ConvForCreate, 23 | ForUpdate: ConvForUpdate, 24 | Filter: ConvFilter, 25 | Suffix: conv 26 | ); 27 | 28 | /// Returns conv_msg 29 | pub async fn add_conv_msg( 30 | ctx: Ctx, 31 | mm: ModelManager, 32 | params: ParamsForCreate, 33 | ) -> Result> { 34 | let ParamsForCreate { data: msg_c } = params; 35 | 36 | let msg_id = ConvBmc::add_msg(&ctx, &mm, msg_c).await?; 37 | let msg = ConvBmc::get_msg(&ctx, &mm, msg_id).await?; 38 | 39 | Ok(msg.into()) 40 | } 41 | 42 | /// Returns conv_msg 43 | #[allow(unused)] 44 | pub async fn get_conv_msg( 45 | ctx: Ctx, 46 | mm: ModelManager, 47 | params: ParamsIded, 48 | ) -> Result> { 49 | let ParamsIded { id: msg_id } = params; 50 | 51 | let msg = ConvBmc::get_msg(&ctx, &mm, msg_id).await?; 52 | 53 | Ok(msg.into()) 54 | } 55 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/store/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | pub(in crate::model) mod dbx; 4 | 5 | use crate::core_config; 6 | use sqlx::postgres::PgPoolOptions; 7 | use sqlx::{Pool, Postgres}; 8 | 9 | // endregion: --- Modules 10 | 11 | pub type Db = Pool; 12 | 13 | pub async fn new_db_pool() -> sqlx::Result { 14 | // * See NOTE 1) below 15 | let max_connections = if cfg!(test) { 1 } else { 5 }; 16 | 17 | PgPoolOptions::new() 18 | .max_connections(max_connections) 19 | .connect(&core_config().DB_URL) 20 | .await 21 | } 22 | 23 | // NOTE 1) This is not an ideal situation; however, with sqlx 0.7.1, when executing `cargo test`, some tests that use sqlx fail at a 24 | // rather low level (in the tokio scheduler). It appears to be a low-level thread/async issue, as removing/adding 25 | // tests causes different tests to fail. The cause remains uncertain, but setting max_connections to 1 resolves the issue. 26 | // The good news is that max_connections still function normally for a regular run. 27 | // This issue is likely due to the unique requirements unit tests impose on their execution, and therefore, 28 | // while not ideal, it should serve as an acceptable temporary solution. 29 | // It's a very challenging issue to investigate and narrow down. The alternative would have been to stick with sqlx 0.6.x, which 30 | // is potentially less ideal and might lead to confusion as to why we are maintaining the older version in this blueprint. 31 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/base/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::model::base::{CommonIden, DbBmc, TimestampIden}; 2 | use lib_utils::time::now_utc; 3 | use modql::field::{SeaField, SeaFields}; 4 | use sea_query::IntoIden; 5 | 6 | /// This method must be called when a model controller intends to create its entity. 7 | pub fn prep_fields_for_create(fields: &mut SeaFields, user_id: i64) 8 | where 9 | MC: DbBmc, 10 | { 11 | if MC::has_owner_id() { 12 | fields.push(SeaField::new(CommonIden::OwnerId.into_iden(), user_id)); 13 | } 14 | if MC::has_timestamps() { 15 | add_timestamps_for_create(fields, user_id); 16 | } 17 | } 18 | 19 | /// This method must be calledwhen a Model Controller plans to update its entity. 20 | pub fn prep_fields_for_update(fields: &mut SeaFields, user_id: i64) 21 | where 22 | MC: DbBmc, 23 | { 24 | if MC::has_timestamps() { 25 | add_timestamps_for_update(fields, user_id); 26 | } 27 | } 28 | 29 | /// Update the timestamps info for create 30 | /// (e.g., cid, ctime, and mid, mtime will be updated with the same values) 31 | fn add_timestamps_for_create(fields: &mut SeaFields, user_id: i64) { 32 | let now = now_utc(); 33 | fields.push(SeaField::new(TimestampIden::Cid, user_id)); 34 | fields.push(SeaField::new(TimestampIden::Ctime, now)); 35 | 36 | fields.push(SeaField::new(TimestampIden::Mid, user_id)); 37 | fields.push(SeaField::new(TimestampIden::Mtime, now)); 38 | } 39 | 40 | /// Update the timestamps info only for update. 41 | /// (.e.g., only mid, mtime will be udpated) 42 | fn add_timestamps_for_update(fields: &mut SeaFields, user_id: i64) { 43 | let now = now_utc(); 44 | fields.push(SeaField::new(TimestampIden::Mid, user_id)); 45 | fields.push(SeaField::new(TimestampIden::Mtime, now)); 46 | } 47 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace.lints.rust] 2 | unsafe_code = "forbid" 3 | # unused = { level = "allow", priority = -1 } # For exploratory dev. 4 | 5 | [workspace] 6 | resolver = "2" 7 | members = [ 8 | # -- Application Libraries 9 | "crates/libs/lib-utils", # e.g., base64, time. 10 | "crates/libs/lib-rpc-core", # e.g., core rpc utils (using rpc-router crate) 11 | "crates/libs/lib-auth", # e.g., for pwd, token. 12 | "crates/libs/lib-core", # e.g., model, ctx, config. 13 | "crates/libs/lib-web", # e.g., logging, common middleware etc 14 | 15 | # -- Application Services 16 | "crates/services/web-server", 17 | 18 | # -- Tools 19 | "crates/tools/gen-key", 20 | ] 21 | 22 | # NOTE: Only the crates that are utilized in two or more sub-crates and benefit from global management 23 | # are handled in workspace.dependencies. Other strategies may also be valid. 24 | [workspace.dependencies] 25 | # -- Serde 26 | serde_with = {version = "3", features = ["time_0_3"] } 27 | # -- Data 28 | # Note: we lock modql version during rcs 29 | modql = { version = "0.4.1", features = ["with-sea-query"]} 30 | sqlx = { version = "0.8", features = [ "macros", "runtime-tokio", "postgres", "uuid" ] } 31 | sea-query = "0.32" 32 | sea-query-binder = { version = "0.7", features = ["sqlx-postgres", "with-uuid", "with-time" ] } 33 | # -- JSON-RPC 34 | # Lock to specific version during 0.1.x 35 | rpc-router = { version = "=0.2.0-alpha.1" } 36 | # -- Web 37 | axum = {version = "0.8", features = ["macros"]} 38 | tower-http = { version = "0.6", features = ["fs"] } 39 | tower-cookies = "0.11" 40 | # -- Others 41 | time = {version = "0.3", features = ["formatting", "parsing", "serde"]} 42 | derive_more = {version = "1.0.0-beta", features = ["from", "display"] } 43 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/base/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod crud_fns; 4 | mod macro_utils; 5 | mod utils; 6 | 7 | // -- Flatten hierarchy for user code. 8 | pub use crud_fns::*; 9 | pub use utils::*; 10 | 11 | use modql::SIden; 12 | use sea_query::{Iden, IntoIden, TableRef}; 13 | 14 | // endregion: --- Modules 15 | 16 | // region: --- Consts 17 | 18 | const LIST_LIMIT_DEFAULT: i64 = 1000; 19 | const LIST_LIMIT_MAX: i64 = 5000; 20 | 21 | // endregion: --- Consts 22 | 23 | // region: --- SeaQuery Idens 24 | 25 | #[derive(Iden)] 26 | pub enum CommonIden { 27 | Id, 28 | OwnerId, 29 | } 30 | 31 | #[derive(Iden)] 32 | pub enum TimestampIden { 33 | Cid, 34 | Ctime, 35 | Mid, 36 | Mtime, 37 | } 38 | 39 | // endregion: --- SeaQuery Idens 40 | 41 | /// The DbBmc trait must be implemented for the Bmc struct of an entity. 42 | /// It specifies meta information such as the table name, 43 | /// whether the table has timestamp columns (cid, ctime, mid, mtime), and more as the 44 | /// code evolves. 45 | /// 46 | /// Note: This trait should not be confused with the BaseCrudBmc trait, which provides 47 | /// common default CRUD BMC functions for a given Bmc/Entity. 48 | pub trait DbBmc { 49 | const TABLE: &'static str; 50 | 51 | fn table_ref() -> TableRef { 52 | TableRef::Table(SIden(Self::TABLE).into_iden()) 53 | } 54 | 55 | /// Specifies that the table for this Bmc has timestamps (cid, ctime, mid, mtime) columns. 56 | /// This will allow the code to update those as needed. 57 | /// 58 | /// default: true 59 | fn has_timestamps() -> bool { 60 | true 61 | } 62 | 63 | /// Specifies if the entity table managed by this BMC 64 | /// has an `owner_id` column that needs to be set on create (by default ctx.user_id). 65 | /// 66 | /// default: false 67 | fn has_owner_id() -> bool { 68 | false 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/rpc_params.rs: -------------------------------------------------------------------------------- 1 | //! Base constructs for the typed RPC Params that will be used in their respective 2 | //! rpc handler functions (e.g., `project_rpc::create_project` and `project_rpc::list_projects`). 3 | //! 4 | //! Most of these base constructs use generics for their respective data elements, allowing 5 | //! each rpc handler function to receive the exact desired type. 6 | //! 7 | //! `IntoParams` or `IntoDefaultRpcParams` are implemented to ensure these Params conform to the 8 | //! `RpcRouter` (i.e., `rpc::router`) model. 9 | 10 | use modql::filter::ListOptions; 11 | use rpc_router::{IntoDefaultRpcParams, IntoParams}; 12 | use serde::de::DeserializeOwned; 13 | use serde::Deserialize; 14 | use serde_with::{serde_as, OneOrMany}; 15 | 16 | /// Params structure for any RPC Create call. 17 | #[derive(Deserialize)] 18 | pub struct ParamsForCreate { 19 | pub data: D, 20 | } 21 | 22 | impl IntoParams for ParamsForCreate where D: DeserializeOwned + Send {} 23 | 24 | /// Params structure for any RPC Update call. 25 | #[derive(Deserialize)] 26 | pub struct ParamsForUpdate { 27 | pub id: i64, 28 | pub data: D, 29 | } 30 | 31 | impl IntoParams for ParamsForUpdate where D: DeserializeOwned + Send {} 32 | 33 | /// Params structure for any RPC Update call. 34 | #[derive(Deserialize)] 35 | pub struct ParamsIded { 36 | pub id: i64, 37 | } 38 | impl IntoParams for ParamsIded {} 39 | 40 | /// Params structure for any RPC List call. 41 | #[serde_as] 42 | #[derive(Deserialize, Default)] 43 | pub struct ParamsList 44 | where 45 | F: DeserializeOwned, 46 | { 47 | #[serde_as(deserialize_as = "Option>")] 48 | pub filters: Option>, 49 | pub list_options: Option, 50 | } 51 | 52 | impl IntoDefaultRpcParams for ParamsList where 53 | D: DeserializeOwned + Send + Default 54 | { 55 | } 56 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/error.rs: -------------------------------------------------------------------------------- 1 | //! This module encompasses errors for all `lib_rpc` modules and rpc handlers. 2 | //! Variants from our application's library errors can be added as required by the handlers. 3 | //! 4 | //! # Note on the `rpc-router::Error` scheme 5 | //! 6 | //! - When used in an rpc handler with the `rpc-router` crate, 7 | //! this type will be encapsulated as a `rpc-router::Error::Handler(RpcHandlerError)` within a 8 | //! "TypeMap" and can subsequently be retrieved (see `web-server::web::Error` for reference). 9 | //! 10 | //! - For this application error to be utilized in the `rpc-router`, it must 11 | //! implement the `IntoRpcHandlerError` trait. This trait has a suitable default implementation, 12 | //! so simply adding `impl rpc_router::IntoRpcHandlerError for Error {}` would suffice. 13 | //! 14 | //! - Alternatively, the `#[derive(RpcHandlerError)]` annotation can be used as demonstrated here, which will 15 | //! automatically provide the `impl rpc_router::IntoRpcHandlerError for Error {}` for this type. 16 | 17 | use derive_more::From; 18 | use rpc_router::RpcHandlerError; 19 | use serde::Serialize; 20 | use serde_with::{serde_as, DisplayFromStr}; 21 | 22 | pub type Result = core::result::Result; 23 | 24 | #[serde_as] 25 | #[derive(Debug, From, Serialize, RpcHandlerError)] 26 | pub enum Error { 27 | // -- App Libs 28 | #[from] 29 | Model(lib_core::model::Error), 30 | 31 | // -- External Modules 32 | #[from] 33 | SerdeJson(#[serde_as(as = "DisplayFromStr")] serde_json::Error), 34 | } 35 | 36 | // region: --- Error Boilerplate 37 | impl core::fmt::Display for Error { 38 | fn fmt( 39 | &self, 40 | fmt: &mut core::fmt::Formatter, 41 | ) -> core::result::Result<(), core::fmt::Error> { 42 | write!(fmt, "{self:?}") 43 | } 44 | } 45 | 46 | impl std::error::Error for Error {} 47 | // endregion: --- Error Boilerplate 48 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! Model Layer 2 | //! 3 | //! Design: 4 | //! 5 | //! - The Model layer normalizes the application's data type 6 | //! structures and access. 7 | //! - All application code data access must go through the Model layer. 8 | //! - The `ModelManager` holds the internal states/resources 9 | //! needed by ModelControllers to access data. 10 | //! (e.g., db_pool, S3 client, redis client). 11 | //! - Model Controllers (e.g., `ConvBmc`, `AgentBmc`) implement 12 | //! CRUD and other data access methods on a given "entity" 13 | //! (e.g., `Conv`, `Agent`). 14 | //! (`Bmc` is short for Backend Model Controller). 15 | //! - In frameworks like Axum, Tauri, `ModelManager` are typically used as App State. 16 | //! - ModelManager are designed to be passed as an argument 17 | //! to all Model Controllers functions. 18 | //! 19 | 20 | // region: --- Modules 21 | 22 | mod acs; 23 | mod base; 24 | mod error; 25 | mod store; 26 | 27 | pub mod agent; 28 | pub mod conv; 29 | pub mod conv_msg; 30 | pub mod conv_user; 31 | pub mod modql_utils; 32 | pub mod user; 33 | 34 | pub use self::error::{Error, Result}; 35 | 36 | use crate::model::store::dbx::Dbx; 37 | use crate::model::store::new_db_pool; 38 | 39 | // endregion: --- Modules 40 | 41 | // region: --- ModelManager 42 | 43 | #[cfg_attr(feature = "with-rpc", derive(rpc_router::RpcResource))] 44 | #[derive(Clone)] 45 | pub struct ModelManager { 46 | dbx: Dbx, 47 | } 48 | 49 | impl ModelManager { 50 | /// Constructor 51 | pub async fn new() -> Result { 52 | let db_pool = new_db_pool() 53 | .await 54 | .map_err(|ex| Error::CantCreateModelManagerProvider(ex.to_string()))?; 55 | let dbx = Dbx::new(db_pool, false)?; 56 | Ok(ModelManager { dbx }) 57 | } 58 | 59 | pub fn new_with_txn(&self) -> Result { 60 | let dbx = Dbx::new(self.dbx.db().clone(), true)?; 61 | Ok(ModelManager { dbx }) 62 | } 63 | 64 | pub fn dbx(&self) -> &Dbx { 65 | &self.dbx 66 | } 67 | } 68 | 69 | // endregion: --- ModelManager 70 | -------------------------------------------------------------------------------- /crates/services/web-server/src/main.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod config; 4 | mod error; 5 | mod web; 6 | 7 | pub use self::error::{Error, Result}; 8 | use config::web_config; 9 | 10 | use lib_web::middleware::mw_auth::{mw_ctx_require, mw_ctx_resolver}; 11 | use lib_web::middleware::mw_req_stamp::mw_req_stamp_resolver; 12 | use lib_web::middleware::mw_res_map::mw_reponse_map; 13 | use lib_web::routes::routes_static; 14 | 15 | use crate::web::routes_login; 16 | 17 | use axum::{middleware, Router}; 18 | use lib_core::_dev_utils; 19 | use lib_core::model::ModelManager; 20 | use tokio::net::TcpListener; 21 | use tower_cookies::CookieManagerLayer; 22 | use tracing::info; 23 | use tracing_subscriber::EnvFilter; 24 | 25 | // endregion: --- Modules 26 | 27 | #[tokio::main] 28 | async fn main() -> Result<()> { 29 | tracing_subscriber::fmt() 30 | .without_time() // For early local development. 31 | .with_target(false) 32 | .with_env_filter(EnvFilter::from_default_env()) 33 | .init(); 34 | 35 | // -- FOR DEV ONLY 36 | _dev_utils::init_dev().await; 37 | 38 | let mm = ModelManager::new().await?; 39 | 40 | // -- Define Routes 41 | let routes_rpc = web::routes_rpc::routes(mm.clone()) 42 | .route_layer(middleware::from_fn(mw_ctx_require)); 43 | 44 | let routes_all = Router::new() 45 | .merge(routes_login::routes(mm.clone())) 46 | .nest("/api", routes_rpc) 47 | .layer(middleware::map_response(mw_reponse_map)) 48 | .layer(middleware::from_fn_with_state(mm.clone(), mw_ctx_resolver)) 49 | .layer(CookieManagerLayer::new()) 50 | .layer(middleware::from_fn(mw_req_stamp_resolver)) 51 | .fallback_service(routes_static::serve_dir(&web_config().WEB_FOLDER)); 52 | 53 | // region: --- Start Server 54 | // Note: For this block, ok to unwrap. 55 | let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); 56 | info!("{:<12} - {:?}\n", "LISTENING", listener.local_addr()); 57 | axum::serve(listener, routes_all.into_make_service()) 58 | .await 59 | .unwrap(); 60 | // endregion: --- Start Server 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/scheme/scheme_01.rs: -------------------------------------------------------------------------------- 1 | use super::{Error, Result}; 2 | use crate::auth_config; 3 | use crate::pwd::scheme::Scheme; 4 | use crate::pwd::ContentToHash; 5 | use hmac::{Hmac, Mac}; 6 | use lib_utils::b64::b64u_encode; 7 | use sha2::Sha512; 8 | 9 | pub struct Scheme01; 10 | 11 | impl Scheme for Scheme01 { 12 | fn hash(&self, to_hash: &ContentToHash) -> Result { 13 | let key = &auth_config().PWD_KEY; 14 | hash(key, to_hash) 15 | } 16 | 17 | fn validate(&self, to_hash: &ContentToHash, raw_pwd_ref: &str) -> Result<()> { 18 | let raw_pwd_new = self.hash(to_hash)?; 19 | if raw_pwd_new == raw_pwd_ref { 20 | Ok(()) 21 | } else { 22 | Err(Error::PwdValidate) 23 | } 24 | } 25 | } 26 | 27 | fn hash(key: &[u8], to_hash: &ContentToHash) -> Result { 28 | let ContentToHash { content, salt } = to_hash; 29 | 30 | // -- Create a HMAC-SHA-512 from key. 31 | let mut hmac_sha512 = 32 | Hmac::::new_from_slice(key).map_err(|_| Error::Key)?; 33 | 34 | // -- Add content. 35 | hmac_sha512.update(content.as_bytes()); 36 | hmac_sha512.update(salt.as_bytes()); 37 | 38 | // -- Finalize and b64u encode. 39 | let hmac_result = hmac_sha512.finalize(); 40 | let result_bytes = hmac_result.into_bytes(); 41 | 42 | let result = b64u_encode(result_bytes); 43 | 44 | Ok(result) 45 | } 46 | 47 | // region: --- Tests 48 | #[cfg(test)] 49 | mod tests { 50 | pub type Result = core::result::Result; 51 | pub type Error = Box; // For early tests. 52 | 53 | use super::*; 54 | use crate::auth_config; 55 | use uuid::Uuid; 56 | 57 | #[test] 58 | fn test_scheme_01_hash_into_b64u_ok() -> Result<()> { 59 | // -- Setup & Fixtures 60 | let fx_salt = Uuid::parse_str("f05e8961-d6ad-4086-9e78-a6de065e5453")?; 61 | let fx_key = &auth_config().PWD_KEY; // 512 bits = 64 bytes 62 | let fx_to_hash = ContentToHash { 63 | content: "hello world".to_string(), 64 | salt: fx_salt, 65 | }; 66 | let fx_res = "qO9A90161DoewhNXFwVcnAaljRIVnajvd5zsVDrySCwxpoLwVCACzaz-8Ev2ZpI8RackUTLBVqFI6H5oMe-OIg"; 67 | 68 | // -- Exec 69 | let res = hash(fx_key, &fx_to_hash)?; 70 | 71 | // -- Check 72 | assert_eq!(res, fx_res); 73 | 74 | Ok(()) 75 | } 76 | } 77 | // endregion: --- Tests 78 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/middleware/mw_res_map.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::handlers::handlers_rpc::RpcInfo; 3 | use crate::log::log_request; 4 | use crate::middleware::mw_auth::CtxW; 5 | use crate::middleware::mw_req_stamp::ReqStamp; 6 | 7 | use axum::http::{Method, Uri}; 8 | use axum::response::{IntoResponse, Response}; 9 | use axum::Json; 10 | use serde_json::{json, to_value}; 11 | use std::sync::Arc; 12 | use tracing::debug; 13 | use uuid::Uuid; 14 | 15 | pub async fn mw_reponse_map( 16 | ctx: Result, // Axum 0.8 does not seem to support Option anymore 17 | uri: Uri, 18 | req_method: Method, 19 | req_stamp: ReqStamp, 20 | res: Response, 21 | ) -> Response { 22 | let ctx = ctx.map(|ctx| ctx.0).ok(); 23 | 24 | debug!("{:<12} - mw_reponse_map", "RES_MAPPER"); 25 | let uuid = Uuid::new_v4(); 26 | 27 | let rpc_info = res.extensions().get::>().map(Arc::as_ref); 28 | 29 | // -- Get the eventual response error. 30 | let web_error = res.extensions().get::>().map(Arc::as_ref); 31 | let client_status_error = web_error.map(|se| se.client_status_and_error()); 32 | 33 | // -- If client error, build the new reponse. 34 | let error_response = 35 | client_status_error 36 | .as_ref() 37 | .map(|(status_code, client_error)| { 38 | let client_error = to_value(client_error).ok(); 39 | let message = client_error.as_ref().and_then(|v| v.get("message")); 40 | let detail = client_error.as_ref().and_then(|v| v.get("detail")); 41 | 42 | let client_error_body = json!({ 43 | "id": rpc_info.as_ref().map(|rpc| rpc.id.clone()), 44 | "error": { 45 | "message": message, // Variant name 46 | "data": { 47 | "req_uuid": uuid.to_string(), 48 | "detail": detail 49 | }, 50 | } 51 | }); 52 | 53 | debug!("CLIENT ERROR BODY:\n{client_error_body}"); 54 | 55 | // Build the new response from the client_error_body 56 | (*status_code, Json(client_error_body)).into_response() 57 | }); 58 | 59 | // -- Build and log the server log line. 60 | let client_error = client_status_error.unzip().1; 61 | 62 | // TODO: Need to hander if log_request fail (but should not fail request) 63 | let _ = log_request( 64 | req_method, 65 | uri, 66 | req_stamp, 67 | rpc_info, 68 | ctx, 69 | web_error, 70 | client_error, 71 | ) 72 | .await; 73 | 74 | debug!("\n"); 75 | 76 | error_response.unwrap_or(res) 77 | } 78 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/scheme/scheme_02.rs: -------------------------------------------------------------------------------- 1 | use super::{Error, Result}; 2 | use crate::config::auth_config; 3 | use crate::pwd::scheme::Scheme; 4 | use argon2::password_hash::SaltString; 5 | use argon2::{ 6 | Algorithm, Argon2, Params, PasswordHash, PasswordHasher as _, 7 | PasswordVerifier as _, Version, 8 | }; 9 | use std::sync::OnceLock; 10 | 11 | pub struct Scheme02; 12 | 13 | impl Scheme for Scheme02 { 14 | fn hash(&self, to_hash: &crate::pwd::ContentToHash) -> Result { 15 | let argon2 = get_argon2(); 16 | 17 | let salt_b64 = SaltString::encode_b64(to_hash.salt.as_bytes()) 18 | .map_err(|_| Error::Salt)?; 19 | 20 | let pwd = argon2 21 | .hash_password(to_hash.content.as_bytes(), &salt_b64) 22 | .map_err(|_| Error::Hash)? 23 | .to_string(); 24 | 25 | Ok(pwd) 26 | } 27 | 28 | fn validate( 29 | &self, 30 | to_hash: &crate::pwd::ContentToHash, 31 | pwd_ref: &str, 32 | ) -> Result<()> { 33 | let argon2 = get_argon2(); 34 | 35 | let parsed_hash_ref = PasswordHash::new(pwd_ref).map_err(|_| Error::Hash)?; 36 | 37 | argon2 38 | .verify_password(to_hash.content.as_bytes(), &parsed_hash_ref) 39 | .map_err(|_| Error::PwdValidate) 40 | } 41 | } 42 | 43 | fn get_argon2() -> &'static Argon2<'static> { 44 | static INSTANCE: OnceLock> = OnceLock::new(); 45 | 46 | INSTANCE.get_or_init(|| { 47 | let key = &auth_config().PWD_KEY; 48 | Argon2::new_with_secret( 49 | key, 50 | Algorithm::Argon2id, // Same as Argon2::default() 51 | Version::V0x13, // Same as Argon2::default() 52 | Params::default(), 53 | ) 54 | .unwrap() // TODO - needs to fail early 55 | }) 56 | } 57 | 58 | // region: --- Tests 59 | #[cfg(test)] 60 | mod tests { 61 | pub type Result = core::result::Result; 62 | pub type Error = Box; // For tests. 63 | 64 | use super::*; 65 | use crate::pwd::ContentToHash; 66 | use uuid::Uuid; 67 | 68 | #[test] 69 | fn test_scheme_02_hash_into_b64u_ok() -> Result<()> { 70 | // -- Setup & Fixtures 71 | let fx_to_hash = ContentToHash { 72 | content: "hello world".to_string(), 73 | salt: Uuid::parse_str("f05e8961-d6ad-4086-9e78-a6de065e5453")?, 74 | }; 75 | let fx_res = "$argon2id$v=19$m=19456,t=2,p=1$8F6JYdatQIaeeKbeBl5UUw$TaRnmmbDdQ1aTzk2qQ2yQzPQoZfnKqhrfuTH/TRP5V4"; 76 | 77 | // -- Exec 78 | let scheme = Scheme02; 79 | let res = scheme.hash(&fx_to_hash)?; 80 | 81 | // -- Check 82 | assert_eq!(res, fx_res); 83 | 84 | Ok(()) 85 | } 86 | } 87 | // endregion: --- Tests 88 | -------------------------------------------------------------------------------- /crates/services/web-server/examples/quick_dev.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] // For example code. 2 | 3 | pub type Result = core::result::Result; 4 | pub type Error = Box; // For examples. 5 | 6 | use serde_json::{json, Value}; 7 | 8 | #[tokio::main] 9 | async fn main() -> Result<()> { 10 | let hc = httpc_test::new_client("http://localhost:8080")?; 11 | 12 | // hc.do_get("/index.html").await?.print().await?; 13 | 14 | // -- Login 15 | let req_login = hc.do_post( 16 | "/api/login", 17 | json!({ 18 | "username": "demo1", 19 | "pwd": "welcome" 20 | }), 21 | ); 22 | req_login.await?.print().await?; 23 | 24 | // -- Create Agent 25 | let req_create_agent = hc.do_post( 26 | "/api/rpc", 27 | json!({ 28 | "jsonrpc": "2.0", 29 | "id": 1, 30 | "method": "create_agent", 31 | "params": { 32 | "data": { 33 | "name": "agent AAA" 34 | } 35 | } 36 | }), 37 | ); 38 | let result = req_create_agent.await?; 39 | result.print().await?; 40 | let agent_id = result.json_value::("/result/data/id")?; 41 | 42 | // -- Get Agent 43 | let req_get_agent = hc.do_post( 44 | "/api/rpc", 45 | json!({ 46 | "jsonrpc": "2.0", 47 | "id": 1, 48 | "method": "get_agent", 49 | "params": { 50 | "id": agent_id 51 | } 52 | }), 53 | ); 54 | let result = req_get_agent.await?; 55 | result.print().await?; 56 | 57 | // -- Create Conv 58 | let req_create_conv = hc.do_post( 59 | "/api/rpc", 60 | json!({ 61 | "jsonrpc": "2.0", 62 | "id": 1, 63 | "method": "create_conv", 64 | "params": { 65 | "data": { 66 | "agent_id": agent_id, 67 | "title": "conv 01" 68 | } 69 | } 70 | }), 71 | ); 72 | let result = req_create_conv.await?; 73 | result.print().await?; 74 | let conv_id = result.json_value::("/result/data/id")?; 75 | 76 | // -- Create ConvMsg 77 | let req_create_conv = hc.do_post( 78 | "/api/rpc", 79 | json!({ 80 | "jsonrpc": "2.0", 81 | "id": 1, 82 | "method": "add_conv_msg", 83 | "params": { 84 | "data": { 85 | "conv_id": conv_id, 86 | "content": "This is the first comment" 87 | } 88 | } 89 | }), 90 | ); 91 | let result = req_create_conv.await?; 92 | result.print().await?; 93 | let conv_msg_id = result.json_value::("/result/data/id")?; 94 | 95 | // -- Logoff 96 | let req_logoff = hc.do_post( 97 | "/api/logoff", 98 | json!({ 99 | "logoff": true 100 | }), 101 | ); 102 | req_logoff.await?.print().await?; 103 | 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/base/macro_utils.rs: -------------------------------------------------------------------------------- 1 | /// Convenience macro rules to generate default CRUD functions for a Bmc/Entity. 2 | /// Note: If custom functionality is required, use the code below as foundational 3 | /// code for the custom implementations. 4 | #[macro_export] 5 | macro_rules! generate_common_bmc_fns { 6 | ( 7 | Bmc: $struct_name:ident, 8 | Entity: $entity:ty, 9 | $(ForCreate: $for_create:ty,)? 10 | $(ForUpdate: $for_update:ty,)? 11 | $(Filter: $filter:ty,)? 12 | ) => { 13 | impl $struct_name { 14 | $( 15 | pub async fn create( 16 | ctx: &Ctx, 17 | mm: &ModelManager, 18 | entity_c: $for_create, 19 | ) -> Result { 20 | base::create::(ctx, mm, entity_c).await 21 | } 22 | 23 | pub async fn create_many( 24 | ctx: &Ctx, 25 | mm: &ModelManager, 26 | entity_c: Vec<$for_create>, 27 | ) -> Result> { 28 | base::create_many::(ctx, mm, entity_c).await 29 | } 30 | )? 31 | 32 | pub async fn get( 33 | ctx: &Ctx, 34 | mm: &ModelManager, 35 | id: i64, 36 | ) -> Result<$entity> { 37 | base::get::(ctx, mm, id).await 38 | } 39 | 40 | $( 41 | pub async fn first( 42 | ctx: &Ctx, 43 | mm: &ModelManager, 44 | filter: Option>, 45 | list_options: Option, 46 | ) -> Result> { 47 | base::first::(ctx, mm, filter, list_options).await 48 | } 49 | 50 | pub async fn list( 51 | ctx: &Ctx, 52 | mm: &ModelManager, 53 | filter: Option>, 54 | list_options: Option, 55 | ) -> Result> { 56 | base::list::(ctx, mm, filter, list_options).await 57 | } 58 | 59 | pub async fn count( 60 | ctx: &Ctx, 61 | mm: &ModelManager, 62 | filter: Option>, 63 | ) -> Result { 64 | base::count::(ctx, mm, filter).await 65 | } 66 | )? 67 | 68 | $( 69 | pub async fn update( 70 | ctx: &Ctx, 71 | mm: &ModelManager, 72 | id: i64, 73 | entity_u: $for_update, 74 | ) -> Result<()> { 75 | base::update::(ctx, mm, id, entity_u).await 76 | } 77 | )? 78 | 79 | pub async fn delete( 80 | ctx: &Ctx, 81 | mm: &ModelManager, 82 | id: i64, 83 | ) -> Result<()> { 84 | base::delete::(ctx, mm, id).await 85 | } 86 | 87 | pub async fn delete_many( 88 | ctx: &Ctx, 89 | mm: &ModelManager, 90 | ids: Vec, 91 | ) -> Result { 92 | base::delete_many::(ctx, mm, ids).await 93 | } 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/log/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::middleware::mw_req_stamp::ReqStamp; 2 | use crate::handlers::handlers_rpc::RpcInfo; 3 | use crate::error::{Error, ClientError}; 4 | use crate::error::Result; 5 | use axum::http::{Method, Uri}; 6 | use lib_core::ctx::Ctx; 7 | use lib_utils::time::{format_time, now_utc}; 8 | use serde::Serialize; 9 | use serde_json::{json, Value}; 10 | use serde_with::skip_serializing_none; 11 | use time::Duration; 12 | use tracing::debug; 13 | 14 | pub async fn log_request( 15 | http_method: Method, 16 | uri: Uri, 17 | req_stamp: ReqStamp, 18 | rpc_info: Option<&RpcInfo>, 19 | ctx: Option, 20 | web_error: Option<&Error>, 21 | client_error: Option, 22 | ) -> Result<()> { 23 | // -- Prep error 24 | let error_type = web_error.map(|se| se.as_ref().to_string()); 25 | let error_data = serde_json::to_value(web_error) 26 | .ok() 27 | .and_then(|mut v| v.get_mut("data").map(|v| v.take())); 28 | 29 | // -- Prep Req Information 30 | let ReqStamp { uuid, time_in } = req_stamp; 31 | let now = now_utc(); 32 | let duration: Duration = now - time_in; 33 | // duration_ms in milliseconds with microseconds precision. 34 | let duration_ms = (duration.as_seconds_f64() * 1_000_000.).floor() / 1_000.; 35 | 36 | // Create the RequestLogLine 37 | let log_line = RequestLogLine { 38 | uuid: uuid.to_string(), 39 | timestamp: format_time(now), // LogLine timestamp ("time_out") 40 | time_in: format_time(time_in), 41 | duration_ms, 42 | 43 | http_path: uri.to_string(), 44 | http_method: http_method.to_string(), 45 | 46 | rpc_id: rpc_info.and_then(|rpc| rpc.id.as_ref().map(|id| id.to_string())), 47 | rpc_method: rpc_info.map(|rpc| rpc.method.to_string()), 48 | 49 | user_id: ctx.map(|c| c.user_id()), 50 | 51 | client_error_type: client_error.map(|e| e.as_ref().to_string()), 52 | 53 | error_type, 54 | error_data, 55 | }; 56 | 57 | debug!("REQUEST LOG LINE:\n{}", json!(log_line)); 58 | 59 | // TODO - Send to cloud-watch and/or have a `pack_and_send` logic as well (newline json and/or parquet file) 60 | 61 | Ok(()) 62 | } 63 | 64 | #[skip_serializing_none] 65 | #[derive(Serialize)] 66 | struct RequestLogLine { 67 | uuid: String, // uuid string formatted 68 | timestamp: String, // (Rfc3339) 69 | time_in: String, // (Rfc3339) 70 | duration_ms: f64, 71 | 72 | // -- User and context attributes. 73 | user_id: Option, 74 | 75 | // -- http request attributes. 76 | http_path: String, 77 | http_method: String, 78 | 79 | // -- rpc info. 80 | rpc_id: Option, 81 | rpc_method: Option, 82 | 83 | // -- Errors attributes. 84 | client_error_type: Option, 85 | error_type: Option, 86 | error_data: Option, 87 | } 88 | -------------------------------------------------------------------------------- /crates/libs/lib-rpc-core/src/utils/macro_utils.rs: -------------------------------------------------------------------------------- 1 | /// Create the base crud rpc functions following the common pattern. 2 | /// - `create_...` 3 | /// - `get_...` 4 | /// 5 | /// NOTE: Make sure to import the Ctx, ModelManager, ... in the model that uses this macro. 6 | #[macro_export] 7 | macro_rules! generate_common_rpc_fns { 8 | ( 9 | Bmc: $bmc:ident, 10 | Entity: $entity:ty, 11 | ForCreate: $for_create:ty, 12 | ForUpdate: $for_update:ty, 13 | Filter: $filter:ty, 14 | Suffix: $suffix:ident 15 | ) => { 16 | paste! { 17 | pub async fn []( 18 | ctx: Ctx, 19 | mm: ModelManager, 20 | params: ParamsForCreate<$for_create>, 21 | ) -> Result> { 22 | let ParamsForCreate { data } = params; 23 | let id = $bmc::create(&ctx, &mm, data).await?; 24 | let entity = $bmc::get(&ctx, &mm, id).await?; 25 | Ok(entity.into()) 26 | } 27 | 28 | pub async fn []( 29 | ctx: Ctx, 30 | mm: ModelManager, 31 | params: ParamsIded, 32 | ) -> Result> { 33 | let entity = $bmc::get(&ctx, &mm, params.id).await?; 34 | Ok(entity.into()) 35 | } 36 | 37 | // Note: for now just add `s` after the suffix. 38 | pub async fn []( 39 | ctx: Ctx, 40 | mm: ModelManager, 41 | params: ParamsList<$filter>, 42 | ) -> Result>> { 43 | let entities = $bmc::list(&ctx, &mm, params.filters, params.list_options).await?; 44 | Ok(entities.into()) 45 | } 46 | 47 | pub async fn []( 48 | ctx: Ctx, 49 | mm: ModelManager, 50 | params: ParamsForUpdate<$for_update>, 51 | ) -> Result> { 52 | let ParamsForUpdate { id, data } = params; 53 | $bmc::update(&ctx, &mm, id, data).await?; 54 | let entity = $bmc::get(&ctx, &mm, id).await?; 55 | Ok(entity.into()) 56 | } 57 | 58 | pub async fn []( 59 | ctx: Ctx, 60 | mm: ModelManager, 61 | params: ParamsIded, 62 | ) -> Result> { 63 | let ParamsIded { id } = params; 64 | let entity = $bmc::get(&ctx, &mm, id).await?; 65 | $bmc::delete(&ctx, &mm, id).await?; 66 | Ok(entity.into()) 67 | } 68 | } 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/handlers/handlers_login.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::utils::token; 3 | use axum::extract::State; 4 | use axum::Json; 5 | use lib_auth::pwd::{self, ContentToHash, SchemeStatus}; 6 | use lib_core::ctx::Ctx; 7 | use lib_core::model::user::{UserBmc, UserForLogin}; 8 | use lib_core::model::ModelManager; 9 | use serde::Deserialize; 10 | use serde_json::{json, Value}; 11 | use tower_cookies::Cookies; 12 | use tracing::debug; 13 | 14 | // region: --- Login 15 | pub async fn api_login_handler( 16 | State(mm): State, 17 | cookies: Cookies, 18 | Json(payload): Json, 19 | ) -> Result> { 20 | debug!("{:<12} - api_login_handler", "HANDLER"); 21 | 22 | let LoginPayload { 23 | username, 24 | pwd: pwd_clear, 25 | } = payload; 26 | let root_ctx = Ctx::root_ctx(); 27 | 28 | // -- Get the user. 29 | let user: UserForLogin = UserBmc::first_by_username(&root_ctx, &mm, &username) 30 | .await? 31 | .ok_or(Error::LoginFailUsernameNotFound)?; 32 | let user_id = user.id; 33 | 34 | // -- Validate the password. 35 | let Some(pwd) = user.pwd else { 36 | return Err(Error::LoginFailUserHasNoPwd { user_id }); 37 | }; 38 | 39 | let scheme_status = pwd::validate_pwd( 40 | ContentToHash { 41 | salt: user.pwd_salt, 42 | content: pwd_clear.clone(), 43 | }, 44 | pwd, 45 | ) 46 | .await 47 | .map_err(|_| Error::LoginFailPwdNotMatching { user_id })?; 48 | 49 | // -- Update password scheme if needed 50 | if let SchemeStatus::Outdated = scheme_status { 51 | debug!("pwd encrypt scheme outdated, upgrading."); 52 | UserBmc::update_pwd(&root_ctx, &mm, user.id, &pwd_clear).await?; 53 | } 54 | 55 | // -- Set web token. 56 | token::set_token_cookie(&cookies, &user.username, user.token_salt)?; 57 | 58 | // Create the success body. 59 | let body = Json(json!({ 60 | "result": { 61 | "success": true 62 | } 63 | })); 64 | 65 | Ok(body) 66 | } 67 | 68 | #[derive(Debug, Deserialize)] 69 | pub struct LoginPayload { 70 | username: String, 71 | pwd: String, 72 | } 73 | // endregion: --- Login 74 | 75 | // region: --- Logoff 76 | pub async fn api_logoff_handler( 77 | cookies: Cookies, 78 | Json(payload): Json, 79 | ) -> Result> { 80 | debug!("{:<12} - api_logoff_handler", "HANDLER"); 81 | let should_logoff = payload.logoff; 82 | 83 | if should_logoff { 84 | token::remove_token_cookie(&cookies)?; 85 | } 86 | 87 | // Create the success body. 88 | let body = Json(json!({ 89 | "result": { 90 | "logged_off": should_logoff 91 | } 92 | })); 93 | 94 | Ok(body) 95 | } 96 | 97 | #[derive(Debug, Deserialize)] 98 | pub struct LogoffPayload { 99 | logoff: bool, 100 | } 101 | // endregion: --- Logoff 102 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/handlers/handlers_rpc.rs: -------------------------------------------------------------------------------- 1 | use crate::middleware::mw_auth::CtxW; 2 | use axum::extract::State; 3 | use axum::response::{IntoResponse, Response}; 4 | use axum::Json; 5 | use rpc_router::resources_builder; 6 | use serde_json::{json, Value}; 7 | use std::sync::Arc; 8 | 9 | /// RPC ID and Method Capture 10 | /// Note: This will be injected into the Axum Response extensions so that 11 | /// it can be used downstream by the `mw_res_map` for logging and eventual 12 | /// error client JSON-RPC serialization 13 | #[derive(Debug)] 14 | pub struct RpcInfo { 15 | pub id: Option, 16 | pub method: String, 17 | } 18 | 19 | pub async fn rpc_axum_handler( 20 | State(rpc_router): State, 21 | ctx: CtxW, 22 | Json(rpc_req): Json, 23 | ) -> Response { 24 | let ctx = ctx.0; 25 | 26 | // -- Parse and RpcRequest validate the rpc_request 27 | let rpc_req = match rpc_router::RpcRequest::try_from(rpc_req) { 28 | Ok(rpc_req) => rpc_req, 29 | Err(rpc_req_error) => { 30 | let res = crate::Error::RpcRequestParsing(rpc_req_error).into_response(); 31 | return res; 32 | } 33 | }; 34 | 35 | // -- Create the RPC Info 36 | // (will be set to the response.extensions) 37 | let rpc_info = RpcInfo { 38 | id: Some(rpc_req.id.to_value()), 39 | method: rpc_req.method.clone(), 40 | }; 41 | 42 | // -- Add the request specific resources 43 | // Note: Since Ctx is per axum request, we construct additional RPC resources. 44 | // These additional resources will be "overlayed" on top of the base router services, 45 | // meaning they will take precedence over the base router ones, but won't replace them. 46 | let additional_resources = resources_builder![ctx].build(); 47 | 48 | // -- Exec Rpc Route 49 | let rpc_call_result = rpc_router 50 | .call_with_resources(rpc_req, additional_resources) 51 | .await; 52 | 53 | // -- Build Json Rpc Success Response 54 | // Note: Error Json response will be generated in the mw_res_map as wil other error. 55 | let res = rpc_call_result.map(|rpc_call_response| { 56 | let body_response = json!({ 57 | "jsonrpc": "2.0", 58 | "id": rpc_call_response.id, 59 | "result": rpc_call_response.value 60 | }); 61 | Json(body_response) 62 | }); 63 | 64 | // -- Create and Update Axum Response 65 | // Note: We store data in the Axum Response extensions so that 66 | // we can unpack it in the `mw_res_map` for client-side rendering. 67 | // This approach centralizes error handling for the client at the `mw_res_map` module 68 | let res: crate::error::Result<_> = res.map_err(crate::error::Error::from); 69 | let mut res = res.into_response(); 70 | // Note: Here, add the capture RpcInfo (RPC ID and method) into the Axum response to be used 71 | // later in the `mw_res_map` for RequestLineLogging, and eventual JSON-RPC error serialization. 72 | res.extensions_mut().insert(Arc::new(rpc_info)); 73 | 74 | res 75 | } 76 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/error.rs: -------------------------------------------------------------------------------- 1 | use crate::model::store::dbx; 2 | use derive_more::From; 3 | use lib_auth::pwd; 4 | use serde::Serialize; 5 | use serde_with::{serde_as, DisplayFromStr}; 6 | use sqlx::error::DatabaseError; 7 | use std::borrow::Cow; 8 | 9 | pub type Result = core::result::Result; 10 | 11 | #[serde_as] 12 | #[derive(Debug, Serialize, From)] 13 | pub enum Error { 14 | EntityNotFound { 15 | entity: &'static str, 16 | id: i64, 17 | }, 18 | ListLimitOverMax { 19 | max: i64, 20 | actual: i64, 21 | }, 22 | 23 | CountFail, 24 | 25 | // -- DB 26 | UserAlreadyExists { 27 | username: String, 28 | }, 29 | UniqueViolation { 30 | table: String, 31 | constraint: String, 32 | }, 33 | 34 | // -- ModelManager 35 | CantCreateModelManagerProvider(String), 36 | 37 | // -- Modules 38 | #[from] 39 | Pwd(pwd::Error), 40 | #[from] 41 | Dbx(dbx::Error), 42 | 43 | // -- Externals 44 | #[from] 45 | SeaQuery(#[serde_as(as = "DisplayFromStr")] sea_query::error::Error), 46 | 47 | #[from] 48 | ModqlIntoSea(#[serde_as(as = "DisplayFromStr")] modql::filter::IntoSeaError), 49 | } 50 | 51 | impl Error { 52 | /// This function will transform the error into a more precise variant if it is an SQLX or PGError Unique Violation. 53 | /// The resolver can contain a function (table_name: &str, constraint: &str) that may return a specific Error if desired. 54 | /// If the resolver is None, or if the resolver function returns None, it will default to Error::UniqueViolation {table, constraint}. 55 | pub fn resolve_unique_violation(self, resolver: Option) -> Self 56 | where 57 | F: FnOnce(&str, &str) -> Option, 58 | { 59 | match self.as_database_error().map(|db_error| { 60 | (db_error.code(), db_error.table(), db_error.constraint()) 61 | }) { 62 | // "23505" => postgresql "unique violation" 63 | Some((Some(Cow::Borrowed("23505")), Some(table), Some(constraint))) => { 64 | resolver 65 | .and_then(|fun| fun(table, constraint)) 66 | .unwrap_or_else(|| Error::UniqueViolation { 67 | table: table.to_string(), 68 | constraint: constraint.to_string(), 69 | }) 70 | } 71 | _ => self, 72 | } 73 | } 74 | 75 | /// A convenient function to return the eventual database error (Postgres) 76 | /// if this Error is an SQLX Error that contains a database error. 77 | pub fn as_database_error(&self) -> Option<&(dyn DatabaseError + 'static)> { 78 | match self { 79 | Error::Dbx(dbx::Error::Sqlx(sqlx_error)) => { 80 | sqlx_error.as_database_error() 81 | } 82 | _ => None, 83 | } 84 | } 85 | } 86 | 87 | // region: --- Error Boilerplate 88 | 89 | impl core::fmt::Display for Error { 90 | fn fmt( 91 | &self, 92 | fmt: &mut core::fmt::Formatter, 93 | ) -> core::result::Result<(), core::fmt::Error> { 94 | write!(fmt, "{self:?}") 95 | } 96 | } 97 | 98 | impl std::error::Error for Error {} 99 | 100 | // endregion: --- Error Boilerplate 101 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/_dev_utils/dev_db.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::user::{User, UserBmc}; 3 | use crate::model::ModelManager; 4 | use sqlx::postgres::PgPoolOptions; 5 | use sqlx::{Pool, Postgres}; 6 | use std::fs; 7 | use std::path::{Path, PathBuf}; 8 | use std::time::Duration; 9 | use tracing::info; 10 | 11 | type Db = Pool; 12 | 13 | // NOTE: Hardcode to prevent deployed system db update. 14 | const PG_DEV_POSTGRES_URL: &str = "postgres://postgres:welcome@localhost/postgres"; 15 | const PG_DEV_APP_URL: &str = "postgres://app_user:dev_only_pwd@localhost/app_db"; 16 | 17 | // sql files 18 | const SQL_RECREATE_DB_FILE_NAME: &str = "00-recreate-db.sql"; 19 | const SQL_DIR: &str = "sql/dev_initial"; 20 | 21 | const DEMO_PWD: &str = "welcome"; 22 | 23 | pub async fn init_dev_db() -> Result<(), Box> { 24 | info!("{:<12} - init_dev_db()", "FOR-DEV-ONLY"); 25 | 26 | // -- Get the sql_dir 27 | // Note: This is because cargo test and cargo run won't give the same 28 | // current_dir given the worspace layout. 29 | let current_dir = std::env::current_dir().unwrap(); 30 | let v: Vec<_> = current_dir.components().collect(); 31 | let path_comp = v.get(v.len().wrapping_sub(3)); 32 | let base_dir = if Some(true) == path_comp.map(|c| c.as_os_str() == "crates") { 33 | v[..v.len() - 3].iter().collect::() 34 | } else { 35 | current_dir.clone() 36 | }; 37 | let sql_dir = base_dir.join(SQL_DIR); 38 | 39 | // -- Create the app_db/app_user with the postgres user. 40 | { 41 | let sql_recreate_db_file = sql_dir.join(SQL_RECREATE_DB_FILE_NAME); 42 | let root_db = new_db_pool(PG_DEV_POSTGRES_URL).await?; 43 | pexec(&root_db, &sql_recreate_db_file).await?; 44 | } 45 | 46 | // -- Get sql files. 47 | let mut paths: Vec = fs::read_dir(sql_dir)? 48 | .filter_map(|entry| entry.ok().map(|e| e.path())) 49 | .collect(); 50 | paths.sort(); 51 | 52 | // -- SQL Execute each file. 53 | let app_db = new_db_pool(PG_DEV_APP_URL).await?; 54 | 55 | for path in paths { 56 | let path_str = path.to_string_lossy(); 57 | 58 | if path_str.ends_with(".sql") 59 | && !path_str.ends_with(SQL_RECREATE_DB_FILE_NAME) 60 | { 61 | pexec(&app_db, &path).await?; 62 | } 63 | } 64 | 65 | // -- Init model layer. 66 | let mm = ModelManager::new().await?; 67 | let ctx = Ctx::root_ctx(); 68 | 69 | // -- Set demo1 pwd 70 | let demo1_user: User = UserBmc::first_by_username(&ctx, &mm, "demo1") 71 | .await? 72 | .unwrap(); 73 | UserBmc::update_pwd(&ctx, &mm, demo1_user.id, DEMO_PWD).await?; 74 | info!("{:<12} - init_dev_db - set demo1 pwd", "FOR-DEV-ONLY"); 75 | 76 | Ok(()) 77 | } 78 | 79 | async fn pexec(db: &Db, file: &Path) -> Result<(), sqlx::Error> { 80 | info!("{:<12} - pexec: {file:?}", "FOR-DEV-ONLY"); 81 | 82 | // -- Read the file. 83 | let content = fs::read_to_string(file)?; 84 | 85 | // FIXME: Make the split more sql proof. 86 | let sqls: Vec<&str> = content.split(';').collect(); 87 | 88 | for sql in sqls { 89 | sqlx::query(sql).execute(db).await.map_err(|e| { 90 | println!("pexec error while running:\n{sql}"); 91 | println!("cause:\n{e}"); 92 | e 93 | })?; 94 | } 95 | 96 | Ok(()) 97 | } 98 | 99 | async fn new_db_pool(db_con_url: &str) -> Result { 100 | PgPoolOptions::new() 101 | .max_connections(1) 102 | .acquire_timeout(Duration::from_millis(500)) 103 | .connect(db_con_url) 104 | .await 105 | } 106 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/conv_msg.rs: -------------------------------------------------------------------------------- 1 | use crate::model::base::DbBmc; 2 | use crate::model::conv::ConvScoped; 3 | use crate::model::modql_utils::time_to_sea_value; 4 | use lib_utils::time::Rfc3339; 5 | use modql::field::Fields; 6 | use modql::filter::{FilterNodes, OpValsInt64, OpValsString, OpValsValue}; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_with::serde_as; 9 | use sqlx::FromRow; 10 | use time::OffsetDateTime; 11 | 12 | // region: --- Types 13 | 14 | #[serde_as] 15 | #[derive(Debug, Clone, Fields, FromRow, Serialize)] 16 | pub struct ConvMsg { 17 | pub id: i64, 18 | 19 | // -- FK 20 | pub conv_id: i64, 21 | pub user_id: i64, 22 | 23 | // -- Properties 24 | pub content: String, 25 | 26 | // -- Timestamps 27 | // creator user_id and time 28 | pub cid: i64, 29 | #[serde_as(as = "Rfc3339")] 30 | pub ctime: OffsetDateTime, 31 | // last modifier user_id and time 32 | pub mid: i64, 33 | #[serde_as(as = "Rfc3339")] 34 | pub mtime: OffsetDateTime, 35 | } 36 | 37 | impl ConvScoped for ConvMsg { 38 | fn conv_id(&self) -> i64 { 39 | self.conv_id 40 | } 41 | } 42 | 43 | #[derive(Fields, Deserialize)] 44 | pub struct ConvMsgForCreate { 45 | pub conv_id: i64, 46 | pub content: String, 47 | } 48 | 49 | impl ConvScoped for ConvMsgForCreate { 50 | fn conv_id(&self) -> i64 { 51 | self.conv_id 52 | } 53 | } 54 | 55 | /// ConvMsg for Insert, which is derived from the public `ConvMsgForCreate`. 56 | /// 57 | /// Notes: 58 | /// - When `...ForCreate` requires additional information for insertion into the DB, the pattern 59 | /// is to create a `...ForInsert` type, visible only in the model layer. 60 | /// - This approach maintains a simple and ergonomic public API while ensuring 61 | /// strong typing for database insertion. 62 | /// - Exceptions apply to lower-level attributes like cid, ctime, mid, mtime, and owner_id, 63 | /// which can be set directly through the base:: functions or some utilities. There's not 64 | /// significant value in introducing `...ForInsert` types for all entities just for these 65 | /// common, low-level database properties. 66 | #[derive(Fields, Deserialize)] 67 | pub(in crate::model) struct ConvMsgForInsert { 68 | pub conv_id: i64, 69 | pub user_id: i64, 70 | pub content: String, 71 | } 72 | 73 | impl ConvMsgForInsert { 74 | pub fn from_msg_for_create(user_id: i64, msg_c: ConvMsgForCreate) -> Self { 75 | Self { 76 | conv_id: msg_c.conv_id, 77 | user_id, 78 | content: msg_c.content, 79 | } 80 | } 81 | } 82 | 83 | #[derive(Fields, Deserialize, Default)] 84 | pub struct ConvMsgForUpdate { 85 | pub conv_id: i64, 86 | pub content: Option, 87 | } 88 | 89 | impl ConvScoped for ConvMsgForUpdate { 90 | fn conv_id(&self) -> i64 { 91 | self.conv_id 92 | } 93 | } 94 | 95 | #[derive(FilterNodes, Deserialize, Default, Debug)] 96 | pub struct ConvMsgFilter { 97 | id: Option, 98 | 99 | conv_id: Option, 100 | content: Option, 101 | 102 | cid: Option, 103 | #[modql(to_sea_value_fn = "time_to_sea_value")] 104 | ctime: Option, 105 | mid: Option, 106 | #[modql(to_sea_value_fn = "time_to_sea_value")] 107 | mtime: Option, 108 | } 109 | 110 | // endregion: --- Types 111 | 112 | // region: --- ConvMsgBmc 113 | 114 | pub struct ConvMsgBmc; 115 | 116 | impl DbBmc for ConvMsgBmc { 117 | const TABLE: &'static str = "conv_msg"; 118 | } 119 | 120 | // Note: The strategy here is to not implement `ConvMsgBmc` CRUD functions, 121 | // as they will be managed directly from the `ConvBmc` construct, 122 | // for instance with `ConvBmc::add_msg`. 123 | // This is because `ConvMsg` is an leaf entity better managed by its container `ConvBmc`. 124 | 125 | // endregion: --- ConvMsgBmc 126 | -------------------------------------------------------------------------------- /sql/dev_initial/01-create-schema.sql: -------------------------------------------------------------------------------- 1 | ---- Base app schema 2 | 3 | -- Org 4 | CREATE TABLE "org" ( 5 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 6 | name varchar(128) NOT NULL UNIQUE, 7 | 8 | -- Timestamps 9 | cid bigint NOT NULL, 10 | ctime timestamp with time zone NOT NULL, 11 | mid bigint NOT NULL, 12 | mtime timestamp with time zone NOT NULL 13 | ); 14 | 15 | -- User 16 | CREATE TYPE user_typ AS ENUM ('Sys', 'User'); 17 | 18 | CREATE TABLE "user" ( 19 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 20 | 21 | username varchar(128) NOT NULL UNIQUE, 22 | typ user_typ NOT NULL DEFAULT 'User', 23 | 24 | -- Auth 25 | pwd varchar(256), 26 | pwd_salt uuid NOT NULL DEFAULT gen_random_uuid(), 27 | token_salt uuid NOT NULL DEFAULT gen_random_uuid(), 28 | 29 | -- Timestamps 30 | cid bigint NOT NULL, 31 | ctime timestamp with time zone NOT NULL, 32 | mid bigint NOT NULL, 33 | mtime timestamp with time zone NOT NULL 34 | ); 35 | 36 | -- Agent 37 | 38 | CREATE TABLE agent ( 39 | -- PK 40 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 41 | 42 | -- FKs 43 | owner_id BIGINT NOT NULL, 44 | 45 | -- Properties 46 | name varchar(256) NOT NULL, 47 | ai_provider varchar(256) NOT NULL default 'dev', -- For now only support 'dev' provider 48 | ai_model varchar(256) NOT NULL default 'parrot', -- For now only support 'parrot' model 49 | 50 | -- Timestamps 51 | cid bigint NOT NULL, 52 | ctime timestamp with time zone NOT NULL, 53 | mid bigint NOT NULL, 54 | mtime timestamp with time zone NOT NULL 55 | ); 56 | 57 | -- Conv 58 | CREATE TYPE conv_kind AS ENUM ('OwnerOnly', 'MultiUsers'); 59 | 60 | CREATE TYPE conv_state AS ENUM ('Active', 'Archived'); 61 | 62 | CREATE TABLE conv ( 63 | -- PK 64 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 65 | 66 | -- FKs 67 | owner_id BIGINT NOT NULL, 68 | agent_id BIGINT NOT NULL, 69 | 70 | -- Properties 71 | title varchar(256), 72 | kind conv_kind NOT NULL default 'OwnerOnly', 73 | state conv_state NOT NULL default 'Active', 74 | 75 | -- Timestamps 76 | cid bigint NOT NULL, 77 | ctime timestamp with time zone NOT NULL, 78 | mid bigint NOT NULL, 79 | mtime timestamp with time zone NOT NULL 80 | ); 81 | 82 | ALTER TABLE conv ADD CONSTRAINT fk_conv_agent 83 | FOREIGN KEY (agent_id) REFERENCES "agent"(id) 84 | ON DELETE CASCADE; 85 | 86 | 87 | -- Conv Participants 88 | CREATE TABLE conv_user ( 89 | -- PK 90 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 91 | 92 | -- Properties / FKs 93 | conv_id BIGINT NOT NULL, 94 | user_id BIGINT NOT NULL, 95 | 96 | -- Machine User Properties 97 | auto_respond BOOLEAN NOT NULL DEFAULT false, 98 | 99 | -- Timestamps 100 | cid bigint NOT NULL, 101 | ctime timestamp with time zone NOT NULL, 102 | mid bigint NOT NULL, 103 | mtime timestamp with time zone NOT NULL 104 | ); 105 | 106 | -- Conv Messages 107 | CREATE TABLE conv_msg ( 108 | -- PK 109 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 110 | 111 | -- FKs 112 | conv_id BIGINT NOT NULL, 113 | user_id BIGINT NOT NULL, -- should be came as cid 114 | 115 | -- Properties 116 | content varchar(1024) NOT NULL, 117 | 118 | -- Timestamps 119 | cid bigint NOT NULL, 120 | ctime timestamp with time zone NOT NULL, 121 | mid bigint NOT NULL, 122 | mtime timestamp with time zone NOT NULL 123 | ); 124 | 125 | ALTER TABLE conv_msg ADD CONSTRAINT fk_conv_msg_conv 126 | FOREIGN KEY (conv_id) REFERENCES "conv"(id) 127 | ON DELETE CASCADE; 128 | 129 | ALTER TABLE conv_user ADD CONSTRAINT fk_conv_user_conv 130 | FOREIGN KEY (user_id) REFERENCES "user"(id) 131 | ON DELETE CASCADE; 132 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/middleware/mw_auth.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::utils::token::{set_token_cookie, AUTH_TOKEN}; 3 | use axum::body::Body; 4 | use axum::extract::{FromRequestParts, State}; 5 | use axum::http::request::Parts; 6 | use axum::http::Request; 7 | use axum::middleware::Next; 8 | use axum::response::Response; 9 | use lib_auth::token::{validate_web_token, Token}; 10 | use lib_core::ctx::Ctx; 11 | use lib_core::model::user::{UserBmc, UserForAuth}; 12 | use lib_core::model::ModelManager; 13 | use serde::Serialize; 14 | use tower_cookies::{Cookie, Cookies}; 15 | use tracing::debug; 16 | 17 | pub async fn mw_ctx_require( 18 | ctx: Result, 19 | req: Request, 20 | next: Next, 21 | ) -> Result { 22 | debug!("{:<12} - mw_ctx_require - {ctx:?}", "MIDDLEWARE"); 23 | 24 | ctx?; 25 | 26 | Ok(next.run(req).await) 27 | } 28 | 29 | // IMPORTANT: This resolver must never fail, but rather capture the potential Auth error and put in in the 30 | // request extension as CtxExtResult. 31 | // This way it won't prevent downstream middleware to be executed, and will still capture the error 32 | // for the appropriate middleware (.e.g., mw_ctx_require which forces successful auth) or handler 33 | // to get the appropriate information. 34 | pub async fn mw_ctx_resolver( 35 | State(mm): State, 36 | cookies: Cookies, 37 | mut req: Request, 38 | next: Next, 39 | ) -> Response { 40 | debug!("{:<12} - mw_ctx_resolve", "MIDDLEWARE"); 41 | 42 | let ctx_ext_result = ctx_resolve(mm, &cookies).await; 43 | 44 | if ctx_ext_result.is_err() 45 | && !matches!(ctx_ext_result, Err(CtxExtError::TokenNotInCookie)) 46 | { 47 | cookies.remove(Cookie::from(AUTH_TOKEN)) 48 | } 49 | 50 | // Store the ctx_ext_result in the request extension 51 | // (for Ctx extractor). 52 | req.extensions_mut().insert(ctx_ext_result); 53 | 54 | next.run(req).await 55 | } 56 | 57 | async fn ctx_resolve(mm: ModelManager, cookies: &Cookies) -> CtxExtResult { 58 | // -- Get Token String 59 | let token = cookies 60 | .get(AUTH_TOKEN) 61 | .map(|c| c.value().to_string()) 62 | .ok_or(CtxExtError::TokenNotInCookie)?; 63 | 64 | // -- Parse Token 65 | let token: Token = token.parse().map_err(|_| CtxExtError::TokenWrongFormat)?; 66 | 67 | // -- Get UserForAuth 68 | let user: UserForAuth = 69 | UserBmc::first_by_username(&Ctx::root_ctx(), &mm, &token.ident) 70 | .await 71 | .map_err(|ex| CtxExtError::ModelAccessError(ex.to_string()))? 72 | .ok_or(CtxExtError::UserNotFound)?; 73 | 74 | // -- Validate Token 75 | validate_web_token(&token, user.token_salt) 76 | .map_err(|_| CtxExtError::FailValidate)?; 77 | 78 | // -- Update Token 79 | set_token_cookie(cookies, &user.username, user.token_salt) 80 | .map_err(|_| CtxExtError::CannotSetTokenCookie)?; 81 | 82 | // -- Create CtxExtResult 83 | Ctx::new(user.id) 84 | .map(CtxW) 85 | .map_err(|ex| CtxExtError::CtxCreateFail(ex.to_string())) 86 | } 87 | 88 | // region: --- Ctx Extractor 89 | #[derive(Debug, Clone)] 90 | pub struct CtxW(pub Ctx); 91 | 92 | impl FromRequestParts for CtxW { 93 | type Rejection = Error; 94 | 95 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 96 | debug!("{:<12} - Ctx", "EXTRACTOR"); 97 | 98 | parts 99 | .extensions 100 | .get::() 101 | .ok_or(Error::CtxExt(CtxExtError::CtxNotInRequestExt))? 102 | .clone() 103 | .map_err(Error::CtxExt) 104 | } 105 | } 106 | // endregion: --- Ctx Extractor 107 | 108 | // region: --- Ctx Extractor Result/Error 109 | type CtxExtResult = core::result::Result; 110 | 111 | #[derive(Clone, Serialize, Debug)] 112 | pub enum CtxExtError { 113 | TokenNotInCookie, 114 | TokenWrongFormat, 115 | 116 | UserNotFound, 117 | ModelAccessError(String), 118 | FailValidate, 119 | CannotSetTokenCookie, 120 | 121 | CtxNotInRequestExt, 122 | CtxCreateFail(String), 123 | } 124 | // endregion: --- Ctx Extractor Result/Error 125 | -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/pwd/mod.rs: -------------------------------------------------------------------------------- 1 | //! The pwd module is responsible for hashing and validating hashes. 2 | //! It follows a multi-scheme hashing code design, allowing each 3 | //! scheme to provide its own hashing and validation methods. 4 | //! 5 | //! Code Design Points: 6 | //! 7 | //! - Exposes two public async functions `hash_pwd(...)` and `validate_pwd(...)` 8 | //! - `ContentToHash` represents the data to be hashed along with the corresponding salt. 9 | //! - `SchemeStatus` is the result of `validate_pwd` which, upon successful validation, indicates 10 | //! whether the password needs to be re-hashed to adopt the latest scheme. 11 | //! - Internally, the `pwd` module implements a multi-scheme code design with the `Scheme` trait. 12 | //! - The `Scheme` trait exposes sync functions `hash` and `validate` to be implemented for each scheme. 13 | //! - The two public async functions `hash_pwd(...)` and `validate_pwd(...)` call the scheme using 14 | //! `spawn_blocking` to ensure that long hashing/validation processes do not hinder the execution of smaller tasks. 15 | //! - Schemes are designed to be agnostic of whether they are in an async or sync context, hence they are async-free. 16 | 17 | // region: --- Modules; 18 | 19 | mod error; 20 | mod scheme; 21 | 22 | pub use self::error::{Error, Result}; 23 | pub use scheme::SchemeStatus; 24 | 25 | use crate::pwd::scheme::{get_scheme, Scheme, DEFAULT_SCHEME}; 26 | use lazy_regex::regex_captures; 27 | use std::str::FromStr; 28 | use uuid::Uuid; 29 | 30 | // endregion: --- Modules 31 | 32 | // region: --- Types 33 | 34 | /// The clean content to hash, with the salt. 35 | /// 36 | /// Notes: 37 | /// - Since content is sensitive information, we do NOT implement default debug for this struct. 38 | /// - The clone is only implement for testing 39 | #[cfg_attr(test, derive(Clone))] 40 | pub struct ContentToHash { 41 | pub content: String, // Clear content. 42 | pub salt: Uuid, 43 | } 44 | 45 | // endregion: --- Types 46 | 47 | // region: --- Public Functions 48 | 49 | /// Hash the password with the default scheme. 50 | pub async fn hash_pwd(to_hash: ContentToHash) -> Result { 51 | tokio::task::spawn_blocking(move || hash_for_scheme(DEFAULT_SCHEME, to_hash)) 52 | .await 53 | .map_err(|_| Error::FailSpawnBlockForHash)? 54 | } 55 | 56 | /// Validate if an ContentToHash matches. 57 | pub async fn validate_pwd( 58 | to_hash: ContentToHash, 59 | pwd_ref: String, 60 | ) -> Result { 61 | let PwdParts { 62 | scheme_name, 63 | hashed, 64 | } = pwd_ref.parse()?; 65 | 66 | // Note: We do first, so that we do not have to clonse the scheme_name. 67 | let scheme_status = if scheme_name == DEFAULT_SCHEME { 68 | SchemeStatus::Ok 69 | } else { 70 | SchemeStatus::Outdated 71 | }; 72 | 73 | // Note: Since validate might take some time depending on algo 74 | // doing a spawn_blocking to avoid 75 | tokio::task::spawn_blocking(move || { 76 | validate_for_scheme(&scheme_name, to_hash, hashed) 77 | }) 78 | .await 79 | .map_err(|_| Error::FailSpawnBlockForValidate)??; 80 | 81 | // validate_for_scheme(&scheme_name, to_hash, &hashed).await?; 82 | Ok(scheme_status) 83 | } 84 | // endregion: --- Public Functions 85 | 86 | // region: --- Privates 87 | 88 | fn hash_for_scheme(scheme_name: &str, to_hash: ContentToHash) -> Result { 89 | let pwd_hashed = get_scheme(scheme_name)?.hash(&to_hash)?; 90 | 91 | Ok(format!("#{scheme_name}#{pwd_hashed}")) 92 | } 93 | 94 | fn validate_for_scheme( 95 | scheme_name: &str, 96 | to_hash: ContentToHash, 97 | pwd_ref: String, 98 | ) -> Result<()> { 99 | get_scheme(scheme_name)?.validate(&to_hash, &pwd_ref)?; 100 | Ok(()) 101 | } 102 | 103 | struct PwdParts { 104 | /// The scheme only (e.g., "01") 105 | scheme_name: String, 106 | /// The hashed password, 107 | hashed: String, 108 | } 109 | 110 | impl FromStr for PwdParts { 111 | type Err = Error; 112 | 113 | fn from_str(pwd_with_scheme: &str) -> Result { 114 | regex_captures!( 115 | r#"^#(\w+)#(.*)"#, // a literal regex 116 | pwd_with_scheme 117 | ) 118 | .map(|(_, scheme, hashed)| Self { 119 | scheme_name: scheme.to_string(), 120 | hashed: hashed.to_string(), 121 | }) 122 | .ok_or(Error::PwdWithSchemeFailedParse) 123 | } 124 | } 125 | 126 | // endregion: --- Privates 127 | 128 | // region: --- Tests 129 | #[cfg(test)] 130 | mod tests { 131 | pub type Result = core::result::Result; 132 | pub type Error = Box; // For tests. 133 | 134 | use super::*; 135 | 136 | #[tokio::test] 137 | async fn test_multi_scheme_ok() -> Result<()> { 138 | // -- Setup & Fixtures 139 | let fx_salt = Uuid::parse_str("f05e8961-d6ad-4086-9e78-a6de065e5453")?; 140 | let fx_to_hash = ContentToHash { 141 | content: "hello world".to_string(), 142 | salt: fx_salt, 143 | }; 144 | 145 | // -- Exec 146 | let pwd_hashed = hash_for_scheme("01", fx_to_hash.clone())?; 147 | let pwd_validate = validate_pwd(fx_to_hash.clone(), pwd_hashed).await?; 148 | 149 | // -- Check 150 | assert!( 151 | matches!(pwd_validate, SchemeStatus::Outdated), 152 | "status should be SchemeStatus::Outdated" 153 | ); 154 | 155 | Ok(()) 156 | } 157 | } 158 | // endregion: --- Tests 159 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/_dev_utils/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod dev_db; 4 | 5 | use crate::ctx::Ctx; 6 | use crate::model::agent::{AgentBmc, AgentFilter, AgentForCreate}; 7 | use crate::model::conv::{ConvBmc, ConvForCreate}; 8 | use crate::model::{self, ModelManager}; 9 | use modql::filter::OpValString; 10 | use tokio::sync::OnceCell; 11 | use tracing::info; 12 | 13 | // endregion: --- Modules 14 | 15 | /// Initialize environment for local development. 16 | /// (for early development, will be called from main()). 17 | pub async fn init_dev() { 18 | static INIT: OnceCell<()> = OnceCell::const_new(); 19 | 20 | INIT.get_or_init(|| async { 21 | info!("{:<12} - init_dev_all()", "FOR-DEV-ONLY"); 22 | 23 | dev_db::init_dev_db().await.unwrap(); 24 | }) 25 | .await; 26 | } 27 | 28 | /// Initialize test environment. 29 | pub async fn init_test() -> ModelManager { 30 | static INIT: OnceCell = OnceCell::const_new(); 31 | 32 | let mm = INIT 33 | .get_or_init(|| async { 34 | init_dev().await; 35 | // NOTE: Rare occasion where unwrap is kind of ok. 36 | ModelManager::new().await.unwrap() 37 | }) 38 | .await; 39 | 40 | mm.clone() 41 | } 42 | 43 | // region: --- User seed/clean 44 | 45 | pub async fn seed_users( 46 | ctx: &Ctx, 47 | mm: &ModelManager, 48 | usernames: &[&str], 49 | ) -> model::Result> { 50 | let mut ids = Vec::new(); 51 | 52 | for name in usernames { 53 | let id = seed_user(ctx, mm, name).await?; 54 | ids.push(id); 55 | } 56 | 57 | Ok(ids) 58 | } 59 | 60 | pub async fn seed_user( 61 | ctx: &Ctx, 62 | mm: &ModelManager, 63 | username: &str, 64 | ) -> model::Result { 65 | let pwd_clear = "seed-user-pwd"; 66 | 67 | let id = model::user::UserBmc::create( 68 | ctx, 69 | mm, 70 | model::user::UserForCreate { 71 | username: username.to_string(), 72 | pwd_clear: pwd_clear.to_string(), 73 | }, 74 | ) 75 | .await?; 76 | 77 | Ok(id) 78 | } 79 | 80 | pub async fn clean_users( 81 | ctx: &Ctx, 82 | mm: &ModelManager, 83 | contains_username: &str, 84 | ) -> model::Result { 85 | let users = model::user::UserBmc::list( 86 | ctx, 87 | mm, 88 | Some(vec![model::user::UserFilter { 89 | username: Some( 90 | OpValString::Contains(contains_username.to_string()).into(), 91 | ), 92 | ..Default::default() 93 | }]), 94 | None, 95 | ) 96 | .await?; 97 | let count = users.len(); 98 | 99 | for user in users { 100 | model::user::UserBmc::delete(ctx, mm, user.id).await?; 101 | } 102 | 103 | Ok(count) 104 | } 105 | 106 | // endregion: --- User seed/clean 107 | 108 | // region: --- Conv seed/clean 109 | 110 | pub async fn seed_convs( 111 | ctx: &Ctx, 112 | mm: &ModelManager, 113 | agent_id: i64, 114 | titles: &[&str], 115 | ) -> model::Result> { 116 | let mut ids = Vec::new(); 117 | 118 | for title in titles { 119 | let id = seed_conv(ctx, mm, agent_id, title).await?; 120 | ids.push(id); 121 | } 122 | 123 | Ok(ids) 124 | } 125 | 126 | pub async fn seed_conv( 127 | ctx: &Ctx, 128 | mm: &ModelManager, 129 | agent_id: i64, 130 | title: &str, 131 | ) -> model::Result { 132 | ConvBmc::create( 133 | ctx, 134 | mm, 135 | ConvForCreate { 136 | agent_id, 137 | title: Some(title.to_string()), 138 | ..Default::default() 139 | }, 140 | ) 141 | .await 142 | } 143 | 144 | pub async fn clean_convs( 145 | ctx: &Ctx, 146 | mm: &ModelManager, 147 | contains_title: &str, 148 | ) -> model::Result { 149 | let convs = ConvBmc::list( 150 | ctx, 151 | mm, 152 | Some(vec![model::conv::ConvFilter { 153 | title: Some(OpValString::Contains(contains_title.to_string()).into()), 154 | ..Default::default() 155 | }]), 156 | None, 157 | ) 158 | .await?; 159 | 160 | let count = convs.len(); 161 | 162 | for conv in convs { 163 | ConvBmc::delete(ctx, mm, conv.id).await?; 164 | } 165 | 166 | Ok(count) 167 | } 168 | 169 | // endregion: --- Conv seed/clean 170 | 171 | // region: --- Agent seed/clean 172 | 173 | pub async fn seed_agents( 174 | ctx: &Ctx, 175 | mm: &ModelManager, 176 | names: &[&str], 177 | ) -> model::Result> { 178 | let mut ids = Vec::new(); 179 | 180 | for name in names { 181 | let id = seed_agent(ctx, mm, name).await?; 182 | ids.push(id); 183 | } 184 | 185 | Ok(ids) 186 | } 187 | 188 | pub async fn seed_agent( 189 | ctx: &Ctx, 190 | mm: &ModelManager, 191 | name: &str, 192 | ) -> model::Result { 193 | AgentBmc::create( 194 | ctx, 195 | mm, 196 | AgentForCreate { 197 | name: name.to_string(), 198 | }, 199 | ) 200 | .await 201 | } 202 | 203 | /// Delete all agents that have their title contains contains_name 204 | pub async fn clean_agents( 205 | ctx: &Ctx, 206 | mm: &ModelManager, 207 | contains_name: &str, 208 | ) -> model::Result { 209 | let agents = AgentBmc::list( 210 | ctx, 211 | mm, 212 | Some(vec![AgentFilter { 213 | name: Some(OpValString::Contains(contains_name.to_string()).into()), 214 | ..Default::default() 215 | }]), 216 | None, 217 | ) 218 | .await?; 219 | let count = agents.len(); 220 | 221 | for agent in agents { 222 | AgentBmc::delete(ctx, mm, agent.id).await?; 223 | } 224 | 225 | Ok(count) 226 | } 227 | 228 | // endregion: --- Agent seed/clean 229 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/store/dbx/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | 5 | pub use error::{Error, Result}; 6 | 7 | use crate::model::store::Db; 8 | use sqlx::query::{Query, QueryAs}; 9 | use sqlx::{FromRow, IntoArguments, Pool, Postgres, Transaction}; 10 | use std::ops::{Deref, DerefMut}; 11 | use std::sync::Arc; 12 | use tokio::sync::Mutex; 13 | 14 | // endregion: --- Modules 15 | 16 | #[derive(Debug, Clone)] 17 | pub struct Dbx { 18 | db_pool: Db, 19 | txn_holder: Arc>>, 20 | with_txn: bool, 21 | } 22 | 23 | impl Dbx { 24 | pub fn new(db_pool: Db, with_txn: bool) -> Result { 25 | Ok(Dbx { 26 | db_pool, 27 | txn_holder: Arc::default(), 28 | with_txn, 29 | }) 30 | } 31 | } 32 | 33 | #[derive(Debug)] 34 | struct TxnHolder { 35 | txn: Transaction<'static, Postgres>, 36 | counter: i32, 37 | } 38 | 39 | impl TxnHolder { 40 | fn new(txn: Transaction<'static, Postgres>) -> Self { 41 | TxnHolder { txn, counter: 1 } 42 | } 43 | 44 | fn inc(&mut self) { 45 | self.counter += 1; 46 | } 47 | 48 | fn dec(&mut self) -> i32 { 49 | self.counter -= 1; 50 | self.counter 51 | } 52 | } 53 | 54 | impl Deref for TxnHolder { 55 | type Target = Transaction<'static, Postgres>; 56 | 57 | fn deref(&self) -> &Self::Target { 58 | &self.txn 59 | } 60 | } 61 | 62 | impl DerefMut for TxnHolder { 63 | fn deref_mut(&mut self) -> &mut Self::Target { 64 | &mut self.txn 65 | } 66 | } 67 | 68 | impl Dbx { 69 | pub async fn begin_txn(&self) -> Result<()> { 70 | if !self.with_txn { 71 | return Err(Error::CannotBeginTxnWithTxnFalse); 72 | } 73 | 74 | let mut txh_g = self.txn_holder.lock().await; 75 | // If we already have a tx holder, then, we increment 76 | if let Some(txh) = txh_g.as_mut() { 77 | txh.inc(); 78 | } 79 | // If not, we create one with a new transaction 80 | else { 81 | let transaction = self.db_pool.begin().await?; 82 | let _ = txh_g.insert(TxnHolder::new(transaction)); 83 | } 84 | 85 | Ok(()) 86 | } 87 | 88 | pub async fn rollback_txn(&self) -> Result<()> { 89 | let mut txh_g = self.txn_holder.lock().await; 90 | if let Some(mut txn_holder) = txh_g.take() { 91 | // Take the TxnHolder out of the Option 92 | if txn_holder.counter > 1 { 93 | txn_holder.counter -= 1; 94 | let _ = txh_g.replace(txn_holder); // Put it back if not the last reference 95 | } else { 96 | // Perform the actual rollback 97 | txn_holder.txn.rollback().await?; 98 | // No need to replace, as we want to leave it as None 99 | } 100 | Ok(()) 101 | } else { 102 | Err(Error::NoTxn) 103 | } 104 | } 105 | 106 | pub async fn commit_txn(&self) -> Result<()> { 107 | if !self.with_txn { 108 | return Err(Error::CannotCommitTxnWithTxnFalse); 109 | } 110 | 111 | let mut txh_g = self.txn_holder.lock().await; 112 | if let Some(txh) = txh_g.as_mut() { 113 | let counter = txh.dec(); 114 | // If 0, then, it should be matching commit for the first first begin_txn 115 | // so we can commit. 116 | if counter == 0 { 117 | // here we take the txh out of the option 118 | if let Some(txn) = txh_g.take() { 119 | txn.txn.commit().await?; 120 | } // TODO: Might want to add a warning on the else. 121 | } // TODO: Might want to add a warning on the else. 122 | 123 | Ok(()) 124 | } 125 | // Ohterwise, we have an error 126 | else { 127 | Err(Error::TxnCantCommitNoOpenTxn) 128 | } 129 | } 130 | 131 | pub fn db(&self) -> &Pool { 132 | &self.db_pool 133 | } 134 | 135 | pub async fn fetch_one<'q, O, A>( 136 | &self, 137 | query: QueryAs<'q, Postgres, O, A>, 138 | ) -> Result 139 | where 140 | O: for<'r> FromRow<'r, ::Row> + Send + Unpin, 141 | A: IntoArguments<'q, Postgres> + 'q, 142 | { 143 | let data = if self.with_txn { 144 | let mut txh_g = self.txn_holder.lock().await; 145 | if let Some(txn) = txh_g.as_deref_mut() { 146 | query.fetch_one(txn.as_mut()).await? 147 | } else { 148 | query.fetch_one(self.db()).await? 149 | } 150 | } else { 151 | query.fetch_one(self.db()).await? 152 | }; 153 | 154 | Ok(data) 155 | } 156 | 157 | pub async fn fetch_optional<'q, O, A>( 158 | &self, 159 | query: QueryAs<'q, Postgres, O, A>, 160 | ) -> Result> 161 | where 162 | O: for<'r> FromRow<'r, ::Row> + Send + Unpin, 163 | A: IntoArguments<'q, Postgres> + 'q, 164 | { 165 | let data = if self.with_txn { 166 | let mut txh_g = self.txn_holder.lock().await; 167 | if let Some(txn) = txh_g.as_deref_mut() { 168 | query.fetch_optional(txn.as_mut()).await? 169 | } else { 170 | query.fetch_optional(self.db()).await? 171 | } 172 | } else { 173 | query.fetch_optional(self.db()).await? 174 | }; 175 | 176 | Ok(data) 177 | } 178 | 179 | pub async fn fetch_all<'q, O, A>( 180 | &self, 181 | query: QueryAs<'q, Postgres, O, A>, 182 | ) -> Result> 183 | where 184 | O: for<'r> FromRow<'r, ::Row> + Send + Unpin, 185 | A: IntoArguments<'q, Postgres> + 'q, 186 | { 187 | let data = if self.with_txn { 188 | let mut txh_g = self.txn_holder.lock().await; 189 | if let Some(txn) = txh_g.as_deref_mut() { 190 | query.fetch_all(txn.as_mut()).await? 191 | } else { 192 | query.fetch_all(self.db()).await? 193 | } 194 | } else { 195 | query.fetch_all(self.db()).await? 196 | }; 197 | 198 | Ok(data) 199 | } 200 | 201 | pub async fn execute<'q, A>(&self, query: Query<'q, Postgres, A>) -> Result 202 | where 203 | A: IntoArguments<'q, Postgres> + 'q, 204 | { 205 | let row_affected = if self.with_txn { 206 | let mut txh_g = self.txn_holder.lock().await; 207 | if let Some(txn) = txh_g.as_deref_mut() { 208 | query.execute(txn.as_mut()).await?.rows_affected() 209 | } else { 210 | query.execute(self.db()).await?.rows_affected() 211 | } 212 | } else { 213 | query.execute(self.db()).await?.rows_affected() 214 | }; 215 | 216 | Ok(row_affected) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust10x Web App Blueprint for Production Coding 2 | 3 | More info at: https://rust10x.com/web-app 4 | Discord: https://discord.gg/XuKWrNGKpC 5 | 6 | # Note last commit with `modql 0.4.0-rc.4` 7 | 8 | - There is a small change in the `SeaField::new(iden, value)` where the value is now `impl Into`. 9 | - `so change:` `SeaField::new(UserIden::Pwd, pwd.into())` 10 | - ` to:` `SeaField::new(UserIden::Pwd, pwd)` 11 | 12 | You can find this change in the `. update to modql 0.4.0-rc.4` 13 | 14 | # IMPORTANT NOTE on E06 - 2024-01-23 BIG UPDATE 15 | 16 | This update ([GitHub tag: E06](https://github.com/rust10x/rust-web-app/releases/tag/E06)) is significant in many respects: 17 | 18 | - **1) Data Model Change** 19 | - We are transitioning from the simple `Project / Task` model to a more intricate one centered around AI chat, specifically `Agent, Conv / ConvMsg`. 20 | - Subsequently, we'll introduce `Org / Space` constructs to demonstrate multi-tenancy and a "workspace" type of container, common in many use cases (like GitHub repositories, Discord servers, etc.). 21 | - The `examples/quick_dev` has been updated to reflect the new data model. 22 | - IMPORTANT - While `Agent` and `Conv` concepts exist, the blueprint's purpose isn't to develop a complete AI chat system. Instead, it aims to illustrate the common structures needed to build such an application and others. The Agents are merely examples of entities and might later exhibit some "Echo" capability to demonstrate the integration of long-running, event-based services. 23 | 24 | - **2) ModelManager DB Transaction Support** 25 | - There's a significant enhancement to the `ModelManager`, which now contains a `lib_core::model::store::Dbx` implementing an on-demand **database transaction** support. 26 | - By default, the ModelManager operates non-transactionally; each query executes as its own DB command. However, Bmc functions can transform a ModelManager into a transactional one and initiate/commit a transaction 27 | - Search for `mm.dbx().begin_txn()` for an example in `UserBmc::create`. 28 | 29 | - **3) Declarative Macros** 30 | - To reduce boilerplate, this Rust10x blueprint now supports flexible declarative macros (i.e., `macro_rules`) at the `lib_rpc` and `lib_core::model` levels. These create the common basic CRUD JSON-RPC functions and the common BMC CRUD methods. 31 | - Search for `generate_common_bmc_fns` or `generate_common_rpc_fns` to see them in actions. 32 | - It's important to note that these declarative macros are additive and optional. In fact, entities can introduce additional behavior as needed or opt out of using these macros if custom logic is required, even for common behaviors. 33 | 34 | - **4) Code Update** 35 | - All JSON-RPC responses now include a `.data` field as `result.data` to represent the requested data. This adds flexibility to later include metadata at the root of the `result` object (the JSON-RPC specification prohibits adding anything at the root of the JSON response). 36 | - This is in the `lib_rpc::response` crate/module. 37 | - The introduction of a `conv_id` in the `Ctx` paves the way for a future `Access Control System`, which will be privilege-based and tied to key container constructs (e.g., `Org`, `Space`, `Conv`). 38 | 39 | ## Rust10x Web App YouTube Videos: 40 | 41 | - [Episode 01 - Rust Web App - Base Production Code](https://youtube.com/watch?v=3cA_mk4vdWY&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 42 | - [Topic video - Code clean - `#[cfg_attr(...)]` for unit test](https://www.youtube.com/watch?v=DCPs5VRTK-U&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 43 | - [Topic video - The Reasoning Behind Differentiating ModelControllers and ModelManager](https://www.youtube.com/watch?v=JdLi69mWIIE&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 44 | - [Topic video - Base64Url - Understanding the Usage and Significance of Base64URL](https://www.youtube.com/watch?v=-9K7zNgsbP0&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 45 | 46 | - [Episode 02 - Sea-Query (sql builder) & modql (mongodb like filter)](https://www.youtube.com/watch?v=-dMH9UiwKqg&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 47 | 48 | - [Episode 03 - Cargo Workspace (multi-crates)](https://www.youtube.com/watch?v=zUxF0kvydJs&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 49 | - [AI-Voice-Remastered](https://www.youtube.com/watch?v=iCGIqEWWTcA&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 50 | 51 | - [Episode 04 - Multi-Scheme Password Hashing](https://www.youtube.com/watch?v=3E0zK5h9zEs&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 52 | 53 | - [Episode 05 - JSON-RPC Dynamic Router](https://www.youtube.com/watch?v=Gc5Nj5LJe1U&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 54 | 55 | - **Episode 06 coming upon request on [discord](https://discord.gg/XuKWrNGKpC)** 56 | 57 | - Other Related videos: 58 | - [Rust Axum Full Course](https://youtube.com/watch?v=XZtlD_m59sM&list=PL7r-PXl6ZPcCIOFaL7nVHXZvBmHNhrh_Q) 59 | 60 | 61 | ## Starting the DB 62 | 63 | ```sh 64 | # Start postgresql server docker image: 65 | docker run --rm --name pg -p 5432:5432 \ 66 | -e POSTGRES_PASSWORD=welcome \ 67 | postgres:17 68 | 69 | # (optional) To have a psql terminal on pg. 70 | # In another terminal (tab) run psql: 71 | docker exec -it -u postgres pg psql 72 | 73 | # (optional) For pg to print all sql statements. 74 | # In psql command line started above. 75 | ALTER DATABASE postgres SET log_statement = 'all'; 76 | ``` 77 | 78 | ## Dev (watch) 79 | 80 | > NOTE: Install cargo watch with `cargo install cargo-watch`. 81 | 82 | ```sh 83 | # Terminal 1 - To run the server. 84 | cargo watch -q -c -w crates/services/web-server/src/ -w crates/libs/ -w .cargo/ -x "run -p web-server" 85 | 86 | # Terminal 2 - To run the quick_dev. 87 | cargo watch -q -c -w crates/services/web-server/examples/ -x "run -p web-server --example quick_dev" 88 | ``` 89 | 90 | ## Dev 91 | 92 | ```sh 93 | # Terminal 1 - To run the server. 94 | cargo run -p web-server 95 | 96 | # Terminal 2 - To run the tests. 97 | cargo run -p web-server --example quick_dev 98 | ``` 99 | 100 | ## Unit Test (watch) 101 | 102 | ```sh 103 | cargo watch -q -c -x "test -- --nocapture" 104 | 105 | # Specific test with filter. 106 | cargo watch -q -c -x "test -p lib-core test_create -- --nocapture" 107 | ``` 108 | 109 | ## Unit Test 110 | 111 | ```sh 112 | cargo test -- --nocapture 113 | 114 | cargo watch -q -c -x "test -p lib-core model::task::tests::test_create -- --nocapture" 115 | ``` 116 | 117 | ## Tools 118 | 119 | ```sh 120 | cargo run -p gen-key 121 | ``` 122 | 123 |
124 | 125 | --- 126 | 127 | More resources for [Rust for Production Coding](https://rust10x.com) 128 | 129 | 130 | [This repo on GitHub](https://github.com/rust10x/rust-web-app) -------------------------------------------------------------------------------- /crates/libs/lib-auth/src/token/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | 5 | pub use self::error::{Error, Result}; 6 | 7 | use crate::config::auth_config; 8 | use lib_utils::b64::{b64u_decode_to_string, b64u_encode}; 9 | use lib_utils::time::{now_utc, now_utc_plus_sec_str, parse_utc}; 10 | use std::fmt::Display; 11 | use std::str::FromStr; 12 | use uuid::Uuid; 13 | 14 | // endregion: --- Modules 15 | 16 | // region: --- Token Type 17 | 18 | /// String format: `ident_b64u.exp_b64u.sign_b64u` 19 | #[derive(Debug)] 20 | #[cfg_attr(test, derive(PartialEq))] 21 | pub struct Token { 22 | pub ident: String, // Identifier (username for example). 23 | pub exp: String, // Expiration date in Rfc3339. 24 | pub sign_b64u: String, // Signature, base64url encoded. 25 | } 26 | 27 | impl FromStr for Token { 28 | type Err = Error; 29 | 30 | fn from_str(token_str: &str) -> std::result::Result { 31 | let splits: Vec<&str> = token_str.split('.').collect(); 32 | if splits.len() != 3 { 33 | return Err(Error::InvalidFormat); 34 | } 35 | let (ident_b64u, exp_b64u, sign_b64u) = (splits[0], splits[1], splits[2]); 36 | 37 | Ok(Self { 38 | ident: b64u_decode_to_string(ident_b64u) 39 | .map_err(|_| Error::CannotDecodeIdent)?, 40 | 41 | exp: b64u_decode_to_string(exp_b64u) 42 | .map_err(|_| Error::CannotDecodeExp)?, 43 | 44 | sign_b64u: sign_b64u.to_string(), 45 | }) 46 | } 47 | } 48 | 49 | impl Display for Token { 50 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 51 | write!( 52 | f, 53 | "{}.{}.{}", 54 | b64u_encode(&self.ident), 55 | b64u_encode(&self.exp), 56 | self.sign_b64u 57 | ) 58 | } 59 | } 60 | 61 | // endregion: --- Token Type 62 | 63 | // region: --- Web Token Gen and Validation 64 | 65 | pub fn generate_web_token(user: &str, salt: Uuid) -> Result { 66 | let config = &auth_config(); 67 | generate_token(user, config.TOKEN_DURATION_SEC, salt, &config.TOKEN_KEY) 68 | } 69 | 70 | pub fn validate_web_token(origin_token: &Token, salt: Uuid) -> Result<()> { 71 | let config = &auth_config(); 72 | validate_token_sign_and_exp(origin_token, salt, &config.TOKEN_KEY)?; 73 | 74 | Ok(()) 75 | } 76 | 77 | // endregion: --- Web Token Gen and Validation 78 | 79 | // region: --- (private) Token Gen and Validation 80 | 81 | fn generate_token( 82 | ident: &str, 83 | duration_sec: f64, 84 | salt: Uuid, 85 | key: &[u8], 86 | ) -> Result { 87 | // -- Compute the two first components. 88 | let ident = ident.to_string(); 89 | let exp = now_utc_plus_sec_str(duration_sec); 90 | 91 | // -- Sign the two first components. 92 | let sign_b64u = token_sign_into_b64u(&ident, &exp, salt, key)?; 93 | 94 | Ok(Token { 95 | ident, 96 | exp, 97 | sign_b64u, 98 | }) 99 | } 100 | 101 | fn validate_token_sign_and_exp( 102 | origin_token: &Token, 103 | salt: Uuid, 104 | key: &[u8], 105 | ) -> Result<()> { 106 | // -- Validate signature. 107 | let new_sign_b64u = 108 | token_sign_into_b64u(&origin_token.ident, &origin_token.exp, salt, key)?; 109 | 110 | if new_sign_b64u != origin_token.sign_b64u { 111 | return Err(Error::SignatureNotMatching); 112 | } 113 | 114 | // -- Validate expiration. 115 | let origin_exp = parse_utc(&origin_token.exp).map_err(|_| Error::ExpNotIso)?; 116 | let now = now_utc(); 117 | 118 | if origin_exp < now { 119 | return Err(Error::Expired); 120 | } 121 | 122 | Ok(()) 123 | } 124 | 125 | /// Create token signature from token parts 126 | /// and salt. 127 | fn token_sign_into_b64u( 128 | ident: &str, 129 | exp: &str, 130 | salt: Uuid, 131 | key: &[u8], 132 | ) -> Result { 133 | let content = format!("{}.{}", b64u_encode(ident), b64u_encode(exp)); 134 | 135 | // -- Create a Black3 Hasher (not from key because blake3 key is fixed length). 136 | let mut hasher = blake3::Hasher::new(); 137 | 138 | // -- Add content. 139 | hasher.update(content.as_bytes()); 140 | hasher.update(salt.as_bytes()); 141 | hasher.update(key); 142 | 143 | // -- Finalize and b64u encode. 144 | let hmac_result = hasher.finalize(); 145 | let result_bytes = hmac_result.as_bytes(); 146 | let result = b64u_encode(result_bytes); 147 | 148 | Ok(result) 149 | } 150 | 151 | // endregion: --- (private) Token Gen and Validation 152 | 153 | // region: --- Tests 154 | #[cfg(test)] 155 | mod tests { 156 | pub type Result = core::result::Result; 157 | pub type Error = Box; // For early dev & tests. 158 | 159 | use super::*; 160 | use crate::token; 161 | use std::thread; 162 | use std::time::Duration; 163 | 164 | #[test] 165 | fn test_token_display_ok() -> Result<()> { 166 | // -- Fixtures 167 | let fx_token_str = 168 | "ZngtaWRlbnQtMDE.MjAyMy0wNS0xN1QxNTozMDowMFo.some-sign-b64u-encoded"; 169 | let fx_token = Token { 170 | ident: "fx-ident-01".to_string(), 171 | exp: "2023-05-17T15:30:00Z".to_string(), 172 | sign_b64u: "some-sign-b64u-encoded".to_string(), 173 | }; 174 | 175 | // -- Exec & Check 176 | assert_eq!(fx_token.to_string(), fx_token_str); 177 | 178 | Ok(()) 179 | } 180 | 181 | #[test] 182 | fn test_token_from_str_ok() -> Result<()> { 183 | // -- Fixtures 184 | let fx_token_str = 185 | "ZngtaWRlbnQtMDE.MjAyMy0wNS0xN1QxNTozMDowMFo.some-sign-b64u-encoded"; 186 | let fx_token = Token { 187 | ident: "fx-ident-01".to_string(), 188 | exp: "2023-05-17T15:30:00Z".to_string(), 189 | sign_b64u: "some-sign-b64u-encoded".to_string(), 190 | }; 191 | 192 | // -- Exec 193 | let token: Token = fx_token_str.parse()?; 194 | 195 | // -- Check 196 | assert_eq!(token, fx_token); 197 | 198 | Ok(()) 199 | } 200 | 201 | #[test] 202 | fn test_token_validate_web_token_ok() -> Result<()> { 203 | // -- Setup & Fixtures 204 | let fx_user = "user_one"; 205 | let fx_salt = 206 | Uuid::parse_str("f05e8961-d6ad-4086-9e78-a6de065e5453").unwrap(); 207 | let fx_duration_sec = 0.02; // 20ms 208 | let token_key = &auth_config().TOKEN_KEY; 209 | let fx_token = generate_token(fx_user, fx_duration_sec, fx_salt, token_key)?; 210 | 211 | // -- Exec 212 | thread::sleep(Duration::from_millis(10)); 213 | let res = validate_web_token(&fx_token, fx_salt); 214 | 215 | // -- Check 216 | res?; 217 | 218 | Ok(()) 219 | } 220 | 221 | #[test] 222 | fn test_token_validate_web_token_err_expired() -> Result<()> { 223 | // -- Setup & Fixtures 224 | let fx_user = "user_one"; 225 | let fx_salt = 226 | Uuid::parse_str("f05e8961-d6ad-4086-9e78-a6de065e5453").unwrap(); 227 | let fx_duration_sec = 0.01; // 10ms 228 | let token_key = &auth_config().TOKEN_KEY; 229 | let fx_token = generate_token(fx_user, fx_duration_sec, fx_salt, token_key)?; 230 | 231 | // -- Exec 232 | thread::sleep(Duration::from_millis(20)); 233 | let res = validate_web_token(&fx_token, fx_salt); 234 | 235 | // -- Check 236 | assert!( 237 | matches!(res, Err(token::Error::Expired)), 238 | "Should have matched `Err(Error::Expired)` but was `{res:?}`" 239 | ); 240 | 241 | Ok(()) 242 | } 243 | } 244 | // endregion: --- Tests 245 | -------------------------------------------------------------------------------- /crates/libs/lib-web/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::middleware; 2 | use axum::http::StatusCode; 3 | use axum::response::{IntoResponse, Response}; 4 | use derive_more::From; 5 | use lib_auth::{pwd, token}; 6 | use lib_core::model; 7 | use serde::Serialize; 8 | use serde_json::Value; 9 | use serde_with::{serde_as, DisplayFromStr}; 10 | use std::sync::Arc; 11 | use tracing::{debug, warn}; 12 | 13 | pub type Result = core::result::Result; 14 | 15 | #[serde_as] 16 | #[derive(Debug, Serialize, From, strum_macros::AsRefStr)] 17 | #[serde(tag = "type", content = "data")] 18 | pub enum Error { 19 | // -- Login 20 | LoginFailUsernameNotFound, 21 | LoginFailUserHasNoPwd { 22 | user_id: i64, 23 | }, 24 | LoginFailPwdNotMatching { 25 | user_id: i64, 26 | }, 27 | 28 | // -- CtxExtError 29 | #[from] 30 | CtxExt(middleware::mw_auth::CtxExtError), 31 | 32 | // -- Extractors 33 | ReqStampNotInReqExt, 34 | 35 | // -- Modules 36 | #[from] 37 | Model(model::Error), 38 | #[from] 39 | Pwd(pwd::Error), 40 | #[from] 41 | Token(token::Error), 42 | #[from] 43 | Rpc(lib_rpc_core::Error), 44 | 45 | // -- RpcError (deconstructed from rpc_router::Error) 46 | // Simple mapping for the RpcRequestParsingError. It will have the eventual id, method context. 47 | #[from] 48 | RpcRequestParsing(rpc_router::RpcRequestParsingError), 49 | 50 | // When encountering `rpc_router::Error::Handler`, we deconstruct it into the appropriate concrete application error types. 51 | RpcLibRpc(lib_rpc_core::Error), 52 | // ... more types might be here, depending on our Error strategy. Usually, one per library crate is sufficient. 53 | 54 | // When it's `rpc_router::Error::Handler` but we did not handle the type, 55 | // we still capture the type name for information. This should not occur once the code is complete. 56 | RpcHandlerErrorUnhandled(&'static str), 57 | // When the `rpc_router::Error` is not a `Handler`, we can pass through the rpc_router::Error 58 | // as all variants contain concrete types. 59 | RpcRouter { 60 | id: Box, 61 | method: String, 62 | error: rpc_router::Error, 63 | }, 64 | 65 | // -- External Modules 66 | #[from] 67 | SerdeJson(#[serde_as(as = "DisplayFromStr")] serde_json::Error), 68 | } 69 | 70 | // region: --- From rpc-router::Error 71 | 72 | /// The purpose of this `From` implementation is to extract the error types we recognize 73 | /// from the `rpc_router`'s `RpcHandlerError` within the `rpc_router::Error::Handler` 74 | /// and place them into the appropriate variant of our application error enum. 75 | /// 76 | /// - The `rpc-router` provides an `RpcHandlerError` scheme to allow application RPC handlers 77 | /// to return the errors they wish with minimal constraints. 78 | /// - This approach requires us to "unpack" those types in our code and assign them to the correct 79 | /// "concrete/direct" variant (not `Box`...). 80 | /// - If it's not an `rpc_router::Error::Handler` variant, then we can capture the `rpc_router::Error` 81 | /// as it is, treating all other variants as "concrete/direct" types. 82 | impl From for Error { 83 | fn from(call_error: rpc_router::CallError) -> Self { 84 | let rpc_router::CallError { id, method, error } = call_error; 85 | match error { 86 | rpc_router::Error::Handler(mut rpc_handler_error) => { 87 | if let Some(lib_rpc_error) = 88 | rpc_handler_error.remove::() 89 | { 90 | Error::RpcLibRpc(lib_rpc_error) 91 | } 92 | // report the unhandled error for debugging and completing code. 93 | else { 94 | let type_name = rpc_handler_error.type_name(); 95 | warn!("Unhandled RpcHandlerError type: {type_name}"); 96 | Error::RpcHandlerErrorUnhandled(type_name) 97 | } 98 | } 99 | error => Error::RpcRouter { 100 | id: Box::new(id.to_value()), 101 | method, 102 | error, 103 | }, 104 | } 105 | } 106 | } 107 | 108 | // endregion: --- From rpc-router::Error 109 | 110 | // region: --- Axum IntoResponse 111 | impl IntoResponse for Error { 112 | fn into_response(self) -> Response { 113 | debug!("{:<12} - model::Error {self:?}", "INTO_RES"); 114 | 115 | // Create a placeholder Axum reponse. 116 | let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response(); 117 | 118 | // Insert the Error into the reponse. 119 | response.extensions_mut().insert(Arc::new(self)); 120 | 121 | response 122 | } 123 | } 124 | // endregion: --- Axum IntoResponse 125 | 126 | // region: --- Error Boilerplate 127 | impl core::fmt::Display for Error { 128 | fn fmt( 129 | &self, 130 | fmt: &mut core::fmt::Formatter, 131 | ) -> core::result::Result<(), core::fmt::Error> { 132 | write!(fmt, "{self:?}") 133 | } 134 | } 135 | 136 | impl std::error::Error for Error {} 137 | // endregion: --- Error Boilerplate 138 | 139 | // region: --- Client Error 140 | 141 | /// From the root error to the http status code and ClientError 142 | impl Error { 143 | pub fn client_status_and_error(&self) -> (StatusCode, ClientError) { 144 | use Error::*; // TODO: should change to `use web::Error as E` 145 | 146 | match self { 147 | // -- Login 148 | LoginFailUsernameNotFound 149 | | LoginFailUserHasNoPwd { .. } 150 | | LoginFailPwdNotMatching { .. } => { 151 | (StatusCode::FORBIDDEN, ClientError::LOGIN_FAIL) 152 | } 153 | 154 | // -- Auth 155 | CtxExt(_) => (StatusCode::FORBIDDEN, ClientError::NO_AUTH), 156 | 157 | // -- Model 158 | Model(model::Error::EntityNotFound { entity, id }) => ( 159 | StatusCode::BAD_REQUEST, 160 | ClientError::ENTITY_NOT_FOUND { entity, id: *id }, 161 | ), 162 | 163 | // -- Rpc 164 | RpcRequestParsing(req_parsing_err) => ( 165 | StatusCode::BAD_REQUEST, 166 | ClientError::RPC_REQUEST_INVALID(req_parsing_err.to_string()), 167 | ), 168 | RpcRouter { 169 | error: rpc_router::Error::MethodUnknown, 170 | method, 171 | .. 172 | } => ( 173 | StatusCode::BAD_REQUEST, 174 | ClientError::RPC_REQUEST_METHOD_UNKNOWN(format!( 175 | "rpc method '{method}' unknown" 176 | )), 177 | ), 178 | RpcRouter { 179 | error: rpc_router::Error::ParamsParsing(params_parsing_err), 180 | .. 181 | } => ( 182 | StatusCode::BAD_REQUEST, 183 | ClientError::RPC_PARAMS_INVALID(params_parsing_err.to_string()), 184 | ), 185 | RpcRouter { 186 | error: rpc_router::Error::ParamsMissingButRequested, 187 | method, 188 | .. 189 | } => ( 190 | StatusCode::BAD_REQUEST, 191 | ClientError::RPC_PARAMS_INVALID(format!( 192 | "Params missing. Method '{method}' requires params" 193 | )), 194 | ), 195 | 196 | // -- Fallback. 197 | _ => ( 198 | StatusCode::INTERNAL_SERVER_ERROR, 199 | ClientError::SERVICE_ERROR, 200 | ), 201 | } 202 | } 203 | } 204 | 205 | #[derive(Debug, Serialize, strum_macros::AsRefStr)] 206 | #[serde(tag = "message", content = "detail")] 207 | #[allow(non_camel_case_types)] 208 | pub enum ClientError { 209 | LOGIN_FAIL, 210 | NO_AUTH, 211 | ENTITY_NOT_FOUND { entity: &'static str, id: i64 }, 212 | 213 | RPC_REQUEST_INVALID(String), 214 | RPC_REQUEST_METHOD_UNKNOWN(String), 215 | RPC_PARAMS_INVALID(String), 216 | 217 | SERVICE_ERROR, 218 | } 219 | // endregion: --- Client Error 220 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/user.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::base::{self, prep_fields_for_update, DbBmc}; 3 | use crate::model::modql_utils::time_to_sea_value; 4 | use crate::model::ModelManager; 5 | use crate::model::{Error, Result}; 6 | use lib_auth::pwd::{self, ContentToHash}; 7 | use modql::field::{Fields, HasSeaFields, SeaField, SeaFields}; 8 | use modql::filter::{ 9 | FilterNodes, ListOptions, OpValsInt64, OpValsString, OpValsValue, 10 | }; 11 | use sea_query::{Expr, Iden, PostgresQueryBuilder, Query}; 12 | use sea_query_binder::SqlxBinder; 13 | use serde::{Deserialize, Serialize}; 14 | use sqlx::postgres::PgRow; 15 | use sqlx::FromRow; 16 | use uuid::Uuid; 17 | 18 | // region: --- User Types 19 | #[derive(Clone, Debug, sqlx::Type, derive_more::Display, Deserialize, Serialize)] 20 | #[sqlx(type_name = "user_typ")] 21 | pub enum UserTyp { 22 | Sys, 23 | User, 24 | } 25 | impl From for sea_query::Value { 26 | fn from(val: UserTyp) -> Self { 27 | val.to_string().into() 28 | } 29 | } 30 | 31 | #[derive(Clone, Fields, FromRow, Debug, Serialize)] 32 | pub struct User { 33 | pub id: i64, 34 | pub username: String, 35 | pub typ: UserTyp, 36 | } 37 | 38 | #[derive(Deserialize)] 39 | pub struct UserForCreate { 40 | pub username: String, 41 | pub pwd_clear: String, 42 | } 43 | 44 | #[derive(Fields)] 45 | pub struct UserForInsert { 46 | pub username: String, 47 | } 48 | 49 | #[derive(Clone, FromRow, Fields, Debug)] 50 | pub struct UserForLogin { 51 | pub id: i64, 52 | pub username: String, 53 | 54 | // -- pwd and token info 55 | pub pwd: Option, // encrypted, #_scheme_id_#.... 56 | pub pwd_salt: Uuid, 57 | pub token_salt: Uuid, 58 | } 59 | 60 | #[derive(Clone, FromRow, Fields, Debug)] 61 | pub struct UserForAuth { 62 | pub id: i64, 63 | pub username: String, 64 | 65 | // -- token info 66 | pub token_salt: Uuid, 67 | } 68 | 69 | /// Marker trait 70 | pub trait UserBy: HasSeaFields + for<'r> FromRow<'r, PgRow> + Unpin + Send {} 71 | 72 | impl UserBy for User {} 73 | impl UserBy for UserForLogin {} 74 | impl UserBy for UserForAuth {} 75 | 76 | // Note: Since the entity properties Iden will be given by modql 77 | // UserIden does not have to be exhaustive, but just have the columns 78 | // we use in our specific code. 79 | #[derive(Iden)] 80 | enum UserIden { 81 | Id, 82 | Username, 83 | Pwd, 84 | } 85 | 86 | #[derive(FilterNodes, Deserialize, Default, Debug)] 87 | pub struct UserFilter { 88 | pub id: Option, 89 | 90 | pub username: Option, 91 | 92 | pub cid: Option, 93 | #[modql(to_sea_value_fn = "time_to_sea_value")] 94 | pub ctime: Option, 95 | pub mid: Option, 96 | #[modql(to_sea_value_fn = "time_to_sea_value")] 97 | pub mtime: Option, 98 | } 99 | 100 | // endregion: --- User Types 101 | 102 | // region: --- UserBmc 103 | 104 | pub struct UserBmc; 105 | 106 | impl DbBmc for UserBmc { 107 | const TABLE: &'static str = "user"; 108 | } 109 | 110 | impl UserBmc { 111 | pub async fn create( 112 | ctx: &Ctx, 113 | mm: &ModelManager, 114 | user_c: UserForCreate, 115 | ) -> Result { 116 | let UserForCreate { 117 | username, 118 | pwd_clear, 119 | } = user_c; 120 | 121 | // -- Create the user row 122 | let user_fi = UserForInsert { 123 | username: username.to_string(), 124 | }; 125 | 126 | // Start the transaction 127 | let mm = mm.new_with_txn()?; 128 | 129 | mm.dbx().begin_txn().await?; 130 | 131 | let user_id = base::create::(ctx, &mm, user_fi).await.map_err( 132 | |model_error| { 133 | Error::resolve_unique_violation( 134 | model_error, 135 | Some(|table: &str, constraint: &str| { 136 | if table == "user" && constraint.contains("username") { 137 | Some(Error::UserAlreadyExists { username }) 138 | } else { 139 | None // Error::UniqueViolation will be created by resolve_unique_violation 140 | } 141 | }), 142 | ) 143 | }, 144 | )?; 145 | 146 | // -- Update the database 147 | Self::update_pwd(ctx, &mm, user_id, &pwd_clear).await?; 148 | 149 | // Commit the transaction 150 | mm.dbx().commit_txn().await?; 151 | 152 | Ok(user_id) 153 | } 154 | 155 | pub async fn get(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result 156 | where 157 | E: UserBy, 158 | { 159 | base::get::(ctx, mm, id).await 160 | } 161 | 162 | pub async fn first_by_username( 163 | _ctx: &Ctx, 164 | mm: &ModelManager, 165 | username: &str, 166 | ) -> Result> 167 | where 168 | E: UserBy, 169 | { 170 | // -- Build query 171 | let mut query = Query::select(); 172 | query 173 | .from(Self::table_ref()) 174 | .columns(E::sea_idens()) 175 | .and_where(Expr::col(UserIden::Username).eq(username)); 176 | 177 | // -- Execute query 178 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 179 | 180 | let sqlx_query = sqlx::query_as_with::<_, E, _>(&sql, values); 181 | let entity = mm.dbx().fetch_optional(sqlx_query).await?; 182 | 183 | Ok(entity) 184 | } 185 | 186 | pub async fn list( 187 | ctx: &Ctx, 188 | mm: &ModelManager, 189 | filter: Option>, 190 | list_options: Option, 191 | ) -> Result> { 192 | base::list::(ctx, mm, filter, list_options).await 193 | } 194 | 195 | pub async fn update_pwd( 196 | ctx: &Ctx, 197 | mm: &ModelManager, 198 | id: i64, 199 | pwd_clear: &str, 200 | ) -> Result<()> { 201 | // -- Prep password 202 | let user: UserForLogin = Self::get(ctx, mm, id).await?; 203 | let pwd = pwd::hash_pwd(ContentToHash { 204 | content: pwd_clear.to_string(), 205 | salt: user.pwd_salt, 206 | }) 207 | .await?; 208 | 209 | // -- Prep the data 210 | let mut fields = SeaFields::new(vec![SeaField::new(UserIden::Pwd, pwd)]); 211 | prep_fields_for_update::(&mut fields, ctx.user_id()); 212 | 213 | // -- Build query 214 | let fields = fields.for_sea_update(); 215 | let mut query = Query::update(); 216 | query 217 | .table(Self::table_ref()) 218 | .values(fields) 219 | .and_where(Expr::col(UserIden::Id).eq(id)); 220 | 221 | // -- Exec query 222 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 223 | let sqlx_query = sqlx::query_with(&sql, values); 224 | let _count = mm.dbx().execute(sqlx_query).await?; 225 | 226 | Ok(()) 227 | } 228 | 229 | /// TODO: For User, deletion will require a soft-delete approach: 230 | /// - Set `deleted: true`. 231 | /// - Change `username` to "DELETED-_user_id_". 232 | /// - Clear any other UUIDs or PII (Personally Identifiable Information). 233 | /// - The automatically set `mid`/`mtime` will record who performed the deletion. 234 | /// - It's likely necessary to record this action in a `um_change_log` (a user management change audit table). 235 | /// - Remove or clean up any user-specific assets (messages, etc.). 236 | pub async fn delete(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result<()> { 237 | base::delete::(ctx, mm, id).await 238 | } 239 | } 240 | 241 | // endregion: --- UserBmc 242 | 243 | // region: --- Tests 244 | 245 | #[cfg(test)] 246 | mod tests { 247 | pub type Result = core::result::Result; 248 | pub type Error = Box; // For tests. 249 | 250 | use super::*; 251 | use crate::_dev_utils; 252 | use serial_test::serial; 253 | 254 | #[serial] 255 | #[tokio::test] 256 | async fn test_create_ok() -> Result<()> { 257 | // -- Setup & Fixtures 258 | let mm = _dev_utils::init_test().await; 259 | let ctx = Ctx::root_ctx(); 260 | let fx_username = "test_create_ok-user-01"; 261 | let fx_pwd_clear = "test_create_ok pwd 01"; 262 | 263 | // -- Exec 264 | let user_id = UserBmc::create( 265 | &ctx, 266 | &mm, 267 | UserForCreate { 268 | username: fx_username.to_string(), 269 | pwd_clear: fx_pwd_clear.to_string(), 270 | }, 271 | ) 272 | .await?; 273 | 274 | // -- Check 275 | let user: UserForLogin = UserBmc::get(&ctx, &mm, user_id).await?; 276 | assert_eq!(user.username, fx_username); 277 | 278 | // -- Clean 279 | UserBmc::delete(&ctx, &mm, user_id).await?; 280 | 281 | Ok(()) 282 | } 283 | 284 | #[serial] 285 | #[tokio::test] 286 | async fn test_first_ok_demo1() -> Result<()> { 287 | // -- Setup & Fixtures 288 | let mm = _dev_utils::init_test().await; 289 | let ctx = Ctx::root_ctx(); 290 | let fx_username = "demo1"; 291 | 292 | // -- Exec 293 | let user: User = UserBmc::first_by_username(&ctx, &mm, fx_username) 294 | .await? 295 | .ok_or("Should have user 'demo1'")?; 296 | 297 | // -- Check 298 | assert_eq!(user.username, fx_username); 299 | 300 | Ok(()) 301 | } 302 | } 303 | 304 | // endregion: --- Tests 305 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/conv.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::generate_common_bmc_fns; 3 | use crate::model::base::{self, DbBmc}; 4 | use crate::model::conv_msg::{ 5 | ConvMsg, ConvMsgBmc, ConvMsgForCreate, ConvMsgForInsert, 6 | }; 7 | use crate::model::modql_utils::time_to_sea_value; 8 | use crate::model::ModelManager; 9 | use crate::model::Result; 10 | use lib_utils::time::Rfc3339; 11 | use modql::field::{Fields, SeaFieldValue}; 12 | use modql::filter::{ 13 | FilterNodes, ListOptions, OpValsInt64, OpValsString, OpValsValue, 14 | }; 15 | use sea_query::Nullable; 16 | use serde::{Deserialize, Serialize}; 17 | use serde_with::serde_as; 18 | use sqlx::types::time::OffsetDateTime; 19 | use sqlx::FromRow; 20 | 21 | // region: --- Conv Types 22 | 23 | /// Trait to implement on entities that have a conv_id 24 | /// This will allow Ctx to be upgraded with the corresponding conv_id for 25 | /// future access control. 26 | pub trait ConvScoped { 27 | fn conv_id(&self) -> i64; 28 | } 29 | 30 | #[derive(Debug, Clone, sqlx::Type, derive_more::Display, Deserialize, Serialize)] 31 | #[sqlx(type_name = "conv_kind")] 32 | #[cfg_attr(test, derive(PartialEq))] 33 | pub enum ConvKind { 34 | OwnerOnly, 35 | MultiUsers, 36 | } 37 | 38 | /// Note: Manual implementation. 39 | /// Required for a modql::field::Fields 40 | impl From for sea_query::Value { 41 | fn from(val: ConvKind) -> Self { 42 | val.to_string().into() 43 | } 44 | } 45 | 46 | /// Note: Manual implementation. 47 | /// This is required for sea::query in case of None. 48 | /// However, in this codebase, we utilize the modql not_none_field, 49 | /// so this will be disregarded anyway. 50 | /// Nonetheless, it's still necessary for compilation. 51 | impl Nullable for ConvKind { 52 | fn null() -> sea_query::Value { 53 | ConvKind::OwnerOnly.into() 54 | } 55 | } 56 | 57 | /// Note: Here we derive from modql `SeaFieldValue` which implements 58 | /// the `From for sea_query::Value` and the 59 | /// `sea_query::value::Nullable for ConvState` 60 | /// See the `ConvKind` for the manual implementation. 61 | /// 62 | #[derive( 63 | Debug, 64 | Clone, 65 | sqlx::Type, 66 | SeaFieldValue, 67 | derive_more::Display, 68 | Deserialize, 69 | Serialize, 70 | )] 71 | #[sqlx(type_name = "conv_state")] 72 | pub enum ConvState { 73 | Active, 74 | Archived, 75 | } 76 | 77 | #[serde_as] 78 | #[derive(Debug, Clone, Fields, FromRow, Serialize)] 79 | pub struct Conv { 80 | pub id: i64, 81 | 82 | // -- Relations 83 | pub agent_id: i64, 84 | pub owner_id: i64, 85 | 86 | // -- Properties 87 | pub title: Option, 88 | pub kind: ConvKind, 89 | pub state: ConvState, 90 | 91 | // -- Timestamps 92 | // creator user_id and time 93 | pub cid: i64, 94 | #[serde_as(as = "Rfc3339")] 95 | pub ctime: OffsetDateTime, 96 | // last modifier user_id and time 97 | pub mid: i64, 98 | #[serde_as(as = "Rfc3339")] 99 | pub mtime: OffsetDateTime, 100 | } 101 | 102 | #[derive(Fields, Deserialize, Default)] 103 | pub struct ConvForCreate { 104 | pub agent_id: i64, 105 | 106 | pub title: Option, 107 | 108 | #[field(cast_as = "conv_kind")] 109 | pub kind: Option, 110 | } 111 | 112 | #[derive(Fields, Deserialize, Default)] 113 | pub struct ConvForUpdate { 114 | pub owner_id: Option, 115 | pub title: Option, 116 | pub closed: Option, 117 | #[field(cast_as = "conv_state")] 118 | pub state: Option, 119 | } 120 | 121 | #[derive(FilterNodes, Deserialize, Default, Debug)] 122 | pub struct ConvFilter { 123 | pub id: Option, 124 | 125 | pub owner_id: Option, 126 | pub agent_id: Option, 127 | 128 | #[modql(cast_as = "conv_kind")] 129 | pub kind: Option, 130 | 131 | pub title: Option, 132 | 133 | pub cid: Option, 134 | #[modql(to_sea_value_fn = "time_to_sea_value")] 135 | pub ctime: Option, 136 | pub mid: Option, 137 | #[modql(to_sea_value_fn = "time_to_sea_value")] 138 | pub mtime: Option, 139 | } 140 | 141 | // endregion: --- Conv Types 142 | 143 | // region: --- ConvBmc 144 | 145 | pub struct ConvBmc; 146 | 147 | impl DbBmc for ConvBmc { 148 | const TABLE: &'static str = "conv"; 149 | 150 | fn has_owner_id() -> bool { 151 | true 152 | } 153 | } 154 | 155 | // This will generate the `impl ConvBmc {...}` with the default CRUD functions. 156 | generate_common_bmc_fns!( 157 | Bmc: ConvBmc, 158 | Entity: Conv, 159 | ForCreate: ConvForCreate, 160 | ForUpdate: ConvForUpdate, 161 | Filter: ConvFilter, 162 | ); 163 | 164 | // Additional ConvBmc methods to manage the `ConvMsg` constructs. 165 | impl ConvBmc { 166 | /// Add a `ConvMsg` to a `Conv` 167 | /// 168 | // For access constrol, we will add: 169 | // #[ctx_add(conv, space)] 170 | // #[requires_privilege_any_of("og:FullAccess", "sp:FullAccess", "conv@owner_id" "conv:AddMsg")] 171 | pub async fn add_msg( 172 | ctx: &Ctx, 173 | mm: &ModelManager, 174 | msg_c: ConvMsgForCreate, 175 | ) -> Result { 176 | let msg_i = ConvMsgForInsert::from_msg_for_create(ctx.user_id(), msg_c); 177 | let conv_msg_id = base::create::(ctx, mm, msg_i).await?; 178 | 179 | Ok(conv_msg_id) 180 | } 181 | 182 | /// NOTE: The current strategy is to not require conv_id, but we will check 183 | /// that user have `conv:ReadMsg` privilege on correponding conv (post base::get). 184 | pub async fn get_msg( 185 | ctx: &Ctx, 186 | mm: &ModelManager, 187 | msg_id: i64, 188 | ) -> Result { 189 | let conv_msg: ConvMsg = base::get::(ctx, mm, msg_id).await?; 190 | 191 | // TODO: Validate conv_msg is with ctx.conv_id 192 | // let _ctx = ctx.add_conv_id(conv_msg.conv_id()); 193 | // assert_privileges(&ctx, &mm, &["conv@owner_id", "conv:ReadMsg"]); 194 | 195 | Ok(conv_msg) 196 | } 197 | } 198 | 199 | // endregion: --- ConvBmc 200 | 201 | // region: --- Tests 202 | 203 | #[cfg(test)] 204 | mod tests { 205 | type Error = Box; 206 | type Result = core::result::Result; // For tests. 207 | 208 | use super::*; 209 | use crate::_dev_utils::{self, seed_agent}; 210 | use crate::ctx::Ctx; 211 | use crate::model::agent::AgentBmc; 212 | use modql::filter::OpValString; 213 | use serial_test::serial; 214 | 215 | #[serial] 216 | #[tokio::test] 217 | async fn test_create_ok() -> Result<()> { 218 | // -- Setup & Fixtures 219 | let mm = _dev_utils::init_test().await; 220 | let ctx = Ctx::root_ctx(); 221 | let fx_title = "test_create_ok conv 01"; 222 | let fx_kind = ConvKind::MultiUsers; 223 | let agent_id = seed_agent(&ctx, &mm, "test_create_ok conv agent 01").await?; 224 | 225 | // -- Exec 226 | let conv_id = ConvBmc::create( 227 | &ctx, 228 | &mm, 229 | ConvForCreate { 230 | agent_id, 231 | title: Some(fx_title.to_string()), 232 | kind: Some(fx_kind.clone()), 233 | }, 234 | ) 235 | .await?; 236 | 237 | // -- Check 238 | let conv: Conv = ConvBmc::get(&ctx, &mm, conv_id).await?; 239 | assert_eq!(&conv.kind, &fx_kind); 240 | assert_eq!(conv.title.ok_or("conv should have title")?, fx_title); 241 | 242 | // -- Clean 243 | ConvBmc::delete(&ctx, &mm, conv_id).await?; 244 | AgentBmc::delete(&ctx, &mm, agent_id).await?; 245 | 246 | Ok(()) 247 | } 248 | 249 | #[serial] 250 | #[tokio::test] 251 | async fn test_list_ok() -> Result<()> { 252 | // -- Setup & Fixtures 253 | let mm = _dev_utils::init_test().await; 254 | let ctx = Ctx::root_ctx(); 255 | let fx_title_prefix = "test_list_ok conv - "; 256 | let agent_id = seed_agent(&ctx, &mm, "test_create_ok conv agent 01").await?; 257 | 258 | for i in 1..=6 { 259 | let kind = if i <= 3 { 260 | ConvKind::OwnerOnly 261 | } else { 262 | ConvKind::MultiUsers 263 | }; 264 | 265 | let _conv_id = ConvBmc::create( 266 | &ctx, 267 | &mm, 268 | ConvForCreate { 269 | agent_id, 270 | title: Some(format!("{fx_title_prefix}{:<02}", i)), 271 | kind: Some(kind), 272 | }, 273 | ) 274 | .await?; 275 | } 276 | 277 | // -- Exec 278 | let convs = ConvBmc::list( 279 | &ctx, 280 | &mm, 281 | Some(vec![ConvFilter { 282 | agent_id: Some(agent_id.into()), 283 | 284 | kind: Some(OpValString::In(vec!["MultiUsers".to_string()]).into()), 285 | // or 286 | // kind: Some(OpValString::Eq("MultiUsers".to_string()).into()), 287 | ..Default::default() 288 | }]), 289 | None, 290 | ) 291 | .await?; 292 | 293 | // -- Check 294 | // extract the 04, 05, 06 parts of the tiles 295 | let num_parts = convs 296 | .iter() 297 | .filter_map(|c| c.title.as_ref().and_then(|s| s.split("- ").nth(1))) 298 | .collect::>(); 299 | assert_eq!(num_parts, &["04", "05", "06"]); 300 | 301 | // -- Clean 302 | // This should delete cascade 303 | AgentBmc::delete(&ctx, &mm, agent_id).await?; 304 | 305 | Ok(()) 306 | } 307 | } 308 | 309 | // endregion: --- Tests 310 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/base/crud_fns.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::base::{ 3 | prep_fields_for_create, prep_fields_for_update, CommonIden, DbBmc, 4 | LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX, 5 | }; 6 | use crate::model::ModelManager; 7 | use crate::model::{Error, Result}; 8 | use modql::field::HasSeaFields; 9 | use modql::filter::{FilterGroups, ListOptions}; 10 | use sea_query::{Condition, Expr, PostgresQueryBuilder, Query}; 11 | use sea_query_binder::SqlxBinder; 12 | use sqlx::postgres::PgRow; 13 | use sqlx::FromRow; 14 | use sqlx::Row; 15 | 16 | pub async fn create(ctx: &Ctx, mm: &ModelManager, data: E) -> Result 17 | where 18 | MC: DbBmc, 19 | E: HasSeaFields, 20 | { 21 | let user_id = ctx.user_id(); 22 | 23 | // -- Extract fields (name / sea-query value expression) 24 | let mut fields = data.not_none_sea_fields(); 25 | prep_fields_for_create::(&mut fields, user_id); 26 | 27 | // -- Build query 28 | let (columns, sea_values) = fields.for_sea_insert(); 29 | let mut query = Query::insert(); 30 | query 31 | .into_table(MC::table_ref()) 32 | .columns(columns) 33 | .values(sea_values)? 34 | .returning(Query::returning().columns([CommonIden::Id])); 35 | 36 | // -- Exec query 37 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 38 | let sqlx_query = sqlx::query_as_with::<_, (i64,), _>(&sql, values); 39 | // NOTE: For now, we will use the _txn for all create. 40 | // We could have a with_txn as function argument if perf is an issue (it should not be) 41 | let (id,) = mm.dbx().fetch_one(sqlx_query).await?; 42 | 43 | Ok(id) 44 | } 45 | 46 | pub async fn create_many( 47 | ctx: &Ctx, 48 | mm: &ModelManager, 49 | data: Vec, 50 | ) -> Result> 51 | where 52 | MC: DbBmc, 53 | E: HasSeaFields, 54 | { 55 | let user_id = ctx.user_id(); 56 | let mut ids = Vec::with_capacity(data.len()); 57 | 58 | // Prepare insert query 59 | let mut query = Query::insert(); 60 | 61 | for item in data { 62 | let mut fields = item.not_none_sea_fields(); 63 | prep_fields_for_create::(&mut fields, user_id); 64 | let (columns, sea_values) = fields.for_sea_insert(); 65 | 66 | // Append values for each item 67 | query 68 | .into_table(MC::table_ref()) 69 | .columns(columns.clone()) 70 | .values(sea_values)?; 71 | } 72 | 73 | query.returning(Query::returning().columns([CommonIden::Id])); 74 | 75 | // Execute query 76 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 77 | let sqlx_query = sqlx::query_as_with::<_, (i64,), _>(&sql, values); 78 | 79 | let rows = mm.dbx().fetch_all(sqlx_query).await?; 80 | 81 | for row in rows { 82 | let (id,): (i64,) = row; 83 | ids.push(id); 84 | } 85 | 86 | Ok(ids) 87 | } 88 | 89 | pub async fn get(_ctx: &Ctx, mm: &ModelManager, id: i64) -> Result 90 | where 91 | MC: DbBmc, 92 | E: for<'r> FromRow<'r, PgRow> + Unpin + Send, 93 | E: HasSeaFields, 94 | { 95 | // -- Build query 96 | let mut query = Query::select(); 97 | query 98 | .from(MC::table_ref()) 99 | .columns(E::sea_column_refs()) 100 | .and_where(Expr::col(CommonIden::Id).eq(id)); 101 | 102 | // -- Exec query 103 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 104 | let sqlx_query = sqlx::query_as_with::<_, E, _>(&sql, values); 105 | let entity = 106 | mm.dbx() 107 | .fetch_optional(sqlx_query) 108 | .await? 109 | .ok_or(Error::EntityNotFound { 110 | entity: MC::TABLE, 111 | id, 112 | })?; 113 | 114 | Ok(entity) 115 | } 116 | 117 | pub async fn first( 118 | ctx: &Ctx, 119 | mm: &ModelManager, 120 | filter: Option, 121 | list_options: Option, 122 | ) -> Result> 123 | where 124 | MC: DbBmc, 125 | F: Into, 126 | E: for<'r> FromRow<'r, PgRow> + Unpin + Send, 127 | E: HasSeaFields, 128 | { 129 | let list_options = match list_options { 130 | Some(mut list_options) => { 131 | // Reset the offset/limit 132 | list_options.offset = None; 133 | list_options.limit = Some(1); 134 | 135 | // Don't change order_bys if not empty, 136 | // otherwise, set it to id (creation asc order) 137 | list_options.order_bys = 138 | list_options.order_bys.or_else(|| Some("id".into())); 139 | 140 | list_options 141 | } 142 | None => ListOptions { 143 | limit: Some(1), 144 | offset: None, 145 | order_bys: Some("id".into()), // default id asc 146 | }, 147 | }; 148 | 149 | list::(ctx, mm, filter, Some(list_options)) 150 | .await 151 | .map(|item| item.into_iter().next()) 152 | } 153 | 154 | pub async fn list( 155 | _ctx: &Ctx, 156 | mm: &ModelManager, 157 | filter: Option, 158 | list_options: Option, 159 | ) -> Result> 160 | where 161 | MC: DbBmc, 162 | F: Into, 163 | E: for<'r> FromRow<'r, PgRow> + Unpin + Send, 164 | E: HasSeaFields, 165 | { 166 | // -- Build the query 167 | let mut query = Query::select(); 168 | query.from(MC::table_ref()).columns(E::sea_column_refs()); 169 | 170 | // condition from filter 171 | if let Some(filter) = filter { 172 | let filters: FilterGroups = filter.into(); 173 | let cond: Condition = filters.try_into()?; 174 | query.cond_where(cond); 175 | } 176 | // list options 177 | let list_options = compute_list_options(list_options)?; 178 | list_options.apply_to_sea_query(&mut query); 179 | 180 | // -- Execute the query 181 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 182 | 183 | let sqlx_query = sqlx::query_as_with::<_, E, _>(&sql, values); 184 | let entities = mm.dbx().fetch_all(sqlx_query).await?; 185 | 186 | Ok(entities) 187 | } 188 | 189 | pub async fn count( 190 | _ctx: &Ctx, 191 | mm: &ModelManager, 192 | filter: Option, 193 | ) -> Result 194 | where 195 | MC: DbBmc, 196 | F: Into, 197 | { 198 | let db = mm.dbx().db(); 199 | // -- Build the query 200 | let mut query = Query::select() 201 | .from(MC::table_ref()) 202 | .expr(Expr::col(sea_query::Asterisk).count()) 203 | .to_owned(); 204 | 205 | // condition from filter 206 | if let Some(filter) = filter { 207 | let filters: FilterGroups = filter.into(); 208 | let cond: Condition = filters.try_into()?; 209 | query.cond_where(cond); 210 | } 211 | 212 | let query_str = query.to_string(PostgresQueryBuilder); 213 | 214 | let result = sqlx::query(&query_str) 215 | .fetch_one(db) 216 | .await 217 | .map_err(|_| Error::CountFail)?; 218 | 219 | let count: i64 = result.try_get("count").map_err(|_| Error::CountFail)?; 220 | 221 | Ok(count) 222 | } 223 | 224 | pub async fn update( 225 | ctx: &Ctx, 226 | mm: &ModelManager, 227 | id: i64, 228 | data: E, 229 | ) -> Result<()> 230 | where 231 | MC: DbBmc, 232 | E: HasSeaFields, 233 | { 234 | // -- Prep Fields 235 | let mut fields = data.not_none_sea_fields(); 236 | prep_fields_for_update::(&mut fields, ctx.user_id()); 237 | 238 | // -- Build query 239 | let fields = fields.for_sea_update(); 240 | let mut query = Query::update(); 241 | query 242 | .table(MC::table_ref()) 243 | .values(fields) 244 | .and_where(Expr::col(CommonIden::Id).eq(id)); 245 | 246 | // -- Execute query 247 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 248 | let sqlx_query = sqlx::query_with(&sql, values); 249 | let count = mm.dbx().execute(sqlx_query).await?; 250 | 251 | // -- Check result 252 | if count == 0 { 253 | Err(Error::EntityNotFound { 254 | entity: MC::TABLE, 255 | id, 256 | }) 257 | } else { 258 | Ok(()) 259 | } 260 | } 261 | 262 | pub async fn delete(_ctx: &Ctx, mm: &ModelManager, id: i64) -> Result<()> 263 | where 264 | MC: DbBmc, 265 | { 266 | // -- Build query 267 | let mut query = Query::delete(); 268 | query 269 | .from_table(MC::table_ref()) 270 | .and_where(Expr::col(CommonIden::Id).eq(id)); 271 | 272 | // -- Execute query 273 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 274 | let sqlx_query = sqlx::query_with(&sql, values); 275 | let count = mm.dbx().execute(sqlx_query).await?; 276 | 277 | // -- Check result 278 | if count == 0 { 279 | Err(Error::EntityNotFound { 280 | entity: MC::TABLE, 281 | id, 282 | }) 283 | } else { 284 | Ok(()) 285 | } 286 | } 287 | 288 | pub async fn delete_many( 289 | _ctx: &Ctx, 290 | mm: &ModelManager, 291 | ids: Vec, 292 | ) -> Result 293 | where 294 | MC: DbBmc, 295 | { 296 | if ids.is_empty() { 297 | return Ok(0); 298 | } 299 | 300 | // -- Build query 301 | let mut query = Query::delete(); 302 | query 303 | .from_table(MC::table_ref()) 304 | .and_where(Expr::col(CommonIden::Id).is_in(ids.clone())); 305 | 306 | // -- Execute query 307 | let (sql, values) = query.build_sqlx(PostgresQueryBuilder); 308 | let sqlx_query = sqlx::query_with(&sql, values); 309 | let result = mm.dbx().execute(sqlx_query).await?; 310 | 311 | // -- Check result 312 | if result as usize != ids.len() { 313 | Err(Error::EntityNotFound { 314 | entity: MC::TABLE, 315 | id: 0, // Using 0 because multiple IDs could be not found, you may want to improve error handling here 316 | }) 317 | } else { 318 | Ok(result) 319 | } 320 | } 321 | 322 | pub fn compute_list_options( 323 | list_options: Option, 324 | ) -> Result { 325 | if let Some(mut list_options) = list_options { 326 | // Validate the limit. 327 | if let Some(limit) = list_options.limit { 328 | if limit > LIST_LIMIT_MAX { 329 | return Err(Error::ListLimitOverMax { 330 | max: LIST_LIMIT_MAX, 331 | actual: limit, 332 | }); 333 | } 334 | } 335 | // Set the default limit if no limit 336 | else { 337 | list_options.limit = Some(LIST_LIMIT_DEFAULT); 338 | } 339 | Ok(list_options) 340 | } 341 | // When None, return default 342 | else { 343 | Ok(ListOptions { 344 | limit: Some(LIST_LIMIT_DEFAULT), 345 | offset: None, 346 | order_bys: Some("id".into()), 347 | }) 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /crates/libs/lib-core/src/model/agent.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::generate_common_bmc_fns; 3 | use crate::model::base::{self, DbBmc}; 4 | use crate::model::modql_utils::time_to_sea_value; 5 | use crate::model::ModelManager; 6 | use crate::model::Result; 7 | use lib_utils::time::Rfc3339; 8 | use modql::field::Fields; 9 | use modql::filter::{FilterNodes, OpValsString, OpValsValue}; 10 | use modql::filter::{ListOptions, OpValsInt64}; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_with::serde_as; 13 | use sqlx::types::time::OffsetDateTime; 14 | use sqlx::FromRow; 15 | 16 | // region: --- Agent Types 17 | 18 | #[serde_as] 19 | #[derive(Debug, Clone, Fields, FromRow, Serialize)] 20 | pub struct Agent { 21 | pub id: i64, 22 | 23 | // -- Relations 24 | pub owner_id: i64, 25 | 26 | // -- Properties 27 | pub name: String, 28 | pub ai_provider: String, 29 | pub ai_model: String, 30 | 31 | // -- Timestamps 32 | // (creator and last modified user_id/time) 33 | pub cid: i64, 34 | #[serde_as(as = "Rfc3339")] 35 | pub ctime: OffsetDateTime, 36 | pub mid: i64, 37 | #[serde_as(as = "Rfc3339")] 38 | pub mtime: OffsetDateTime, 39 | } 40 | 41 | #[derive(Fields, Deserialize)] 42 | pub struct AgentForCreate { 43 | pub name: String, 44 | } 45 | 46 | #[derive(Fields, Deserialize)] 47 | pub struct AgentForUpdate { 48 | pub name: Option, 49 | } 50 | 51 | #[derive(FilterNodes, Default, Deserialize)] 52 | pub struct AgentFilter { 53 | pub id: Option, 54 | pub name: Option, 55 | 56 | pub cid: Option, 57 | #[modql(to_sea_value_fn = "time_to_sea_value")] 58 | pub ctime: Option, 59 | pub mid: Option, 60 | #[modql(to_sea_value_fn = "time_to_sea_value")] 61 | pub mtime: Option, 62 | } 63 | 64 | // endregion: --- Agent Types 65 | 66 | // region: --- AgentBmc 67 | 68 | pub struct AgentBmc; 69 | 70 | impl DbBmc for AgentBmc { 71 | const TABLE: &'static str = "agent"; 72 | 73 | fn has_owner_id() -> bool { 74 | true 75 | } 76 | } 77 | 78 | // This will generate the `impl AgentBmc {...}` with the default CRUD functions. 79 | generate_common_bmc_fns!( 80 | Bmc: AgentBmc, 81 | Entity: Agent, 82 | ForCreate: AgentForCreate, 83 | ForUpdate: AgentForUpdate, 84 | Filter: AgentFilter, 85 | ); 86 | 87 | // endregion: --- AgentBmc 88 | 89 | // region: --- Tests 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | type Error = Box; 94 | type Result = core::result::Result; // For tests. 95 | 96 | use super::*; 97 | use crate::_dev_utils::{self, clean_agents, seed_agent, seed_agents}; 98 | use crate::model; 99 | use serde_json::json; 100 | use serial_test::serial; 101 | 102 | #[serial] 103 | #[tokio::test] 104 | async fn test_create_ok() -> Result<()> { 105 | // -- Setup & Fixtures 106 | let mm = _dev_utils::init_test().await; 107 | let ctx = Ctx::root_ctx(); 108 | let fx_name = "test_create_ok agent 01"; 109 | 110 | // -- Exec 111 | let fx_agent_c = AgentForCreate { 112 | name: fx_name.to_string(), 113 | }; 114 | let agent_id = AgentBmc::create(&ctx, &mm, fx_agent_c).await?; 115 | 116 | // -- Check 117 | let agent = AgentBmc::get(&ctx, &mm, agent_id).await?; 118 | assert_eq!(agent.name, fx_name); 119 | 120 | // -- Clean 121 | let count = clean_agents(&ctx, &mm, "test_create_ok").await?; 122 | assert_eq!(count, 1, "Should have cleaned only 1 agent"); 123 | 124 | Ok(()) 125 | } 126 | 127 | #[serial] 128 | #[tokio::test] 129 | async fn test_create_many_ok() -> Result<()> { 130 | // -- Setup & Fixtures 131 | let mm = _dev_utils::init_test().await; 132 | let ctx = Ctx::root_ctx(); 133 | let fx_name = "test_create_many_ok agent 01"; 134 | 135 | // -- Exec 136 | let fx_agent_c = AgentForCreate { 137 | name: fx_name.to_string(), 138 | }; 139 | let fx_agent_c2 = AgentForCreate { 140 | name: fx_name.to_string(), 141 | }; 142 | 143 | let agent_ids = 144 | AgentBmc::create_many(&ctx, &mm, vec![fx_agent_c, fx_agent_c2]).await?; 145 | 146 | let agent_filter: AgentFilter = serde_json::from_value(json!( 147 | { 148 | "id": {"$in": agent_ids} 149 | } 150 | ))?; 151 | 152 | let agents = 153 | AgentBmc::list(&ctx, &mm, Some(vec![agent_filter]), None).await?; 154 | 155 | assert_eq!(agents.len(), 2, "should have only retrieved 2 agents"); 156 | 157 | Ok(()) 158 | } 159 | 160 | #[serial] 161 | #[tokio::test] 162 | async fn test_update_ok() -> Result<()> { 163 | // -- Setup & Fixtures 164 | let mm = _dev_utils::init_test().await; 165 | let ctx = Ctx::root_ctx(); 166 | 167 | let fx_name = "test_update_ok agent 01"; 168 | let fx_agent_id = seed_agent(&ctx, &mm, fx_name).await?; 169 | let fx_name_updated = "test_update_ok agent 02 - updated"; 170 | 171 | // -- Exec 172 | let fx_agent_u = AgentForUpdate { 173 | name: Some(fx_name_updated.to_string()), 174 | }; 175 | AgentBmc::update(&ctx, &mm, fx_agent_id, fx_agent_u).await?; 176 | 177 | // -- Check 178 | let agent = AgentBmc::get(&ctx, &mm, fx_agent_id).await?; 179 | assert_eq!(agent.name, fx_name_updated); 180 | 181 | // -- Clean 182 | let count = clean_agents(&ctx, &mm, "test_update_ok agent").await?; 183 | assert_eq!(count, 1, "Should have cleaned only 1 agent"); 184 | 185 | Ok(()) 186 | } 187 | 188 | #[serial] 189 | #[tokio::test] 190 | async fn test_delete_ok() -> Result<()> { 191 | // -- Setup & Fixtures 192 | let mm = _dev_utils::init_test().await; 193 | let ctx = Ctx::root_ctx(); 194 | 195 | let fx_name = "test_delete_ok agent 01"; 196 | let fx_agent_id = seed_agent(&ctx, &mm, fx_name).await?; 197 | 198 | // -- Exec 199 | // check it's there 200 | AgentBmc::get(&ctx, &mm, fx_agent_id).await?; 201 | // do the delete 202 | AgentBmc::delete(&ctx, &mm, fx_agent_id).await?; 203 | 204 | // -- Check 205 | let res = AgentBmc::get(&ctx, &mm, fx_agent_id).await; 206 | assert!( 207 | matches!(&res, Err(model::Error::EntityNotFound { .. })), 208 | "should return a EntityNotFound" 209 | ); 210 | 211 | Ok(()) 212 | } 213 | #[serial] 214 | #[tokio::test] 215 | async fn test_delete_many_ok() -> Result<()> { 216 | // -- Setup & Fixtures 217 | let mm = _dev_utils::init_test().await; 218 | let ctx = Ctx::root_ctx(); 219 | let fx_name = "test_create_ok agent 01"; 220 | 221 | // -- Exec 222 | let fx_agent_c = AgentForCreate { 223 | name: fx_name.to_string(), 224 | }; 225 | let fx_agent_c2 = AgentForCreate { 226 | name: fx_name.to_string(), 227 | }; 228 | 229 | let agent_ids = 230 | AgentBmc::create_many(&ctx, &mm, vec![fx_agent_c, fx_agent_c2]).await?; 231 | 232 | let agent_filter: AgentFilter = serde_json::from_value(json!( 233 | { 234 | "id": {"$in": agent_ids} 235 | } 236 | ))?; 237 | 238 | let agents = 239 | AgentBmc::list(&ctx, &mm, Some(vec![agent_filter]), None).await?; 240 | 241 | assert_eq!(agents.len(), 2, "should have only retrieved 2 agents"); 242 | 243 | let deleted = AgentBmc::delete_many(&ctx, &mm, agent_ids).await?; 244 | 245 | assert_eq!(deleted, agents.len() as u64); 246 | 247 | Ok(()) 248 | } 249 | 250 | #[serial] 251 | #[tokio::test] 252 | async fn test_first_ok() -> Result<()> { 253 | // -- Setup & Fixtures 254 | let mm = _dev_utils::init_test().await; 255 | let ctx = Ctx::root_ctx(); 256 | 257 | let fx_agent_names = &["test_first_ok agent 01", "test_first_ok agent 02"]; 258 | seed_agents(&ctx, &mm, fx_agent_names).await?; 259 | 260 | // -- Exec 261 | let agent_filter: AgentFilter = serde_json::from_value(json!( 262 | { 263 | "name": {"$startsWith": "test_first_ok agent"} 264 | } 265 | ))?; 266 | let agent = 267 | AgentBmc::first(&ctx, &mm, Some(vec![agent_filter]), None).await?; 268 | 269 | // -- Check 270 | let agent = agent.ok_or("No Agent Returned (should have returned one")?; 271 | assert_eq!(agent.name, fx_agent_names[0]); 272 | 273 | // -- Clean 274 | let count = clean_agents(&ctx, &mm, "test_first_ok agent").await?; 275 | assert_eq!(count, 2, "Should have cleaned 2 agents"); 276 | 277 | Ok(()) 278 | } 279 | 280 | #[serial] 281 | #[tokio::test] 282 | async fn test_list_ok() -> Result<()> { 283 | // -- Setup & Fixtures 284 | let mm = _dev_utils::init_test().await; 285 | let ctx = Ctx::root_ctx(); 286 | 287 | let fx_agent_names = &["test_list_ok agent 01", "test_list_ok agent 02"]; 288 | seed_agents(&ctx, &mm, fx_agent_names).await?; 289 | let fx_asst_names = &[ 290 | "test_list_ok asst 01", 291 | "test_list_ok asst 02", 292 | "test_list_ok asst 03", 293 | ]; 294 | seed_agents(&ctx, &mm, fx_asst_names).await?; 295 | 296 | // -- Exec 297 | let agent_filter: AgentFilter = serde_json::from_value(json!( 298 | { 299 | "name": {"$contains": "list_ok agent"} 300 | } 301 | ))?; 302 | let agents = 303 | AgentBmc::list(&ctx, &mm, Some(vec![agent_filter]), None).await?; 304 | 305 | // -- Check 306 | assert_eq!(agents.len(), 2); 307 | let names = agents.iter().map(|a| &a.name).collect::>(); 308 | assert_eq!(names, fx_agent_names); 309 | 310 | // -- Clean 311 | let count = clean_agents(&ctx, &mm, "test_list_ok agent").await?; 312 | assert_eq!(count, 2, "Should have cleaned 2 agents"); 313 | let count = clean_agents(&ctx, &mm, "test_list_ok asst").await?; 314 | assert_eq!(count, 3, "Should have cleaned 3 assts"); 315 | 316 | Ok(()) 317 | } 318 | #[serial] 319 | #[tokio::test] 320 | async fn test_count_ok() -> Result<()> { 321 | // -- Setup & Fixtures 322 | let mm = _dev_utils::init_test().await; 323 | let ctx = Ctx::root_ctx(); 324 | 325 | let fx_agent_names = &["test_list_ok agent 01", "test_list_ok agent 02"]; 326 | seed_agents(&ctx, &mm, fx_agent_names).await?; 327 | 328 | // -- Exec 329 | let agent_filter: AgentFilter = serde_json::from_value(json!( 330 | { 331 | "name": {"$contains": "list_ok agent"} 332 | } 333 | ))?; 334 | let count = AgentBmc::count(&ctx, &mm, Some(vec![agent_filter])).await?; 335 | 336 | // -- Check 337 | assert_eq!(count, 2); 338 | 339 | // -- Clean 340 | let count = clean_agents(&ctx, &mm, "test_list_ok agent").await?; 341 | assert_eq!(count, 2, "Should have cleaned 2 agents"); 342 | 343 | Ok(()) 344 | } 345 | } 346 | 347 | // endregion: --- Tests 348 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Jeremy Chone 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------