From 0cf47db3e807a34b88f9c61260915efa63ce26e0 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 4 Jul 2025 11:56:23 +0800 Subject: [PATCH] initial commit --- .env | 3 + .gitignore | 1 + Cargo.toml | 18 ++++ migrations/20250704000000_init.sql | 16 +++ src/db.rs | 19 ++++ src/errors.rs | 34 ++++++ src/handlers.rs | 167 +++++++++++++++++++++++++++++ src/main.rs | 41 +++++++ src/models.rs | 62 +++++++++++ 9 files changed, 361 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 migrations/20250704000000_init.sql create mode 100755 src/db.rs create mode 100644 src/errors.rs create mode 100644 src/handlers.rs create mode 100644 src/main.rs create mode 100644 src/models.rs diff --git a/.env b/.env new file mode 100644 index 0000000..9547c50 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +PGUSER=postgres +PGHOST=127.0.0.1 +PGPASSWORD=password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..001f9a2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "intrasys" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8", features = ["json"] } +tokio = { version = "1.46", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "chrono", "bigdecimal", "derive"] } +thiserror = "2.0" +uuid = { version = "1.17", features = ["v4"] } +# validator = { version = "0.20", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenv = "0.15" +bigdecimal = { version = "0.4", features = ["serde"] } diff --git a/migrations/20250704000000_init.sql b/migrations/20250704000000_init.sql new file mode 100644 index 0000000..f75d1e3 --- /dev/null +++ b/migrations/20250704000000_init.sql @@ -0,0 +1,16 @@ +-- Create accounts table +CREATE TABLE accounts ( + account_id BIGINT PRIMARY KEY, + balance DECIMAL(20, 10) NOT NULL +); + +-- Create transactions table for audit purposes +CREATE TABLE transactions ( + transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_account_id BIGINT NOT NULL, + destination_account_id BIGINT NOT NULL, + amount DECIMAL(20, 10) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (source_account_id) REFERENCES accounts (account_id), + FOREIGN KEY (destination_account_id) REFERENCES accounts (account_id) +); diff --git a/src/db.rs b/src/db.rs new file mode 100755 index 0000000..9426238 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,19 @@ +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{PgPool, Postgres, Transaction}; +use tracing::info; + +pub type DbPool = PgPool; +pub type DbTransaction<'a> = Transaction<'a, Postgres>; + +pub async fn create_pool() -> Result { + info!("Creating database connection pool..."); + PgPoolOptions::new() + .max_connections(5) + .connect_with(PgConnectOptions::new()) + .await +} + +pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::migrate::MigrateError> { + info!("Running database migrations..."); + sqlx::migrate!("./migrations").run(pool).await +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..2d38625 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,34 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use sqlx::Error as SqlxError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Account not found: {0}")] + AccountNotFound(i64), + #[error("Account already exists")] + AccountAlreadyExists, + #[error("Insufficient funds")] + InsufficientFunds, + #[error("Database error: {0}")] + DatabaseError(#[from] SqlxError), + #[error("Invalid input: {0}")] + InvalidInput(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match self { + AppError::AccountNotFound(_) => StatusCode::NOT_FOUND, + AppError::InsufficientFunds => StatusCode::BAD_REQUEST, + AppError::AccountAlreadyExists => StatusCode::BAD_REQUEST, + AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::InvalidInput(_) => StatusCode::BAD_REQUEST, + }; + + (status, self.to_string()).into_response() + } +} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..126da51 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,167 @@ +use std::str::FromStr; + +use axum::{ + Json, + extract::{Path, State}, +}; +use bigdecimal::BigDecimal; +use sqlx::types::BigDecimal as SqlxBigDecimal; +use tracing::info; + +use crate::{ + db::DbPool, + errors::AppError, + models::{Account, CreateAccount, TransactionRequest}, +}; + +pub async fn create_account( + State(pool): State, + Json(payload): Json, +) -> Result<(), AppError> { + if payload.account_id < 1 { + return Err(AppError::InvalidInput(String::from( + "account_id is invalid", + ))); + } + + if payload.initial_balance < BigDecimal::from(0) { + return Err(AppError::InvalidInput(String::from( + "initial_balance is negative", + ))); + } + info!("Creating account: {:?}", payload.account_id); + sqlx::query( + r#" + INSERT INTO accounts (account_id, balance) + VALUES ($1, $2) + "#, + ) + .bind(payload.account_id) + .bind(SqlxBigDecimal::from(payload.initial_balance)) + .execute(&pool) + .await + .map_err(|err| match err { + sqlx::Error::Database(_) => AppError::AccountAlreadyExists, + _ => AppError::from(err), + })?; + + Ok(()) +} + +pub async fn get_account( + State(pool): State, + Path(account_id): Path, +) -> Result, AppError> { + if account_id < 1 { + return Err(AppError::InvalidInput(String::from( + "account_id is invalid", + ))); + } + info!("Fetching account: {}", account_id); + + let account = sqlx::query_as::<_, Account>( + r#" + SELECT account_id, balance + FROM accounts + WHERE account_id = $1 + "#, + ) + .bind(account_id) + .fetch_one(&pool) + .await + .map_err(|_| AppError::AccountNotFound(account_id))?; + + Ok(Json(account)) +} + +pub async fn process_transaction( + State(pool): State, + Json(payload): Json, +) -> Result<(), AppError> { + if payload.source_account_id < 1 { + return Err(AppError::InvalidInput(String::from( + "source_account_id is invalid", + ))); + } + + if payload.destination_account_id < 1 { + return Err(AppError::InvalidInput(String::from( + "destination_account_id is invalid", + ))); + } + + if payload.amount < BigDecimal::from_str("0.01").unwrap() { + return Err(AppError::InvalidInput(String::from( + "Minimum amount is 0.01", + ))); + } + info!( + "Processing transaction from {} to {} for amount {}", + payload.source_account_id, payload.destination_account_id, payload.amount + ); + + let mut tx = pool.begin().await.map_err(AppError::from)?; + + // Check source account balance + let source_balance: SqlxBigDecimal = sqlx::query_scalar( + r#" + SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE + "#, + ) + .bind(payload.source_account_id) + .fetch_one(&mut *tx) + .await + .map_err(|_| AppError::AccountNotFound(payload.source_account_id))?; + + if source_balance < SqlxBigDecimal::from(payload.amount.clone()) { + return Err(AppError::InsufficientFunds); + } + + // Update source account + sqlx::query( + r#" + UPDATE accounts + SET balance = balance - $1 + WHERE account_id = $2 + "#, + ) + .bind(SqlxBigDecimal::from(payload.amount.clone())) + .bind(payload.source_account_id) + .execute(&mut *tx) + .await + .map_err(AppError::from)?; + // Update destination account + let update_result = sqlx::query( + r#" + UPDATE accounts + SET balance = balance + $1 + WHERE account_id = $2 + "#, + ) + .bind(SqlxBigDecimal::from(payload.amount.clone())) + .bind(payload.destination_account_id) + .execute(&mut *tx) + .await + .map_err(AppError::from)?; + if update_result.rows_affected() == 0 { + return Err(AppError::AccountNotFound(payload.destination_account_id)); + } + + // Record transaction + sqlx::query( + r#" + INSERT INTO transactions (source_account_id, destination_account_id, amount) + VALUES ($1, $2, $3) + "#, + ) + .bind(payload.source_account_id) + .bind(payload.destination_account_id) + .bind(SqlxBigDecimal::from(payload.amount.clone())) + .execute(&mut *tx) + .await + .map_err(AppError::from)?; + + tx.commit().await.map_err(AppError::from)?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..509f04f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,41 @@ +use axum::{ + Router, + routing::{get, post}, +}; +use dotenv::dotenv; +use tracing::Level; +use tracing_subscriber::{FmtSubscriber, layer::SubscriberExt, util::SubscriberInitExt}; + +mod db; +mod errors; +mod handlers; +mod models; + +#[tokio::main] +async fn main() { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::TRACE) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("Setting default tracing subscriber failed"); + dotenv().ok(); + + let pool = db::create_pool().await.expect("Failed to create pool"); + + // Run migrations + db::run_migrations(&pool) + .await + .expect("Failed to run migrations"); + + // Build our application's routes + let app = Router::new() + .route("/accounts", post(handlers::create_account)) + .route("/accounts/{account_id}", get(handlers::get_account)) + .route("/transactions", post(handlers::process_transaction)) + .with_state(pool); + + // Run our app + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + tracing::debug!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..4669eb0 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,62 @@ +use std::str::FromStr; + +use bigdecimal::{BigDecimal, ParseBigDecimalError}; +use serde::{Deserialize, Serialize}; +use sqlx::{Database, Decode, Encode, FromRow, Type, error::BoxDynError}; +use std::cmp::PartialOrd; +use std::error::Error; +use std::fmt::Display; +// use validator::{Validate, ValidateRange}; + +// #[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, Clone, Type)] +// #[sqlx(transparent)] +// pub struct Amount(BigDecimal); + +// impl Amount { +// fn new(text: &str) -> Result { +// Ok(Amount(BigDecimal::from_str(text)?)) +// } +// } + +// impl Display for Amount { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// self.0.fmt(f) +// } +// } + +// impl ValidateRange for Amount { +// fn greater_than(&self, max: Amount) -> Option { +// Some(self > &max) +// } + +// fn less_than(&self, min: Amount) -> Option { +// Some(self < &min) +// } +// } + +// impl From for BigDecimal { +// fn from(value: Amount) -> Self { +// value.0 +// } +// } + +type Amount = BigDecimal; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Account { + pub account_id: i64, + pub balance: Amount, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateAccount { + pub account_id: i64, + pub initial_balance: Amount, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TransactionRequest { + pub source_account_id: i64, + pub destination_account_id: i64, + pub amount: Amount, +}