split into bank_core and server

This commit is contained in:
DSeeLP 2025-03-16 16:11:55 +01:00
parent 0e90a4e2e4
commit 2d3cc8edc1
24 changed files with 848 additions and 835 deletions

24
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

19
bank_core/Cargo.toml Normal file
View File

@ -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

47
bank_core/src/account.rs Normal file
View File

@ -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<AccountInfo>, Pagination<UserAccountInfo>));

58
bank_core/src/chat.rs Normal file
View File

@ -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<Utc>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct ChatInfo {
pub id: Uuid,
pub created: DateTime<Utc>,
pub read_until: Option<Uuid>,
}
#[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<Utc>,
pub text: String,
pub extra: Option<serde_json::Value>,
}
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<serde_json::Value>,
}
#[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<Chat>, Pagination<ChatInfo>, Pagination<ChatMessage>));

61
bank_core/src/error.rs Normal file
View File

@ -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<Cow<'a, str>>,
message: impl Into<Cow<'a, str>>,
) -> 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()),
}
}
}

13
bank_core/src/lib.rs Normal file
View File

@ -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]);

152
bank_core/src/pagination.rs Normal file
View File

@ -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<T> {
pub pagination: PaginationMeta,
pub result: Vec<T>,
}
impl<T> Pagination<T> {
pub fn new(result: Vec<T>, total: u64, pagination: RequestPagination) -> Self {
Self {
pagination: PaginationMeta {
total,
limit: pagination.limit,
offset: pagination.offset,
},
result,
}
}
}
#[cfg(feature = "schemas")]
impl<T: PaginationType + schemars::JsonSchema> schemars::JsonSchema for Pagination<T> {
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::<Self>())
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let result_schema = generator.subschema_for::<Vec<T>>();
let pagination_schema = generator.subschema_for::<PaginationMeta>();
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<E>(self, v: $ty) -> Result<Self::Value, E>
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<E>(self, v: $ty) -> Result<Self::Value, E>
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<u64, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
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
}
}

95
bank_core/src/schemas.rs Normal file
View File

@ -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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, 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<T>() {}
$(
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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, schemars::Schema>,
),
pub response_bodies: fn(
&mut schemars::SchemaGenerator,
&mut std::collections::HashMap<std::borrow::Cow<'static, str>, 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);
}
}

View File

@ -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<Utc>,
pub message: Option<String>,
}
#[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<Utc>,
pub message: Option<String>,
}
#[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<Direction>,
}
impl PaginationType for FullTransaction {
const NAME: &str = "Transactions";
}
make_schemas!((Direction); (Transaction, FullTransaction, Pagination<FullTransaction>));

33
bank_core/src/user.rs Normal file
View File

@ -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<UserAccountInfo>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct UserBalance {
pub balance: u64,
}
make_schemas!((); (User, Pagination<User>, UserAccounts, UserBalance));

65
bank_core/src/util.rs Normal file
View File

@ -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<Regex> = 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,
}

View File

@ -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<Arc<AppState>> {
Router::new()
@ -21,8 +21,6 @@ pub(super) fn router() -> Router<Arc<AppState>> {
.route("/{id}/transactions", get(account_transactions))
}
make_schemas!((); (Pagination<AccountInfo>));
#[instrument(skip(state))]
pub async fn account_info(EState(state): State, _: Auth, Path(id): Path<Uuid>) {}
@ -37,14 +35,14 @@ pub async fn account_transactions(
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<Json<Pagination<AccountInfo>>, 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))
}

View File

@ -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<Arc<AppState>> {
Router::new()
@ -30,33 +27,13 @@ pub(super) fn router() -> Router<Arc<AppState>> {
.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<Credentials>,
) -> Result<(StatusCode, Json<TokenResponse>), 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<Json<TokenResponse>, 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")]

View File

@ -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<Arc<AppState>> {
@ -26,32 +25,17 @@ pub(super) fn router() -> Router<Arc<AppState>> {
.route("/{id}/messages", get(get_messages).post(send_message))
}
make_schemas!((StartChat, SendMessage); (Pagination<Chat>, Pagination<ChatInfo>, Pagination<ChatMessage>));
#[instrument(skip(state))]
pub async fn list_chats(
EState(state): State,
auth: Auth,
pagination: RequestPagination,
PaginationQuery(pagination): PaginationQuery,
) -> Result<Json<Pagination<Chat>>, 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<serde_json::Value>,
}
#[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<Json<ChatInfo>, 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<Uuid>,
pagination: RequestPagination,
PaginationQuery(pagination): PaginationQuery,
) -> Result<Json<Pagination<ChatMessage>>, 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<Json<ChatMessage>, 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;
}

View File

@ -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>(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<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for Query<T> {
}
pub struct Error {
#[allow(unused)]
trace: Option<SpanTrace>,
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<Cow<'a, str>>,
message: impl Into<Cow<'a, str>>,
) -> 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<Arc<AppState>> {
.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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, 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<E>(self, v: $ty) -> Result<Self::Value, E>
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<E>(self, v: $ty) -> Result<Self::Value, E>
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<u64, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
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<S: Send + Sync> FromRequestParts<S> for RequestPagination {
impl<S: Send + Sync> FromRequestParts<S> for PaginationQuery {
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Query::<RequestPagination>::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<T> {
pub pagination: PaginationMeta,
pub result: Vec<T>,
}
impl<T> Pagination<T> {
pub fn new(result: Vec<T>, total: u64, pagination: RequestPagination) -> Self {
Self {
pagination: PaginationMeta {
total,
limit: pagination.limit,
offset: pagination.offset,
},
result,
}
}
}
pub trait PaginationType {
const NAME: &str;
}
impl<T: PaginationType + JsonSchema> JsonSchema for Pagination<T> {
fn schema_name() -> Cow<'static, str> {
format!("Paginated{}", T::NAME).into()
}
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(std::any::type_name::<Self>())
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let result_schema = generator.subschema_for::<Vec<T>>();
let pagination_schema = generator.subschema_for::<PaginationMeta>();
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<std::borrow::Cow<'static, str>, schemars::Schema>,
),
pub response_bodies: fn(
&mut schemars::SchemaGenerator,
&mut std::collections::HashMap<std::borrow::Cow<'static, str>, 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))
}
}

View File

@ -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<Arc<AppState>> {
Router::new().route("/", get(handle))
}
make_schemas!((); ());
#[instrument(skip(state))]
pub async fn handle(
EState(state): State,

View File

@ -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<Arc<AppState>> {
Router::new().route("/", post(make_payment))
}
make_schemas!((MakePayment); (Pagination<FullTransaction>));
impl PaginationType for FullTransaction {
const NAME: &str = "Transactions";
}
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct TransactionHistory {
transactions: Vec<Transaction>,
}
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<Option<Uuid>, 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<Option<(Uuid, Uuid)>, 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<Direction>,
}
pub async fn transaction_history(
EState(state): State,
auth: Auth,
@ -366,7 +331,7 @@ pub async fn transaction_history(
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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::{
use bank_core::Name;
use crate::api::transactions::{
AccountSelector, NameOrUuid, UnvalidatedAccountSelector, UnvalidatedTransform,
},
model::Name,
};
use super::MakePayment;

View File

@ -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<Arc<AppState>> {
Router::new()
@ -40,20 +42,6 @@ impl UserTarget {
}
}
make_schemas!((); (Pagination<User>, UserAccounts, UserBalance));
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct UserAccounts {
pub result: Vec<UserAccountInfo>,
}
#[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<Json<User>, 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<Json<UserBalance>, 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<Json<UserB
pub async fn list_users(
EState(state): State,
_: Auth,
pagination: RequestPagination,
PaginationQuery(pagination): PaginationQuery,
) -> Result<Json<Pagination<User>>, 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<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<Json<UserAccounts>, 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 }))
}

View File

@ -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<Row> 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<Row> 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))
}
}

View File

@ -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<Utc>,
}
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct ChatInfo {
pub id: Uuid,
pub created: DateTime<Utc>,
pub read_until: Option<Uuid>,
}
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct ChatMessage {
pub id: Uuid,
pub sender: Uuid,
pub time: DateTime<Utc>,
pub text: String,
pub extra: Option<serde_json::Value>,
}
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<Row> for Chat {
fn from(value: Row) -> Self {
Self {
id: value.get("id"),
created: value.get("created"),
}
}
}
impl From<Row> for ChatInfo {
fn from(value: Row) -> Self {
Self {
id: value.get("id"),
created: value.get("created"),
read_until: value.get("read_until"),
}
}
}
impl From<ChatInfo> for Chat {
fn from(value: ChatInfo) -> Self {
fn chat_from_row(row: Row) -> Chat {
Chat {
id: value.id,
created: value.created,
id: row.get("id"),
created: row.get("created"),
}
}
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<Row> 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"),
}
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 Chat {
pub struct Chats;
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<Option<ChatInfo>, 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?;

View File

@ -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<Regex> = 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(crate) trait NameOrUuidExt {
async fn user_id(
&self,
client: &impl GenericClient,
) -> Result<Option<Uuid>, tokio_postgres::Error>;
}
pub fn validate_name(name: &str) -> Result<(), garde::Error> {
garde::rules::pattern::apply(&name, (&NAME_PATTERN_RE,))
impl NameOrUuidExt for NameOrUuid {
async fn user_id(
&self,
client: &impl GenericClient,
) -> Result<Option<Uuid>, 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)),
}
}
#[derive(Debug)]
pub struct IdWithName {
pub id: Uuid,
pub name: Name,
}
#[instrument(skip(client))]
@ -61,5 +41,3 @@ async fn count<T: ?Sized + ToStatement + Sync + Send + std::fmt::Debug>(
) -> Result<u64, tokio_postgres::Error> {
Ok(client.query_one(statement, params).await?.get::<_, i64>(0) as u64)
}
make_schemas!((Direction); (User, Account, AccountInfo, UserAccountInfo, Transaction, FullTransaction, Chat, ChatInfo, ChatMessage));

View File

@ -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<Utc>,
message: Option<String>,
}
impl From<Row> for FullTransaction {
fn from(value: Row) -> Self {
Self {
fn full_transaction_from_row(row: Row) -> FullTransaction {
FullTransaction {
from: Participant {
user: User {
id: value.get("from_user_id"),
name: value.get("from_user_name"),
id: row.get("from_user_id"),
name: row.get("from_user_name"),
},
account: ReducedAccountInfo {
id: value.get("from_account_id"),
name: value.get("from_account_name"),
id: row.get("from_account_id"),
name: row.get("from_account_name"),
},
},
to: Participant {
user: User {
id: value.get("to_user_id"),
name: value.get("to_user_name"),
id: row.get("to_user_id"),
name: row.get("to_user_name"),
},
account: ReducedAccountInfo {
id: value.get("to_account_id"),
name: value.get("to_account_name"),
id: row.get("to_account_id"),
name: row.get("to_account_name"),
},
},
timestamp: value.get("timestamp"),
amount: value.get::<_, i64>("amount") as u64,
message: value.get("message"),
}
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<Utc>,
pub message: Option<String>,
}
pub struct Transactions;
impl From<Row> 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<Vec<Transaction>, 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<Vec<Transaction>, 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<Vec<Transaction>, 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<Vec<Transaction>, tokio_postgres::Error> {
todo!()
}
pub async fn get_all_from_user(
_client: &mut impl GenericClient,
_id: Uuid,
) -> Result<Vec<Transaction>, tokio_postgres::Error> {
todo!()
}
pub async fn get_all_for_user(
_client: &mut impl GenericClient,
_id: Uuid,
) -> Result<Vec<Transaction>, 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?;

View File

@ -1,33 +1,25 @@
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<Row> 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 {
@ -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)
}
}