From f5162daa6132dca7d7f23ef2c886f0c0072142f4 Mon Sep 17 00:00:00 2001 From: Yang Jing Date: Thu, 15 Aug 2024 15:28:48 +0800 Subject: [PATCH] feat: Add api-example project --- .vscode/extensions.json | 6 + .vscode/settings.json | 3 +- Cargo.toml | 16 ++- examples/README.md | 51 +++++++ examples/api-example/Cargo.toml | 32 +++++ examples/api-example/README.md | 1 + examples/api-example/resources/app.toml | 19 +++ examples/api-example/src/auth/auth_serv.rs | 42 ++++++ examples/api-example/src/auth/mod.rs | 7 + examples/api-example/src/auth/model.rs | 48 +++++++ examples/api-example/src/auth/web.rs | 15 ++ examples/api-example/src/ctx.rs | 66 +++++++++ examples/api-example/src/lib.rs | 6 + examples/api-example/src/main.rs | 12 ++ examples/api-example/src/router.rs | 7 + examples/api-example/src/state.rs | 43 ++++++ examples/api-example/src/user/mod.rs | 13 ++ examples/api-example/src/user/user_bmc.rs | 17 +++ .../src/user/user_credential_bmc.rs | 17 +++ .../src/user/user_credential_model.rs | 45 ++++++ examples/api-example/src/user/user_model.rs | 130 ++++++++++++++++++ examples/api-example/src/user/user_serv.rs | 64 +++++++++ examples/api-example/src/user/web.rs | 43 ++++++ examples/api-example/src/util.rs | 14 ++ examples/docker-compose.yml | 22 +++ examples/software/postgres/.pgpass | 2 + examples/software/postgres/Dockerfile | 10 ++ .../software/postgres/scripts/01-init.sql | 127 +++++++++++++++++ examples/software/postgres/scripts/02-ddl.sql | 4 + .../postgres/scripts/03-ultimate-iam.sql | 46 +++++++ ultimates/ultimate-db/src/auth.rs | 40 ------ ultimates/ultimate-db/src/base/macro_utils.rs | 6 +- ultimates/ultimate-db/src/lib.rs | 1 - ultimates/ultimate-db/src/modql_utils.rs | 26 +++- ultimates/ultimate-db/src/page.rs | 2 +- ultimates/ultimate-web/Cargo.toml | 5 +- ultimates/ultimate-web/src/error.rs | 11 -- ultimates/ultimate-web/src/util.rs | 25 +++- ultimates/ultimate/Cargo.toml | 1 - ultimates/ultimate/src/security/pwd.rs | 10 +- 40 files changed, 983 insertions(+), 72 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 examples/README.md create mode 100644 examples/api-example/Cargo.toml create mode 100644 examples/api-example/README.md create mode 100644 examples/api-example/resources/app.toml create mode 100644 examples/api-example/src/auth/auth_serv.rs create mode 100644 examples/api-example/src/auth/mod.rs create mode 100644 examples/api-example/src/auth/model.rs create mode 100644 examples/api-example/src/auth/web.rs create mode 100644 examples/api-example/src/ctx.rs create mode 100644 examples/api-example/src/lib.rs create mode 100644 examples/api-example/src/main.rs create mode 100644 examples/api-example/src/router.rs create mode 100644 examples/api-example/src/state.rs create mode 100644 examples/api-example/src/user/mod.rs create mode 100644 examples/api-example/src/user/user_bmc.rs create mode 100644 examples/api-example/src/user/user_credential_bmc.rs create mode 100644 examples/api-example/src/user/user_credential_model.rs create mode 100644 examples/api-example/src/user/user_model.rs create mode 100644 examples/api-example/src/user/user_serv.rs create mode 100644 examples/api-example/src/user/web.rs create mode 100644 examples/api-example/src/util.rs create mode 100644 examples/docker-compose.yml create mode 100644 examples/software/postgres/.pgpass create mode 100644 examples/software/postgres/Dockerfile create mode 100644 examples/software/postgres/scripts/01-init.sql create mode 100644 examples/software/postgres/scripts/02-ddl.sql create mode 100644 examples/software/postgres/scripts/03-ultimate-iam.sql delete mode 100644 ultimates/ultimate-db/src/auth.rs diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d3c4e11 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "fill-labs.dependi", + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 73c7fed..b40b51b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -76,5 +76,6 @@ }, "accessibility.signals.positionHasError": { "sound": "off" - } + }, + "editor.tabSize": 2 } diff --git a/Cargo.toml b/Cargo.toml index 0457cd3..736510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,17 @@ [workspace] -members = ["ultimates/*", "crates/*", "examples/db-example"] +members = [ + "ultimates/*", + "crates/*", + "examples/db-example", + "examples/api-example", +] resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" rust-version = "1.79" -description = "Rust libraries" +description = "Rust libraries of The ultimate-common" license-file = "LICENSE" repository = "https://gitee.com/yangbajing/ultimate-common" @@ -25,7 +30,7 @@ ultimate = { version = "0.1", path = "ultimates/ultimate" } ultimate-db = { version = "0.1", path = "ultimates/ultimate-db" } ultimate-web = { version = "0.1", path = "ultimates/ultimate-web" } # -- projects end -derive_more = { version = "1.0", features = ["from", "display"] } +derive_more = { version = "1.0", features = ["from", "display", "constructor"] } toml = "0.8" config = { version = "0.14", default-features = false, features = [ "toml", @@ -54,8 +59,7 @@ typed-builder = "0.19" derive-getters = "0.5" clap = { version = "4.5.7", features = ["derive"] } # -- Helpful macros for working with enums and strings -strum = { version = "0.26", features = ["derive"] } -strum_macros = "0.26" +enum-iterator = "2" # -- Error anyhow = "1" thiserror = "1" @@ -158,3 +162,5 @@ opendal = { version = "0.48", features = ["services-obs"] } # build-dependencies # tonic-build = "0.12" +strum = { version = "0.26", features = ["derive"] } +strum_macros = "0.26" diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b595a9a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,51 @@ +# Examples + +## 服务依赖 + +使用 docker compose 启动并初始化 PG 数据库 + +```sh +# 当前目录下有容器,先删除容器及数据卷(一般在 sql 有变更时需要) +#docker compose down --volumes --remove-orphans + +docker compose up -d --build +``` + +## api-example + +### 启动服务 + +```sh +cargo run --bin api-example +``` + +### 测试服务 + +#### 使用密码登录 + +```sh +curl -v --location 'http://localhost:8888/auth/login/pwd' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@ultimate.com", + "pwd": "2024.Ultimate" +}' | python -m json.tool +``` + +登录成功返回 token + +```sh +{ + "token": "eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..EZwETCBq1CNs8yO5Zec09Q.g3JoMryHoq01ZO3TQ2Ja_ppJZb9SYdon-LfB6OGyH7s.sBCGn14NuoxujmAgRpkYPg", + "token_type": "Bearer" +} +``` + +#### 用户-分页查询 + +```sh +curl -v --location 'http://localhost:8888/v1/user/page' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..EZwETCBq1CNs8yO5Zec09Q.g3JoMryHoq01ZO3TQ2Ja_ppJZb9SYdon-LfB6OGyH7s.sBCGn14NuoxujmAgRpkYPg' \ +--data '{}' | python -m json.tool --no-ensure-ascii +``` diff --git a/examples/api-example/Cargo.toml b/examples/api-example/Cargo.toml new file mode 100644 index 0000000..720cd95 --- /dev/null +++ b/examples/api-example/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "api-example" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description.workspace = true +license-file.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +ultimate-common = { workspace = true } +ultimate = { workspace = true, features = ["ulid"] } +ultimate-web = { workspace = true } +ultimate-db = { workspace = true } +thiserror.workspace = true +tokio.workspace = true +tower-http.workspace = true +axum.workspace = true +typed-builder.workspace = true +derive-getters.workspace = true +derive_more.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_repr.workspace = true +sqlx.workspace = true +sea-query.workspace = true +sea-query-binder.workspace = true +modql.workspace = true +enum-iterator.workspace = true diff --git a/examples/api-example/README.md b/examples/api-example/README.md new file mode 100644 index 0000000..8f799fe --- /dev/null +++ b/examples/api-example/README.md @@ -0,0 +1 @@ +# 使用 Rust 开发高效能的 API 服务 diff --git a/examples/api-example/resources/app.toml b/examples/api-example/resources/app.toml new file mode 100644 index 0000000..f9b9803 --- /dev/null +++ b/examples/api-example/resources/app.toml @@ -0,0 +1,19 @@ +[ultimate.app] +run_mode = "DEV" +name = "app-example" + +[ultimate.security.pwd] +expires_in = 604800 +default_pwd = "2024.Ultimate" + +[ultimate.web] +enable = true +server_addr = "0.0.0.0:8888" + +[ultimate.db] +enable = true +host = "localhost" +port = 15432 +database = "ultimate" +username = "ultimate" +password = "2024.Ultimate" diff --git a/examples/api-example/src/auth/auth_serv.rs b/examples/api-example/src/auth/auth_serv.rs new file mode 100644 index 0000000..2039921 --- /dev/null +++ b/examples/api-example/src/auth/auth_serv.rs @@ -0,0 +1,42 @@ +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use derive_more::derive::Constructor; +use ultimate::{security::pwd::verify_pwd, Result}; +use ultimate_web::AppError; + +use crate::{ + state::AppState, + user::{UserFilter, UserServ}, + util::make_token, +}; + +use super::{LoginByPwdReq, LoginResp, TokenType}; + +#[derive(Constructor)] +pub struct AuthServ { + app: AppState, +} + +impl AuthServ { + pub async fn login_by_pwd(&self, req: LoginByPwdReq) -> Result { + let user_serv = UserServ::new(self.app.clone(), self.app.create_super_admin_ctx()); + + let (u, uc) = user_serv.get_fetch_credential(UserFilter::from(&req)).await?; + verify_pwd(&req.pwd, &uc.encrypted_pwd).await?; + + let token = make_token(self.app.ultimate_config().security(), u.id)?; + Ok(LoginResp { token, token_type: TokenType::Bearer }) + } +} + +#[async_trait] +impl FromRequestParts for AuthServ { + type Rejection = (StatusCode, AppError); + + async fn from_request_parts(_parts: &mut Parts, state: &AppState) -> core::result::Result { + Ok(AuthServ::new(state.clone())) + } +} diff --git a/examples/api-example/src/auth/mod.rs b/examples/api-example/src/auth/mod.rs new file mode 100644 index 0000000..0d37271 --- /dev/null +++ b/examples/api-example/src/auth/mod.rs @@ -0,0 +1,7 @@ +mod auth_serv; +mod model; +mod web; + +use auth_serv::AuthServ; +use model::*; +pub use web::*; diff --git a/examples/api-example/src/auth/model.rs b/examples/api-example/src/auth/model.rs new file mode 100644 index 0000000..25fe849 --- /dev/null +++ b/examples/api-example/src/auth/model.rs @@ -0,0 +1,48 @@ +use modql::filter::{FilterNodes, OpValString, OpValsString}; +use serde::{Deserialize, Serialize}; + +use crate::user::UserFilter; + +#[derive(FilterNodes)] +pub struct LoginFilter { + pub email: Option, + pub phone: Option, +} + +impl From<&LoginByPwdReq> for LoginFilter { + fn from(req: &LoginByPwdReq) -> Self { + Self { + email: req.email.as_deref().map(|s| OpValString::Eq(s.to_string()).into()), + phone: req.phone.as_deref().map(|s| OpValString::Eq(s.to_string()).into()), + } + } +} + +#[derive(Deserialize)] +pub struct LoginByPwdReq { + pub email: Option, + pub phone: Option, + #[serde(skip_serializing)] + pub pwd: String, +} + +impl From<&LoginByPwdReq> for UserFilter { + fn from(value: &LoginByPwdReq) -> Self { + UserFilter { + email: value.email.as_deref().map(|s| OpValString::Eq(s.to_string()).into()), + phone: value.phone.as_deref().map(|s| OpValString::Eq(s.to_string()).into()), + ..Default::default() + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginResp { + pub token: String, + pub token_type: TokenType, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum TokenType { + Bearer, +} diff --git a/examples/api-example/src/auth/web.rs b/examples/api-example/src/auth/web.rs new file mode 100644 index 0000000..5614eff --- /dev/null +++ b/examples/api-example/src/auth/web.rs @@ -0,0 +1,15 @@ +use axum::{routing::post, Json, Router}; +use ultimate_web::{ok, AppResult}; + +use crate::state::AppState; + +use super::{AuthServ, LoginByPwdReq, LoginResp}; + +pub fn auth_routes() -> Router { + Router::new().route("/login/pwd", post(login_pwd)) +} + +async fn login_pwd(auth_serv: AuthServ, Json(req): Json) -> AppResult { + let resp = auth_serv.login_by_pwd(req).await?; + ok(resp) +} diff --git a/examples/api-example/src/ctx.rs b/examples/api-example/src/ctx.rs new file mode 100644 index 0000000..fa4f935 --- /dev/null +++ b/examples/api-example/src/ctx.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, HeaderMap, StatusCode}, + Json, +}; +use derive_getters::Getters; +use ultimate::ctx::Ctx; +use ultimate_db::ModelManager; +use ultimate_web::{extract_session, AppError}; + +use crate::state::AppState; + +static X_APP_VERSION: &str = "X-APP-VARSION"; +static X_DEVICE_ID: &str = "X-DEVICE-ID"; + +#[derive(Clone, Getters)] +pub struct CtxW { + ctx: Ctx, + mm: ModelManager, + req_meta: Arc, +} +impl CtxW { + pub fn new(state: &AppState, ctx: Ctx, req_meta: Arc) -> Self { + let mm = state.mm().clone().with_ctx(ctx.clone()); + Self { ctx, mm, req_meta } + } +} + +#[async_trait] +impl FromRequestParts for CtxW { + type Rejection = (StatusCode, Json); + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> core::result::Result { + match extract_session(parts, state.ultimate_config().security()) { + Ok(ctx) => Ok(CtxW::new(state, ctx, Arc::new(RequestMetadata::from(&parts.headers)))), + Err(e) => Err((StatusCode::UNAUTHORIZED, Json(e.into()))), + } + } +} + +#[derive(Clone, Default)] +pub struct RequestMetadata { + app_ver: String, + dev_id: String, +} + +impl RequestMetadata { + pub fn app_ver(&self) -> &str { + self.app_ver.as_str() + } + + pub fn dev_id(&self) -> &str { + self.dev_id.as_str() + } +} + +impl From<&HeaderMap> for RequestMetadata { + fn from(headers: &HeaderMap) -> Self { + let app_ver = headers.get(X_APP_VERSION).map(|v| v.to_str().unwrap_or("").to_string()).unwrap_or_default(); + let dev_id = headers.get(X_DEVICE_ID).map(|v| v.to_str().unwrap_or("").to_string()).unwrap_or_default(); + Self { app_ver, dev_id } + } +} diff --git a/examples/api-example/src/lib.rs b/examples/api-example/src/lib.rs new file mode 100644 index 0000000..2233b8d --- /dev/null +++ b/examples/api-example/src/lib.rs @@ -0,0 +1,6 @@ +mod auth; +pub mod ctx; +pub mod router; +pub mod state; +mod user; +mod util; diff --git a/examples/api-example/src/main.rs b/examples/api-example/src/main.rs new file mode 100644 index 0000000..b57d476 --- /dev/null +++ b/examples/api-example/src/main.rs @@ -0,0 +1,12 @@ +use api_example::{router::new_api_router, state::new_app_state}; +use ultimate_web::server::init_server; + +#[tokio::main] +async fn main() -> ultimate::Result<()> { + let state = new_app_state().await?; + let conf = state.ultimate_config(); + let router = new_api_router(state.clone()); + + init_server(conf, router).await?; + Ok(()) +} diff --git a/examples/api-example/src/router.rs b/examples/api-example/src/router.rs new file mode 100644 index 0000000..18191b8 --- /dev/null +++ b/examples/api-example/src/router.rs @@ -0,0 +1,7 @@ +use axum::Router; + +use crate::{auth::auth_routes, state::AppState, user::user_routes}; + +pub fn new_api_router(app_state: AppState) -> Router { + Router::new().nest("/v1/user", user_routes()).nest("/auth", auth_routes()).with_state(app_state) +} diff --git a/examples/api-example/src/state.rs b/examples/api-example/src/state.rs new file mode 100644 index 0000000..f769ed1 --- /dev/null +++ b/examples/api-example/src/state.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use derive_getters::Getters; +use typed_builder::TypedBuilder; +use ultimate::{ + configuration::{ConfigState, UltimateConfig}, + ctx::Ctx, + starter, +}; +use ultimate_db::{DbState, ModelManager}; + +use crate::ctx::{CtxW, RequestMetadata}; + +#[derive(Clone, TypedBuilder, Getters)] +pub struct AppState { + config_state: ConfigState, + db_state: DbState, +} + +impl AppState { + pub fn ultimate_config(&self) -> &UltimateConfig { + self.config_state().ultimate_config() + } + + pub fn mm(&self) -> &ModelManager { + self.db_state().mm() + } + + pub fn create_root_ctx(&self) -> crate::ctx::CtxW { + CtxW::new(self, Ctx::new_root(), Arc::new(RequestMetadata::default())) + } + + pub fn create_super_admin_ctx(&self) -> crate::ctx::CtxW { + CtxW::new(self, Ctx::new_super_admin(), Arc::new(RequestMetadata::default())) + } +} + +pub async fn new_app_state() -> ultimate::Result { + let config = starter::load_and_init(); + let db = DbState::from_config(config.ultimate_config().db()).await?; + let app = AppState::builder().config_state(config).db_state(db).build(); + Ok(app) +} diff --git a/examples/api-example/src/user/mod.rs b/examples/api-example/src/user/mod.rs new file mode 100644 index 0000000..71319d7 --- /dev/null +++ b/examples/api-example/src/user/mod.rs @@ -0,0 +1,13 @@ +mod user_bmc; +mod user_credential_bmc; +mod user_credential_model; +mod user_model; +mod user_serv; +mod web; + +use user_bmc::UserBmc; +use user_credential_bmc::UserCredentialBmc; +pub use user_credential_model::*; +pub use user_model::*; +pub use user_serv::UserServ; +pub use web::user_routes; diff --git a/examples/api-example/src/user/user_bmc.rs b/examples/api-example/src/user/user_bmc.rs new file mode 100644 index 0000000..081ba49 --- /dev/null +++ b/examples/api-example/src/user/user_bmc.rs @@ -0,0 +1,17 @@ +use ultimate_db::{base::DbBmc, generate_common_bmc_fns}; + +use super::{User, UserFilter, UserForCreate, UserForUpdate}; + +pub struct UserBmc; +impl DbBmc for UserBmc { + const SCHEMA: &'static str = "iam"; + const TABLE: &'static str = "user"; +} + +generate_common_bmc_fns!( + Bmc: UserBmc, + Entity: User, + ForCreate: UserForCreate, + ForUpdate: UserForUpdate, + Filter: UserFilter, +); diff --git a/examples/api-example/src/user/user_credential_bmc.rs b/examples/api-example/src/user/user_credential_bmc.rs new file mode 100644 index 0000000..c86062b --- /dev/null +++ b/examples/api-example/src/user/user_credential_bmc.rs @@ -0,0 +1,17 @@ +use ultimate_db::{base::DbBmc, generate_common_bmc_fns}; + +use super::{UserCredential, UserCredentialFilter, UserCredentialForCreate, UserCredentialForUpdate}; + +pub struct UserCredentialBmc; +impl DbBmc for UserCredentialBmc { + const SCHEMA: &'static str = "iam"; + const TABLE: &'static str = "user_credential"; +} + +generate_common_bmc_fns!( + Bmc: UserCredentialBmc, + Entity: UserCredential, + ForCreate: UserCredentialForCreate, + ForUpdate: UserCredentialForUpdate, + Filter: UserCredentialFilter, +); diff --git a/examples/api-example/src/user/user_credential_model.rs b/examples/api-example/src/user/user_credential_model.rs new file mode 100644 index 0000000..79edfa3 --- /dev/null +++ b/examples/api-example/src/user/user_credential_model.rs @@ -0,0 +1,45 @@ +use modql::{ + field::Fields, + filter::{FilterNodes, OpValsInt64, OpValsValue}, +}; +use sqlx::FromRow; +use ultimate_common::time::UtcDateTime; +use ultimate_db::{to_sea_chrono_utc, DbRowType}; + +#[derive(FromRow, Fields)] +pub struct UserCredential { + pub id: i64, + pub encrypted_pwd: String, + pub cid: i64, + pub ctime: UtcDateTime, + pub mid: Option, + pub mtime: Option, +} +impl DbRowType for UserCredential {} + +#[derive(Fields)] +pub struct UserCredentialForCreate { + pub id: i64, + pub encrypted_pwd: String, +} + +#[derive(Default, Fields)] +pub struct UserCredentialForUpdate { + pub id: Option, + pub encrypted_pwd: Option, +} + +#[derive(Default, FilterNodes)] +pub struct UserCredentialFilter { + pub id: Option, + + pub cid: Option, + + #[modql(to_sea_value_fn = "to_sea_chrono_utc")] + pub ctime: Option, + + pub mid: Option, + + #[modql(to_sea_value_fn = "to_sea_chrono_utc")] + pub mtime: Option, +} diff --git a/examples/api-example/src/user/user_model.rs b/examples/api-example/src/user/user_model.rs new file mode 100644 index 0000000..897fb52 --- /dev/null +++ b/examples/api-example/src/user/user_model.rs @@ -0,0 +1,130 @@ +use enum_iterator::Sequence; +use modql::{ + field::Fields, + filter::{FilterNodes, OpValsInt32, OpValsInt64, OpValsString, OpValsValue}, +}; +use sea_query::enum_def; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use sqlx::prelude::FromRow; +use ultimate::{DataError, Result}; +use ultimate_common::{regex, time::UtcDateTime}; +use ultimate_db::{to_sea_chrono_utc, DbRowType, Page, PagePayload, Pagination}; + +#[derive(Debug, Serialize, FromRow, Fields)] +#[enum_def] +pub struct User { + pub id: i64, + pub email: Option, + pub phone: Option, + pub name: String, + pub status: UserStatus, + pub cid: i64, + pub ctime: UtcDateTime, + pub mid: Option, + pub mtime: Option, +} +impl DbRowType for User {} + +#[derive(Debug, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Sequence, sqlx::Type)] +#[repr(i32)] +pub enum UserStatus { + #[default] + Normal = 10, + Disable = 99, + Enable = 100, +} + +impl From for sea_query::Value { + fn from(value: UserStatus) -> Self { + sea_query::Value::Int(Some(value as i32)) + } +} + +impl sea_query::Nullable for UserStatus { + fn null() -> sea_query::Value { + sea_query::Value::Int(None) + } +} + +#[derive(Debug, Deserialize, Fields)] +pub struct UserForCreate { + pub email: Option, + pub phone: Option, + pub name: Option, + pub status: Option, +} + +impl UserForCreate { + /// 校验数据并进行初始化。`email` 或 `phone` 至少有一个,若两个值都设置,则只有 `email` 有效。 + /// + /// 当 `name` 未设置时,将从 `email` 或 `phone` 中取值。 + pub fn validate_and_init(mut self) -> Result { + if let Some(email) = self.email.as_deref() { + if !regex::is_email(email) { + return Err(DataError::bad_request("The 'email' field is invalid")); + } + } else if let Some(phone) = self.phone.as_deref() { + if !regex::is_phone(phone) { + return Err(DataError::bad_request("The 'phone' field is invalid")); + } + } else { + return Err(DataError::bad_request("At least one 'email' or 'phone' is required")); + }; + + let has_name = self.name.as_deref().is_some_and(|n| !n.is_empty()); + if !has_name { + self.name = match self.email.as_deref() { + Some(email) => email.split('@').next().map(ToString::to_string), + None => self.phone.clone(), + }; + } + + Ok(self) + } +} + +#[derive(Debug, Deserialize, Fields)] +pub struct UserForUpdate { + pub name: Option, + pub status: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct UserForPage { + pub page: Option, + pub filter: Option, +} + +#[derive(Debug, Default, Deserialize, FilterNodes)] +pub struct UserFilter { + pub email: Option, + + pub phone: Option, + + pub name: Option, + + pub status: Option, + + pub cid: Option, + + #[modql(to_sea_value_fn = "to_sea_chrono_utc")] + pub ctime: Option, + + pub mid: Option, + + #[modql(to_sea_value_fn = "to_sea_chrono_utc")] + pub mtime: Option, +} + +#[derive(Debug, Serialize)] +pub struct UserPage { + pub page: Page, + pub records: Vec, +} + +impl From> for UserPage { + fn from(value: PagePayload) -> Self { + Self { page: value.page, records: value.records } + } +} diff --git a/examples/api-example/src/user/user_serv.rs b/examples/api-example/src/user/user_serv.rs new file mode 100644 index 0000000..90eecf7 --- /dev/null +++ b/examples/api-example/src/user/user_serv.rs @@ -0,0 +1,64 @@ +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + Json, +}; +use derive_more::derive::Constructor; +use ultimate::{DataError, Result}; +use ultimate_web::AppError; + +use crate::{ctx::CtxW, state::AppState}; + +use super::{ + User, UserBmc, UserCredential, UserCredentialBmc, UserFilter, UserForCreate, UserForPage, UserForUpdate, UserPage, +}; + +#[derive(Constructor)] +pub struct UserServ { + _app: AppState, + ctx: CtxW, +} + +impl UserServ { + pub async fn create(&self, req: UserForCreate) -> Result { + let id = UserBmc::create(self.ctx.mm(), req.validate_and_init()?).await?; + Ok(id) + } + + pub async fn page(&self, req: UserForPage) -> Result { + let page = UserBmc::page(self.ctx.mm(), req.page.unwrap_or_default(), req.filter.unwrap_or_default()).await?; + Ok(page.into()) + } + + pub async fn get_by_id(&self, id: i64) -> Result { + let u = UserBmc::get_by_id(self.ctx.mm(), id).await?; + Ok(u) + } + + pub async fn update_by_id(&self, id: i64, req: UserForUpdate) -> Result<()> { + UserBmc::update_by_id(self.ctx.mm(), id, req).await?; + Ok(()) + } + + pub async fn delete_by_id(&self, id: i64) -> Result<()> { + UserBmc::delete_by_id(self.ctx.mm(), id).await?; + Ok(()) + } + + pub(crate) async fn get_fetch_credential(&self, req: UserFilter) -> Result<(User, UserCredential)> { + let u = UserBmc::find(self.ctx.mm(), req).await?.ok_or_else(|| DataError::not_found("User not exists."))?; + let uc = UserCredentialBmc::get_by_id(self.ctx.mm(), u.id).await?; + Ok((u, uc)) + } +} + +#[async_trait] +impl FromRequestParts for UserServ { + type Rejection = (StatusCode, Json); + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> core::result::Result { + let ctx = CtxW::from_request_parts(parts, state).await?; + Ok(UserServ::new(state.clone(), ctx)) + } +} diff --git a/examples/api-example/src/user/web.rs b/examples/api-example/src/user/web.rs new file mode 100644 index 0000000..0100fa9 --- /dev/null +++ b/examples/api-example/src/user/web.rs @@ -0,0 +1,43 @@ +use axum::{ + extract::Path, + routing::{get, post}, + Json, Router, +}; +use ultimate::IdI64Result; +use ultimate_web::{ok, AppResult}; + +use crate::state::AppState; + +use super::{User, UserForCreate, UserForPage, UserForUpdate, UserPage, UserServ}; + +pub fn user_routes() -> Router { + Router::new() + .route("/", post(create_user)) + .route("/page", post(page_user)) + .route("/:id", get(get_user).put(update_user).delete(delete_user)) +} + +async fn create_user(user_serv: UserServ, Json(req): Json) -> AppResult { + let id = user_serv.create(req).await?; + ok(IdI64Result::new(id)) +} + +async fn page_user(user_serv: UserServ, Json(req): Json) -> AppResult { + let page = user_serv.page(req).await?; + ok(page) +} + +async fn get_user(user_serv: UserServ, Path(id): Path) -> AppResult { + let u = user_serv.get_by_id(id).await?; + ok(u) +} + +async fn update_user(user_serv: UserServ, Path(id): Path, Json(req): Json) -> AppResult<()> { + user_serv.update_by_id(id, req).await?; + ok(()) +} + +async fn delete_user(user_serv: UserServ, Path(id): Path) -> AppResult<()> { + user_serv.delete_by_id(id).await?; + ok(()) +} diff --git a/examples/api-example/src/util.rs b/examples/api-example/src/util.rs new file mode 100644 index 0000000..9754e99 --- /dev/null +++ b/examples/api-example/src/util.rs @@ -0,0 +1,14 @@ +use ultimate::{ + configuration::model::SecruityConfig, + security::{jose::JwtPayload, SecurityUtils}, + DataError, Result, +}; + +pub fn make_token(sc: &SecruityConfig, uid: i64) -> Result { + let mut payload = JwtPayload::new(); + payload.set_subject(uid.to_string()); + + let token = + SecurityUtils::encrypt_jwt(sc.pwd(), payload).map_err(|_e| DataError::unauthorized("Failed generate token"))?; + Ok(token) +} diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 0000000..04963b3 --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,22 @@ +name: "ultimate" + +services: + db: + build: + context: software/postgres + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - .env + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - ultimate + ports: + - "15432:15432" + +networks: + ultimate: + +volumes: + postgres-data: diff --git a/examples/software/postgres/.pgpass b/examples/software/postgres/.pgpass new file mode 100644 index 0000000..7837a35 --- /dev/null +++ b/examples/software/postgres/.pgpass @@ -0,0 +1,2 @@ +localhost:15432:postgres:postgres:2024.Ultimate +localhost:15432:ultimate:ultimate:2024.Ultimate diff --git a/examples/software/postgres/Dockerfile b/examples/software/postgres/Dockerfile new file mode 100644 index 0000000..653162f --- /dev/null +++ b/examples/software/postgres/Dockerfile @@ -0,0 +1,10 @@ +FROM postgres:16 + +ENV LANG zh_CN.utf8 +ENV TZ Asia/Chongqing +ENV PGPORT 15432 + +RUN localedef -i zh_CN -c -f UTF-8 -A /usr/share/locale/locale.alias zh_CN.UTF-8 + +COPY scripts/*.sql /docker-entrypoint-initdb.d/ +COPY --chmod=0600 .pgpass /root/.pgpass diff --git a/examples/software/postgres/scripts/01-init.sql b/examples/software/postgres/scripts/01-init.sql new file mode 100644 index 0000000..cee2506 --- /dev/null +++ b/examples/software/postgres/scripts/01-init.sql @@ -0,0 +1,127 @@ +set timezone to 'Asia/Chongqing'; +\c template1; +-- create extension adminpack; +---------------------------------------- +-- #functions +---------------------------------------- +-- 将数组反序 +create or replace function array_reverse(anyarray) returns anyarray as +$$ +select array( + select $1[i] + from generate_subscripts($1, 1) as s (i) + order by i desc + ); +$$ language 'sql' strict + immutable; +---------------------------------------- +-- #functions +---------------------------------------- +---------------------------------------- +-- init tables, views, sequences begin +---------------------------------------- +---------------------------------------- +-- init tables, views, sequences end +---------------------------------------- +-- change tables, views, sequences owner to massdata +-- DO +-- $$ +-- DECLARE +-- r record; +-- BEGIN +-- FOR r IN SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' +-- LOOP +-- EXECUTE 'alter table ' || r.table_name || ' owner to massdata;'; +-- END LOOP; +-- END +-- $$; +-- DO +-- $$ +-- DECLARE +-- r record; +-- BEGIN +-- FOR r IN select sequence_name from information_schema.sequences where sequence_schema = 'public' +-- LOOP +-- EXECUTE 'alter sequence ' || r.sequence_name || ' owner to massdata;'; +-- END LOOP; +-- END +-- $$; +-- DO +-- $$ +-- DECLARE +-- r record; +-- BEGIN +-- FOR r IN select table_name from information_schema.views where table_schema = 'public' +-- LOOP +-- EXECUTE 'alter table ' || r.table_name || ' owner to massdata;'; +-- END LOOP; +-- END +-- $$; +-- grant all privileges on all tables in schema public to massdata; +-- grant all privileges on all sequences in schema public to massdata; +-- 批量 grant/ revoke 用户权限 +create or replace function g_or_v( + g_or_v text, + -- 输入 grant or revoke 表示赋予或回收 + own name, + -- 指定用户 owner + target name, + -- 赋予给哪个目标用户 grant privilege to who? + objtyp text, + -- 对象类别: 表, 物化视图, 视图 object type 'r', 'v' or 'm', means table,view,materialized view + exp text[], + -- 排除哪些对象, 用数组表示, excluded objects + priv text -- 权限列表, privileges, ,splits, like 'select,insert,update' +) returns void as +$$ +declare + nsp name; + rel name; + sql text; + tmp_nsp name := ''; +begin + for nsp, + rel in + select t2.nspname, + t1.relname + from pg_class t1, + pg_namespace t2 + where t1.relkind = objtyp + and t1.relnamespace = t2.oid + and t1.relowner = (select oid + from pg_roles + where rolname = own) + loop + if ( + tmp_nsp = '' + or tmp_nsp <> nsp + ) + and lower(g_or_v) = 'grant' then -- auto grant schema to target user + sql := 'GRANT usage on schema "' || nsp || '" to ' || target; + execute sql; + raise notice '%', + sql; + end if; + tmp_nsp := nsp; + if ( + exp is not null + and nsp || '.' || rel = any (exp) + ) then + raise notice '% excluded % .', + g_or_v, + nsp || '.' || rel; + else + if lower(g_or_v) = 'grant' then + sql := g_or_v || ' ' || priv || ' on "' || nsp || '"."' || rel || '" to ' || target; + elsif lower(g_or_v) = 'revoke' then + sql := g_or_v || ' ' || priv || ' on "' || nsp || '"."' || rel || '" from ' || target; + else + raise notice 'you must enter grant or revoke'; + end if; + raise notice '%', + sql; + execute sql; + end if; + end loop; +end; +$$ language plpgsql; diff --git a/examples/software/postgres/scripts/02-ddl.sql b/examples/software/postgres/scripts/02-ddl.sql new file mode 100644 index 0000000..41c8e13 --- /dev/null +++ b/examples/software/postgres/scripts/02-ddl.sql @@ -0,0 +1,4 @@ +set timezone to 'Asia/Chongqing'; +-- +create user ultimate with nosuperuser replication encrypted password '2024.Ultimate'; +create database ultimate owner = ultimate template = template1; diff --git a/examples/software/postgres/scripts/03-ultimate-iam.sql b/examples/software/postgres/scripts/03-ultimate-iam.sql new file mode 100644 index 0000000..c5c0662 --- /dev/null +++ b/examples/software/postgres/scripts/03-ultimate-iam.sql @@ -0,0 +1,46 @@ +set timezone to 'Asia/Chongqing'; +\c ultimate; +\c - ultimate; +create schema if not exists iam; +-- +-- User +create table if not exists iam.user +( + id bigserial not null, + email varchar + constraint user_uk_email unique, + phone varchar + constraint user_uk_phone unique, + name varchar, + status int not null, + cid bigint not null, + ctime timestamptz not null, + mid bigint, + mtime timestamptz, + constraint user_pk primary key (id) +); +-- +-- UserCredential +create table if not exists iam.user_credential +( + id bigint not null + constraint user_credential_fk_user references iam.user (id), + encrypted_pwd varchar(255) not null, + cid bigint not null, + ctime timestamptz not null, + mid bigint, + mtime timestamptz, + constraint user_credential_pk primary key (id) +); + +-- initial data +------------------ +insert into iam."user" (id, email, phone, name, status, cid, ctime) +values (1, 'admin@ultimate.com', null, '超管', 100, 1, current_timestamp), + (10000, null, '13912345678', '普通用户', 100, 1, current_timestamp); +insert into iam.user_credential (id, encrypted_pwd, cid, ctime) +values (1, + '#1#$argon2id$v=19$m=19456,t=2,p=1$hAPRw63nW4mdwOd0l0WnmA$wN1i4uYbL+h/FjsaMVae6n93A3LikkqJ4IwiAqr78x0', -- 密码为:2024.Ultimate + 1, current_timestamp); +-- 重置 user_id_seq,使新用户注册从ID为 10001 开始 +alter sequence iam.user_id_seq restart 10001; diff --git a/ultimates/ultimate-db/src/auth.rs b/ultimates/ultimate-db/src/auth.rs deleted file mode 100644 index 2c15ea7..0000000 --- a/ultimates/ultimate-db/src/auth.rs +++ /dev/null @@ -1,40 +0,0 @@ -use modql::filter::{FilterNodes, OpValsString}; -use serde::{Deserialize, Serialize}; - -#[derive(FilterNodes)] -pub struct LoginFilter { - pub username: Option, - pub email: Option, - pub phone: Option, -} - -impl From<&LoginByPasswordReq> for LoginFilter { - fn from(req: &LoginByPasswordReq) -> Self { - Self { - username: req.username.clone().map(OpValsString::from), - email: req.email.clone().map(OpValsString::from), - phone: req.phone.clone().map(OpValsString::from), - } - } -} - -#[derive(Deserialize)] -pub struct LoginByPasswordReq { - pub username: Option, - pub email: Option, - pub phone: Option, - #[serde(skip_serializing)] - pub pwd: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginResp { - pub token: String, - pub token_type: TokenType, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TokenType { - Bearer, -} diff --git a/ultimates/ultimate-db/src/base/macro_utils.rs b/ultimates/ultimate-db/src/base/macro_utils.rs index 4eff5b9..6c80afc 100644 --- a/ultimates/ultimate-db/src/base/macro_utils.rs +++ b/ultimates/ultimate-db/src/base/macro_utils.rs @@ -60,7 +60,7 @@ macro_rules! generate_common_bmc_fns { pub async fn list( mm: &ultimate_db::ModelManager, - filter: Vec<$filter>, + filter: $filter, pagination: Option<&ultimate_db::Pagination>, ) -> ultimate_db::Result> { ultimate_db::base::list::(mm, Some(filter), pagination.map(Into::into)).await @@ -68,7 +68,7 @@ macro_rules! generate_common_bmc_fns { pub async fn count( mm: &ultimate_db::ModelManager, - filter: Vec<$filter>, + filter: $filter, ) -> ultimate_db::Result { ultimate_db::base::count::(mm, Some(filter)).await } @@ -76,7 +76,7 @@ macro_rules! generate_common_bmc_fns { pub async fn page( mm: &ultimate_db::ModelManager, pagination: ultimate_db::Pagination, - filter: Vec<$filter>, + filter: $filter, ) -> ultimate_db::Result> { ultimate_db::base::page::(mm, pagination, Some(filter)).await } diff --git a/ultimates/ultimate-db/src/lib.rs b/ultimates/ultimate-db/src/lib.rs index 4a044ff..f1a71f5 100644 --- a/ultimates/ultimate-db/src/lib.rs +++ b/ultimates/ultimate-db/src/lib.rs @@ -1,7 +1,6 @@ use ultimate::configuration::model::DbConfig; pub mod acs; -pub mod auth; pub mod base; mod error; mod id; diff --git a/ultimates/ultimate-db/src/modql_utils.rs b/ultimates/ultimate-db/src/modql_utils.rs index 75064d4..f0d07ca 100644 --- a/ultimates/ultimate-db/src/modql_utils.rs +++ b/ultimates/ultimate-db/src/modql_utils.rs @@ -1,10 +1,32 @@ +use modql::filter::{IntoSeaError, SeaResult}; use serde::Deserialize; -use ultimate_common::time::UtcDateTime; +use ultimate_common::time::{local_offset, Duration, OffsetDateTime, UtcDateTime}; -pub fn time_to_sea_value(json_value: serde_json::Value) -> modql::filter::SeaResult { +pub fn time_to_sea_value(json_value: serde_json::Value) -> SeaResult { Ok(UtcDateTime::deserialize(json_value)?.into()) } +pub fn to_sea_chrono_utc(v: serde_json::Value) -> SeaResult { + if v.as_str().is_some() { + Ok(UtcDateTime::deserialize(v)?.into()) + } else if let Some(i) = v.as_i64() { + let d = UtcDateTime::MIN_UTC + Duration::milliseconds(i); + Ok(sea_query::Value::ChronoDateTimeUtc(Some(Box::new(d)))) + } else { + Err(IntoSeaError::Custom(format!("Invalid value: incoming is {:?}", v))) + } +} +pub fn to_sea_chrono_offset(v: serde_json::Value) -> SeaResult { + if v.as_str().is_some() { + Ok(OffsetDateTime::deserialize(v)?.into()) + } else if let Some(i) = v.as_i64() { + let d = (OffsetDateTime::MIN_UTC + Duration::milliseconds(i)).with_timezone(local_offset()); + Ok(sea_query::Value::ChronoDateTimeWithTimeZone(Some(Box::new(d)))) + } else { + Err(IntoSeaError::Custom(format!("Invalid value: incoming is {:?}", v))) + } +} + #[cfg(feature = "utoipa")] pub fn op_vals_integer_schema() -> utoipa::openapi::Object { utoipa::openapi::ObjectBuilder::new() diff --git a/ultimates/ultimate-db/src/page.rs b/ultimates/ultimate-db/src/page.rs index f7bd5f6..4b4529b 100644 --- a/ultimates/ultimate-db/src/page.rs +++ b/ultimates/ultimate-db/src/page.rs @@ -13,7 +13,7 @@ impl PagePayload { } } -#[derive(Serialize)] +#[derive(Debug, Clone, Serialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct Page { pub page: i64, diff --git a/ultimates/ultimate-web/Cargo.toml b/ultimates/ultimate-web/Cargo.toml index f9335a6..c1e297d 100644 --- a/ultimates/ultimate-web/Cargo.toml +++ b/ultimates/ultimate-web/Cargo.toml @@ -11,7 +11,9 @@ repository.workspace = true workspace = true [features] -# default = ["utoipa"] +default = ["with-ulid"] +with-ulid = ["ultimate/ulid"] +uuid = ["dep:uuid", "ultimate/uuid"] [dependencies] ultimate-common.workspace = true @@ -27,5 +29,6 @@ serde.workspace = true serde_json.workspace = true serde_with.workspace = true ulid.workspace = true +uuid = { workspace = true, optional = true } tonic = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } diff --git a/ultimates/ultimate-web/src/error.rs b/ultimates/ultimate-web/src/error.rs index 049789a..6989f45 100644 --- a/ultimates/ultimate-web/src/error.rs +++ b/ultimates/ultimate-web/src/error.rs @@ -6,20 +6,9 @@ use serde_json::Value; use ulid::Ulid; use ultimate::security; use ultimate::DataError; -use ultimate::IdI64Result; pub type AppResult = core::result::Result, AppError>; -#[allow(unused)] -pub fn ok_result(data: T) -> AppResult { - Ok(data.into()) -} - -#[allow(unused)] -pub fn ok_id(id: i64) -> AppResult { - Ok(IdI64Result::new(id).into()) -} - /// A default error response for most API errors. #[derive(Debug, Serialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] diff --git a/ultimates/ultimate-web/src/util.rs b/ultimates/ultimate-web/src/util.rs index 2102874..74659d0 100644 --- a/ultimates/ultimate-web/src/util.rs +++ b/ultimates/ultimate-web/src/util.rs @@ -5,15 +5,38 @@ use axum::Json; use axum_extra::headers::authorization::Bearer; use axum_extra::headers::{Authorization, HeaderMapExt}; use serde::de::DeserializeOwned; +use serde::Serialize; +use ulid::Ulid; use ultimate::configuration::model::SecruityConfig; use ultimate::ctx::Ctx; use ultimate::security::{AccessToken, SecurityUtils}; -use ultimate::DataError; +use ultimate::{DataError, IdI64Result, IdUlidResult}; use ultimate_common::time; use crate::error::AppError; use crate::AppResult; +#[inline] +pub fn ok(v: T) -> AppResult { + Ok(Json(v)) +} + +#[inline] +pub fn ok_id(id: i64) -> AppResult { + Ok(IdI64Result::new(id).into()) +} + +#[inline] +pub fn ok_ulid(id: Ulid) -> AppResult { + Ok(IdUlidResult::new(id).into()) +} + +#[inline] +#[cfg(feature = "uuid")] +pub fn ok_uuid(id: uuid::Uuid) -> AppResult { + Ok(ultimate::IdUuidResult::new(id).into()) +} + pub fn unauthorized_app_error(msg: impl Into) -> (StatusCode, Json) { (StatusCode::UNAUTHORIZED, Json(AppError::new(msg).with_err_code(401))) } diff --git a/ultimates/ultimate/Cargo.toml b/ultimates/ultimate/Cargo.toml index d633162..51b43bd 100644 --- a/ultimates/ultimate/Cargo.toml +++ b/ultimates/ultimate/Cargo.toml @@ -36,7 +36,6 @@ josekit.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true -derive_more.workspace = true tonic = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } diff --git a/ultimates/ultimate/src/security/pwd.rs b/ultimates/ultimate/src/security/pwd.rs index 1cd8a2c..cea855d 100644 --- a/ultimates/ultimate/src/security/pwd.rs +++ b/ultimates/ultimate/src/security/pwd.rs @@ -82,13 +82,13 @@ mod tests { #[tokio::test] async fn test_pwd() -> Result<()> { - let password = "Lightshadow.2024"; - let pwd = generate_pwd(password).await?; - println!("The pwd is {pwd}"); + let plain_pwd = "2024.Ultimate"; + let encrypted_pwd = generate_pwd(plain_pwd).await?; + println!("The pwd is {}", encrypted_pwd); - assert!(pwd.starts_with("#1#")); + assert!(encrypted_pwd.starts_with("#1#")); - let version = verify_pwd(password, &pwd).await?; + let version = verify_pwd(plain_pwd, &encrypted_pwd).await?; assert_eq!(version, 1); Ok(())