diff --git a/Cargo.lock b/Cargo.lock index c1bc11c..a2efa18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,12 +173,28 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "bank_core" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "garde", + "http", + "regex", + "schemars", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "bankserver" version = "0.1.0" dependencies = [ "async-broadcast", "axum", + "bank_core", "chrono", "concread", "config", @@ -722,9 +738,9 @@ dependencies = [ [[package]] name = "http" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -1415,9 +1431,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ "itoa", "serde", diff --git a/Cargo.toml b/Cargo.toml index e909369..0947e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,17 @@ +[workspace] +members = [ "bank_core" ] + +[workspace.dependencies] +chrono = { version = "0.4.40", features = ["serde"] } +garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] } +http = "1.2" +regex = "1.11.1" +schemars = { version = "1.0.0-alpha.17", features = ["chrono04", "uuid1"] } +serde = { version = "1.0.218", features = ["derive"] } +serde_json = "1.0.139" +uuid = { version = "1.15.1", features = ["serde", "v7"] } +axum = { version = "0.8", default-features = false } + [package] name = "bankserver" version = "0.1.0" @@ -5,33 +19,34 @@ edition = "2024" [features] default = ["schemas"] -schemas = ["dep:schemars"] +schemas = ["dep:schemars", "bank_core/schemas"] [[bin]] name = "generate-schemas" -features = ["schemas"] +required-features = ["schemas"] [dependencies] async-broadcast = "0.7.2" -axum = { version = "0.8", features = ["ws"] } -chrono = { version = "0.4.40", features = ["serde"] } +axum = { workspace = true, features = ["form", "http1", "json", "matched-path", "query", "tokio", "tower-log", "tracing", "ws"] } +chrono.workspace = true concread = { version = "0.5.4", default-features = false, features = ["ahash", "asynch", "maps"] } config = { version = "0.15.8", default-features = false } dbmigrator = { git = "https://github.com/DSeeLP/dbmigrator.git", branch = "macros", version = "0.4.4-alpha", features = ["tokio-postgres"] } deadpool = "0.12" deadpool-postgres = { version = "0.14", features = ["serde"] } futures-util = "0.3.31" -garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] } +garde.workspace = true jsonwebtoken = { version = "9.3", default-features = false } password-auth = "1.0.0" -regex = "1.11.1" -schemars = { version = "1.0.0-alpha.17", optional = true, features = ["chrono04", "uuid1"] } -serde = { version = "1.0.218", features = ["derive"] } -serde_json = "1.0.139" +regex.workspace = true +schemars = { workspace = true, optional = true } +serde.workspace = true +serde_json.workspace = true serde_with = "3.12.0" tokio = { version = "1.43", features = ["tracing", "time", "sync", "net", "io-std", "io-util", "macros", "rt-multi-thread", "signal"] } tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } tracing = "0.1.41" tracing-error = "0.2.1" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -uuid = { version = "1.15.1", features = ["serde", "v7"] } +uuid.workspace = true +bank_core = { path = "./bank_core", features = ["axum"] } diff --git a/bank_core/Cargo.toml b/bank_core/Cargo.toml new file mode 100644 index 0000000..4a1151a --- /dev/null +++ b/bank_core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bank_core" +version = "0.1.0" +edition = "2024" + +[features] +schemas = ["dep:schemars"] +axum = ["dep:axum"] + +[dependencies] +axum = { workspace = true, optional = true } +chrono.workspace = true +garde.workspace = true +http.workspace = true +regex.workspace = true +schemars = { workspace = true, optional = true } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true diff --git a/bank_core/src/account.rs b/bank_core/src/account.rs new file mode 100644 index 0000000..de24c58 --- /dev/null +++ b/bank_core/src/account.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + make_schemas, + pagination::{Pagination, PaginationType}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Account { + pub id: Uuid, + pub user: Uuid, + pub name: String, + pub balance: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct AccountInfo { + pub id: Uuid, + pub user: Uuid, + pub name: String, +} +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserAccountInfo { + pub id: Uuid, + pub name: String, + pub balance: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ReducedAccountInfo { + pub id: Uuid, + pub name: String, +} + +impl PaginationType for UserAccountInfo { + const NAME: &str = "UserAccounts"; +} +impl PaginationType for AccountInfo { + const NAME: &str = "Accounts"; +} + +make_schemas!((); (Account, AccountInfo, UserAccountInfo, Pagination, Pagination)); diff --git a/bank_core/src/chat.rs b/bank_core/src/chat.rs new file mode 100644 index 0000000..fc12877 --- /dev/null +++ b/bank_core/src/chat.rs @@ -0,0 +1,58 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + NameOrUuid, make_schemas, + pagination::{Pagination, PaginationType}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Chat { + pub id: Uuid, + pub created: DateTime, +} +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ChatInfo { + pub id: Uuid, + pub created: DateTime, + pub read_until: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ChatMessage { + pub id: Uuid, + pub sender: Uuid, + pub time: DateTime, + pub text: String, + pub extra: Option, +} + +impl PaginationType for Chat { + const NAME: &str = "Chats"; +} +impl PaginationType for ChatInfo { + const NAME: &str = "ChatInfos"; +} + +impl PaginationType for ChatMessage { + const NAME: &str = "ChatMessages"; +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct SendMessage { + pub text: String, + pub extra: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct StartChat { + pub user: NameOrUuid, +} + +make_schemas!((StartChat, SendMessage); (Chat, ChatInfo, ChatMessage, Pagination, Pagination, Pagination)); diff --git a/bank_core/src/error.rs b/bank_core/src/error.rs new file mode 100644 index 0000000..523788c --- /dev/null +++ b/bank_core/src/error.rs @@ -0,0 +1,61 @@ +use std::borrow::Cow; + +use http::StatusCode; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ApiError<'a> { + #[serde(skip)] + pub status: StatusCode, + pub id: Cow<'a, str>, + pub message: Cow<'a, str>, +} + +#[cfg(feature = "axum")] +impl<'a> axum::response::IntoResponse for ApiError<'a> { + fn into_response(self) -> axum::response::Response { + (self.status, axum::Json(self)).into_response() + } +} + +impl<'a> ApiError<'a> { + pub const NOT_FOUND: ApiError<'static> = + ApiError::const_new(StatusCode::NOT_FOUND, "not-found", "Not found"); + pub const INTERNAL_SERVER_ERROR: ApiError<'static> = ApiError::const_new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal-server-error", + "Internal Server Error", + ); + pub const FORBIDDEN: ApiError<'static> = + ApiError::const_new(StatusCode::FORBIDDEN, "forbidden", "Forbidden"); + + pub const CONFLICT: ApiError<'static> = + ApiError::const_new(StatusCode::CONFLICT, "conflict", "Conflict"); + pub const fn const_new(status: StatusCode, id: &'static str, message: &'static str) -> Self { + Self { + status, + id: Cow::Borrowed(id), + message: Cow::Borrowed(message), + } + } + pub fn new( + status: StatusCode, + id: impl Into>, + message: impl Into>, + ) -> Self { + Self { + status, + id: id.into(), + message: message.into(), + } + } + pub fn into_static(self) -> ApiError<'static> { + ApiError { + status: self.status, + id: Cow::Owned(self.id.into_owned()), + message: Cow::Owned(self.message.into_owned()), + } + } +} diff --git a/bank_core/src/lib.rs b/bank_core/src/lib.rs new file mode 100644 index 0000000..4f13060 --- /dev/null +++ b/bank_core/src/lib.rs @@ -0,0 +1,13 @@ +pub mod account; +pub mod chat; +pub mod pagination; +mod schemas; +pub mod transaction; +pub mod user; +pub use schemas::*; +mod error; +mod util; +pub use error::*; +pub use util::*; + +make_schemas!((Credentials); (ApiError, TokenResponse), [account::schemas, chat::schemas, transaction::schemas, user::schemas]); diff --git a/bank_core/src/pagination.rs b/bank_core/src/pagination.rs new file mode 100644 index 0000000..4f42409 --- /dev/null +++ b/bank_core/src/pagination.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Deserializer, Serialize}; + +pub trait PaginationType { + const NAME: &str; +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct RequestPagination { + #[serde(deserialize_with = "deserialize_u64")] + pub limit: u64, + #[serde(default)] + #[serde(deserialize_with = "deserialize_u64")] + pub offset: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename = "Pagination")] +pub struct PaginationMeta { + pub total: u64, + pub limit: u64, + pub offset: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Pagination { + pub pagination: PaginationMeta, + pub result: Vec, +} + +impl Pagination { + pub fn new(result: Vec, total: u64, pagination: RequestPagination) -> Self { + Self { + pagination: PaginationMeta { + total, + limit: pagination.limit, + offset: pagination.offset, + }, + result, + } + } +} + +#[cfg(feature = "schemas")] +impl schemars::JsonSchema for Pagination { + fn schema_name() -> std::borrow::Cow<'static, str> { + format!("Paginated{}", T::NAME).into() + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(std::any::type_name::()) + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let result_schema = generator.subschema_for::>(); + let pagination_schema = generator.subschema_for::(); + schemars::Schema::try_from(serde_json::json!({ + "type": "object", + "properties": { + "pagination": pagination_schema, + "result": result_schema + }, + "required": ["pagination", "result"] + })) + .unwrap() + } +} + +macro_rules! visit { + (signed $ty:ident : $visit:ident) => { + #[inline] + fn $visit(self, v: $ty) -> Result + where + E: serde::de::Error, + { + if 0 <= v && v as u64 <= Self::Value::max_value() as u64 { + Ok(v as Self::Value) + } else { + Err(E::invalid_value( + serde::de::Unexpected::Signed(v as i64), + &self, + )) + } + } + }; + ($ty:ident : $visit:ident) => { + #[inline] + fn $visit(self, v: $ty) -> Result + where + E: serde::de::Error, + { + if v as u64 <= Self::Value::max_value() as u64 { + Ok(v as Self::Value) + } else { + Err(E::invalid_value( + serde::de::Unexpected::Unsigned(v as u64), + &self, + )) + } + } + }; +} + +fn deserialize_u64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct Helper; + impl<'a> serde::de::Visitor<'a> for Helper { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a u64") + } + + visit!(signed i8: visit_i8); + visit!(signed i16: visit_i16); + visit!(signed i32: visit_i32); + visit!(signed i64: visit_i64); + + visit!(u8: visit_u8); + visit!(u16: visit_u16); + visit!(u32: visit_u32); + visit!(u64: visit_u64); + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + match v.parse() { + Ok(value) => Ok(value), + Err(_) => Err(E::invalid_value( + serde::de::Unexpected::Str(v), + &"valid unsigned integer", + )), + } + } + } + deserializer.deserialize_any(Helper) +} + +impl RequestPagination { + #[inline] + pub const fn limit(&self) -> i64 { + self.limit as i64 + } + #[inline] + pub const fn offset(&self) -> i64 { + self.offset as i64 + } +} diff --git a/bank_core/src/schemas.rs b/bank_core/src/schemas.rs new file mode 100644 index 0000000..75a4b2c --- /dev/null +++ b/bank_core/src/schemas.rs @@ -0,0 +1,95 @@ +#[macro_export] +macro_rules! make_schemas { + (($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => { + #[cfg(feature = "schemas")] + #[allow(unused)] + pub const fn schemas() -> $crate::Schemas { + fn request_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { + $( + generator.subschema_for::<$request_bodies>(); + // schemas.insert(<$request_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$request_bodies>()); + )* + } + fn response_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { + $( + generator.subschema_for::<$response_bodies>(); + // schemas.insert(<$response_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$response_bodies>()); + )* + } + static DEPS: &'static [$crate::Schemas] = &[$($($deps()),*)?]; + $crate::Schemas { + request_bodies, + response_bodies, + contains: DEPS + } + } + #[cfg(not(feature = "schemas"))] + pub const fn schemas() -> $crate::Schemas { + #[allow(unused)] + const fn test() {} + $( + test::<$request_bodies>(); + )* + $( + test::<$response_bodies>(); + )* + const _DEPS: &'static [$crate::Schemas] = &[$($($deps()),*)?]; + $crate::Schemas + } + }; + ([$($deps:expr),*]) => { + #[cfg(feature = "schemas")] + pub const fn schemas() -> $crate::Schemas { + fn ident(_: &mut schemars::SchemaGenerator, _: &mut std::collections::HashMap, schemars::Schema>) {} + static DEPS: &'static [$crate::Schemas] = &[$($deps()),*]; + $crate::Schemas { + request_bodies: ident, + response_bodies: ident, + contains: DEPS + } + } + #[cfg(not(feature = "schemas"))] + pub const fn schemas() -> $crate::Schemas { + const _DEPS: &'static [$crate::Schemas] = &[$($deps()),*]; + $crate::Schemas + } + }; +} + +#[cfg(not(feature = "schemas"))] +pub struct Schemas; +#[cfg(feature = "schemas")] +pub struct Schemas { + pub request_bodies: fn( + &mut schemars::SchemaGenerator, + &mut std::collections::HashMap, schemars::Schema>, + ), + pub response_bodies: fn( + &mut schemars::SchemaGenerator, + &mut std::collections::HashMap, schemars::Schema>, + ), + pub contains: &'static [Self], +} + +#[cfg(feature = "schemas")] +impl Schemas { + pub fn generate( + &self, + requests: &mut schemars::SchemaGenerator, + responses: &mut schemars::SchemaGenerator, + request_schemas: &mut std::collections::HashMap< + std::borrow::Cow<'static, str>, + schemars::Schema, + >, + response_schemas: &mut std::collections::HashMap< + std::borrow::Cow<'static, str>, + schemars::Schema, + >, + ) { + for schemas in self.contains { + schemas.generate(requests, responses, request_schemas, response_schemas); + } + (self.request_bodies)(requests, request_schemas); + (self.response_bodies)(requests, response_schemas); + } +} diff --git a/bank_core/src/transaction.rs b/bank_core/src/transaction.rs new file mode 100644 index 0000000..e83f9ad --- /dev/null +++ b/bank_core/src/transaction.rs @@ -0,0 +1,58 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + account::ReducedAccountInfo, + make_schemas, + pagination::{Pagination, PaginationType, RequestPagination}, + user::User, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Transaction { + pub from: Uuid, + pub to: Uuid, + pub amount: u64, + pub timestamp: DateTime, + pub message: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct FullTransaction { + pub from: Participant, + pub to: Participant, + pub amount: u64, + pub timestamp: DateTime, + pub message: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Received, + Sent, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Participant { + pub user: User, + pub account: ReducedAccountInfo, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct TransactionQuery { + #[serde(flatten)] + pub pagination: RequestPagination, + pub direction: Option, +} + +impl PaginationType for FullTransaction { + const NAME: &str = "Transactions"; +} + +make_schemas!((Direction); (Transaction, FullTransaction, Pagination)); diff --git a/bank_core/src/user.rs b/bank_core/src/user.rs new file mode 100644 index 0000000..22b01d8 --- /dev/null +++ b/bank_core/src/user.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + account::UserAccountInfo, + make_schemas, + pagination::{Pagination, PaginationType}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct User { + pub id: Uuid, + pub name: String, +} + +impl PaginationType for User { + const NAME: &str = "UserList"; +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserAccounts { + pub result: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserBalance { + pub balance: u64, +} + +make_schemas!((); (User, Pagination, UserAccounts, UserBalance)); diff --git a/bank_core/src/util.rs b/bank_core/src/util.rs new file mode 100644 index 0000000..5625c66 --- /dev/null +++ b/bank_core/src/util.rs @@ -0,0 +1,65 @@ +use std::{ops::Deref, sync::LazyLock}; + +use garde::Validate; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +static NAME_PATTERN_RE: LazyLock = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap()); + +pub const NAME_PATTERN: &str = "^[a-z0-9_-]+$"; +pub const USER_ACCOUNT_PATTERN: &str = "([a-z0-9_-]+):([a-z0-9_-]+)"; + +#[derive(Debug, Clone, PartialEq, Eq, Validate, Serialize, Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[garde(transparent)] +pub struct Name(#[garde(length(min = 3, max = 32), pattern(NAME_PATTERN_RE))] pub String); + +impl Deref for Name { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} +impl Name { + pub fn into_inner(self) -> String { + self.0 + } + + pub fn validate_name(name: &str) -> Result<(), garde::Error> { + garde::rules::pattern::apply(&name, (&NAME_PATTERN_RE,)) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum NameOrUuid { + Id(#[garde(skip)] Uuid), + Name(#[garde(dive)] Name), +} + +#[derive(Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Credentials { + #[garde(dive)] + pub name: Name, + #[garde(length(min = 8, max = 96))] + pub password: String, +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("name", &self.name) + .field("password", &"...") + .finish() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct TokenResponse { + pub token: String, +} diff --git a/src/api/account.rs b/src/api/account.rs index 4e5df65..4db59d1 100644 --- a/src/api/account.rs +++ b/src/api/account.rs @@ -1,18 +1,18 @@ use std::sync::Arc; use axum::{Router, extract::Path, routing::get}; +use bank_core::{ + ApiError, + account::AccountInfo, + pagination::Pagination, + transaction::{FullTransaction, TransactionQuery}, +}; use tracing::instrument; use uuid::Uuid; -use crate::{ - api::{ApiError, Pagination}, - model::{Account, AccountInfo, FullTransaction, Transaction}, -}; +use crate::model::{Accounts, Transactions}; -use super::{ - AppState, EState, Error, Json, Query, RequestPagination, State, auth::Auth, make_schemas, - transactions::TransactionQuery, -}; +use super::{AppState, EState, Error, Json, PaginationQuery, Query, State, auth::Auth}; pub(super) fn router() -> Router> { Router::new() @@ -21,8 +21,6 @@ pub(super) fn router() -> Router> { .route("/{id}/transactions", get(account_transactions)) } -make_schemas!((); (Pagination)); - #[instrument(skip(state))] pub async fn account_info(EState(state): State, _: Auth, Path(id): Path) {} @@ -37,14 +35,14 @@ pub async fn account_transactions( }): Query, ) -> Result>, Error> { let conn = state.conn().await?; - match Account::owned_by(&conn, id, auth.user_id()).await? { + match Accounts::owned_by(&conn, id, auth.user_id()).await? { Some(false) => { return Err(ApiError::FORBIDDEN.into()); } None => return Err(ApiError::NOT_FOUND.into()), _ => {} } - let result = Transaction::account_history(&conn, id, direction, pagination).await?; + let result = Transactions::account_history(&conn, id, direction, pagination).await?; Ok(Json(result)) } @@ -53,9 +51,9 @@ pub async fn account_transactions( pub async fn list_accounts( EState(state): State, _: Auth, - pagination: RequestPagination, + PaginationQuery(pagination): PaginationQuery, ) -> Result>, Error> { let conn = state.conn().await?; - let result = Account::list_all(&conn, pagination).await?; + let result = Accounts::list_all(&conn, pagination).await?; Ok(Json(result)) } diff --git a/src/api/auth.rs b/src/api/auth.rs index 8a8da5a..92c38f2 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -6,23 +6,20 @@ use std::{ use axum::{ Router, - extract::{FromRef, FromRequestParts}, + extract::FromRequestParts, http::{StatusCode, request::Parts}, routing::post, }; -use garde::Validate; +use bank_core::{ApiError, Credentials, TokenResponse}; use jsonwebtoken::{Algorithm, Header, Validation}; use password_auth::verify_password; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; -use crate::{ - api::{ApiError, InnerError}, - model::{Name, User}, -}; +use crate::{api::InnerError, model::Users}; -use super::{AppState, EState, Error, Json, State, make_schemas}; +use super::{AppState, EState, Error, Json, State}; pub(super) fn router() -> Router> { Router::new() @@ -30,33 +27,13 @@ pub(super) fn router() -> Router> { .route("/login", post(login)) } -make_schemas!((Credentials); (TokenResponse)); - -#[derive(Deserialize, Validate)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Credentials { - #[garde(dive)] - pub name: Name, - #[garde(length(min = 8, max = 96))] - pub password: String, -} - -impl std::fmt::Debug for Credentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Credentials") - .field("name", &self.name) - .field("password", &"...") - .finish() - } -} - #[instrument(skip(state))] async fn register( EState(state): State, Json(credentials): Json, ) -> Result<(StatusCode, Json), Error> { let mut conn = state.conn().await?; - let id = User::create(&mut conn, &credentials.name, &credentials.password).await?; + let id = Users::create(&mut conn, &credentials.name, &credentials.password).await?; let token = Claims::new(id).encode(&state.encoding_key).unwrap(); Ok((StatusCode::CREATED, Json(TokenResponse { token }))) } @@ -68,7 +45,7 @@ async fn login( ) -> Result, Error> { let conn = state.conn().await?; let Some((hash, info)) = - User::get_password_and_info_by_username(&conn, &credentials.name).await? + Users::get_password_and_info_by_username(&conn, &credentials.name).await? else { return Err(invalid_username_or_password().into()); }; @@ -82,12 +59,6 @@ async fn login( Ok(Json(TokenResponse { token: jwt })) } -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct TokenResponse { - token: String, -} - #[derive(Serialize, Deserialize)] struct Claims<'a> { #[serde(rename = "iss")] diff --git a/src/api/chats.rs b/src/api/chats.rs index 11e2ccd..35b864d 100644 --- a/src/api/chats.rs +++ b/src/api/chats.rs @@ -1,22 +1,21 @@ use std::sync::Arc; use axum::{Router, extract::Path, routing::get}; +use bank_core::{ + ApiError, + chat::{Chat, ChatInfo, ChatMessage, SendMessage, StartChat}, + pagination::Pagination, +}; use deadpool_postgres::GenericClient; -use serde::Deserialize; use tracing::instrument; use uuid::Uuid; -use crate::{ - api::ApiError, - model::{Chat, ChatInfo, ChatMessage}, -}; +use crate::model::{Chats, NameOrUuidExt}; use super::{ - AppState, EState, Error, Json, Pagination, RequestPagination, State, + AppState, EState, Error, Json, PaginationQuery, State, auth::Auth, - make_schemas, socket::{SocketEvent, SocketMessage}, - transactions::NameOrUuid, }; pub(super) fn router() -> Router> { @@ -26,32 +25,17 @@ pub(super) fn router() -> Router> { .route("/{id}/messages", get(get_messages).post(send_message)) } -make_schemas!((StartChat, SendMessage); (Pagination, Pagination, Pagination)); - #[instrument(skip(state))] pub async fn list_chats( EState(state): State, auth: Auth, - pagination: RequestPagination, + PaginationQuery(pagination): PaginationQuery, ) -> Result>, Error> { let client = state.conn().await?; - let chats = Chat::list_for_user(&client, auth.user_id(), pagination).await?; + let chats = Chats::list_for_user(&client, auth.user_id(), pagination).await?; Ok(Json(chats)) } -#[derive(Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct SendMessage { - pub text: String, - pub extra: Option, -} - -#[derive(Debug, Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct StartChat { - pub user: NameOrUuid, -} - #[instrument(skip(state))] pub async fn create_chat( EState(state): State, @@ -62,7 +46,7 @@ pub async fn create_chat( let Some(target) = body.user.user_id(&client).await? else { return Err(ApiError::NOT_FOUND.into()); }; - let chat = Chat::start(&mut client, auth.user_id(), target).await?; + let chat = Chats::start(&mut client, auth.user_id(), target).await?; Ok(Json(chat)) } @@ -74,7 +58,7 @@ pub async fn get_chat( ) -> Result, Error> { let client = state.conn().await?; check_chat(&client, id, auth.user_id()).await?; - let Some(chat) = Chat::get_by_id_for_user(&client, id, auth.user_id()).await? else { + let Some(chat) = Chats::get_by_id_for_user(&client, id, auth.user_id()).await? else { return Err(ApiError::NOT_FOUND.into()); }; Ok(Json(chat)) @@ -85,16 +69,16 @@ pub async fn get_messages( EState(state): State, auth: Auth, Path(id): Path, - pagination: RequestPagination, + PaginationQuery(pagination): PaginationQuery, ) -> Result>, Error> { let client = state.conn().await?; check_chat(&client, id, auth.user_id()).await?; - let messages = Chat::messages(&client, id, pagination).await?; + let messages = Chats::messages(&client, id, pagination).await?; Ok(Json(messages)) } async fn check_chat(client: &impl GenericClient, id: Uuid, user: Uuid) -> Result<(), Error> { - match Chat::exists_and_has_access(client, id, user).await? { + match Chats::exists_and_has_access(client, id, user).await? { Some(true) => Ok(()), Some(false) => Err(ApiError::FORBIDDEN.into()), None => Err(ApiError::NOT_FOUND.into()), @@ -110,13 +94,13 @@ pub async fn send_message( ) -> Result, Error> { let mut client = state.conn().await?; check_chat(&client, id, auth.user_id()).await?; - let message = Chat::send(&mut client, id, auth.user_id(), body.text, body.extra).await?; + let message = Chats::send(&mut client, id, auth.user_id(), body.text, body.extra).await?; let notfication = SocketMessage::Event(SocketEvent::MessageReceived { chat: id, from: auth.user_id(), message_id: message.id, }); - for id in Chat::member_ids(&client, id).await? { + for id in Chats::member_ids(&client, id).await? { if id == auth.user_id() { continue; } diff --git a/src/api/mod.rs b/src/api/mod.rs index 8f930e4..54bedc2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,9 +7,9 @@ use axum::{ response::IntoResponse, }; +use bank_core::{ApiError, make_schemas, pagination::RequestPagination}; use jsonwebtoken::{DecodingKey, EncodingKey}; -use schemars::{JsonSchema, Schema}; -use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use tracing_error::SpanTrace; pub use axum::extract::State as EState; @@ -30,7 +30,7 @@ pub struct Json(T); macro_rules! rejection_error { ($id:expr, $rejection:expr) => {{ let rejection = $rejection; - $crate::api::ApiError { + bank_core::ApiError { status: rejection.status(), id: Cow::Borrowed($id), message: Cow::Owned(rejection.body_text()), @@ -82,6 +82,7 @@ impl FromRequestParts for Query { } pub struct Error { + #[allow(unused)] trace: Option, inner: InnerError, } @@ -95,58 +96,6 @@ pub enum InnerError { Validation(ValidationErrors), } -#[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct ApiError<'a> { - #[serde(skip)] - pub status: StatusCode, - pub id: Cow<'a, str>, - pub message: Cow<'a, str>, -} - -impl<'a> ApiError<'a> { - pub const NOT_FOUND: ApiError<'static> = - ApiError::const_new(StatusCode::NOT_FOUND, "not-found", "Not found"); - pub const INTERNAL_SERVER_ERROR: ApiError<'static> = ApiError::const_new( - StatusCode::INTERNAL_SERVER_ERROR, - "internal-server-error", - "Internal Server Error", - ); - pub const FORBIDDEN: ApiError<'static> = - ApiError::const_new(StatusCode::FORBIDDEN, "forbidden", "Forbidden"); - pub const fn const_new(status: StatusCode, id: &'static str, message: &'static str) -> Self { - Self { - status, - id: Cow::Borrowed(id), - message: Cow::Borrowed(message), - } - } - pub fn new( - status: StatusCode, - id: impl Into>, - message: impl Into>, - ) -> Self { - Self { - status, - id: id.into(), - message: message.into(), - } - } - pub fn into_static(self) -> ApiError<'static> { - ApiError { - status: self.status, - id: Cow::Owned(self.id.into_owned()), - message: Cow::Owned(self.message.into_owned()), - } - } -} - -impl<'a> IntoResponse for ApiError<'a> { - fn into_response(self) -> axum::response::Response { - (self.status, axum::Json(self)).into_response() - } -} - impl Error { pub fn new(inner: InnerError) -> Self { let trace = if cfg!(debug_assertions) || inner.internal() { @@ -228,12 +177,13 @@ struct ValidationError { message: String, } +#[cfg(feature = "schemas")] impl schemars::JsonSchema for ValidationError { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("SingleValidationError") } - fn json_schema(_: &mut schemars::SchemaGenerator) -> Schema { + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { schemars::Schema::try_from(serde_json::json!({ "type": "object", "properties": { @@ -336,253 +286,19 @@ pub fn router() -> Router> { .nest("/socket", socket::router()) } -make_schemas!((); (ApiError, _ValidationErrors), [crate::model::schemas, auth::schemas, user::schemas, transactions::schemas, account::schemas, chats::schemas]); +make_schemas!((); (ApiError, _ValidationErrors), [ bank_core::schemas, transactions::schemas]); -macro_rules! make_schemas { - (($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => { - #[cfg(feature = "schemas")] - #[allow(unused)] - pub const fn schemas() -> $crate::api::Schemas { - fn request_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { - $( - generator.subschema_for::<$request_bodies>(); - // schemas.insert(<$request_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$request_bodies>()); - )* - } - fn response_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { - $( - generator.subschema_for::<$response_bodies>(); - // schemas.insert(<$response_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$response_bodies>()); - )* - } - static DEPS: &'static [$crate::api::Schemas] = &[$($($deps()),*)?]; - $crate::api::Schemas { - request_bodies, - response_bodies, - contains: DEPS - } - } - #[cfg(not(feature = "schemas"))] - pub const fn schemas() -> $crate::api::Schemas { - $crate::api::Schemas - } - }; - ([$($deps:expr),*]) => { - #[cfg(feature = "schemas")] - pub const fn schemas() -> $crate::api::Schemas { - fn ident(_: &mut schemars::SchemaGenerator, _: &mut std::collections::HashMap, schemars::Schema>) {} - static DEPS: &'static [$crate::api::Schemas] = &[$($deps()),*]; - $crate::api::Schemas { - request_bodies: ident, - response_bodies: ident, - contains: DEPS - } - } - #[cfg(not(feature = "schemas"))] - pub const fn schemas() -> $crate::api::Schemas { - $crate::api::Schemas - } - }; -} +#[derive(Deserialize)] +#[repr(transparent)] +#[serde(transparent)] +pub struct PaginationQuery(pub RequestPagination); -pub(crate) use make_schemas; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct RequestPagination { - #[serde(deserialize_with = "deserialize_u64")] - pub limit: u64, - #[serde(default)] - #[serde(deserialize_with = "deserialize_u64")] - pub offset: u64, -} - -macro_rules! visit { - (signed $ty:ident : $visit:ident) => { - #[inline] - fn $visit(self, v: $ty) -> Result - where - E: serde::de::Error, - { - if 0 <= v && v as u64 <= Self::Value::max_value() as u64 { - Ok(v as Self::Value) - } else { - Err(E::invalid_value( - serde::de::Unexpected::Signed(v as i64), - &self, - )) - } - } - }; - ($ty:ident : $visit:ident) => { - #[inline] - fn $visit(self, v: $ty) -> Result - where - E: serde::de::Error, - { - if v as u64 <= Self::Value::max_value() as u64 { - Ok(v as Self::Value) - } else { - Err(E::invalid_value( - serde::de::Unexpected::Unsigned(v as u64), - &self, - )) - } - } - }; -} - -fn deserialize_u64<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct Helper; - impl<'a> serde::de::Visitor<'a> for Helper { - type Value = u64; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a u64") - } - - visit!(signed i8: visit_i8); - visit!(signed i16: visit_i16); - visit!(signed i32: visit_i32); - visit!(signed i64: visit_i64); - - visit!(u8: visit_u8); - visit!(u16: visit_u16); - visit!(u32: visit_u32); - visit!(u64: visit_u64); - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - match v.parse() { - Ok(value) => Ok(value), - Err(_) => Err(E::invalid_value( - serde::de::Unexpected::Str(v), - &"valid unsigned integer", - )), - } - } - } - deserializer.deserialize_any(Helper) -} - -impl FromRequestParts for RequestPagination { +impl FromRequestParts for PaginationQuery { type Rejection = ApiError<'static>; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { Query::::from_request_parts(parts, state) .await - .map(|query| query.0) - } -} - -impl RequestPagination { - #[inline] - pub const fn limit(&self) -> i64 { - self.limit as i64 - } - #[inline] - pub const fn offset(&self) -> i64 { - self.offset as i64 - } -} - -#[derive(Debug, Clone, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -#[serde(rename = "Pagination")] -pub struct PaginationMeta { - pub total: u64, - pub limit: u64, - pub offset: u64, -} - -#[derive(Debug, Clone, Serialize)] -// #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Pagination { - pub pagination: PaginationMeta, - pub result: Vec, -} - -impl Pagination { - pub fn new(result: Vec, total: u64, pagination: RequestPagination) -> Self { - Self { - pagination: PaginationMeta { - total, - limit: pagination.limit, - offset: pagination.offset, - }, - result, - } - } -} - -pub trait PaginationType { - const NAME: &str; -} - -impl JsonSchema for Pagination { - fn schema_name() -> Cow<'static, str> { - format!("Paginated{}", T::NAME).into() - } - - fn schema_id() -> Cow<'static, str> { - Cow::Borrowed(std::any::type_name::()) - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let result_schema = generator.subschema_for::>(); - let pagination_schema = generator.subschema_for::(); - schemars::Schema::try_from(serde_json::json!({ - "type": "object", - "properties": { - "pagination": pagination_schema, - "result": result_schema - }, - "required": ["pagination", "result"] - })) - .unwrap() - } -} - -#[cfg(not(feature = "schemas"))] -pub struct Schemas; -#[cfg(feature = "schemas")] -pub struct Schemas { - pub request_bodies: fn( - &mut schemars::SchemaGenerator, - &mut std::collections::HashMap, schemars::Schema>, - ), - pub response_bodies: fn( - &mut schemars::SchemaGenerator, - &mut std::collections::HashMap, schemars::Schema>, - ), - pub contains: &'static [Self], -} - -#[cfg(feature = "schemas")] -impl Schemas { - pub fn generate( - &self, - requests: &mut schemars::SchemaGenerator, - responses: &mut schemars::SchemaGenerator, - request_schemas: &mut std::collections::HashMap< - std::borrow::Cow<'static, str>, - schemars::Schema, - >, - response_schemas: &mut std::collections::HashMap< - std::borrow::Cow<'static, str>, - schemars::Schema, - >, - ) { - for schemas in self.contains { - schemas.generate(requests, responses, request_schemas, response_schemas); - } - (self.request_bodies)(requests, request_schemas); - (self.response_bodies)(requests, response_schemas); + .map(|query| Self(query.0)) } } diff --git a/src/api/socket.rs b/src/api/socket.rs index 84cb69a..d615b38 100644 --- a/src/api/socket.rs +++ b/src/api/socket.rs @@ -18,14 +18,12 @@ use serde::Serialize; use tracing::{error, info, instrument}; use uuid::Uuid; -use super::{AppState, EState, Error, State, auth::Auth, make_schemas}; +use super::{AppState, EState, Error, State, auth::Auth}; pub(super) fn router() -> Router> { Router::new().route("/", get(handle)) } -make_schemas!((); ()); - #[instrument(skip(state))] pub async fn handle( EState(state): State, diff --git a/src/api/transactions.rs b/src/api/transactions.rs index ac8e1be..407c74a 100644 --- a/src/api/transactions.rs +++ b/src/api/transactions.rs @@ -1,21 +1,23 @@ use std::{borrow::Cow, cell::RefCell, sync::Arc}; use axum::{Router, routing::post}; +use bank_core::{ + Name, NameOrUuid, USER_ACCOUNT_PATTERN, make_schemas, + pagination::Pagination, + transaction::{FullTransaction, Transaction, TransactionQuery}, +}; use deadpool_postgres::GenericClient; use garde::Validate; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tracing::instrument; use uuid::Uuid; -use crate::model::{ - Account, Direction, FullTransaction, Name, Transaction, USER_ACCOUNT_PATTERN, User, -}; +use crate::model::{Accounts, Transactions, Users}; use super::{ - AppState, EState, Error, Json, Pagination, PaginationType, Query, RequestPagination, State, + AppState, EState, Error, Json, Query, State, auth::Auth, - make_schemas, socket::{SocketEvent, SocketMessage}, }; @@ -23,17 +25,7 @@ pub(super) fn router() -> Router> { Router::new().route("/", post(make_payment)) } -make_schemas!((MakePayment); (Pagination)); - -impl PaginationType for FullTransaction { - const NAME: &str = "Transactions"; -} - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct TransactionHistory { - transactions: Vec, -} +make_schemas!((MakePayment); ()); #[derive(Deserialize, Validate)] #[serde(untagged, rename = "PaymentTarget")] @@ -122,26 +114,6 @@ pub struct BodyMakePayment { pub amount: u64, } -#[derive(Debug, Deserialize, Validate, PartialEq)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -#[serde(untagged)] -pub enum NameOrUuid { - Id(#[garde(skip)] Uuid), - Name(#[garde(dive)] Name), -} - -impl NameOrUuid { - pub async fn user_id( - &self, - client: &impl GenericClient, - ) -> Result, tokio_postgres::Error> { - match self { - NameOrUuid::Id(id) => Ok(User::exists(client, *id).await?.then_some(*id)), - NameOrUuid::Name(name) => Ok(User::info_by_name(client, &name.0).await?.map(|v| v.id)), - } - } -} - #[derive(Debug, Validate, PartialEq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct AccountSelector { @@ -159,13 +131,13 @@ impl AccountSelector { ) -> Result, tokio_postgres::Error> { let user_id = match &self.user { NameOrUuid::Id(uuid) => *uuid, - NameOrUuid::Name(name) => match User::info_by_name(client, &*name).await? { + NameOrUuid::Name(name) => match Users::info_by_name(client, &*name).await? { Some(info) => info.id, None => return Ok(None), }, }; let account_id = match self.account.as_ref() { - Some(name) => match Account::get_for_user(client, user_id, &*name).await? { + Some(name) => match Accounts::get_for_user(client, user_id, &*name).await? { Some(info) => info.id, None => return Ok(None), }, @@ -350,13 +322,6 @@ impl MakePayment { } } -#[derive(Debug, Deserialize)] -pub struct TransactionQuery { - #[serde(flatten)] - pub pagination: RequestPagination, - pub direction: Option, -} - pub async fn transaction_history( EState(state): State, auth: Auth, @@ -366,7 +331,7 @@ pub async fn transaction_history( }): Query, ) -> Result>, Error> { let client = state.conn().await?; - let result = Transaction::user_history(&client, auth.user_id(), direction, pagination).await?; + let result = Transactions::user_history(&client, auth.user_id(), direction, pagination).await?; Ok(Json(result)) } pub async fn make_payment( @@ -381,8 +346,8 @@ pub async fn make_payment( let mut client = client.transaction().await?; let Some(from) = (match from { - NameOrUuid::Id(uuid) => Account::by_id(&client, uuid).await?, - NameOrUuid::Name(name) => Account::get_for_user(&client, user_id, &*name).await?, + NameOrUuid::Id(uuid) => Accounts::by_id(&client, uuid).await?, + NameOrUuid::Name(name) => Accounts::get_for_user(&client, user_id, &*name).await?, }) else { todo!("from account doesn't exist") }; @@ -401,7 +366,7 @@ pub async fn make_payment( client .execute(&update_balance_stmt, &[&to, &(amount as i64)]) .await?; - let transaction = Transaction::create(&mut client, from.id, to, amount, None).await?; + let transaction = Transactions::create(&mut client, from.id, to, amount, None).await?; client.commit().await?; state .sockets @@ -419,11 +384,10 @@ pub async fn make_payment( #[cfg(test)] mod tests { - use crate::{ - api::transactions::{ - AccountSelector, NameOrUuid, UnvalidatedAccountSelector, UnvalidatedTransform, - }, - model::Name, + use bank_core::Name; + + use crate::api::transactions::{ + AccountSelector, NameOrUuid, UnvalidatedAccountSelector, UnvalidatedTransform, }; use super::MakePayment; diff --git a/src/api/user.rs b/src/api/user.rs index 0dbdd95..f22b4fb 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,19 +1,21 @@ use std::sync::Arc; use axum::{Router, extract::Path, routing::get}; +use bank_core::{ + pagination::Pagination, + transaction::{FullTransaction, TransactionQuery}, + user::{User, UserAccounts, UserBalance}, +}; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use crate::{ api::ApiError, - model::{Account, FullTransaction, Transaction, User, UserAccountInfo}, + model::{Accounts, Transactions, Users}, }; -use super::{ - AppState, EState, Error, Json, Pagination, Query, RequestPagination, State, auth::Auth, - make_schemas, transactions::TransactionQuery, -}; +use super::{AppState, EState, Error, Json, PaginationQuery, Query, State, auth::Auth}; pub(super) fn router() -> Router> { Router::new() @@ -40,20 +42,6 @@ impl UserTarget { } } -make_schemas!((); (Pagination, UserAccounts, UserBalance)); - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct UserAccounts { - pub result: Vec, -} - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct UserBalance { - pub balance: u64, -} - pub async fn user_info( EState(state): State, auth: Auth, @@ -61,7 +49,7 @@ pub async fn user_info( ) -> Result, Error> { let user = target.user_id(&auth); let conn = state.conn().await?; - let info = User::info(&conn, user).await?; + let info = Users::info(&conn, user).await?; if let Some(info) = info { return Ok(Json(info)); } @@ -74,7 +62,7 @@ pub async fn user_info( #[instrument(skip(state))] pub async fn user_balance(EState(state): State, auth: Auth) -> Result, Error> { let conn = state.conn().await?; - let info = Account::list_for_user(&conn, auth.user_id()).await?; + let info = Accounts::list_for_user(&conn, auth.user_id()).await?; let balance = info.iter().map(|info| info.balance).sum(); Ok(Json(UserBalance { balance })) } @@ -83,10 +71,10 @@ pub async fn user_balance(EState(state): State, auth: Auth) -> Result Result>, Error> { let conn = state.conn().await?; - let users = User::list(&conn, pagination).await?; + let users = Users::list(&conn, pagination).await?; Ok(Json(users)) } @@ -100,7 +88,7 @@ pub async fn me_transaction_history( }): Query, ) -> Result>, Error> { let conn = state.conn().await?; - let result = Transaction::user_history(&conn, auth.user_id(), direction, pagination).await?; + let result = Transactions::user_history(&conn, auth.user_id(), direction, pagination).await?; Ok(Json(result)) } @@ -108,7 +96,7 @@ pub async fn me_transaction_history( pub async fn user_accounts(EState(state): State, auth: Auth) -> Result, Error> { let user = auth.user_id(); let conn = state.conn().await?; - let result = Account::list_for_user(&conn, user).await?; + let result = Accounts::list_for_user(&conn, user).await?; Ok(Json(UserAccounts { result })) } diff --git a/src/model/account.rs b/src/model/account.rs index ff06d74..209cd28 100644 --- a/src/model/account.rs +++ b/src/model/account.rs @@ -1,72 +1,32 @@ +use bank_core::{ + account::{AccountInfo, UserAccountInfo}, + pagination::{Pagination, RequestPagination}, +}; use deadpool_postgres::GenericClient; -use serde::Serialize; use tokio_postgres::Row; use tracing::instrument; use uuid::{NoContext, Timestamp, Uuid}; -use crate::api::{Pagination, PaginationType, RequestPagination}; - use super::count; -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Account { - pub id: Uuid, - pub user: Uuid, - pub name: String, - pub balance: u64, +fn account_info_from_row(row: Row) -> AccountInfo { + AccountInfo { + id: row.get("id"), + user: row.get("user"), + name: row.get("name"), + } } - -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct AccountInfo { - pub id: Uuid, - pub user: Uuid, - pub name: String, -} -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct UserAccountInfo { - pub id: Uuid, - pub name: String, - pub balance: u64, -} - -#[derive(Debug, Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct ReducedAccountInfo { - pub id: Uuid, - pub name: String, -} - -impl PaginationType for UserAccountInfo { - const NAME: &str = "UserAccounts"; -} -impl PaginationType for AccountInfo { - const NAME: &str = "Accounts"; -} - -impl From for AccountInfo { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - user: value.get("user"), - name: value.get("name"), - } +fn user_account_info_from_row(row: Row) -> UserAccountInfo { + UserAccountInfo { + id: row.get("id"), + name: row.get("name"), + balance: row.get::<_, i64>("balance") as u64, } } -impl From for UserAccountInfo { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - name: value.get("name"), - balance: value.get::<_, i64>("balance") as u64, - } - } -} +pub struct Accounts; -impl Account { +impl Accounts { #[instrument(skip(client))] pub async fn create( client: &mut impl GenericClient, @@ -102,7 +62,7 @@ impl Account { .query(&stmt, &[&pagination.limit(), &pagination.offset()]) .await? .into_iter() - .map(AccountInfo::from) + .map(account_info_from_row) .collect(); let total = count(client, &stmt_count, &[]).await?; Ok(Pagination::new(users, total, pagination)) @@ -120,7 +80,7 @@ impl Account { .query(&stmt, &[&user]) .await? .into_iter() - .map(UserAccountInfo::from) + .map(user_account_info_from_row) .collect(); Ok(users) } @@ -133,7 +93,7 @@ impl Account { .prepare_cached("select id,name,balance from accounts where id =$1") .await?; let res = client.query_opt(&stmt, &[&id]).await?; - Ok(res.map(UserAccountInfo::from)) + Ok(res.map(user_account_info_from_row)) } pub async fn owned_by( @@ -161,6 +121,6 @@ impl Account { ) .await?; let res = client.query_opt(&stmt, &[&user, &name]).await?; - Ok(res.map(UserAccountInfo::from)) + Ok(res.map(user_account_info_from_row)) } } diff --git a/src/model/chats.rs b/src/model/chats.rs index bcd44bc..91e84de 100644 --- a/src/model/chats.rs +++ b/src/model/chats.rs @@ -1,89 +1,42 @@ +use bank_core::{ + chat::{Chat, ChatInfo, ChatMessage}, + pagination::{Pagination, RequestPagination}, +}; use chrono::{DateTime, Utc}; use deadpool_postgres::GenericClient; -use serde::Serialize; use tokio_postgres::Row; use tracing::instrument; use uuid::{NoContext, Timestamp, Uuid}; -use crate::api::{Pagination, PaginationType, RequestPagination}; - use super::count; -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Chat { - pub id: Uuid, - pub created: DateTime, -} -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct ChatInfo { - pub id: Uuid, - pub created: DateTime, - pub read_until: Option, -} - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct ChatMessage { - pub id: Uuid, - pub sender: Uuid, - pub time: DateTime, - pub text: String, - pub extra: Option, -} - -impl PaginationType for Chat { - const NAME: &str = "Chats"; -} -impl PaginationType for ChatInfo { - const NAME: &str = "ChatInfos"; -} - -impl PaginationType for ChatMessage { - const NAME: &str = "ChatMessages"; -} - -impl From for Chat { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - created: value.get("created"), - } +fn chat_from_row(row: Row) -> Chat { + Chat { + id: row.get("id"), + created: row.get("created"), } } -impl From for ChatInfo { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - created: value.get("created"), - read_until: value.get("read_until"), - } +fn chat_info_from_row(row: Row) -> ChatInfo { + ChatInfo { + id: row.get("id"), + created: row.get("created"), + read_until: row.get("read_until"), } } -impl From for Chat { - fn from(value: ChatInfo) -> Self { - Chat { - id: value.id, - created: value.created, - } +fn chat_message_from_row(row: Row) -> ChatMessage { + ChatMessage { + id: row.get("id"), + sender: row.get("sender"), + time: row.get("timestamp"), + text: row.get("text"), + extra: row.get("extra"), } } -impl From for ChatMessage { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - sender: value.get("sender"), - time: value.get("timestamp"), - text: value.get("text"), - extra: value.get("extra"), - } - } -} +pub struct Chats; -impl Chat { +impl Chats { #[instrument(skip(client))] pub async fn exists( client: &impl GenericClient, @@ -120,7 +73,7 @@ impl Chat { .prepare_cached("select * from chats where id = $1") .await?; let res = client.query_opt(&stmt, &[&id]).await?; - Ok(res.map(Chat::from)) + Ok(res.map(chat_from_row)) } #[instrument(skip(client))] @@ -131,7 +84,7 @@ impl Chat { ) -> Result, tokio_postgres::Error> { let stmt = client.prepare_cached("select c.id as id, c.created as created, cm.read_until as read_until from chat_members cm join chats c on cm.chat = c.id where c.id = $1 and cm.\"user\" = $2").await?; let res = client.query_opt(&stmt, &[&id, &user]).await?; - Ok(res.map(ChatInfo::from)) + Ok(res.map(chat_info_from_row)) } pub async fn member_ids( @@ -181,7 +134,7 @@ impl Chat { client.execute(&insert_member, &[&id, &target]).await?; client.commit().await?; - Ok(Self { + Ok(Chat { id, created: result.get(0), }) @@ -199,7 +152,7 @@ impl Chat { .query(&stmt, &[&user, &pagination.limit(), &pagination.offset()]) .await? .into_iter() - .map(Chat::from) + .map(chat_from_row) .collect(); let total = count(client, &count_stmt, &[&user]).await?; Ok(Pagination::new(result, total, pagination)) @@ -220,7 +173,7 @@ impl Chat { .query(&stmt, &[&chat, &pagination.limit(), &pagination.offset()]) .await? .into_iter() - .map(ChatMessage::from) + .map(chat_message_from_row) .collect(); let total = count(client, &count_stmt, &[&chat]).await?; diff --git a/src/model/mod.rs b/src/model/mod.rs index 0d88fd0..8df764b 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,9 +1,5 @@ -use std::{ops::Deref, sync::LazyLock}; - +use bank_core::NameOrUuid; use deadpool_postgres::GenericClient; -use garde::Validate; -use regex::Regex; -use serde::{Deserialize, Serialize}; use tokio_postgres::{ToStatement, types::ToSql}; use tracing::instrument; use uuid::Uuid; @@ -13,44 +9,28 @@ mod chats; mod transaction; mod user; -pub use account::{Account, AccountInfo, UserAccountInfo}; -pub use chats::{Chat, ChatInfo, ChatMessage}; -pub use transaction::{Direction, FullTransaction, Transaction}; -pub use user::User; +pub use account::Accounts; +pub use chats::Chats; +pub use transaction::Transactions; +pub use user::Users; -use crate::api::make_schemas; - -static NAME_PATTERN_RE: LazyLock = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap()); - -pub const NAME_PATTERN: &str = "^[a-z0-9_-]+$"; -pub const USER_ACCOUNT_PATTERN: &str = "([a-z0-9_-]+):([a-z0-9_-]+)"; - -#[derive(Debug, Clone, PartialEq, Eq, Validate, Serialize, Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -#[garde(transparent)] -pub struct Name(#[garde(length(min = 3, max = 32), pattern(NAME_PATTERN_RE))] pub String); - -impl Deref for Name { - type Target = str; - - fn deref(&self) -> &Self::Target { - self.0.as_str() - } -} -impl Name { - pub fn into_inner(self) -> String { - self.0 - } - - pub fn validate_name(name: &str) -> Result<(), garde::Error> { - garde::rules::pattern::apply(&name, (&NAME_PATTERN_RE,)) - } +pub(crate) trait NameOrUuidExt { + async fn user_id( + &self, + client: &impl GenericClient, + ) -> Result, tokio_postgres::Error>; } -#[derive(Debug)] -pub struct IdWithName { - pub id: Uuid, - pub name: Name, +impl NameOrUuidExt for NameOrUuid { + async fn user_id( + &self, + client: &impl GenericClient, + ) -> Result, tokio_postgres::Error> { + match self { + NameOrUuid::Id(id) => Ok(Users::exists(client, *id).await?.then_some(*id)), + NameOrUuid::Name(name) => Ok(Users::info_by_name(client, &name.0).await?.map(|v| v.id)), + } + } } #[instrument(skip(client))] @@ -61,5 +41,3 @@ async fn count( ) -> Result { Ok(client.query_one(statement, params).await?.get::<_, i64>(0) as u64) } - -make_schemas!((Direction); (User, Account, AccountInfo, UserAccountInfo, Transaction, FullTransaction, Chat, ChatInfo, ChatMessage)); diff --git a/src/model/transaction.rs b/src/model/transaction.rs index cce9611..fcd5869 100644 --- a/src/model/transaction.rs +++ b/src/model/transaction.rs @@ -1,94 +1,46 @@ -use chrono::{DateTime, Utc}; +use bank_core::{ + account::ReducedAccountInfo, + pagination::{Pagination, RequestPagination}, + transaction::{Direction, FullTransaction, Participant, Transaction}, + user::User, +}; use deadpool_postgres::GenericClient; -use serde::{Deserialize, Serialize}; use tokio_postgres::Row; use uuid::{NoContext, Timestamp, Uuid}; -use crate::{ - api::{Pagination, RequestPagination}, - model::count, -}; +use crate::model::count; -use super::{User, account::ReducedAccountInfo}; - -#[derive(Debug, Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -#[serde(rename_all = "lowercase")] -pub enum Direction { - Received, - Sent, -} - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Participant { - user: User, - account: ReducedAccountInfo, -} - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct FullTransaction { - from: Participant, - to: Participant, - amount: u64, - timestamp: DateTime, - message: Option, -} - -impl From for FullTransaction { - fn from(value: Row) -> Self { - Self { - from: Participant { - user: User { - id: value.get("from_user_id"), - name: value.get("from_user_name"), - }, - account: ReducedAccountInfo { - id: value.get("from_account_id"), - name: value.get("from_account_name"), - }, +fn full_transaction_from_row(row: Row) -> FullTransaction { + FullTransaction { + from: Participant { + user: User { + id: row.get("from_user_id"), + name: row.get("from_user_name"), }, - to: Participant { - user: User { - id: value.get("to_user_id"), - name: value.get("to_user_name"), - }, - account: ReducedAccountInfo { - id: value.get("to_account_id"), - name: value.get("to_account_name"), - }, + account: ReducedAccountInfo { + id: row.get("from_account_id"), + name: row.get("from_account_name"), }, - timestamp: value.get("timestamp"), - amount: value.get::<_, i64>("amount") as u64, - message: value.get("message"), - } + }, + to: Participant { + user: User { + id: row.get("to_user_id"), + name: row.get("to_user_name"), + }, + account: ReducedAccountInfo { + id: row.get("to_account_id"), + name: row.get("to_account_name"), + }, + }, + timestamp: row.get("timestamp"), + amount: row.get::<_, i64>("amount") as u64, + message: row.get("message"), } } -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct Transaction { - pub from: Uuid, - pub to: Uuid, - pub amount: u64, - pub timestamp: DateTime, - pub message: Option, -} +pub struct Transactions; -impl From for Transaction { - fn from(value: Row) -> Self { - Self { - from: value.get("from"), - to: value.get("to"), - amount: value.get::<_, i64>("amount") as u64, - timestamp: value.get("timestamp"), - message: value.get("message"), - } - } -} - -impl Transaction { +impl Transactions { pub async fn create( client: &mut impl GenericClient, from: Uuid, @@ -104,7 +56,7 @@ impl Transaction { .query_one(&stmt, &[&id, &from, &to, &(amount as i64), &message]) .await?; let timestamp = row.get(0); - Ok(Self { + Ok(Transaction { from, to, amount, @@ -113,75 +65,6 @@ impl Transaction { }) } - pub async fn get_to_account( - client: &mut impl GenericClient, - id: Uuid, - ) -> Result, tokio_postgres::Error> { - let stmt = client - .prepare_cached("select * from transactions where \"to\" = $1") - .await?; - let transactions = client - .query(&stmt, &[&id]) - .await? - .into_iter() - .map(Transaction::from) - .collect(); - Ok(transactions) - } - - pub async fn get_from_account( - client: &mut impl GenericClient, - id: Uuid, - ) -> Result, tokio_postgres::Error> { - let stmt = client - .prepare_cached("select * from transactions where \"from\" = $1") - .await?; - let transactions = client - .query(&stmt, &[&id]) - .await? - .into_iter() - .map(Transaction::from) - .collect(); - Ok(transactions) - } - - pub async fn get_all_for_account( - client: &mut impl GenericClient, - id: Uuid, - ) -> Result, tokio_postgres::Error> { - let stmt = client - .prepare_cached("select * from transactions where \"from\" = $1 or \"to\" = $1") - .await?; - let transactions = client - .query(&stmt, &[&id]) - .await? - .into_iter() - .map(Transaction::from) - .collect(); - Ok(transactions) - } - - pub async fn get_all_to_user( - _client: &mut impl GenericClient, - _id: Uuid, - ) -> Result, tokio_postgres::Error> { - todo!() - } - - pub async fn get_all_from_user( - _client: &mut impl GenericClient, - _id: Uuid, - ) -> Result, tokio_postgres::Error> { - todo!() - } - - pub async fn get_all_for_user( - _client: &mut impl GenericClient, - _id: Uuid, - ) -> Result, tokio_postgres::Error> { - todo!() - } - pub async fn account_history( client: &impl GenericClient, id: Uuid, @@ -242,7 +125,7 @@ impl Transaction { .await?; let transactions = transactions .into_iter() - .map(FullTransaction::from) + .map(full_transaction_from_row) .collect(); let total = count(client, &count_stmt, &[&id]).await?; diff --git a/src/model/user.rs b/src/model/user.rs index bb2f280..f658e39 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -1,34 +1,26 @@ -use axum::http::StatusCode; +use bank_core::{ + ApiError, + pagination::{Pagination, RequestPagination}, + user::User, +}; use deadpool_postgres::GenericClient; -use serde::Serialize; use tokio_postgres::{Row, error::SqlState}; use tracing::instrument; use uuid::{NoContext, Timestamp, Uuid}; -use crate::api::{ApiError, Error, Pagination, PaginationType, RequestPagination}; +use crate::api::Error; -use super::{Account, count}; +use super::{Accounts, count}; -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct User { - pub id: Uuid, - pub name: String, -} +pub struct Users; -impl From for User { - fn from(value: Row) -> Self { - Self { - id: value.get("id"), - name: value.get("name"), - } +pub fn user_from_row(row: Row) -> User { + User { + id: row.get("id"), + name: row.get("name"), } } -fn conflict_error() -> ApiError<'static> { - ApiError::new(StatusCode::CONFLICT, "conflict", "Conflict") -} - fn unique_violation(error: tokio_postgres::Error, api_error: fn() -> ApiError<'static>) -> Error { let Some(code) = error.code() else { return error.into(); @@ -39,11 +31,7 @@ fn unique_violation(error: tokio_postgres::Error, api_error: fn() -> ApiError<'s return error.into(); } -impl PaginationType for User { - const NAME: &str = "UserList"; -} - -impl User { +impl Users { pub async fn exists( client: &impl GenericClient, id: Uuid, @@ -68,10 +56,10 @@ impl User { let id = Uuid::new_v7(Timestamp::now(NoContext)); tx.execute(&stmt, &[&id, &name, &hash]) .await - .map_err(|err| unique_violation(err, conflict_error))?; - Account::create(&mut tx, Some(id), id, "personal") + .map_err(|err| unique_violation(err, || ApiError::CONFLICT))?; + Accounts::create(&mut tx, Some(id), id, "personal") .await - .map_err(|err| unique_violation(err, conflict_error))?; + .map_err(|err| unique_violation(err, || ApiError::CONFLICT))?; tx.commit().await?; Ok(id) } @@ -87,7 +75,7 @@ impl User { let res = client.query_opt(&stmt, &[&name]).await?; Ok(res.map(|res| { let password = res.get("password"); - (password, res.into()) + (password, user_from_row(res)) })) } #[instrument(skip(client))] @@ -99,7 +87,7 @@ impl User { .prepare_cached("select id,name from users where name = $1") .await?; let res = client.query_opt(&stmt, &[&name]).await?; - Ok(res.map(User::from)) + Ok(res.map(user_from_row)) } #[instrument(skip(client))] @@ -115,7 +103,7 @@ impl User { .query(&stmt, &[&pagination.limit(), &pagination.offset()]) .await? .into_iter() - .map(User::from) + .map(user_from_row) .collect(); let count = count(client, &stmt_count, &[]).await?; Ok(Pagination::new(users, count, pagination)) @@ -129,7 +117,7 @@ impl User { let stmt = client .prepare_cached("select id,name from users where id = $1") .await?; - let info = client.query_opt(&stmt, &[&id]).await?.map(User::from); + let info = client.query_opt(&stmt, &[&id]).await?.map(user_from_row); Ok(info) } }