├── src ├── domain │ ├── mod.rs │ └── models │ │ ├── mod.rs │ │ ├── account.rs │ │ ├── transaction.rs │ │ └── user.rs ├── infrastructure │ ├── mod.rs │ ├── database │ │ ├── postgres │ │ │ ├── queries │ │ │ │ └── list_users.sql │ │ │ ├── mod.rs │ │ │ ├── migrations │ │ │ │ ├── 20231113222801_db.sql │ │ │ │ └── 20250115163600_transactions.sql │ │ │ ├── options.rs │ │ │ └── postgres.rs │ │ ├── mod.rs │ │ └── database.rs │ └── redis │ │ ├── mod.rs │ │ └── connection.rs ├── application │ ├── security │ │ ├── mod.rs │ │ ├── roles.rs │ │ ├── jwt.rs │ │ └── auth.rs │ ├── service │ │ ├── mod.rs │ │ ├── transaction_service.rs │ │ └── token_service.rs │ ├── mod.rs │ ├── repository │ │ ├── mod.rs │ │ ├── transaction_repo.rs │ │ ├── account_repo.rs │ │ └── user_repo.rs │ ├── state.rs │ ├── constants.rs │ ├── app.rs │ └── config.rs ├── api │ ├── routes │ │ ├── mod.rs │ │ ├── transaction_routes.rs │ │ ├── user_routes.rs │ │ ├── account_routes.rs │ │ └── auth_routes.rs │ ├── handlers │ │ ├── mod.rs │ │ ├── account_handlers.rs │ │ ├── user_handlers.rs │ │ ├── transaction_handlers.rs │ │ └── auth_handlers.rs │ ├── mod.rs │ ├── extractors.rs │ ├── version.rs │ ├── server.rs │ └── error.rs ├── lib.rs └── main.rs ├── .dockerignore ├── TODO.md ├── tests ├── common │ ├── mod.rs │ ├── constants.rs │ ├── root.rs │ ├── error.rs │ ├── hyper_fetch.rs │ ├── transactions.rs │ ├── test_app.rs │ ├── helpers.rs │ ├── accounts.rs │ ├── users.rs │ └── auth.rs ├── version_test.rs ├── health_test.rs ├── auth_logout_test.rs ├── auth_login_test.rs ├── auth_refresh_tests.rs ├── auth_expire_tests.rs ├── auth_revoke_tests.rs ├── user_tests.rs ├── account_tests.rs ├── endpoints.http └── transaction_tests.rs ├── .gitignore ├── clippy.toml ├── Dockerfile ├── docker-compose.yml ├── .env_test ├── .env_test_docker ├── .env_docker ├── docker-compose.full.yml ├── CHANGELOG.md ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── .env ├── Cargo.toml ├── PROJECT_CONTEXT.md ├── README.md ├── docs └── api-docs.md └── deny.toml /src/domain/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | .git 3 | 4 | **/target 5 | target -------------------------------------------------------------------------------- /src/infrastructure/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod redis; 3 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/queries/list_users.sql: -------------------------------------------------------------------------------- 1 | SELECT * from users; -------------------------------------------------------------------------------- /src/infrastructure/redis/mod.rs: -------------------------------------------------------------------------------- 1 | mod connection; 2 | pub use connection::open; 3 | -------------------------------------------------------------------------------- /src/application/security/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod jwt; 3 | pub mod roles; 4 | -------------------------------------------------------------------------------- /src/domain/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod transaction; 3 | pub mod user; 4 | -------------------------------------------------------------------------------- /src/application/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod token_service; 2 | pub mod transaction_service; 3 | -------------------------------------------------------------------------------- /src/api/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account_routes; 2 | pub mod auth_routes; 3 | pub mod transaction_routes; 4 | pub mod user_routes; 5 | -------------------------------------------------------------------------------- /src/api/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account_handlers; 2 | pub mod auth_handlers; 3 | pub mod transaction_handlers; 4 | pub mod user_handlers; 5 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/mod.rs: -------------------------------------------------------------------------------- 1 | mod options; 2 | mod postgres; 3 | 4 | pub use options::PostgresOptions; 5 | pub use postgres::PostgresDatabase; 6 | -------------------------------------------------------------------------------- /src/application/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod config; 3 | pub mod constants; 4 | pub mod repository; 5 | pub mod security; 6 | pub mod service; 7 | pub mod state; 8 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Next features/samples to add 2 | 3 | - Logs: file rolling and non blocking appender 4 | - Rate limiting 5 | - Observability 6 | - Metrics 7 | - OpenTelemetry 8 | -------------------------------------------------------------------------------- /src/application/repository/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account_repo; 2 | pub mod transaction_repo; 3 | pub mod user_repo; 4 | 5 | pub type RepositoryResult = Result; 6 | -------------------------------------------------------------------------------- /src/infrastructure/database/mod.rs: -------------------------------------------------------------------------------- 1 | mod database; 2 | mod postgres; 3 | 4 | pub use database::{ 5 | Database, DatabaseConnection, DatabaseError, DatabaseOptions, DatabasePool, TestDatabase, 6 | }; 7 | pub use postgres::PostgresOptions; 8 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod extractors; 3 | mod routes; 4 | mod version; 5 | 6 | pub mod handlers; 7 | pub mod server; 8 | pub use error::{APIError, APIErrorCode, APIErrorEntry, APIErrorKind}; 9 | pub use version::APIVersion; 10 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::all)] 2 | #![warn(clippy::nursery)] 3 | #![allow(clippy::uninlined_format_args)] 4 | #![allow(clippy::cognitive_complexity)] 5 | 6 | pub mod api; 7 | pub mod application; 8 | pub mod domain; 9 | pub mod infrastructure; 10 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod accounts; 2 | pub mod auth; 3 | pub mod constants; 4 | pub mod error; 5 | pub mod helpers; 6 | pub mod hyper_fetch; 7 | pub mod root; 8 | pub mod test_app; 9 | pub mod transactions; 10 | pub mod users; 11 | 12 | pub use error::TestError; 13 | pub type TestResult = Result; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | 4 | # Generated by Cargo 5 | # will have compiled files and executables 6 | debug/ 7 | target/ 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # MSVC Windows builds of rustc generate these, which store debugging information 13 | *.pdb 14 | 15 | axum-api.session.sql 16 | .DS_Store -------------------------------------------------------------------------------- /src/application/state.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use crate::{application::config::Config, infrastructure::database::DatabasePool}; 6 | 7 | pub type SharedState = Arc; 8 | 9 | pub struct AppState { 10 | pub config: Config, 11 | pub db_pool: DatabasePool, 12 | pub redis: Mutex, 13 | } 14 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | msrv = "1.90" 2 | 3 | disallowed-types = [ 4 | { path = "std::collections::LinkedList", reason = "LinkedList as a slow and almost never needed" }, 5 | { path = "ring::digest::SHA1_FOR_LEGACY_USE_ONLY", reason = "SHA-1 is cryptographically broken, and we are building new code so should not use it", allow-invalid = true }, 6 | ] 7 | disallowed-macros = [ 8 | "std::dbg", 9 | ] 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.85 as builder 2 | WORKDIR /opt 3 | COPY . . 4 | RUN cargo build --release 5 | RUN cp /opt/target/release/axum-web . 6 | RUN cargo clean 7 | 8 | FROM ubuntu:24.04 9 | RUN apt-get update \ 10 | && apt-get install -y --no-install-recommends vim \ 11 | && rm -rf /var/lib/apt/lists/* 12 | WORKDIR /opt 13 | COPY --from=builder /opt/axum-web . 14 | EXPOSE 8080 15 | CMD ["./axum-web"] 16 | -------------------------------------------------------------------------------- /src/domain/models/account.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use serde::{Deserialize, Serialize}; 3 | use sqlx::{FromRow, types::Uuid}; 4 | 5 | #[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq, Eq)] 6 | pub struct Account { 7 | pub id: Uuid, 8 | pub user_id: Uuid, 9 | pub balance_cents: i64, 10 | pub created_at: Option, 11 | pub updated_at: Option, 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | volumes: 5 | - redis:/data 6 | ports: 7 | - '6379:6379' 8 | 9 | postgres: 10 | image: postgres:17-alpine 11 | restart: always 12 | volumes: 13 | - postgres:/var/lib/postgresql/data 14 | env_file: 15 | - .env 16 | ports: 17 | - "5432:5432" 18 | 19 | volumes: 20 | redis: 21 | postgres: -------------------------------------------------------------------------------- /src/domain/models/transaction.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use serde::{Deserialize, Serialize}; 3 | use sqlx::{FromRow, types::Uuid}; 4 | 5 | #[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq, Eq)] 6 | pub struct Transaction { 7 | pub id: Uuid, 8 | pub source_account_id: Uuid, 9 | pub destination_account_id: Uuid, 10 | pub amount_cents: i64, 11 | pub created_at: Option, 12 | } 13 | -------------------------------------------------------------------------------- /src/api/routes/transaction_routes.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Router, 3 | routing::{get, post}, 4 | }; 5 | 6 | use crate::{ 7 | api::handlers::transaction_handlers::{get_transaction_handler, transfer_handler}, 8 | application::state::SharedState, 9 | }; 10 | 11 | pub fn routes() -> Router { 12 | Router::new() 13 | .route("/transfer", post(transfer_handler)) 14 | .route("/{id}", get(get_transaction_handler)) 15 | } 16 | -------------------------------------------------------------------------------- /src/application/constants.rs: -------------------------------------------------------------------------------- 1 | // Roles. 2 | pub const USER_ROLE_ADMIN: &str = "admin"; 3 | pub const USER_ROLE_CUSTOMER: &str = "customer"; 4 | pub const USER_ROLE_GUEST: &str = "guest"; 5 | 6 | // JWT related constants. 7 | pub const JWT_REDIS_REVOKE_GLOBAL_BEFORE_KEY: &str = "jwt.revoke.global.before"; 8 | pub const JWT_REDIS_REVOKE_USER_BEFORE_KEY: &str = "jwt.revoke.user.before"; 9 | pub const JWT_REDIS_REVOKED_TOKENS_KEY: &str = "jwt.revoked.tokens"; 10 | -------------------------------------------------------------------------------- /.env_test: -------------------------------------------------------------------------------- 1 | ENV_TEST = 1 2 | 3 | SERVICE_HOST = 127.0.0.1 4 | SERVICE_PORT = 8080 5 | 6 | REDIS_HOST = 127.0.0.1 7 | REDIS_PORT = 6379 8 | 9 | POSTGRES_USER = admin 10 | POSTGRES_PASSWORD = pswd1234 11 | POSTGRES_HOST = 127.0.0.1 12 | POSTGRES_PORT = 5432 13 | POSTGRES_DB = axum_web 14 | POSTGRES_CONNECTION_POOL = 5 15 | 16 | JWT_SECRET = h3eX9ZaWGQr1uwd8MkzbxA2yQmBPvSfK 17 | JWT_EXPIRE_ACCESS_TOKEN_SECONDS = 2 18 | JWT_EXPIRE_REFRESH_TOKEN_SECONDS = 5 19 | JWT_VALIDATION_LEEWAY_SECONDS = 1 20 | JWT_ENABLE_REVOKED_TOKENS = true -------------------------------------------------------------------------------- /tests/common/constants.rs: -------------------------------------------------------------------------------- 1 | pub const API_V1: &str = "v1"; 2 | pub const API_PATH_HEALTH: &str = "health"; 3 | pub const API_PATH_VERSION: &str = "version"; 4 | pub const API_PATH_AUTH: &str = "auth"; 5 | pub const API_PATH_USERS: &str = "users"; 6 | pub const API_PATH_ACCOUNTS: &str = "accounts"; 7 | pub const API_PATH_TRANSACTIONS: &str = "transactions"; 8 | 9 | pub const TEST_ADMIN_USERNAME: &str = "admin"; 10 | pub const TEST_ADMIN_PASSWORD_HASH: &str = 11 | "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51"; 12 | -------------------------------------------------------------------------------- /.env_test_docker: -------------------------------------------------------------------------------- 1 | ENV_TEST = 1 2 | RUST_TEST_THREADS = 1 3 | 4 | SERVICE_HOST = 127.0.0.1 5 | SERVICE_PORT = 8080 6 | 7 | REDIS_HOST = redis 8 | REDIS_PORT = 6379 9 | 10 | POSTGRES_USER = admin 11 | POSTGRES_PASSWORD = pswd1234 12 | POSTGRES_HOST = postgres 13 | POSTGRES_PORT = 5432 14 | POSTGRES_DB = axum_web 15 | POSTGRES_CONNECTION_POOL = 5 16 | 17 | JWT_SECRET = h3eX9ZaWGQr1uwd8MkzbxA2yQmBPvSfK 18 | JWT_EXPIRE_ACCESS_TOKEN_SECONDS = 2 19 | JWT_EXPIRE_REFRESH_TOKEN_SECONDS = 5 20 | JWT_VALIDATION_LEEWAY_SECONDS = 1 21 | JWT_ENABLE_REVOKED_TOKENS = true -------------------------------------------------------------------------------- /.env_docker: -------------------------------------------------------------------------------- 1 | SERVICE_HOST = 0.0.0.0 2 | SERVICE_PORT = 8080 3 | 4 | REDIS_HOST = redis 5 | REDIS_PORT = 6379 6 | 7 | POSTGRES_USER = admin 8 | POSTGRES_PASSWORD = pswd1234 9 | POSTGRES_HOST = postgres 10 | POSTGRES_PORT = 5432 11 | POSTGRES_DB = axum_web 12 | POSTGRES_CONNECTION_POOL = 5 13 | 14 | JWT_SECRET = h3EX6ZaWGrR9uwd8MkzbxA2yQmBPvCfn 15 | JWT_EXPIRE_ACCESS_TOKEN_SECONDS = 3600 # 1 hour 16 | JWT_EXPIRE_REFRESH_TOKEN_SECONDS = 7776000 # 90 days 17 | JWT_VALIDATION_LEEWAY_SECONDS = 60 # 1 minute, default 18 | JWT_ENABLE_REVOKED_TOKENS = true # using revoked tokens -------------------------------------------------------------------------------- /docker-compose.full.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | volumes: 5 | - redis:/data 6 | ports: 7 | - '6379:6379' 8 | 9 | postgres: 10 | image: postgres:17-alpine 11 | restart: always 12 | volumes: 13 | - postgres:/var/lib/postgresql/data 14 | env_file: 15 | - .env_docker 16 | ports: 17 | - "5432:5432" 18 | 19 | api: 20 | build: . 21 | depends_on: 22 | - postgres 23 | - redis 24 | ports: 25 | - "8080:8080" 26 | env_file: 27 | - .env_docker 28 | 29 | volumes: 30 | redis: 31 | postgres: -------------------------------------------------------------------------------- /src/api/routes/user_routes.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Router, 3 | routing::{delete, get, post, put}, 4 | }; 5 | 6 | use crate::{ 7 | api::handlers::user_handlers::{ 8 | add_user_handler, delete_user_handler, get_user_handler, list_users_handler, 9 | update_user_handler, 10 | }, 11 | application::state::SharedState, 12 | }; 13 | 14 | pub fn routes() -> Router { 15 | Router::new() 16 | .route("/", get(list_users_handler)) 17 | .route("/", post(add_user_handler)) 18 | .route("/{id}", get(get_user_handler)) 19 | .route("/{id}", put(update_user_handler)) 20 | .route("/{id}", delete(delete_user_handler)) 21 | } 22 | -------------------------------------------------------------------------------- /src/domain/models/user.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use serde::{Deserialize, Serialize}; 3 | use sqlx::{FromRow, types::Uuid}; 4 | 5 | use crate::application::security; 6 | 7 | #[derive(Debug, FromRow, Serialize, Deserialize, PartialEq, Eq, Clone)] 8 | pub struct User { 9 | pub id: Uuid, 10 | pub username: String, 11 | pub email: String, 12 | pub password_hash: String, 13 | pub password_salt: String, 14 | pub active: bool, 15 | pub roles: String, 16 | pub created_at: Option, 17 | pub updated_at: Option, 18 | } 19 | 20 | impl User { 21 | pub fn is_admin(&self) -> bool { 22 | security::roles::contains_role_admin(&self.roles) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/routes/account_routes.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Router, 3 | routing::{delete, get, post, put}, 4 | }; 5 | 6 | use crate::{ 7 | api::handlers::account_handlers::{ 8 | add_account_handler, delete_account_handler, get_account_handler, list_accounts_handler, 9 | update_account_handler, 10 | }, 11 | application::state::SharedState, 12 | }; 13 | 14 | pub fn routes() -> Router { 15 | Router::new() 16 | .route("/", get(list_accounts_handler)) 17 | .route("/", post(add_account_handler)) 18 | .route("/{id}", get(get_account_handler)) 19 | .route("/{id}", put(update_account_handler)) 20 | .route("/{id}", delete(delete_account_handler)) 21 | } 22 | -------------------------------------------------------------------------------- /src/api/routes/auth_routes.rs: -------------------------------------------------------------------------------- 1 | use axum::{Router, routing::post}; 2 | 3 | use crate::{ 4 | api::handlers::auth_handlers::{ 5 | cleanup_handler, login_handler, logout_handler, refresh_handler, revoke_all_handler, 6 | revoke_user_handler, 7 | }, 8 | application::state::SharedState, 9 | }; 10 | 11 | pub fn routes() -> Router { 12 | Router::new() 13 | .route("/login", post(login_handler)) 14 | .route("/logout", post(logout_handler)) 15 | .route("/refresh", post(refresh_handler)) 16 | .route("/revoke-all", post(revoke_all_handler)) 17 | .route("/revoke-user", post(revoke_user_handler)) 18 | .route("/cleanup", post(cleanup_handler)) 19 | } 20 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 2 | 3 | use axum_web::application::app; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | // Tracing configuration. 8 | let filter_layer = tracing_subscriber::EnvFilter::try_from_default_env() 9 | .unwrap_or_else(|_| "axum_web=trace".into()); 10 | let fmt_layer = tracing_subscriber::fmt::layer() 11 | .compact() 12 | .with_target(false) 13 | .with_file(true) 14 | .with_line_number(true); 15 | tracing_subscriber::registry() 16 | .with(filter_layer) 17 | .with(fmt_layer) 18 | .init(); 19 | 20 | tracing::info!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); 21 | 22 | app::run().await; 23 | } 24 | -------------------------------------------------------------------------------- /src/infrastructure/redis/connection.rs: -------------------------------------------------------------------------------- 1 | use redis::aio::MultiplexedConnection; 2 | 3 | use crate::application::config::Config; 4 | 5 | pub async fn open(config: &Config) -> MultiplexedConnection { 6 | match redis::Client::open(config.redis_url()) { 7 | Ok(redis) => match redis.get_multiplexed_async_connection().await { 8 | Ok(connection) => { 9 | tracing::info!("Connected to redis"); 10 | connection 11 | } 12 | Err(e) => { 13 | tracing::error!("Could not connect to redis: {}", e); 14 | std::process::exit(1); 15 | } 16 | }, 17 | Err(e) => { 18 | tracing::error!("Could not open redis: {}", e); 19 | std::process::exit(1); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/common/root.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | 3 | use crate::common::{TestResult, helpers}; 4 | 5 | // Fetch the root using `reqwest`. 6 | pub async fn fetch_root(access_token: &str) -> TestResult { 7 | let url = helpers::config().service_http_addr(); 8 | 9 | let authorization = format!("Bearer {}", access_token); 10 | let response = reqwest::Client::new() 11 | .get(&url) 12 | .header("Authorization", authorization) 13 | .send() 14 | .await?; 15 | 16 | let response_status = response.status(); 17 | if response_status == StatusCode::OK { 18 | let found = response.text().await.unwrap(); 19 | let expected = r#"{"message":"Hello from Axum-Web!"}"#; 20 | assert_eq!(found, expected); 21 | } 22 | Ok(response_status) 23 | } 24 | -------------------------------------------------------------------------------- /tests/version_test.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | constants::{API_PATH_VERSION, API_V1}, 7 | helpers, test_app, 8 | }; 9 | 10 | #[tokio::test] 11 | #[serial] 12 | async fn version_test() { 13 | // Start API server. 14 | let test_db = test_app::run().await; 15 | 16 | let url = helpers::build_path(API_V1, API_PATH_VERSION); 17 | let response = reqwest::get(url.as_str()).await.unwrap(); 18 | assert_eq!(response.status(), StatusCode::OK); 19 | 20 | let body = response.text().await.unwrap(); 21 | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); 22 | assert_eq!(json["name"], env!("CARGO_PKG_NAME")); 23 | assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); 24 | 25 | // Drop test database. 26 | test_db.drop().await.unwrap(); 27 | } 28 | -------------------------------------------------------------------------------- /tests/common/error.rs: -------------------------------------------------------------------------------- 1 | use axum_web::api::APIError; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum TestError { 6 | #[error("API error: {0}")] 7 | APIError(APIError), 8 | #[error("Network error: {0}")] 9 | NetworkError(reqwest::Error), 10 | #[error("Unexpected response status: {}", response.status())] 11 | UnexpectedResponse { response: reqwest::Response }, 12 | } 13 | 14 | impl From for TestError { 15 | fn from(error: APIError) -> Self { 16 | Self::APIError(error) 17 | } 18 | } 19 | 20 | impl From for TestError { 21 | fn from(error: reqwest::Error) -> Self { 22 | Self::NetworkError(error) 23 | } 24 | } 25 | 26 | impl From for TestError { 27 | fn from(response: reqwest::Response) -> Self { 28 | Self::UnexpectedResponse { response } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/application/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use crate::{ 6 | api::server, 7 | application::{config, state::AppState}, 8 | infrastructure::{database::Database, redis}, 9 | }; 10 | 11 | pub async fn run() { 12 | // Load configuration. 13 | let config = config::load(); 14 | 15 | // Connect to Redis. 16 | let redis = redis::open(&config).await; 17 | 18 | // Connect to PostgreSQL. 19 | let db_pool = Database::connect(config.clone().into()) 20 | .await 21 | .expect("Failed to connect to the database."); 22 | 23 | // Run migrations. 24 | Database::migrate(&db_pool) 25 | .await 26 | .expect("Failed to run database migrations."); 27 | 28 | // Build the application state. 29 | let shared_state = Arc::new(AppState { 30 | config, 31 | db_pool, 32 | redis: Mutex::new(redis), 33 | }); 34 | 35 | server::start(shared_state).await; 36 | } 37 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/migrations/20231113222801_db.sql: -------------------------------------------------------------------------------- 1 | -- create users table 2 | CREATE TABLE users ( 3 | id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 4 | username TEXT NOT NULL, 5 | email TEXT UNIQUE NOT NULL, 6 | password_hash TEXT NOT NULL, 7 | password_salt TEXT NOT NULL, 8 | active BOOLEAN NOT NULL DEFAULT TRUE, 9 | roles TEXT NOT NULL, 10 | created_at TIMESTAMP NOT NULL, 11 | updated_at TIMESTAMP NOT NULL 12 | ); 13 | -- populate users table 14 | INSERT INTO users ( 15 | username, 16 | email, 17 | password_hash, 18 | password_salt, 19 | active, 20 | roles, 21 | created_at, 22 | updated_at 23 | ) 24 | VALUES ( 25 | 'admin', 26 | 'admin@admin.com', 27 | -- password: pswd1234, hash(pswd1234pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF) 28 | '7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51', 29 | 'pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF', 30 | 'true', 31 | 'admin', 32 | now(), 33 | now() 34 | ); -------------------------------------------------------------------------------- /tests/health_test.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | constants::{API_PATH_HEALTH, API_V1}, 7 | helpers, 8 | hyper_fetch::hyper_fetch, 9 | test_app, 10 | }; 11 | 12 | #[tokio::test] 13 | #[serial] 14 | async fn health_test() { 15 | // Start API server. 16 | let test_db = test_app::run().await; 17 | 18 | let url = helpers::build_path(API_V1, API_PATH_HEALTH); 19 | 20 | // Fetch using `reqwest`. 21 | let response = reqwest::get(url.as_str()).await.unwrap(); 22 | assert_eq!(response.status(), StatusCode::OK); 23 | let body = response.text().await.unwrap(); 24 | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); 25 | assert_eq!(json["status"], "healthy"); 26 | 27 | // Fetch using `hyper`. 28 | let body = hyper_fetch(url.as_str()).await.unwrap(); 29 | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); 30 | assert_eq!(json["status"], "healthy"); 31 | 32 | // Drop test database. 33 | test_db.drop().await.unwrap(); 34 | } 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.1.7 (2025-12-14) 6 | 7 | * chore: updated to axum 0.8.7 and Rust v1.92 8 | * chore: updated other dependencies 9 | * security fix: using `aws_lc_rs` crypto backend for `jsonwebtoken` to prevent RUSTSEC-2023-0071 10 | 11 | ## 0.1.6 (2025-09-22) 12 | 13 | * refactor: upgrade to Rust v1.90 14 | * chore: updated dependencies 15 | 16 | ## 0.1.5 (2025-09-17) 17 | 18 | * security fix: tracing-subscriber upgraded to prevent CVE-2025-58160 19 | * refactor: upgrade to Rust v1.89 20 | * chore: updated dependencies 21 | 22 | ## 0.1.4 (2025-04-09) 23 | 24 | * chore: updated dependencies 25 | 26 | ## 0.1.3 (2025-02-20) 27 | 28 | * refactor: upgrade to Rust v1.85, edition 2024 29 | * refactor: error handling 30 | * docs: updated readme, changelog and API documentation 31 | 32 | ## 0.1.2 (2025-01-29) 33 | 34 | * feat: transaction samples. 35 | * feat: structured error response. 36 | 37 | ## 0.1.1 (2025-01-04) 38 | 39 | * fix: breaking changes after upgrading to axum 0.8 40 | 41 | ## 0.1.0 (2023-11-09) 42 | 43 | * project started. 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sheroz Khaydarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/options.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub struct PostgresOptions { 3 | /// Database name. 4 | pub db: String, 5 | 6 | /// Database host. 7 | pub host: String, 8 | 9 | /// Database port. 10 | pub port: u16, 11 | 12 | /// Database user. 13 | pub user: String, 14 | 15 | /// Database password. 16 | pub password: String, 17 | 18 | /// Max connections (connection pool). 19 | pub max_connections: u32, 20 | } 21 | 22 | impl PostgresOptions { 23 | pub fn connection_url(&self) -> String { 24 | format!( 25 | "postgresql://{}:{}@{}:{}/{}", 26 | self.user, self.password, self.host, self.port, self.db 27 | ) 28 | } 29 | 30 | pub fn set_db(&mut self, postgres_db: &str) { 31 | self.db = postgres_db.to_owned() 32 | } 33 | 34 | pub fn db(&self) -> String { 35 | self.db.clone() 36 | } 37 | 38 | pub const fn set_max_connections(&mut self, max_connections: u32) { 39 | self.max_connections = max_connections 40 | } 41 | 42 | pub const fn max_connections(&self) -> u32 { 43 | self.max_connections 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/application/repository/transaction_repo.rs: -------------------------------------------------------------------------------- 1 | use sqlx::query_as; 2 | use uuid::Uuid; 3 | 4 | use crate::{ 5 | application::{repository::RepositoryResult, state::SharedState}, 6 | domain::models::transaction::Transaction, 7 | infrastructure::database::DatabaseConnection, 8 | }; 9 | 10 | pub async fn get_by_id(id: Uuid, state: &SharedState) -> RepositoryResult { 11 | let transaction = sqlx::query_as::<_, Transaction>("SELECT * FROM transactions WHERE id = $1") 12 | .bind(id) 13 | .fetch_one(&state.db_pool) 14 | .await?; 15 | 16 | Ok(transaction) 17 | } 18 | 19 | pub async fn add( 20 | source_account_id: Uuid, 21 | destination_account_id: Uuid, 22 | amount_cents: i64, 23 | connection: &mut DatabaseConnection, 24 | ) -> RepositoryResult { 25 | let transaction = query_as::<_, Transaction>( 26 | r#"INSERT INTO transactions (source_account_id, destination_account_id, amount_cents) 27 | VALUES ($1, $2, $3) 28 | RETURNING transactions.*"#, 29 | ) 30 | .bind(source_account_id) 31 | .bind(destination_account_id) 32 | .bind(amount_cents) 33 | .fetch_one(connection) 34 | .await?; 35 | 36 | Ok(transaction) 37 | } 38 | -------------------------------------------------------------------------------- /tests/common/hyper_fetch.rs: -------------------------------------------------------------------------------- 1 | use bytes::{Buf, Bytes}; 2 | use http_body_util::{BodyExt, Empty}; 3 | use hyper::Request; 4 | use hyper_util::rt::TokioIo; 5 | use tokio::net::TcpStream; 6 | 7 | // Fetch using `hyper`. 8 | pub async fn hyper_fetch(url: &str) -> Result> { 9 | let uri = url.parse::().unwrap(); 10 | let host = uri.host().expect("uri has no host"); 11 | let port = uri.port_u16().unwrap_or(80); 12 | let addr = format!("{}:{}", host, port); 13 | 14 | let stream = TcpStream::connect(addr).await?; 15 | let io = TokioIo::new(stream); 16 | 17 | let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?; 18 | tokio::task::spawn(async move { 19 | if let Err(err) = conn.await { 20 | println!("Connection failed: {:?}", err); 21 | } 22 | }); 23 | 24 | let authority = uri.authority().unwrap().clone(); 25 | 26 | // Fetch the url. 27 | let req = Request::builder() 28 | .uri(uri) 29 | .header(hyper::header::HOST, authority.as_str()) 30 | .body(Empty::::new())?; 31 | 32 | let res = sender.send_request(req).await?; 33 | 34 | // Asynchronously aggregate the chunks of the body. 35 | let body = res.collect().await?.aggregate(); 36 | let content = String::from_utf8(body.chunk().to_vec())?; 37 | Ok(content) 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: check & build & test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | cargo-deny: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: EmbarkStudios/cargo-deny-action@v2 18 | with: 19 | rust-version: "1.92" 20 | 21 | cargo-fmt-clippy: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Update the Rust toolchain 26 | run: rustup default 1.92 && rustup component add rustfmt && rustup component add clippy && rustc --version 27 | - name: Cargo formatting check 28 | run: cargo fmt --all -- --check 29 | - name: Cargo clippy check 30 | run: cargo clippy --all-targets --all-features 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Build and run the docker services 37 | run: docker compose up -d 38 | - name: Update the Rust toolchain 39 | run: rustup default 1.92 && rustc --version 40 | - name: Run tests 41 | run: cargo test --verbose 42 | 43 | build: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Update the Rust toolchain 48 | run: rustup default 1.92 && rustc --version 49 | - name: Build 50 | run: cargo build --verbose 51 | -------------------------------------------------------------------------------- /tests/auth_logout_test.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | auth, 7 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 8 | helpers, root, test_app, 9 | }; 10 | 11 | #[tokio::test] 12 | #[serial] 13 | async fn logout_test() { 14 | // Start API server. 15 | let test_db = test_app::run().await; 16 | 17 | let config = helpers::config(); 18 | 19 | // Assert that revoked options are enabled. 20 | assert!(config.jwt_enable_revoked_tokens); 21 | 22 | // Try unauthorized access to the root handler. 23 | assert_eq!( 24 | root::fetch_root("").await.unwrap(), 25 | StatusCode::UNAUTHORIZED 26 | ); 27 | 28 | // Login as an admin. 29 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 30 | .await 31 | .expect("Login error."); 32 | 33 | // Access to the root handler. 34 | assert_eq!( 35 | root::fetch_root(&tokens.access_token).await.unwrap(), 36 | StatusCode::OK 37 | ); 38 | 39 | // Logout. 40 | assert_eq!( 41 | auth::logout(&tokens.refresh_token).await.unwrap(), 42 | StatusCode::OK 43 | ); 44 | 45 | // Try access to the root handler after logout. 46 | assert_eq!( 47 | root::fetch_root(&tokens.access_token).await.unwrap(), 48 | StatusCode::UNAUTHORIZED 49 | ); 50 | 51 | // Drop test database. 52 | test_db.drop().await.unwrap(); 53 | } 54 | -------------------------------------------------------------------------------- /src/infrastructure/database/database.rs: -------------------------------------------------------------------------------- 1 | use sqlx::{PgConnection, PgPool}; 2 | use thiserror::Error; 3 | 4 | use crate::infrastructure::database::postgres::{PostgresDatabase, PostgresOptions}; 5 | 6 | pub type DatabasePool = PgPool; 7 | pub type DatabaseConnection = PgConnection; 8 | pub type TestDatabase = PostgresDatabase; 9 | 10 | #[derive(Clone, Debug)] 11 | pub struct DatabaseOptions { 12 | pub postgres: PostgresOptions, 13 | } 14 | 15 | pub struct Database; 16 | 17 | impl Database { 18 | pub async fn connect(options: DatabaseOptions) -> Result { 19 | let db = PostgresDatabase::connect(options).await?; 20 | Ok(db.pool().clone()) 21 | } 22 | 23 | pub async fn open_test_database( 24 | options: DatabaseOptions, 25 | ) -> Result { 26 | // Create a test database. 27 | let db = PostgresDatabase::connect_test(options).await?; 28 | 29 | // Run database migrations. 30 | Self::migrate(db.pool()).await?; 31 | 32 | Ok(db) 33 | } 34 | 35 | pub async fn migrate(pool: &DatabasePool) -> Result<(), DatabaseError> { 36 | sqlx::migrate!("src/infrastructure/database/postgres/migrations") 37 | .run(pool) 38 | .await?; 39 | 40 | Ok(()) 41 | } 42 | } 43 | 44 | #[derive(Error, Debug)] 45 | pub enum DatabaseError { 46 | #[error(transparent)] 47 | SQLxError(#[from] sqlx::Error), 48 | #[error(transparent)] 49 | SQLxMigrateError(#[from] sqlx::migrate::MigrateError), 50 | } 51 | -------------------------------------------------------------------------------- /tests/auth_login_test.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | auth, 7 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 8 | root, test_app, 9 | }; 10 | 11 | #[tokio::test] 12 | #[serial] 13 | async fn login_test() { 14 | // Start API server. 15 | let test_db = test_app::run().await; 16 | 17 | // Try unauthorized access to the root handler. 18 | assert_eq!( 19 | root::fetch_root("").await.unwrap(), 20 | StatusCode::UNAUTHORIZED 21 | ); 22 | 23 | let username_wrong = format!("{}1", TEST_ADMIN_USERNAME); 24 | let result = auth::login(&username_wrong, TEST_ADMIN_PASSWORD_HASH).await; 25 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 26 | 27 | let password_wrong = format!("{}1", TEST_ADMIN_PASSWORD_HASH); 28 | let result = auth::login(TEST_ADMIN_USERNAME, &password_wrong).await; 29 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 30 | 31 | let result = auth::login(&username_wrong, &password_wrong).await; 32 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 33 | 34 | // Login as an admin. 35 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 36 | .await 37 | .expect("Login error."); 38 | 39 | // Access to the root handler. 40 | assert_eq!( 41 | root::fetch_root(&tokens.access_token).await.unwrap(), 42 | StatusCode::OK 43 | ); 44 | 45 | // Drop test database. 46 | test_db.drop().await.unwrap(); 47 | } 48 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Configuration for the Axum REST API Sample. 2 | 3 | # Service configuration. 4 | # The hostname or IP address where the service is running. 5 | SERVICE_HOST = 127.0.0.1 6 | # The port number on which the service listens. 7 | SERVICE_PORT = 8080 8 | 9 | # Redis configuration. 10 | # The hostname or IP address of the Redis server. 11 | REDIS_HOST = 127.0.0.1 12 | # The port number on which the Redis server listens. 13 | REDIS_PORT = 6379 14 | 15 | # PostgreSQL configuration. 16 | # The username for connecting to the PostgreSQL database. 17 | POSTGRES_USER = admin 18 | # The password for connecting to the PostgreSQL database. 19 | POSTGRES_PASSWORD = pswd1234 20 | # The hostname or IP address of the PostgreSQL server. 21 | POSTGRES_HOST = 127.0.0.1 22 | # The port number on which the PostgreSQL server listens. 23 | POSTGRES_PORT = 5432 24 | # The name of the PostgreSQL database. 25 | POSTGRES_DB = axum_web 26 | # The number of connections in the PostgreSQL connection pool. 27 | POSTGRES_CONNECTION_POOL = 5 28 | 29 | # JWT (JSON Web Token) configuration. 30 | # The secret key used for signing JWTs. 31 | JWT_SECRET = h3EX6ZaWGrR9uwd8MkzbxA2yQmBPvCfn 32 | # The expiration time for access tokens in seconds. 33 | JWT_EXPIRE_ACCESS_TOKEN_SECONDS = 3600 # 1 hour 34 | # The expiration time for refresh tokens in seconds. 35 | JWT_EXPIRE_REFRESH_TOKEN_SECONDS = 7776000 # 90 days 36 | # The leeway time in seconds for validating JWTs. 37 | JWT_VALIDATION_LEEWAY_SECONDS = 60 # 1 minute, default 38 | # Enable or disable the use of revoked tokens. 39 | # Set to 'true' to allow revoked tokens, 'false' to disallow. 40 | JWT_ENABLE_REVOKED_TOKENS = true 41 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-web" 3 | version = "0.1.7" 4 | edition = "2024" 5 | authors = ["Sheroz Khaydarov"] 6 | description = "A sample starter project for building REST API Web service in Rust using axum, JWT, SQLx, PostgreSQL, and Redis" 7 | readme = "README.md" 8 | repository = "https://github.com/sheroz/axum-web" 9 | license = "MIT" 10 | rust-version = "1.92" 11 | 12 | [dependencies] 13 | dotenvy = "0.15" 14 | axum = { version = "0.8" } 15 | axum-extra = { version = "0.12", features = ["typed-header"] } 16 | tokio = { version = "1.48", features = ["full"] } 17 | bytes = "1.10" 18 | tower-http = { version = "0.6", features = ["cors"] } 19 | tracing = { version = "0.1", features = ["attributes"] } 20 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 21 | http-body-util = { version = "0.1" } 22 | hyper = { version = "1.6", features = ["full"] } 23 | hyper-util = { version = "0.1", features = ["full"] } 24 | 25 | serde = { version = "1.0", features = ["derive"] } 26 | serde_json = "1.0" 27 | uuid = { version = "1.16", features = [ 28 | "v4", 29 | "fast-rng", 30 | "macro-diagnostics", 31 | "serde", 32 | ] } 33 | chrono = { version = "0.4", features = ["serde"] } 34 | thiserror = "2" 35 | 36 | redis = { version = "1.0", features = ["tokio-comp"] } 37 | sqlx = { version = "0.8", features = [ 38 | "runtime-tokio-rustls", 39 | "postgres", 40 | "uuid", 41 | "macros", 42 | "chrono", 43 | ] } 44 | 45 | jsonwebtoken = {version = "10", default-features = false, features = ["aws_lc_rs"] } 46 | 47 | [dev-dependencies] 48 | serial_test = "3.2" 49 | reqwest = { version = "0.12", features = ["json"] } 50 | -------------------------------------------------------------------------------- /src/application/security/roles.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::application::{ 4 | constants::{USER_ROLE_ADMIN, USER_ROLE_CUSTOMER, USER_ROLE_GUEST}, 5 | security::auth::AuthError, 6 | }; 7 | 8 | /// User roles. 9 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 10 | pub enum UserRole { 11 | Admin, 12 | Customer, 13 | Guest, 14 | } 15 | 16 | impl TryFrom<&str> for UserRole { 17 | type Error = &'static str; 18 | 19 | fn try_from(value: &str) -> Result { 20 | match value { 21 | USER_ROLE_ADMIN => Ok(Self::Admin), 22 | USER_ROLE_CUSTOMER => Ok(Self::Customer), 23 | USER_ROLE_GUEST => Ok(Self::Guest), 24 | _ => Err("Unknown role"), 25 | } 26 | } 27 | } 28 | 29 | impl Display for UserRole { 30 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 31 | match self { 32 | Self::Admin => write!(f, "{}", USER_ROLE_ADMIN), 33 | Self::Customer => write!(f, "{}", USER_ROLE_CUSTOMER), 34 | Self::Guest => write!(f, "{}", USER_ROLE_GUEST), 35 | } 36 | } 37 | } 38 | 39 | impl UserRole { 40 | pub fn is_role_admin(&self) -> bool { 41 | *self == Self::Admin 42 | } 43 | } 44 | 45 | pub fn contains_role_admin(roles: &str) -> bool { 46 | if roles.is_empty() { 47 | return false; 48 | } 49 | 50 | let role_admin = UserRole::Admin.to_string(); 51 | roles.split(',').map(|s| s.trim()).any(|x| x == role_admin) 52 | } 53 | 54 | pub fn is_role_admin(roles: &str) -> Result<(), AuthError> { 55 | if !contains_role_admin(roles) { 56 | return Err(AuthError::Forbidden); 57 | } 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /PROJECT_CONTEXT.md: -------------------------------------------------------------------------------- 1 | # Axum REST API Sample — Project Context 2 | 3 | - **Rust (2024), Axum, async/Tokio** 4 | - **Purpose:** Modular, production-ready REST API template 5 | - **Features:** JWT auth (refresh, revoke), SQLx/Postgres, Redis, error handling, API versioning, Docker, CI/CD, E2E tests 6 | 7 | **Structure:** 8 | - `src/api/`: HTTP, routes, handlers, error, version 9 | - `src/application/`: business logic, services, security, repo, config, state 10 | - `src/domain/models/`: User, Account, Transaction 11 | - `src/infrastructure/`: Postgres (migrations, queries), Redis 12 | - Entry: `src/main.rs` → `application::app::run()` 13 | - Exports: `src/lib.rs` 14 | 15 | **API:** (see `docs/api-docs.md`) 16 | - `/v1/` endpoints: auth (login, refresh, logout, revoke, cleanup), users (CRUD), accounts (CRUD), transactions (transfer, get), health, version 17 | - JWT (access/refresh), roles in claims, RBAC 18 | - Structured JSON errors (code, kind, trace, doc_url) 19 | 20 | **Security:** 21 | - JWT revocation (Redis), refresh rotation, RBAC, CORS, error hygiene, graceful shutdown 22 | 23 | **Testing:** 24 | - `tests/`: auth, user, account, transaction, health, version, E2E HTTP, test DB, isolation, `tests/common/` utils, `endpoints.http` samples 25 | 26 | **Dev/Deploy:** 27 | - Local: `docker-compose up -d`, `cargo run`, `.env` 28 | - Test: `cargo test` 29 | - Full stack: `docker-compose -f docker-compose.full.yml up -d` 30 | - CI: GitHub Actions (lint, audit, test, build) 31 | 32 | **Quick Reference:** 33 | - Main: `src/main.rs` 34 | - Routes: `src/api/routes/` 35 | - Auth: `src/application/security/` 36 | - Errors: `src/api/error.rs` 37 | - DB: `src/infrastructure/database/postgres/migrations/` 38 | - Redis: `src/infrastructure/redis/` 39 | - Docs: `docs/api-docs.md` 40 | - Tests: `tests/` 41 | - Config: `.env` 42 | -------------------------------------------------------------------------------- /tests/common/transactions.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use uuid::Uuid; 3 | 4 | use axum_web::{ 5 | api::handlers::transaction_handlers::TransferOrder, domain::models::transaction::Transaction, 6 | }; 7 | 8 | use crate::common::{ 9 | TestResult, 10 | constants::{API_PATH_TRANSACTIONS, API_V1}, 11 | helpers, 12 | }; 13 | 14 | pub async fn get(transaction_id: Uuid, access_token: &str) -> TestResult { 15 | let url = helpers::build_url(API_V1, API_PATH_TRANSACTIONS, &transaction_id.to_string()); 16 | 17 | let authorization = format!("Bearer {}", access_token); 18 | let response = reqwest::Client::new() 19 | .get(url.as_str()) 20 | .header("Accept", "application/json") 21 | .header("Authorization", authorization) 22 | .send() 23 | .await?; 24 | 25 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 26 | .await 27 | .map(|v| v.unwrap()) 28 | } 29 | 30 | pub async fn transfer( 31 | source_account_id: Uuid, 32 | destination_account_id: Uuid, 33 | amount_cents: i64, 34 | access_token: &str, 35 | ) -> TestResult { 36 | let url = helpers::build_url(API_V1, API_PATH_TRANSACTIONS, "transfer"); 37 | 38 | let transfer_order = TransferOrder { 39 | source_account_id, 40 | destination_account_id, 41 | amount_cents, 42 | }; 43 | 44 | let json_param = serde_json::json!(transfer_order); 45 | let authorization = format!("Bearer {}", access_token); 46 | let response = reqwest::Client::new() 47 | .post(url.as_str()) 48 | .header("Accept", "application/json") 49 | .header("Authorization", authorization) 50 | .json(&json_param) 51 | .send() 52 | .await?; 53 | 54 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 55 | .await 56 | .map(|v| v.unwrap()) 57 | } 58 | -------------------------------------------------------------------------------- /tests/common/test_app.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use reqwest::StatusCode; 4 | use tokio::{sync::Mutex, time::Instant}; 5 | 6 | use axum_web::{ 7 | api, 8 | application::{config, state::AppState}, 9 | infrastructure::{ 10 | database::{Database, TestDatabase}, 11 | redis, 12 | }, 13 | }; 14 | 15 | use crate::common::{ 16 | constants::{API_PATH_HEALTH, API_V1}, 17 | helpers, 18 | }; 19 | 20 | #[must_use] 21 | pub async fn run() -> TestDatabase { 22 | // Set the environment variable. 23 | unsafe { std::env::set_var("ENV_TEST", "1") }; 24 | 25 | // Load configuration. 26 | let config = config::load(); 27 | helpers::CONFIG.get_or_init(|| config.clone()); 28 | 29 | // Connect to Redis. 30 | let redis = redis::open(&config).await; 31 | 32 | // Connect to PostgreSQL. 33 | let test_database = Database::open_test_database(config.clone().into()) 34 | .await 35 | .expect("Failed to connect to the test database."); 36 | 37 | // Build the application state. 38 | let shared_state = Arc::new(AppState { 39 | config, 40 | db_pool: test_database.pool().clone(), 41 | redis: Mutex::new(redis), 42 | }); 43 | 44 | // Run the api server. 45 | tokio::spawn(async move { 46 | api::server::start(shared_state).await; 47 | }); 48 | 49 | wait_for_service(Duration::from_secs(5)).await; 50 | 51 | test_database 52 | } 53 | 54 | async fn wait_for_service(duration: Duration) { 55 | let timeout = Instant::now() + duration; 56 | loop { 57 | let url = helpers::build_path(API_V1, API_PATH_HEALTH); 58 | if let Ok(response) = reqwest::get(url.as_str()).await 59 | && response.status() == StatusCode::OK 60 | { 61 | break; 62 | } 63 | if Instant::now() > timeout { 64 | panic!("Could not start API Server in: {:?}", duration); 65 | } 66 | tokio::time::sleep(Duration::from_millis(20)).await; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/api/extractors.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | RequestPartsExt, 5 | extract::{FromRef, FromRequestParts}, 6 | http::request::Parts, 7 | }; 8 | use axum_extra::{ 9 | TypedHeader, 10 | headers::{Authorization, authorization::Bearer}, 11 | }; 12 | 13 | use crate::{ 14 | api::APIError, 15 | application::{ 16 | security::{ 17 | auth::{self, AuthError}, 18 | jwt::{AccessClaims, ClaimsMethods, RefreshClaims, decode_token}, 19 | }, 20 | state::SharedState, 21 | }, 22 | }; 23 | 24 | impl FromRequestParts for AccessClaims 25 | where 26 | SharedState: FromRef, 27 | S: Send + Sync, 28 | { 29 | type Rejection = APIError; 30 | 31 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 32 | decode_token_from_request_part(parts, state).await 33 | } 34 | } 35 | 36 | impl FromRequestParts for RefreshClaims 37 | where 38 | SharedState: FromRef, 39 | S: Send + Sync, 40 | { 41 | type Rejection = APIError; 42 | 43 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 44 | decode_token_from_request_part(parts, state).await 45 | } 46 | } 47 | 48 | async fn decode_token_from_request_part(parts: &mut Parts, state: &S) -> Result 49 | where 50 | SharedState: FromRef, 51 | S: Send + Sync, 52 | T: for<'de> serde::Deserialize<'de> + std::fmt::Debug + ClaimsMethods + Sync + Send, 53 | { 54 | // Extract the token from the authorization header. 55 | let TypedHeader(Authorization(bearer)) = parts 56 | .extract::>>() 57 | .await 58 | .map_err(|_| { 59 | tracing::error!("Invalid authorization header"); 60 | AuthError::WrongCredentials 61 | })?; 62 | 63 | // Take the state from a reference. 64 | let state = Arc::from_ref(state); 65 | 66 | // Decode the token. 67 | let claims = decode_token::(bearer.token(), &state.config)?; 68 | 69 | // Check for revoked tokens if enabled by configuration. 70 | if state.config.jwt_enable_revoked_tokens { 71 | auth::validate_revoked(&claims, &state).await? 72 | } 73 | Ok(claims) 74 | } 75 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/migrations/20250115163600_transactions.sql: -------------------------------------------------------------------------------- 1 | -- create accounts table 2 | CREATE TABLE accounts ( 3 | id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 4 | user_id UUID NOT NULL, 5 | balance_cents bigint NOT NULL, 6 | created_at TIMESTAMP NOT NULL DEFAULT now(), 7 | updated_at TIMESTAMP NOT NULL DEFAULT now() 8 | ); 9 | -- create transactions table 10 | CREATE TABLE transactions ( 11 | id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 12 | source_account_id UUID NOT NULL, 13 | destination_account_id UUID NOT NULL, 14 | amount_cents bigint NOT NULL, 15 | created_at TIMESTAMP NOT NULL DEFAULT now() 16 | ); 17 | -- add users with customer role 18 | INSERT INTO users ( 19 | username, 20 | email, 21 | password_hash, 22 | password_salt, 23 | active, 24 | roles, 25 | created_at, 26 | updated_at 27 | ) 28 | VALUES ( 29 | 'alice', 30 | 'alice@mail.com', 31 | -- password: pswd1234, hash(pswd1234pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF) 32 | '7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51', 33 | 'pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF', 34 | 'true', 35 | 'customer', 36 | now(), 37 | now() 38 | ); 39 | INSERT INTO users ( 40 | username, 41 | email, 42 | password_hash, 43 | password_salt, 44 | active, 45 | roles, 46 | created_at, 47 | updated_at 48 | ) 49 | VALUES ( 50 | 'bob', 51 | 'bob@mail.com', 52 | -- password: pswd1234, hash(pswd1234pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF) 53 | '7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51', 54 | 'pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF', 55 | 'true', 56 | 'customer', 57 | now(), 58 | now() 59 | ); 60 | -- populate accounts table with initial balance 61 | INSERT INTO accounts (user_id, balance_cents) 62 | VALUES ( 63 | ( 64 | SELECT id 65 | FROM users 66 | WHERE username = 'alice' 67 | ), 68 | 10000 69 | ); 70 | INSERT INTO accounts (user_id, balance_cents) 71 | VALUES ( 72 | ( 73 | SELECT id 74 | FROM users 75 | WHERE username = 'bob' 76 | ), 77 | 10000 78 | ); -------------------------------------------------------------------------------- /tests/auth_refresh_tests.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | auth, 7 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 8 | helpers, root, test_app, 9 | }; 10 | 11 | #[tokio::test] 12 | #[serial] 13 | async fn refresh_test() { 14 | // Start API server. 15 | let test_db = test_app::run().await; 16 | 17 | // Login as an admin. 18 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 19 | .await 20 | .expect("Login error."); 21 | 22 | // Refresh tokens. 23 | let refreshed = auth::refresh(&tokens.refresh_token) 24 | .await 25 | .expect("Auth refresh error."); 26 | 27 | assert_ne!(tokens.access_token, refreshed.access_token); 28 | assert_ne!(tokens.refresh_token, refreshed.refresh_token); 29 | 30 | // Try access to the root handler with old token. 31 | assert_eq!( 32 | root::fetch_root(&tokens.access_token).await.unwrap(), 33 | StatusCode::UNAUTHORIZED 34 | ); 35 | 36 | // Try access to the root handler with new token. 37 | assert_eq!( 38 | root::fetch_root(&refreshed.access_token).await.unwrap(), 39 | StatusCode::OK 40 | ); 41 | 42 | // Drop test database. 43 | test_db.drop().await.unwrap(); 44 | } 45 | 46 | #[tokio::test] 47 | #[serial] 48 | async fn refresh_logout_test() { 49 | // Start API server. 50 | let test_db = test_app::run().await; 51 | 52 | let config = helpers::config(); 53 | 54 | // Assert that revoked options are enabled. 55 | assert!(config.jwt_enable_revoked_tokens); 56 | 57 | // Login as an admin. 58 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 59 | .await 60 | .expect("Login error."); 61 | 62 | // Refresh tokens. 63 | let refreshed = auth::refresh(&tokens.refresh_token) 64 | .await 65 | .expect("Auth refresh error."); 66 | 67 | // Try logout with old token. 68 | assert_eq!( 69 | auth::logout(&tokens.refresh_token).await.unwrap(), 70 | StatusCode::UNAUTHORIZED 71 | ); 72 | 73 | // Logout with new token. 74 | assert_eq!( 75 | auth::logout(&refreshed.refresh_token).await.unwrap(), 76 | StatusCode::OK 77 | ); 78 | 79 | // Drop test database. 80 | test_db.drop().await.unwrap(); 81 | } 82 | -------------------------------------------------------------------------------- /src/api/version.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use axum::{ 4 | RequestPartsExt, 5 | extract::{FromRequestParts, Path}, 6 | http::{StatusCode, request::Parts}, 7 | }; 8 | use thiserror::Error; 9 | 10 | use crate::api::error::{APIError, APIErrorCode, APIErrorEntry, APIErrorKind}; 11 | 12 | #[derive(Debug, Clone, Copy)] 13 | pub enum APIVersion { 14 | V1, 15 | V2, 16 | } 17 | 18 | impl std::str::FromStr for APIVersion { 19 | type Err = (); 20 | fn from_str(s: &str) -> Result { 21 | match s { 22 | "v1" => Ok(Self::V1), 23 | "v2" => Ok(Self::V2), 24 | _ => Err(()), 25 | } 26 | } 27 | } 28 | 29 | impl std::fmt::Display for APIVersion { 30 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 31 | let v = match self { 32 | Self::V1 => "v1", 33 | Self::V2 => "v2", 34 | }; 35 | write!(f, "{}", v) 36 | } 37 | } 38 | 39 | pub fn parse_version(version: &str) -> Result { 40 | version.parse().map_or_else( 41 | |_| Err(ApiVersionError::InvalidVersion(version.to_owned()).into()), 42 | |v| Ok(v), 43 | ) 44 | } 45 | 46 | impl FromRequestParts for APIVersion 47 | where 48 | S: Send + Sync, 49 | { 50 | type Rejection = APIError; 51 | 52 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 53 | let params: Path> = parts 54 | .extract() 55 | .await 56 | .map_err(|_| ApiVersionError::VersionExtractError)?; 57 | 58 | let version = params 59 | .get("version") 60 | .ok_or(ApiVersionError::ParameterMissing)?; 61 | 62 | parse_version(version) 63 | } 64 | } 65 | 66 | #[derive(Debug, Error)] 67 | pub enum ApiVersionError { 68 | #[error("unknown version: {0}")] 69 | InvalidVersion(String), 70 | #[error("parameter is missing: version")] 71 | ParameterMissing, 72 | #[error("could not extract api version")] 73 | VersionExtractError, 74 | } 75 | 76 | impl From for APIError { 77 | fn from(err: ApiVersionError) -> Self { 78 | let error_entry = APIErrorEntry::new(&err.to_string()) 79 | .code(APIErrorCode::ApiVersionError) 80 | .kind(APIErrorKind::ValidationError); 81 | 82 | (StatusCode::BAD_REQUEST, error_entry).into() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/common/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use reqwest::{Response, StatusCode}; 4 | use serde::Deserialize; 5 | 6 | use axum_web::{api::APIError, application::config::Config}; 7 | 8 | use crate::common::{TestError, TestResult}; 9 | 10 | pub static CONFIG: OnceLock = OnceLock::new(); 11 | 12 | pub fn config() -> &'static Config { 13 | CONFIG.get().unwrap() 14 | } 15 | 16 | pub fn build_url(version: &str, path: &str, url: &str) -> reqwest::Url { 17 | let url = format!( 18 | "{}/{}/{}/{}", 19 | config().service_http_addr(), 20 | version, 21 | path, 22 | url 23 | ); 24 | reqwest::Url::parse(&url).unwrap() 25 | } 26 | 27 | pub fn build_path(version: &str, path: &str) -> reqwest::Url { 28 | let url = format!("{}/{}/{}", config().service_http_addr(), version, path); 29 | reqwest::Url::parse(&url).unwrap() 30 | } 31 | 32 | pub async fn dispatch_reqwest_response( 33 | response: Response, 34 | expected_status: StatusCode, 35 | ) -> TestResult> 36 | where 37 | T: for<'a> Deserialize<'a>, 38 | { 39 | let status = response.status(); 40 | if status == expected_status { 41 | let body = response.text().await.unwrap(); 42 | if body.is_empty() { 43 | return Ok(None); 44 | } else { 45 | let result: T = serde_json::from_str(&body).unwrap(); 46 | return Ok(Some(result)); 47 | } 48 | } 49 | 50 | if status.is_client_error() || status.is_server_error() { 51 | let body = response.text().await.unwrap(); 52 | let api_error = serde_json::from_str::(&body).unwrap(); 53 | Err(api_error)? 54 | } else { 55 | Err(TestError::UnexpectedResponse { response })? 56 | } 57 | } 58 | 59 | #[macro_export] 60 | macro_rules! assert_api_error_status { 61 | ($result:expr, $expected:expr) => { 62 | assert!($result.is_err()); 63 | let error = $result.err().unwrap(); 64 | match error { 65 | $crate::common::TestError::APIError(api_error) => { 66 | assert_eq!(api_error.status, $expected); 67 | } 68 | $crate::common::TestError::NetworkError(error) => { 69 | panic!("Unexpected network error: {}", error) 70 | } 71 | $crate::common::TestError::UnexpectedResponse { response } => panic!( 72 | "Unexpected response status. Expected: {}, Found: {}", 73 | $expected, 74 | response.status() 75 | ), 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /tests/auth_expire_tests.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | pub mod common; 5 | use common::{ 6 | auth, 7 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 8 | helpers, root, test_app, 9 | }; 10 | 11 | #[tokio::test] 12 | #[serial] 13 | async fn access_token_expire_test() { 14 | // Start API server. 15 | let test_db = test_app::run().await; 16 | 17 | let config = helpers::config(); 18 | 19 | // Assert that revoked options are enabled. 20 | assert!(config.jwt_enable_revoked_tokens); 21 | 22 | // Login as an admin. 23 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 24 | .await 25 | .expect("Login error."); 26 | 27 | // Wait to expire access token. 28 | tokio::time::sleep(tokio::time::Duration::from_secs( 29 | (config.jwt_expire_access_token_seconds + config.jwt_validation_leeway_seconds + 1) as u64, 30 | )) 31 | .await; 32 | 33 | // Check the access to the root handler with expired token. 34 | assert_eq!( 35 | root::fetch_root(&tokens.access_token).await.unwrap(), 36 | StatusCode::UNAUTHORIZED 37 | ); 38 | 39 | // Refresh tokens. 40 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 41 | .await 42 | .expect("Login error."); 43 | 44 | // Try access to the root handler with new token. 45 | assert_eq!( 46 | root::fetch_root(&tokens.access_token).await.unwrap(), 47 | StatusCode::OK 48 | ); 49 | 50 | // Drop test database. 51 | test_db.drop().await.unwrap(); 52 | } 53 | 54 | #[tokio::test] 55 | #[serial] 56 | async fn refresh_token_expire_test() { 57 | // Start API server. 58 | let test_db = test_app::run().await; 59 | 60 | let config = helpers::config(); 61 | 62 | // Assert that revoked options are enabled. 63 | assert!(config.jwt_enable_revoked_tokens); 64 | 65 | // Login as an admin. 66 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 67 | .await 68 | .expect("Login error."); 69 | 70 | // Wait to expire refresh token. 71 | tokio::time::sleep(tokio::time::Duration::from_secs( 72 | (config.jwt_expire_refresh_token_seconds + config.jwt_validation_leeway_seconds + 1) as u64, 73 | )) 74 | .await; 75 | 76 | // Try to refresh with expired token 77 | let result = auth::refresh(&tokens.refresh_token).await; 78 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 79 | 80 | // Drop test database. 81 | test_db.drop().await.unwrap(); 82 | } 83 | -------------------------------------------------------------------------------- /tests/common/accounts.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | 3 | use axum_web::domain::models::account::Account; 4 | use reqwest::StatusCode; 5 | 6 | use crate::common::{ 7 | TestResult, 8 | constants::{API_PATH_ACCOUNTS, API_V1}, 9 | helpers, 10 | }; 11 | 12 | pub async fn list(access_token: &str) -> TestResult> { 13 | let url = helpers::build_path(API_V1, API_PATH_ACCOUNTS); 14 | 15 | let authorization = format!("Bearer {}", access_token); 16 | let response = reqwest::Client::new() 17 | .get(url.as_str()) 18 | .header("Accept", "application/json") 19 | .header("Authorization", authorization) 20 | .send() 21 | .await?; 22 | 23 | helpers::dispatch_reqwest_response::>(response, StatusCode::OK) 24 | .await 25 | .map(|v| v.unwrap()) 26 | } 27 | 28 | pub async fn get(account_id: Uuid, access_token: &str) -> TestResult { 29 | let url = helpers::build_url(API_V1, API_PATH_ACCOUNTS, &account_id.to_string()); 30 | 31 | let authorization = format!("Bearer {}", access_token); 32 | let response = reqwest::Client::new() 33 | .get(url.as_str()) 34 | .header("Accept", "application/json") 35 | .header("Authorization", authorization) 36 | .send() 37 | .await?; 38 | 39 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 40 | .await 41 | .map(|v| v.unwrap()) 42 | } 43 | 44 | pub async fn add(account: Account, access_token: &str) -> TestResult { 45 | let url = helpers::build_path(API_V1, API_PATH_ACCOUNTS); 46 | let json_param = serde_json::json!(account); 47 | let authorization = format!("Bearer {}", access_token); 48 | let response = reqwest::Client::new() 49 | .post(url.as_str()) 50 | .header("Accept", "application/json") 51 | .header("Authorization", authorization) 52 | .json(&json_param) 53 | .send() 54 | .await?; 55 | 56 | helpers::dispatch_reqwest_response::(response, StatusCode::CREATED) 57 | .await 58 | .map(|v| v.unwrap()) 59 | } 60 | 61 | pub async fn update(account: Account, access_token: &str) -> TestResult { 62 | let url = helpers::build_url(API_V1, API_PATH_ACCOUNTS, &account.id.to_string()); 63 | let json_param = serde_json::json!(account); 64 | let authorization = format!("Bearer {}", access_token); 65 | let response = reqwest::Client::new() 66 | .put(url.as_str()) 67 | .header("Accept", "application/json") 68 | .header("Authorization", authorization) 69 | .json(&json_param) 70 | .send() 71 | .await?; 72 | 73 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 74 | .await 75 | .map(|v| v.unwrap()) 76 | } 77 | -------------------------------------------------------------------------------- /src/infrastructure/database/postgres/postgres.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sqlx::{PgPool, postgres::PgPoolOptions}; 3 | 4 | use crate::infrastructure::database::{DatabaseError, DatabaseOptions}; 5 | 6 | #[non_exhaustive] 7 | pub struct PostgresDatabase { 8 | pool: PgPool, 9 | options: DatabaseOptions, 10 | test_db_to_drop: Option, 11 | } 12 | 13 | impl PostgresDatabase { 14 | pub async fn connect(options: DatabaseOptions) -> Result { 15 | // Get postgres configuration. 16 | let connection_url = options.postgres.connection_url(); 17 | let max_connections = options.postgres.max_connections(); 18 | 19 | // Connect to the database and get a connection pool. 20 | let pool = PgPoolOptions::new() 21 | .max_connections(max_connections) 22 | .connect(&connection_url) 23 | .await?; 24 | 25 | tracing::info!("Connected to PostgreSQL database."); 26 | 27 | Ok(Self { 28 | pool, 29 | options, 30 | test_db_to_drop: None, 31 | }) 32 | } 33 | 34 | pub async fn connect_test(options: DatabaseOptions) -> Result { 35 | // Generate a temporary name for the test database. 36 | let nanos_since_epoch = Utc::now().timestamp_nanos_opt().unwrap(); 37 | let test_db_name = format!("tmp_{:x}", nanos_since_epoch); 38 | 39 | // Create a temporary database. 40 | { 41 | let mut options = options.clone(); 42 | options.postgres.set_max_connections(1); 43 | let db = Self::connect(options).await?; 44 | let pool = db.pool(); 45 | let query = format!("CREATE DATABASE {}", test_db_name); 46 | sqlx::query(&query).execute(pool).await?; 47 | } 48 | 49 | // Connect to the temporary database. 50 | let mut test_options = options.clone(); 51 | test_options.postgres.set_db(&test_db_name); 52 | let mut test_db = Self::connect(test_options).await?; 53 | 54 | // Prepare for the drop on close. 55 | test_db.test_db_to_drop = Some(test_db_name.to_owned()); 56 | test_db.options = options; 57 | 58 | Ok(test_db) 59 | } 60 | 61 | pub const fn pool(&self) -> &PgPool { 62 | &self.pool 63 | } 64 | 65 | pub async fn drop(&self) -> Result<(), DatabaseError> { 66 | if let Some(test_db_to_drop) = self.test_db_to_drop.as_ref() { 67 | // Close connections. 68 | self.pool.close().await; 69 | 70 | // Drop the temporary database. 71 | let db = Self::connect(self.options.clone()).await?; 72 | let pool = db.pool(); 73 | let query = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", test_db_to_drop); 74 | sqlx::query(&query).execute(pool).await?; 75 | } 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/application/repository/account_repo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sqlx::query_as; 3 | use uuid::Uuid; 4 | 5 | use crate::{ 6 | application::repository::RepositoryResult, domain::models::account::Account, 7 | infrastructure::database::DatabaseConnection, 8 | }; 9 | 10 | pub async fn list(connection: &mut DatabaseConnection) -> RepositoryResult> { 11 | let accounts = query_as::<_, Account>("SELECT * FROM accounts") 12 | .fetch_all(connection) 13 | .await?; 14 | 15 | Ok(accounts) 16 | } 17 | 18 | pub async fn add( 19 | account: Account, 20 | connection: &mut DatabaseConnection, 21 | ) -> RepositoryResult { 22 | let time_now = Utc::now().naive_utc(); 23 | tracing::trace!("account: {:#?}", account); 24 | let account = sqlx::query_as::<_, Account>( 25 | r#"INSERT INTO accounts (id, 26 | user_id, 27 | balance_cents, 28 | created_at, 29 | updated_at) 30 | VALUES ($1,$2,$3,$4,$5) 31 | RETURNING accounts.*"#, 32 | ) 33 | .bind(account.id) 34 | .bind(account.user_id) 35 | .bind(account.balance_cents) 36 | .bind(time_now) 37 | .bind(time_now) 38 | .fetch_one(connection) 39 | .await?; 40 | 41 | Ok(account) 42 | } 43 | 44 | pub async fn get_by_id(id: Uuid, connection: &mut DatabaseConnection) -> RepositoryResult { 45 | let account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = $1") 46 | .bind(id) 47 | .fetch_one(connection) 48 | .await?; 49 | 50 | Ok(account) 51 | } 52 | 53 | pub async fn get_by_user_id( 54 | user_id: Uuid, 55 | connection: &mut DatabaseConnection, 56 | ) -> RepositoryResult> { 57 | let accounts = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE user_id = $1") 58 | .bind(user_id) 59 | .fetch_all(connection) 60 | .await?; 61 | 62 | Ok(accounts) 63 | } 64 | 65 | pub async fn update( 66 | account: Account, 67 | connection: &mut DatabaseConnection, 68 | ) -> RepositoryResult { 69 | tracing::trace!("account: {:#?}", account); 70 | let time_now = Utc::now().naive_utc(); 71 | let account = sqlx::query_as::<_, Account>( 72 | r#"UPDATE accounts 73 | SET 74 | user_id = $1, 75 | balance_cents = $2, 76 | updated_at = $3 77 | WHERE id = $4 78 | RETURNING accounts.*"#, 79 | ) 80 | .bind(account.user_id) 81 | .bind(account.balance_cents) 82 | .bind(time_now) 83 | .bind(account.id) 84 | .fetch_one(connection) 85 | .await?; 86 | 87 | Ok(account) 88 | } 89 | 90 | pub async fn delete(id: Uuid, connection: &mut DatabaseConnection) -> RepositoryResult { 91 | let query_result = sqlx::query("DELETE FROM accounts WHERE id = $1") 92 | .bind(id) 93 | .execute(connection) 94 | .await?; 95 | 96 | Ok(query_result.rows_affected() == 1) 97 | } 98 | -------------------------------------------------------------------------------- /src/application/repository/user_repo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use sqlx::query_as; 3 | use uuid::Uuid; 4 | 5 | use crate::{ 6 | application::{repository::RepositoryResult, state::SharedState}, 7 | domain::models::user::User, 8 | }; 9 | 10 | pub async fn list(state: &SharedState) -> RepositoryResult> { 11 | let users = query_as::<_, User>("SELECT * FROM users") 12 | .fetch_all(&state.db_pool) 13 | .await?; 14 | 15 | Ok(users) 16 | } 17 | 18 | pub async fn add(user: User, state: &SharedState) -> RepositoryResult { 19 | let time_now = Utc::now().naive_utc(); 20 | tracing::trace!("user: {:#?}", user); 21 | let user = sqlx::query_as::<_, User>( 22 | r#"INSERT INTO users (id, 23 | username, 24 | email, 25 | password_hash, 26 | password_salt, 27 | active, 28 | roles, 29 | created_at, 30 | updated_at) 31 | VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) 32 | RETURNING users.*"#, 33 | ) 34 | .bind(user.id) 35 | .bind(user.username) 36 | .bind(user.email) 37 | .bind(user.password_hash) 38 | .bind(user.password_salt) 39 | .bind(true) 40 | .bind(user.roles) 41 | .bind(time_now) 42 | .bind(time_now) 43 | .fetch_one(&state.db_pool) 44 | .await?; 45 | 46 | Ok(user) 47 | } 48 | 49 | pub async fn get_by_id(id: Uuid, state: &SharedState) -> RepositoryResult { 50 | let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") 51 | .bind(id) 52 | .fetch_one(&state.db_pool) 53 | .await?; 54 | Ok(user) 55 | } 56 | 57 | pub async fn get_by_username(username: &str, state: &SharedState) -> RepositoryResult { 58 | let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") 59 | .bind(username) 60 | .fetch_one(&state.db_pool) 61 | .await?; 62 | 63 | Ok(user) 64 | } 65 | 66 | pub async fn update(user: User, state: &SharedState) -> RepositoryResult { 67 | tracing::trace!("user: {:#?}", user); 68 | let time_now = Utc::now().naive_utc(); 69 | let user = sqlx::query_as::<_, User>( 70 | r#"UPDATE users 71 | SET 72 | username = $1, 73 | email = $2, 74 | password_hash = $3, 75 | password_salt = $4, 76 | active = $5, 77 | roles = $6, 78 | updated_at = $7 79 | WHERE id = $8 80 | RETURNING users.*"#, 81 | ) 82 | .bind(user.username) 83 | .bind(user.email) 84 | .bind(user.password_hash) 85 | .bind(user.password_salt) 86 | .bind(user.active) 87 | .bind(user.roles) 88 | .bind(time_now) 89 | .bind(user.id) 90 | .fetch_one(&state.db_pool) 91 | .await?; 92 | 93 | Ok(user) 94 | } 95 | 96 | pub async fn delete(id: Uuid, state: &SharedState) -> RepositoryResult { 97 | let query_result = sqlx::query("DELETE FROM users WHERE id = $1") 98 | .bind(id) 99 | .execute(&state.db_pool) 100 | .await?; 101 | 102 | Ok(query_result.rows_affected() == 1) 103 | } 104 | -------------------------------------------------------------------------------- /tests/common/users.rs: -------------------------------------------------------------------------------- 1 | use axum_web::domain::models::user::User; 2 | use reqwest::StatusCode; 3 | use uuid::Uuid; 4 | 5 | use crate::common::{ 6 | TestResult, 7 | constants::{API_PATH_USERS, API_V1}, 8 | helpers, 9 | }; 10 | 11 | pub async fn list(access_token: &str) -> TestResult> { 12 | let url = helpers::build_path(API_V1, API_PATH_USERS); 13 | 14 | let authorization = format!("Bearer {}", access_token); 15 | let response = reqwest::Client::new() 16 | .get(url.as_str()) 17 | .header("Accept", "application/json") 18 | .header("Authorization", authorization) 19 | .send() 20 | .await?; 21 | 22 | helpers::dispatch_reqwest_response::>(response, StatusCode::OK) 23 | .await 24 | .map(|v| v.unwrap()) 25 | } 26 | 27 | pub async fn get(user_id: Uuid, access_token: &str) -> TestResult { 28 | let url = helpers::build_url(API_V1, API_PATH_USERS, &user_id.to_string()); 29 | 30 | let authorization = format!("Bearer {}", access_token); 31 | let response = reqwest::Client::new() 32 | .get(url.as_str()) 33 | .header("Accept", "application/json") 34 | .header("Authorization", authorization) 35 | .send() 36 | .await?; 37 | 38 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 39 | .await 40 | .map(|v| v.unwrap()) 41 | } 42 | 43 | pub async fn add(user: User, access_token: &str) -> TestResult { 44 | let url = helpers::build_path(API_V1, API_PATH_USERS); 45 | let json_param = serde_json::json!(user); 46 | let authorization = format!("Bearer {}", access_token); 47 | let response = reqwest::Client::new() 48 | .post(url.as_str()) 49 | .header("Accept", "application/json") 50 | .header("Authorization", authorization) 51 | .json(&json_param) 52 | .send() 53 | .await?; 54 | 55 | helpers::dispatch_reqwest_response::(response, StatusCode::CREATED) 56 | .await 57 | .map(|v| v.unwrap()) 58 | } 59 | 60 | pub async fn update(user: User, access_token: &str) -> TestResult { 61 | let url = helpers::build_url(API_V1, API_PATH_USERS, &user.id.to_string()); 62 | let json_param = serde_json::json!(user); 63 | let authorization = format!("Bearer {}", access_token); 64 | let response = reqwest::Client::new() 65 | .put(url.as_str()) 66 | .header("Accept", "application/json") 67 | .header("Authorization", authorization) 68 | .json(&json_param) 69 | .send() 70 | .await?; 71 | 72 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 73 | .await 74 | .map(|v| v.unwrap()) 75 | } 76 | 77 | pub async fn delete(user_id: Uuid, access_token: &str) -> TestResult<()> { 78 | let url = helpers::build_url(API_V1, API_PATH_USERS, &user_id.to_string()); 79 | let authorization = format!("Bearer {}", access_token); 80 | let response = reqwest::Client::new() 81 | .delete(url.as_str()) 82 | .header("Accept", "application/json") 83 | .header("Authorization", authorization) 84 | .send() 85 | .await?; 86 | 87 | helpers::dispatch_reqwest_response::(response, StatusCode::OK).await?; 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /src/application/security/jwt.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::application::{ 4 | config::Config, 5 | security::{auth::AuthError, roles}, 6 | }; 7 | 8 | // [JWT Claims] 9 | // [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519#section-4) 10 | // roles, groups: https://www.rfc-editor.org/rfc/rfc7643.html#section-4.1.2 11 | // https://www.rfc-editor.org/rfc/rfc9068.html#name-authorization-claims 12 | 13 | #[derive(Debug, Serialize, Deserialize)] 14 | pub struct AccessClaims { 15 | /// Subject. 16 | pub sub: String, 17 | /// JWT ID. 18 | pub jti: String, 19 | /// Issued time. 20 | pub iat: usize, 21 | /// Expiration time. 22 | pub exp: usize, 23 | /// Token type. 24 | pub typ: u8, 25 | /// Roles. 26 | pub roles: String, 27 | } 28 | 29 | #[derive(Debug, Serialize, Deserialize)] 30 | pub struct RefreshClaims { 31 | /// Subject. 32 | pub sub: String, 33 | /// JWT ID. 34 | pub jti: String, 35 | /// Issued time. 36 | pub iat: usize, 37 | /// Expiration time. 38 | pub exp: usize, 39 | /// Reference to paired access token, 40 | pub prf: String, 41 | /// Expiration time of paired access token, 42 | pub pex: usize, 43 | /// Token type. 44 | pub typ: u8, 45 | /// Roles. 46 | pub roles: String, 47 | } 48 | 49 | #[derive(Debug, Clone, Copy)] 50 | #[repr(u8)] 51 | pub enum JwtTokenType { 52 | AccessToken, 53 | RefreshToken, 54 | UnknownToken, 55 | } 56 | impl From for JwtTokenType { 57 | fn from(value: u8) -> Self { 58 | match value { 59 | 0 => Self::AccessToken, 60 | 1 => Self::RefreshToken, 61 | _ => Self::UnknownToken, 62 | } 63 | } 64 | } 65 | 66 | pub trait ClaimsMethods { 67 | fn validate_role_admin(&self) -> Result<(), AuthError>; 68 | fn get_sub(&self) -> &str; 69 | fn get_exp(&self) -> usize; 70 | fn get_iat(&self) -> usize; 71 | fn get_jti(&self) -> &str; 72 | } 73 | 74 | impl ClaimsMethods for AccessClaims { 75 | fn validate_role_admin(&self) -> Result<(), AuthError> { 76 | roles::is_role_admin(&self.roles) 77 | } 78 | fn get_sub(&self) -> &str { 79 | &self.sub 80 | } 81 | 82 | fn get_iat(&self) -> usize { 83 | self.iat 84 | } 85 | 86 | fn get_exp(&self) -> usize { 87 | self.exp 88 | } 89 | 90 | fn get_jti(&self) -> &str { 91 | &self.jti 92 | } 93 | } 94 | impl ClaimsMethods for RefreshClaims { 95 | fn validate_role_admin(&self) -> Result<(), AuthError> { 96 | roles::is_role_admin(&self.roles) 97 | } 98 | fn get_sub(&self) -> &str { 99 | &self.sub 100 | } 101 | 102 | fn get_iat(&self) -> usize { 103 | self.iat 104 | } 105 | 106 | fn get_exp(&self) -> usize { 107 | self.exp 108 | } 109 | 110 | fn get_jti(&self) -> &str { 111 | &self.jti 112 | } 113 | } 114 | 115 | pub fn decode_token serde::Deserialize<'de>>( 116 | token: &str, 117 | config: &Config, 118 | ) -> Result { 119 | let mut validation = jsonwebtoken::Validation::default(); 120 | validation.leeway = config.jwt_validation_leeway_seconds as u64; 121 | let token_data = jsonwebtoken::decode::(token, &config.jwt_keys.decoding, &validation) 122 | .map_err(|_| { 123 | tracing::error!("Invalid token: {}", token); 124 | AuthError::WrongCredentials 125 | })?; 126 | 127 | Ok(token_data.claims) 128 | } 129 | -------------------------------------------------------------------------------- /src/api/handlers/account_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Json, 3 | extract::{Path, State}, 4 | http::StatusCode, 5 | response::IntoResponse, 6 | }; 7 | use sqlx::types::Uuid; 8 | 9 | use crate::{ 10 | api::{ 11 | APIError, 12 | version::{self, APIVersion}, 13 | }, 14 | application::{ 15 | repository::account_repo, 16 | security::jwt::{AccessClaims, ClaimsMethods}, 17 | state::SharedState, 18 | }, 19 | domain::models::account::Account, 20 | }; 21 | 22 | pub async fn list_accounts_handler( 23 | api_version: APIVersion, 24 | access_claims: AccessClaims, 25 | State(state): State, 26 | ) -> Result>, APIError> { 27 | tracing::trace!("api version: {}", api_version); 28 | tracing::trace!("authentication details: {:#?}", access_claims); 29 | 30 | access_claims.validate_role_admin()?; 31 | 32 | let mut connection = state.db_pool.acquire().await?; 33 | let accounts = account_repo::list(&mut connection).await?; 34 | Ok(Json(accounts)) 35 | } 36 | 37 | pub async fn add_account_handler( 38 | api_version: APIVersion, 39 | access_claims: AccessClaims, 40 | State(state): State, 41 | Json(account): Json, 42 | ) -> Result { 43 | tracing::trace!("api version: {}", api_version); 44 | tracing::trace!("authentication details: {:#?}", access_claims); 45 | 46 | access_claims.validate_role_admin()?; 47 | 48 | let mut connection = state.db_pool.acquire().await?; 49 | let account = account_repo::add(account, &mut connection).await?; 50 | Ok((StatusCode::CREATED, Json(account))) 51 | } 52 | 53 | pub async fn get_account_handler( 54 | access_claims: AccessClaims, 55 | Path((version, id)): Path<(String, Uuid)>, 56 | State(state): State, 57 | ) -> Result, APIError> { 58 | let api_version: APIVersion = version::parse_version(&version)?; 59 | tracing::trace!("api version: {}", api_version); 60 | tracing::trace!("authentication details: {:#?}", access_claims); 61 | tracing::trace!("id: {}", id); 62 | 63 | access_claims.validate_role_admin()?; 64 | 65 | let mut connection = state.db_pool.acquire().await?; 66 | let account = account_repo::get_by_id(id, &mut connection).await?; 67 | Ok(Json(account)) 68 | } 69 | 70 | pub async fn update_account_handler( 71 | access_claims: AccessClaims, 72 | Path((version, id)): Path<(String, Uuid)>, 73 | State(state): State, 74 | Json(account): Json, 75 | ) -> Result, APIError> { 76 | let api_version: APIVersion = version::parse_version(&version)?; 77 | tracing::trace!("api version: {}", api_version); 78 | tracing::trace!("authentication details: {:#?}", access_claims); 79 | tracing::trace!("id: {}", id); 80 | tracing::trace!("account: {:?}", account); 81 | access_claims.validate_role_admin()?; 82 | 83 | let mut connection = state.db_pool.acquire().await?; 84 | let account = account_repo::update(account, &mut connection).await?; 85 | Ok(Json(account)) 86 | } 87 | 88 | pub async fn delete_account_handler( 89 | access_claims: AccessClaims, 90 | Path((version, id)): Path<(String, Uuid)>, 91 | State(state): State, 92 | ) -> Result { 93 | let api_version: APIVersion = version::parse_version(&version)?; 94 | tracing::trace!("api version: {}", api_version); 95 | tracing::trace!("authentication details: {:#?}", access_claims); 96 | tracing::trace!("id: {}", id); 97 | access_claims.validate_role_admin()?; 98 | 99 | let mut connection = state.db_pool.acquire().await.unwrap(); 100 | if account_repo::delete(id, &mut connection).await? { 101 | Ok(StatusCode::OK) 102 | } else { 103 | Err(StatusCode::NOT_FOUND)? 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/common/auth.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serde::Deserialize; 3 | 4 | use crate::common::{ 5 | TestResult, 6 | constants::{API_PATH_AUTH, API_V1}, 7 | helpers, 8 | }; 9 | 10 | #[derive(Debug, Deserialize)] 11 | pub struct AuthTokens { 12 | pub access_token: String, 13 | pub refresh_token: String, 14 | } 15 | 16 | pub async fn login(username: &str, password_hash: &str) -> TestResult { 17 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "login"); 18 | 19 | let params = format!( 20 | "{{\"username\":\"{}\", \"password_hash\":\"{}\"}}", 21 | username, password_hash 22 | ); 23 | 24 | let response = reqwest::Client::new() 25 | .post(url.as_str()) 26 | .header("Accept", "application/json") 27 | .header("Content-type", "application/json; charset=utf8") 28 | .body(params) 29 | .send() 30 | .await?; 31 | 32 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 33 | .await 34 | .map(|v| v.unwrap()) 35 | } 36 | 37 | pub async fn refresh(refresh_token: &str) -> TestResult { 38 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "refresh"); 39 | 40 | let authorization = format!("Bearer {}", refresh_token); 41 | let response = reqwest::Client::new() 42 | .post(url.as_str()) 43 | .header("Accept", "application/json") 44 | .header("Authorization", authorization) 45 | .send() 46 | .await?; 47 | 48 | helpers::dispatch_reqwest_response::(response, StatusCode::OK) 49 | .await 50 | .map(|v| v.unwrap()) 51 | } 52 | 53 | pub async fn logout(refresh_token: &str) -> TestResult { 54 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "logout"); 55 | 56 | let authorization = format!("Bearer {}", refresh_token); 57 | let response = reqwest::Client::new() 58 | .post(url.as_str()) 59 | .header("Accept", "application/json") 60 | .header("Authorization", authorization) 61 | .send() 62 | .await?; 63 | 64 | Ok(response.status()) 65 | } 66 | 67 | pub async fn revoke_all(access_token: &str) -> TestResult { 68 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "revoke-all"); 69 | let authorization = format!("Bearer {}", access_token); 70 | let response = reqwest::Client::new() 71 | .post(url.as_str()) 72 | .header("Accept", "application/json") 73 | .header("Authorization", authorization) 74 | .send() 75 | .await?; 76 | Ok(response.status()) 77 | } 78 | 79 | pub async fn revoke_user(access_token: &str, user_id: &str) -> TestResult { 80 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "revoke-user"); 81 | let params = format!("{{\"user_id\":\"{}\"}}", user_id); 82 | let authorization = format!("Bearer {}", access_token); 83 | let response = reqwest::Client::new() 84 | .post(url.as_str()) 85 | .header("Accept", "application/json") 86 | .header("Content-type", "application/json; charset=utf8") 87 | .header("Authorization", authorization) 88 | .body(params) 89 | .send() 90 | .await?; 91 | Ok(response.status()) 92 | } 93 | 94 | pub async fn cleanup(access_token: &str) -> TestResult { 95 | let url = helpers::build_url(API_V1, API_PATH_AUTH, "cleanup"); 96 | let authorization = format!("Bearer {}", access_token); 97 | let response = reqwest::Client::new() 98 | .post(url.as_str()) 99 | .header("Accept", "application/json") 100 | .header("Authorization", authorization) 101 | .send() 102 | .await?; 103 | 104 | assert_eq!(response.status(), reqwest::StatusCode::OK); 105 | 106 | let json: serde_json::Value = response.json().await.unwrap(); 107 | let deleted_tokens = json["deleted_tokens"].as_u64().unwrap(); 108 | 109 | Ok(deleted_tokens) 110 | } 111 | -------------------------------------------------------------------------------- /src/application/service/transaction_service.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | use uuid::Uuid; 3 | 4 | use crate::{ 5 | application::{ 6 | repository::{account_repo, transaction_repo}, 7 | state::SharedState, 8 | }, 9 | domain::models::transaction::Transaction, 10 | }; 11 | 12 | pub async fn transfer( 13 | source_account_id: Uuid, 14 | destination_account_id: Uuid, 15 | amount_cents: i64, 16 | state: &SharedState, 17 | ) -> Result { 18 | tracing::trace!( 19 | "transfer: source_account_id: {}, destination_account_id: {}, amount_cents: {} ", 20 | source_account_id, 21 | destination_account_id, 22 | amount_cents 23 | ); 24 | 25 | // Start transaction. 26 | let mut tx = state.db_pool.begin().await?; 27 | 28 | let mut validation_errors = vec![]; 29 | 30 | // Find the source account. 31 | let mut source_account = match account_repo::get_by_id(source_account_id, &mut tx).await { 32 | Ok(account) => { 33 | // Check the balance of the source account. 34 | if account.balance_cents < amount_cents { 35 | validation_errors.push(TransferValidationError::InsufficientFunds); 36 | } 37 | Some(account) 38 | } 39 | Err(e) => { 40 | let error = match e { 41 | sqlx::Error::RowNotFound => { 42 | TransferValidationError::SourceAccountNotFound(source_account_id) 43 | } 44 | _ => Err(e)?, 45 | }; 46 | validation_errors.push(error); 47 | None 48 | } 49 | }; 50 | 51 | // Check if accounts are distinct. 52 | if source_account_id == destination_account_id { 53 | validation_errors.push(TransferValidationError::AccountsAreSame); 54 | 55 | // No need for futher validations. 56 | return Err(TransferError::TransferValidationErrors(validation_errors))?; 57 | } 58 | 59 | // Find the destination account. 60 | let mut destination_account = 61 | match account_repo::get_by_id(destination_account_id, &mut tx).await { 62 | Ok(account) => Some(account), 63 | Err(e) => { 64 | let error = match e { 65 | sqlx::Error::RowNotFound => { 66 | TransferValidationError::DestinationAccountNotFound(destination_account_id) 67 | } 68 | _ => Err(e)?, 69 | }; 70 | validation_errors.push(error); 71 | None 72 | } 73 | }; 74 | 75 | if !validation_errors.is_empty() { 76 | Err(TransferError::TransferValidationErrors(validation_errors))? 77 | } 78 | 79 | let mut source_account = source_account.take().unwrap(); 80 | let mut destination_account = destination_account.take().unwrap(); 81 | 82 | // Transfer money. 83 | source_account.balance_cents -= amount_cents; 84 | destination_account.balance_cents += amount_cents; 85 | 86 | // Update accounts. 87 | account_repo::update(source_account, &mut tx).await?; 88 | account_repo::update(destination_account, &mut tx).await?; 89 | 90 | // Add transaction. 91 | let transaction = transaction_repo::add( 92 | source_account_id, 93 | destination_account_id, 94 | amount_cents, 95 | &mut tx, 96 | ) 97 | .await?; 98 | 99 | // Commit transaction. 100 | tx.commit().await?; 101 | 102 | Ok(transaction) 103 | } 104 | 105 | #[derive(Debug, Error)] 106 | pub enum TransferError { 107 | #[error("transfer validation errors")] 108 | TransferValidationErrors(Vec), 109 | #[error(transparent)] 110 | SQLxError(#[from] sqlx::Error), 111 | } 112 | 113 | #[derive(Debug, Error)] 114 | pub enum TransferValidationError { 115 | #[error("source account does not have sufficient funds for the transfer")] 116 | InsufficientFunds, 117 | #[error("source account not found: {0}")] 118 | SourceAccountNotFound(Uuid), 119 | #[error("destination account not found: {0}")] 120 | DestinationAccountNotFound(Uuid), 121 | #[error("source and destination accounts are the same")] 122 | AccountsAreSame, 123 | } 124 | -------------------------------------------------------------------------------- /tests/auth_revoke_tests.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | 4 | use axum_web::application::security::jwt::{self, AccessClaims}; 5 | 6 | pub mod common; 7 | use common::{ 8 | auth, 9 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 10 | helpers, root, test_app, 11 | }; 12 | 13 | #[tokio::test] 14 | #[serial] 15 | async fn revoke_user_test() { 16 | // Start API server. 17 | let test_db = test_app::run().await; 18 | 19 | let config = helpers::config(); 20 | 21 | // Assert that revoked options are enabled. 22 | assert!(config.jwt_enable_revoked_tokens); 23 | 24 | // Login as an admin. 25 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 26 | .await 27 | .expect("Login error."); 28 | 29 | let access_claims: AccessClaims = jwt::decode_token(&tokens.access_token, config).unwrap(); 30 | let user_id = access_claims.sub; 31 | 32 | assert_eq!( 33 | auth::revoke_user(&tokens.access_token, &user_id) 34 | .await 35 | .unwrap(), 36 | StatusCode::OK 37 | ); 38 | 39 | // Try access to the root handler with the same token again. 40 | assert_eq!( 41 | root::fetch_root(&tokens.access_token).await.unwrap(), 42 | StatusCode::UNAUTHORIZED 43 | ); 44 | 45 | // Currently, timestamps in claims are defined as the number of seconds since Epoch (RFC 7519). 46 | // We need to pause for one second so as not to interfere with the authentication of the next logins. 47 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 48 | 49 | // Drop test database. 50 | test_db.drop().await.unwrap(); 51 | } 52 | 53 | #[tokio::test] 54 | #[serial] 55 | async fn revoke_all_test() { 56 | // Start API server. 57 | let test_db = test_app::run().await; 58 | 59 | let config = helpers::config(); 60 | 61 | // Assert that revoked options are enabled. 62 | assert!(config.jwt_enable_revoked_tokens); 63 | 64 | // Login as an admin. 65 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 66 | .await 67 | .expect("Login error."); 68 | 69 | auth::revoke_all(&tokens.access_token).await.unwrap(); 70 | 71 | // Try access to the root handler with the same token again. 72 | assert_eq!( 73 | root::fetch_root(&tokens.access_token).await.unwrap(), 74 | StatusCode::UNAUTHORIZED 75 | ); 76 | 77 | // Currently, timestamps in claims are defined as the number of seconds since Epoch (RFC 7519). 78 | // We need to pause for one second so as not to interfere with the authentication of the next logins. 79 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 80 | 81 | // Drop test database. 82 | test_db.drop().await.unwrap(); 83 | } 84 | 85 | #[tokio::test] 86 | #[serial] 87 | async fn cleanup_test() { 88 | // Start API server. 89 | let test_db = test_app::run().await; 90 | 91 | let config = helpers::config(); 92 | 93 | // Assert that revoked options are enabled. 94 | assert!(config.jwt_enable_revoked_tokens); 95 | 96 | // Login as an admin. 97 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 98 | .await 99 | .expect("Login error."); 100 | 101 | let _initial_cleanup = auth::cleanup(&tokens.access_token) 102 | .await 103 | .expect("Auth cleanup error."); 104 | 105 | // Expected 2 tokens to expire after resfresh. 106 | let refreshed = auth::refresh(&tokens.refresh_token) 107 | .await 108 | .expect("Auth refresh error."); 109 | 110 | // Expected 2 tokens to expire after logout. 111 | assert_eq!( 112 | auth::logout(&refreshed.refresh_token).await.unwrap(), 113 | StatusCode::OK 114 | ); 115 | 116 | // Wait to make sure that tokens expire. 117 | tokio::time::sleep(tokio::time::Duration::from_secs( 118 | (config.jwt_expire_access_token_seconds + config.jwt_validation_leeway_seconds) as u64, 119 | )) 120 | .await; 121 | tokio::time::sleep(tokio::time::Duration::from_secs( 122 | (config.jwt_expire_refresh_token_seconds + config.jwt_validation_leeway_seconds) as u64, 123 | )) 124 | .await; 125 | 126 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 127 | .await 128 | .expect("Login error."); 129 | 130 | let deleted_tokens = auth::cleanup(&tokens.access_token).await.unwrap(); 131 | assert!(deleted_tokens >= 4); 132 | 133 | // Drop test database. 134 | test_db.drop().await.unwrap(); 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting started with REST API Web Services in Rust using Axum, JWT, SQLx, PostgreSQL, and Redis 2 | 3 | [![build & test](https://github.com/sheroz/axum-web/actions/workflows/ci.yml/badge.svg)](https://github.com/sheroz/axum-web/actions/workflows/ci.yml) 4 | [![MIT](https://img.shields.io/github/license/sheroz/axum-web)](https://github.com/sheroz/axum-web/tree/main/LICENSE) 5 | 6 | This project demonstrates how to build a REST API web server in Rust using `axum`, `JSON Web Tokens (JWT)`, `SQLx`, `PostgreSQL`, and `Redis` 7 | 8 | The REST API web server supports JWT-based authentication and authorization, asynchronous database operations for user and account models, a basic transaction example that transfers money between accounts, and detailed API error handling in a structured format. 9 | 10 | The brief description: [rust-axum-rest-api-postgres-redis-jwt-docker.html](https://sheroz.com/pages/blog/rust-axum-rest-api-postgres-redis-jwt-docker.html) 11 | 12 | ## Covers 13 | 14 | - REST API web server based on [axum](https://github.com/tokio-rs/axum) 15 | - Routing and request handling 16 | - API versioning 17 | - API Error handling using structured format 18 | - Cross-Origin Resource Sharing (CORS) 19 | - Graceful shutdown 20 | - Authentication & authorization using `JSON Web Tokens (JWT)` 21 | - Login, logout, refresh, and revoking operations 22 | - Role based authorization 23 | - Generating and validating access and refresh tokens 24 | - Setting tokens expiry time (based on configuration) 25 | - Using refresh tokens rotation technique 26 | - Revoking issued tokens by using Redis (based on configuration) 27 | - Revoke all tokens issued until the current time 28 | - Revoke tokens belonging to the user issued until the current time 29 | - Cleanup of revoked tokens 30 | - Using `PostgreSQL`database with `SQLx` 31 | - Database migrations 32 | - Async connection pooling 33 | - Async CRUD operations and transactions 34 | - Using `Redis` in-memory storage 35 | - Async `Redis` operations 36 | - Configuration settings 37 | - Loading and parsing `.env` file 38 | - Using environment variables 39 | - Logs 40 | - `tracing` based logs 41 | - Tests 42 | - End-to-end API tests 43 | - Database isolation in tests 44 | - Using `Docker` 45 | - Running `PostgreSQL` and `Redis` services 46 | - Building the application using the official `Rust` image 47 | - Running the full stack: API + `PostgreSQL` + `Redis` 48 | - GitHub CI configuration 49 | - Running `cargo deny` to check for security vulnerabilities and licenses 50 | - Running `cargo fmt` to check for the Rust code format according to style guidelines 51 | - Running `cargo clippy` to catch common mistakes and improving the Rust code 52 | - Running tests 53 | - Building the application 54 | 55 | ## REST API Endpoints 56 | 57 | - List of available API endpoints: [docs/api-docs.md](/docs/api-docs.md) 58 | - API request samples in the format RFC 2616: [tests/endpoints.http](/tests/endpoints.http) 59 | 60 | ## API Request Samples 61 | 62 | - Using [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) for Visual Studio Code, 63 | supports RFC 2616, request samples: [tests/endpoints.http](/tests/endpoints.http). 64 | - Using [curl](https://curl.se/): 65 | 66 | Health check 67 | 68 | ```shell 69 | curl -i http://127.0.0.1:8080/v1/health 70 | ``` 71 | 72 | Login 73 | 74 | ```shell 75 | curl -i http://127.0.0.1:8080/v1/auth/login \ 76 | -H "Content-Type: application/json" \ 77 | -d '{"username":"admin","password_hash":"7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51"}' 78 | ``` 79 | 80 | List of users 81 | 82 | ```shell 83 | curl -i http://127.0.0.1:8080/v1/users \ 84 | -H "Content-Type: application/json" \ 85 | -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkNTFlNjE4Ny1jYmFjLTQ0ZmEtOWE5NS04ZjFkZWJkYmFlZWEiLCJqdGkiOiIwN2Y3OWE0OC1kMWFhLTQ1ZjItOWE5NS05Y2M5MGZiY2UyYTciLCJpYXQiOjE3MzYwMTA3MjIsImV4cCI6MTczNjAxNDMyMiwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.3f2c_5PyPXMhgu0FIX4--SGjnSDW1GLxL0ba6gSImfM" 86 | ``` 87 | 88 | ## Running end-to-end API tests 89 | 90 | REST API tests: [/tests](/tests) 91 | 92 | ```shell 93 | docker-compose up -d 94 | cargo test 95 | ``` 96 | 97 | ## Running the service (debug build) 98 | 99 | ```shell 100 | docker-compose up -d 101 | cargo run 102 | ``` 103 | 104 | ## Running the service in test configuration 105 | 106 | ```shell 107 | ENV_TEST=1 cargo run 108 | ``` 109 | 110 | ## Running the service at a specific log level 111 | 112 | Setting the `RUST_LOG` - logging level on the launch: 113 | 114 | ```shell 115 | RUST_LOG=info,hyper=debug,axum_web=trace cargo run 116 | ``` 117 | 118 | ## Running the Docker based full stack build 119 | 120 | ```shell 121 | docker-compose -f docker-compose.full.yml up -d 122 | ``` 123 | 124 | ## Project Stage 125 | 126 | **Development**: this project is under development. 127 | -------------------------------------------------------------------------------- /src/api/handlers/user_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Json, 3 | extract::{Path, State}, 4 | http::StatusCode, 5 | response::IntoResponse, 6 | }; 7 | use sqlx::types::Uuid; 8 | use thiserror::Error; 9 | 10 | use crate::{ 11 | api::{ 12 | APIError, APIErrorCode, APIErrorEntry, APIErrorKind, 13 | error::API_DOCUMENT_URL, 14 | version::{self, APIVersion}, 15 | }, 16 | application::{ 17 | repository::user_repo, 18 | security::jwt::{AccessClaims, ClaimsMethods}, 19 | state::SharedState, 20 | }, 21 | domain::models::user::User, 22 | }; 23 | 24 | pub async fn list_users_handler( 25 | api_version: APIVersion, 26 | access_claims: AccessClaims, 27 | State(state): State, 28 | ) -> Result>, APIError> { 29 | tracing::trace!("api version: {}", api_version); 30 | tracing::trace!("authentication details: {:#?}", access_claims); 31 | access_claims.validate_role_admin()?; 32 | let users = user_repo::list(&state).await?; 33 | Ok(Json(users)) 34 | } 35 | 36 | pub async fn add_user_handler( 37 | api_version: APIVersion, 38 | access_claims: AccessClaims, 39 | State(state): State, 40 | Json(user): Json, 41 | ) -> Result { 42 | tracing::trace!("api version: {}", api_version); 43 | tracing::trace!("authentication details: {:#?}", access_claims); 44 | access_claims.validate_role_admin()?; 45 | let user = user_repo::add(user, &state).await?; 46 | Ok((StatusCode::CREATED, Json(user))) 47 | } 48 | 49 | pub async fn get_user_handler( 50 | access_claims: AccessClaims, 51 | Path((version, id)): Path<(String, Uuid)>, 52 | State(state): State, 53 | ) -> Result, APIError> { 54 | let api_version: APIVersion = version::parse_version(&version)?; 55 | tracing::trace!("api version: {}", api_version); 56 | tracing::trace!("authentication details: {:#?}", access_claims); 57 | tracing::trace!("id: {}", id); 58 | access_claims.validate_role_admin()?; 59 | let user = user_repo::get_by_id(id, &state) 60 | .await 61 | .map_err(|e| match e { 62 | sqlx::Error::RowNotFound => { 63 | let user_error = UserError::UserNotFound(id); 64 | (user_error.status_code(), APIErrorEntry::from(user_error)).into() 65 | } 66 | _ => APIError::from(e), 67 | })?; 68 | 69 | Ok(Json(user)) 70 | } 71 | 72 | pub async fn update_user_handler( 73 | access_claims: AccessClaims, 74 | Path((version, id)): Path<(String, Uuid)>, 75 | State(state): State, 76 | Json(user): Json, 77 | ) -> Result, APIError> { 78 | let api_version: APIVersion = version::parse_version(&version)?; 79 | tracing::trace!("api version: {}", api_version); 80 | tracing::trace!("authentication details: {:#?}", access_claims); 81 | tracing::trace!("id: {}", id); 82 | access_claims.validate_role_admin()?; 83 | let user = user_repo::update(user, &state).await?; 84 | Ok(Json(user)) 85 | } 86 | 87 | pub async fn delete_user_handler( 88 | access_claims: AccessClaims, 89 | Path((version, id)): Path<(String, Uuid)>, 90 | State(state): State, 91 | ) -> Result { 92 | let api_version: APIVersion = version::parse_version(&version)?; 93 | tracing::trace!("api version: {}", api_version); 94 | tracing::trace!("authentication details: {:#?}", access_claims); 95 | tracing::trace!("id: {}", id); 96 | access_claims.validate_role_admin()?; 97 | if user_repo::delete(id, &state).await? { 98 | Ok(StatusCode::OK) 99 | } else { 100 | Err(StatusCode::NOT_FOUND)? 101 | } 102 | } 103 | 104 | #[derive(Debug, Error)] 105 | enum UserError { 106 | #[error("user not found: {0}")] 107 | UserNotFound(Uuid), 108 | } 109 | 110 | impl UserError { 111 | const fn status_code(&self) -> StatusCode { 112 | match self { 113 | Self::UserNotFound(_) => StatusCode::NOT_FOUND, 114 | } 115 | } 116 | } 117 | 118 | impl From for APIErrorEntry { 119 | fn from(user_error: UserError) -> Self { 120 | let message = user_error.to_string(); 121 | match user_error { 122 | UserError::UserNotFound(user_id) => Self::new(&message) 123 | .code(APIErrorCode::UserNotFound) 124 | .kind(APIErrorKind::ResourceNotFound) 125 | .description(&format!("user with the ID '{}' does not exist in our records", user_id)) 126 | .detail(serde_json::json!({"user_id": user_id})) 127 | .reason("must be an existing user") 128 | .instance(&format!("/api/v1/users/{}", user_id)) 129 | .trace_id() 130 | .help(&format!("please check if the user ID is correct or refer to our documentation at {}#errors for more information", API_DOCUMENT_URL)) 131 | .doc_url() 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/application/config.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, net::SocketAddr}; 2 | 3 | use jsonwebtoken::{DecodingKey, EncodingKey}; 4 | 5 | use crate::infrastructure::database::{DatabaseOptions, PostgresOptions}; 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct Config { 9 | // REST API configuration. 10 | pub service_host: String, 11 | pub service_port: u16, 12 | 13 | // Redis configuration. 14 | pub redis_host: String, 15 | pub redis_port: u16, 16 | 17 | // PostgreSQL configuration. 18 | pub postgres_user: String, 19 | pub postgres_password: String, 20 | pub postgres_host: String, 21 | pub postgres_port: u16, 22 | pub postgres_db: String, 23 | pub postgres_connection_pool: u32, 24 | 25 | // JWT configuration. 26 | pub jwt_secret: String, 27 | pub jwt_keys: JwtKeys, 28 | pub jwt_expire_access_token_seconds: i64, 29 | pub jwt_expire_refresh_token_seconds: i64, 30 | pub jwt_validation_leeway_seconds: i64, 31 | pub jwt_enable_revoked_tokens: bool, 32 | } 33 | #[derive(Clone)] 34 | pub struct JwtKeys { 35 | pub encoding: EncodingKey, 36 | pub decoding: DecodingKey, 37 | } 38 | 39 | // A blank impl fmt::Debug for JwtKeys 40 | // there is no debug(skip) option for #[derive(Debug)] currently in Rust 1.74 41 | impl fmt::Debug for JwtKeys { 42 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 | f.debug_struct("JwtKeys").finish() 44 | } 45 | } 46 | 47 | impl JwtKeys { 48 | fn new(secret: &[u8]) -> Self { 49 | Self { 50 | encoding: EncodingKey::from_secret(secret), 51 | decoding: DecodingKey::from_secret(secret), 52 | } 53 | } 54 | } 55 | 56 | impl Config { 57 | pub fn service_http_addr(&self) -> String { 58 | format!("{}://{}:{}", "http", self.service_host, self.service_port) 59 | } 60 | 61 | pub fn service_socket_addr(&self) -> SocketAddr { 62 | use std::str::FromStr; 63 | SocketAddr::from_str(&format!("{}:{}", self.service_host, self.service_port)).unwrap() 64 | } 65 | 66 | pub fn redis_url(&self) -> String { 67 | format!("redis://{}:{}", self.redis_host, self.redis_port) 68 | } 69 | 70 | pub fn postgres_url(&self) -> String { 71 | format!( 72 | "postgresql://{}:{}@{}:{}/{}", 73 | self.postgres_user, 74 | self.postgres_password, 75 | self.postgres_host, 76 | self.postgres_port, 77 | self.postgres_db 78 | ) 79 | } 80 | } 81 | 82 | pub fn load() -> Config { 83 | let env_file = if env_get_or("ENV_TEST", "0") == "1" { 84 | ".env_test" 85 | } else { 86 | ".env" 87 | }; 88 | 89 | // Try to load environment variables from file. 90 | if dotenvy::from_filename(env_file).is_ok() { 91 | tracing::info!("{} file loaded", env_file); 92 | } else { 93 | tracing::info!("{} file not found, using existing environment", env_file); 94 | } 95 | 96 | let jwt_secret = env_get("JWT_SECRET"); 97 | 98 | // Parse configuration. 99 | let config = Config { 100 | service_host: env_get("SERVICE_HOST"), 101 | service_port: env_parse("SERVICE_PORT"), 102 | redis_host: env_get("REDIS_HOST"), 103 | redis_port: env_parse("REDIS_PORT"), 104 | postgres_user: env_get("POSTGRES_USER"), 105 | postgres_password: env_get("POSTGRES_PASSWORD"), 106 | postgres_host: env_get("POSTGRES_HOST"), 107 | postgres_port: env_parse("POSTGRES_PORT"), 108 | postgres_db: env_get("POSTGRES_DB"), 109 | postgres_connection_pool: env_parse("POSTGRES_CONNECTION_POOL"), 110 | jwt_keys: JwtKeys::new(jwt_secret.as_bytes()), 111 | jwt_secret, 112 | jwt_expire_access_token_seconds: env_parse("JWT_EXPIRE_ACCESS_TOKEN_SECONDS"), 113 | jwt_expire_refresh_token_seconds: env_parse("JWT_EXPIRE_REFRESH_TOKEN_SECONDS"), 114 | jwt_validation_leeway_seconds: env_parse("JWT_VALIDATION_LEEWAY_SECONDS"), 115 | jwt_enable_revoked_tokens: env_parse("JWT_ENABLE_REVOKED_TOKENS"), 116 | }; 117 | 118 | tracing::trace!("configuration: {:#?}", config); 119 | config 120 | } 121 | 122 | impl From for PostgresOptions { 123 | fn from(config: Config) -> Self { 124 | Self { 125 | db: config.postgres_db, 126 | host: config.postgres_host, 127 | port: config.postgres_port, 128 | user: config.postgres_user, 129 | password: config.postgres_password, 130 | max_connections: config.postgres_connection_pool, 131 | } 132 | } 133 | } 134 | 135 | impl From for DatabaseOptions { 136 | fn from(config: Config) -> Self { 137 | Self { 138 | postgres: config.into(), 139 | } 140 | } 141 | } 142 | 143 | #[inline] 144 | fn env_get(key: &str) -> String { 145 | match std::env::var(key) { 146 | Ok(v) => v, 147 | Err(e) => { 148 | let msg = format!("{} {}", key, e); 149 | tracing::error!(msg); 150 | panic!("{msg}"); 151 | } 152 | } 153 | } 154 | 155 | #[inline] 156 | fn env_get_or(key: &str, default: &str) -> String { 157 | if let Ok(v) = std::env::var(key) { 158 | return v; 159 | } 160 | default.to_owned() 161 | } 162 | 163 | #[inline] 164 | fn env_parse(key: &str) -> T { 165 | env_get(key).parse().map_or_else( 166 | |_| { 167 | let msg = format!("Failed to parse: {}", key); 168 | tracing::error!(msg); 169 | panic!("{msg}"); 170 | }, 171 | |v| v, 172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /tests/user_tests.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use serial_test::serial; 3 | use uuid::Uuid; 4 | 5 | use axum_web::{ 6 | application::security::jwt::{self, AccessClaims}, 7 | domain::models::user::User, 8 | }; 9 | use reqwest::StatusCode; 10 | 11 | pub mod common; 12 | use common::{ 13 | auth, 14 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 15 | helpers::{self}, 16 | test_app, users, 17 | }; 18 | 19 | fn test_user() -> User { 20 | let username = format!("test-{}", Utc::now().timestamp() as usize); 21 | User { 22 | id: Uuid::new_v4(), 23 | username: username.clone(), 24 | email: format!("{}@email.com", username), 25 | password_hash: "xyz123".to_string(), 26 | password_salt: "xyz123".to_string(), 27 | active: true, 28 | roles: "guest".to_string(), 29 | created_at: None, 30 | updated_at: None, 31 | } 32 | } 33 | 34 | #[tokio::test] 35 | #[serial] 36 | async fn user_unauthorized_test() { 37 | // Start API server. 38 | let test_db = test_app::run().await; 39 | 40 | // Try unauthorized access to user handlers. 41 | let wrong_access_token = "xyz"; 42 | 43 | let user = test_user(); 44 | 45 | let result = users::get(user.id, wrong_access_token).await; 46 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 47 | 48 | let result = users::add(user.clone(), wrong_access_token).await; 49 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 50 | 51 | let result = users::update(user.clone(), wrong_access_token).await; 52 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 53 | 54 | let result = users::delete(user.id, wrong_access_token).await; 55 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 56 | 57 | // Drop test database. 58 | test_db.drop().await.unwrap(); 59 | } 60 | 61 | #[tokio::test] 62 | #[serial] 63 | async fn list_users_test() { 64 | // Start API server. 65 | let test_db = test_app::run().await; 66 | 67 | let config = helpers::config(); 68 | 69 | // Try unauthorized access to the users handler. 70 | let result = users::list("xyz").await; 71 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 72 | 73 | // Login as an admin. 74 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 75 | .await 76 | .expect("Login error."); 77 | 78 | let access_claims = jwt::decode_token::(&tokens.access_token, config).unwrap(); 79 | let user_id: Uuid = access_claims.sub.parse().unwrap(); 80 | 81 | // Try authorized access to the users handler. 82 | let users = users::list(&tokens.access_token) 83 | .await 84 | .expect("User list fetch error."); 85 | assert!(!users.is_empty()); 86 | assert!(users.iter().any(|u| u.id == user_id)); 87 | 88 | // Drop test database. 89 | test_db.drop().await.unwrap(); 90 | } 91 | 92 | #[tokio::test] 93 | #[serial] 94 | async fn get_user_test() { 95 | // Start API server. 96 | let test_db = test_app::run().await; 97 | 98 | let config = helpers::config(); 99 | 100 | // Try unauthorized access to the get user handler 101 | let result = users::get(uuid::Uuid::new_v4(), "").await; 102 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 103 | 104 | // Login as an admin. 105 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 106 | .await 107 | .expect("Login error."); 108 | 109 | let access_claims = jwt::decode_token::(&tokens.access_token, config).unwrap(); 110 | let user_id = access_claims.sub.parse().unwrap(); 111 | 112 | // Get the user. 113 | let user = users::get(user_id, &tokens.access_token) 114 | .await 115 | .expect("User fetch error."); 116 | assert_eq!(user.id, user_id); 117 | 118 | // Drop test database. 119 | test_db.drop().await.unwrap(); 120 | } 121 | 122 | #[tokio::test] 123 | #[serial] 124 | async fn add_get_update_delete_user_test() { 125 | // Start API server. 126 | let test_db = test_app::run().await; 127 | 128 | let mut user = test_user(); 129 | 130 | // Login as an admin. 131 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 132 | .await 133 | .expect("Login error."); 134 | 135 | // Add a user. 136 | let user_result = users::add(user.clone(), &tokens.access_token) 137 | .await 138 | .expect("User creation error."); 139 | assert!(user_result.updated_at.is_some()); 140 | assert!(user_result.created_at.is_some()); 141 | 142 | user.created_at = user_result.created_at; 143 | user.updated_at = user_result.updated_at; 144 | assert_eq!(user_result, user); 145 | 146 | // Get the added user. 147 | let user_result = users::get(user.id, &tokens.access_token) 148 | .await 149 | .expect("User fetch error."); 150 | assert_eq!(user_result, user); 151 | 152 | // Update user. 153 | user.username = format!("test-{}", chrono::Utc::now().timestamp() as usize); 154 | let user_result = users::update(user.clone(), &tokens.access_token) 155 | .await 156 | .expect("User update error."); 157 | assert_ne!(user_result.updated_at, user.updated_at); 158 | user.updated_at = user_result.updated_at; 159 | assert_eq!(user_result, user); 160 | 161 | // Delete user. 162 | users::delete(user.id, &tokens.access_token) 163 | .await 164 | .expect("User delete error."); 165 | 166 | // Check the user. 167 | let result = users::get(user.id, &tokens.access_token).await; 168 | assert_api_error_status!(result, StatusCode::NOT_FOUND); 169 | 170 | // Drop test database. 171 | test_db.drop().await.unwrap(); 172 | } 173 | -------------------------------------------------------------------------------- /src/application/security/auth.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | use uuid::Uuid; 3 | 4 | use crate::{ 5 | application::{ 6 | config::Config, repository::user_repo, security::jwt::*, service::token_service, 7 | state::SharedState, 8 | }, 9 | domain::models::user::User, 10 | }; 11 | 12 | pub struct JwtTokens { 13 | pub access_token: String, 14 | pub refresh_token: String, 15 | } 16 | 17 | pub async fn logout(refresh_claims: RefreshClaims, state: SharedState) -> Result<(), AuthError> { 18 | // Check if revoked tokens are enabled. 19 | if !state.config.jwt_enable_revoked_tokens { 20 | Err(AuthError::RevokedTokensInactive)? 21 | } 22 | 23 | // Decode and validate the refresh token. 24 | if !validate_token_type(&refresh_claims, JwtTokenType::RefreshToken) { 25 | return Err(AuthError::InvalidToken.into()); 26 | } 27 | revoke_refresh_token(&refresh_claims, &state).await?; 28 | Ok(()) 29 | } 30 | 31 | pub async fn refresh( 32 | refresh_claims: RefreshClaims, 33 | state: SharedState, 34 | ) -> Result { 35 | // Decode and validate the refresh token. 36 | if !validate_token_type(&refresh_claims, JwtTokenType::RefreshToken) { 37 | return Err(AuthError::InvalidToken.into()); 38 | } 39 | 40 | // Check if revoked tokens are enabled. 41 | if state.config.jwt_enable_revoked_tokens { 42 | revoke_refresh_token(&refresh_claims, &state).await?; 43 | } 44 | 45 | let user_id = refresh_claims.sub.parse().unwrap(); 46 | let user = user_repo::get_by_id(user_id, &state).await?; 47 | let tokens = generate_tokens(user, &state.config); 48 | Ok(tokens) 49 | } 50 | 51 | pub async fn cleanup_revoked_and_expired( 52 | _access_claims: &AccessClaims, 53 | state: &SharedState, 54 | ) -> Result { 55 | // Check if revoked tokens are enabled. 56 | if !state.config.jwt_enable_revoked_tokens { 57 | Err(AuthError::RevokedTokensInactive)? 58 | } 59 | 60 | let deleted = token_service::cleanup_expired(state).await?; 61 | Ok(deleted) 62 | } 63 | 64 | pub fn validate_token_type(claims: &RefreshClaims, expected_type: JwtTokenType) -> bool { 65 | if claims.typ == expected_type as u8 { 66 | true 67 | } else { 68 | tracing::error!( 69 | "Invalid token type. Expected {:?}, Found {:?}", 70 | expected_type, 71 | JwtTokenType::from(claims.typ), 72 | ); 73 | false 74 | } 75 | } 76 | 77 | async fn revoke_refresh_token( 78 | refresh_claims: &RefreshClaims, 79 | state: &SharedState, 80 | ) -> Result<(), AuthError> { 81 | // Check the validity of refresh token. 82 | validate_revoked(refresh_claims, state).await?; 83 | 84 | token_service::revoke_refresh_token(refresh_claims, state).await?; 85 | Ok(()) 86 | } 87 | 88 | pub fn generate_tokens(user: User, config: &Config) -> JwtTokens { 89 | let time_now = chrono::Utc::now(); 90 | let iat = time_now.timestamp() as usize; 91 | let sub = user.id.to_string(); 92 | 93 | let access_token_id = Uuid::new_v4().to_string(); 94 | let refresh_token_id = Uuid::new_v4().to_string(); 95 | let access_token_exp = (time_now 96 | + chrono::Duration::seconds(config.jwt_expire_access_token_seconds)) 97 | .timestamp() as usize; 98 | 99 | let access_claims = AccessClaims { 100 | sub: sub.clone(), 101 | jti: access_token_id.clone(), 102 | iat, 103 | exp: access_token_exp, 104 | typ: JwtTokenType::AccessToken as u8, 105 | roles: user.roles.clone(), 106 | }; 107 | 108 | let refresh_claims = RefreshClaims { 109 | sub, 110 | jti: refresh_token_id, 111 | iat, 112 | exp: (time_now + chrono::Duration::seconds(config.jwt_expire_refresh_token_seconds)) 113 | .timestamp() as usize, 114 | prf: access_token_id, 115 | pex: access_token_exp, 116 | typ: JwtTokenType::RefreshToken as u8, 117 | roles: user.roles, 118 | }; 119 | 120 | tracing::info!( 121 | "JWT: generated claims\naccess {:#?}\nrefresh {:#?}", 122 | access_claims, 123 | refresh_claims 124 | ); 125 | 126 | let access_token = jsonwebtoken::encode( 127 | &jsonwebtoken::Header::default(), 128 | &access_claims, 129 | &jsonwebtoken::EncodingKey::from_secret(config.jwt_secret.as_ref()), 130 | ) 131 | .unwrap(); 132 | 133 | let refresh_token = jsonwebtoken::encode( 134 | &jsonwebtoken::Header::default(), 135 | &refresh_claims, 136 | &jsonwebtoken::EncodingKey::from_secret(config.jwt_secret.as_ref()), 137 | ) 138 | .unwrap(); 139 | 140 | tracing::info!( 141 | "JWT: generated tokens\naccess {:#?}\nrefresh {:#?}", 142 | access_token, 143 | refresh_token 144 | ); 145 | 146 | JwtTokens { 147 | access_token, 148 | refresh_token, 149 | } 150 | } 151 | 152 | pub async fn validate_revoked( 153 | claims: &T, 154 | state: &SharedState, 155 | ) -> Result<(), AuthError> { 156 | let revoked = token_service::is_revoked(claims, state).await?; 157 | if revoked { 158 | Err(AuthError::WrongCredentials)?; 159 | } 160 | Ok(()) 161 | } 162 | 163 | #[derive(Debug, Error)] 164 | pub enum AuthError { 165 | #[error("wrong credentials")] 166 | WrongCredentials, 167 | #[error("missing credentials")] 168 | MissingCredentials, 169 | #[error("token creation error")] 170 | TokenCreationError, 171 | #[error("invalid token")] 172 | InvalidToken, 173 | #[error("use of revoked tokens is inactive")] 174 | RevokedTokensInactive, 175 | #[error("forbidden")] 176 | Forbidden, 177 | #[error(transparent)] 178 | RedisError(#[from] redis::RedisError), 179 | #[error(transparent)] 180 | SQLxError(#[from] sqlx::Error), 181 | } 182 | -------------------------------------------------------------------------------- /src/api/handlers/transaction_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | Json, 3 | extract::{Path, State}, 4 | http::StatusCode, 5 | }; 6 | use serde::{Deserialize, Serialize}; 7 | use sqlx::types::Uuid; 8 | use thiserror::Error; 9 | 10 | use crate::{ 11 | api::{ 12 | APIError, APIErrorCode, APIErrorEntry, APIErrorKind, 13 | version::{self, APIVersion}, 14 | }, 15 | application::{ 16 | repository::transaction_repo, 17 | security::jwt::{AccessClaims, ClaimsMethods}, 18 | service::transaction_service::{self, TransferError, TransferValidationError}, 19 | state::SharedState, 20 | }, 21 | domain::models::transaction::Transaction, 22 | }; 23 | 24 | #[derive(Debug, Serialize, Deserialize)] 25 | pub struct TransferOrder { 26 | pub source_account_id: Uuid, 27 | pub destination_account_id: Uuid, 28 | pub amount_cents: i64, 29 | } 30 | 31 | pub async fn get_transaction_handler( 32 | access_claims: AccessClaims, 33 | Path((version, id)): Path<(String, Uuid)>, 34 | State(state): State, 35 | ) -> Result, APIError> { 36 | let api_version: APIVersion = version::parse_version(&version)?; 37 | tracing::trace!("api version: {}", api_version); 38 | tracing::trace!("authentication details: {:#?}", access_claims); 39 | tracing::trace!("id: {}", id); 40 | 41 | access_claims.validate_role_admin()?; 42 | 43 | let transaction = transaction_repo::get_by_id(id, &state) 44 | .await 45 | .map_err(|e| match e { 46 | sqlx::Error::RowNotFound => APIError::from(TransactionError::TransactionNotFound(id)), 47 | _ => e.into(), 48 | })?; 49 | 50 | Ok(Json(transaction)) 51 | } 52 | 53 | pub async fn transfer_handler( 54 | api_version: APIVersion, 55 | access_claims: AccessClaims, 56 | State(state): State, 57 | Json(transfer_order): Json, 58 | ) -> Result, APIError> { 59 | tracing::trace!("api version: {}", api_version); 60 | tracing::trace!("authentication details: {:#?}", access_claims); 61 | tracing::trace!("transfer: {:?}", transfer_order); 62 | 63 | access_claims.validate_role_admin()?; 64 | 65 | let transaction = transaction_service::transfer( 66 | transfer_order.source_account_id, 67 | transfer_order.destination_account_id, 68 | transfer_order.amount_cents, 69 | &state, 70 | ) 71 | .await?; 72 | 73 | Ok(Json(transaction)) 74 | } 75 | 76 | #[derive(Debug, Error)] 77 | pub enum TransactionError { 78 | #[error("transaction not found: {0}")] 79 | TransactionNotFound(Uuid), 80 | } 81 | 82 | impl From for APIError { 83 | fn from(error: TransactionError) -> Self { 84 | (error.status_code(), vec![APIErrorEntry::from(error)]).into() 85 | } 86 | } 87 | 88 | impl TransactionError { 89 | const fn status_code(&self) -> StatusCode { 90 | match self { 91 | Self::TransactionNotFound(_) => StatusCode::NOT_FOUND, 92 | } 93 | } 94 | } 95 | 96 | impl From for APIErrorEntry { 97 | fn from(transaction_error: TransactionError) -> Self { 98 | let error = Self::new(&transaction_error.to_string()); 99 | match transaction_error { 100 | TransactionError::TransactionNotFound(transaction_id) => error 101 | .code(APIErrorCode::TransactionNotFound) 102 | .kind(APIErrorKind::ResourceNotFound) 103 | .detail(serde_json::json!({"transaction_id": transaction_id})) 104 | .trace_id(), 105 | } 106 | } 107 | } 108 | 109 | impl From for APIError { 110 | fn from(transfer_error: TransferError) -> Self { 111 | match transfer_error { 112 | TransferError::TransferValidationErrors(validation_errors) => { 113 | let errors: Vec<_> = validation_errors 114 | .into_iter() 115 | .map(APIErrorEntry::from) 116 | .collect(); 117 | (StatusCode::UNPROCESSABLE_ENTITY, errors).into() 118 | } 119 | TransferError::SQLxError(e) => e.into(), 120 | } 121 | } 122 | } 123 | 124 | impl From for APIErrorEntry { 125 | fn from(transfer_validation_error: TransferValidationError) -> Self { 126 | let error = Self::new(&transfer_validation_error.to_string()); 127 | match transfer_validation_error { 128 | TransferValidationError::InsufficientFunds => error 129 | .code(APIErrorCode::TransferInsufficientFunds) 130 | .kind(APIErrorKind::ValidationError) 131 | .reason("source account balance must be sufficient to cover the transfer amount") 132 | .trace_id(), 133 | TransferValidationError::SourceAccountNotFound(source_account_id) => error 134 | .code(APIErrorCode::TransferSourceAccountNotFound) 135 | .kind(APIErrorKind::ValidationError) 136 | .detail(serde_json::json!({"source_account_id": source_account_id})) 137 | .reason("must be an existing account") 138 | .trace_id(), 139 | TransferValidationError::DestinationAccountNotFound(destination_account_id) => error 140 | .code(APIErrorCode::TransferDestinationAccountNotFound) 141 | .kind(APIErrorKind::ValidationError) 142 | .detail(serde_json::json!({"destination_account_id": destination_account_id})) 143 | .reason("must be an existing account") 144 | .trace_id(), 145 | TransferValidationError::AccountsAreSame => error 146 | .code(APIErrorCode::TransferAccountsAreSame) 147 | .kind(APIErrorKind::ValidationError) 148 | .reason("source and destination accounts must be different") 149 | .trace_id(), 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/account_tests.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | use uuid::Uuid; 4 | 5 | use axum_web::{ 6 | api::{APIErrorCode, APIErrorKind}, 7 | application::security::roles::UserRole, 8 | domain::models::{account::Account, user::User}, 9 | }; 10 | 11 | pub mod common; 12 | use common::{ 13 | TestError, accounts, 14 | auth::{self, AuthTokens}, 15 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 16 | test_app, users, 17 | }; 18 | 19 | // TODO: run account tests in parallel and remove `serial` dependencies. 20 | 21 | #[serial] 22 | #[tokio::test] 23 | async fn account_unauthorized_test() { 24 | // Start api server. 25 | let test_db = test_app::run().await; 26 | 27 | let account = Account { 28 | id: Uuid::new_v4(), 29 | user_id: Uuid::new_v4(), 30 | balance_cents: 0, 31 | created_at: None, 32 | updated_at: None, 33 | }; 34 | 35 | // Try unauthorized access to account handlers. 36 | let wrong_access_token = "xyz"; 37 | let result = accounts::get(account.id, wrong_access_token).await; 38 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 39 | 40 | let result = accounts::add(account.clone(), wrong_access_token).await; 41 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 42 | 43 | let result = accounts::update(account.clone(), wrong_access_token).await; 44 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 45 | 46 | // Drop test database. 47 | test_db.drop().await.unwrap(); 48 | } 49 | 50 | #[serial] 51 | #[tokio::test] 52 | async fn account_api_error_test() { 53 | // Start api server. 54 | let test_db = test_app::run().await; 55 | 56 | // Login as an admin. 57 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 58 | .await 59 | .expect("Login error."); 60 | 61 | let AuthTokens { 62 | access_token, 63 | refresh_token: _, 64 | } = tokens; 65 | 66 | // Check for non existing account. 67 | let account_id = Uuid::new_v4(); 68 | let result = accounts::get(account_id, &access_token).await; 69 | 70 | assert!(result.is_err()); 71 | 72 | // We do not have error mapping in accounts::get handler, 73 | // let's test the default behavior for database-originated errors. 74 | match result.err().unwrap() { 75 | TestError::APIError(api_error) => { 76 | assert_eq!(api_error.status, StatusCode::NOT_FOUND); 77 | assert_eq!(api_error.errors.len(), 1); 78 | 79 | let error_entry = api_error.errors[0].clone(); 80 | 81 | assert_eq!( 82 | error_entry.code, 83 | Some(APIErrorCode::ResourceNotFound.to_string()) 84 | ); 85 | 86 | assert_eq!( 87 | error_entry.kind, 88 | Some(APIErrorKind::ResourceNotFound.to_string()) 89 | ); 90 | 91 | // We expect to see raw database error message for non production builds. 92 | // The message is valid for PostgreSQL database. 93 | assert!(error_entry.message.contains("no rows returned")); 94 | 95 | // We do not expect the error details exist. 96 | assert_eq!(error_entry.detail, None); 97 | } 98 | _ => panic!("invalid account result"), 99 | } 100 | 101 | // Drop test database. 102 | test_db.drop().await.unwrap(); 103 | } 104 | 105 | #[serial] 106 | #[tokio::test] 107 | async fn account_test() { 108 | // Start api server. 109 | let test_db = test_app::run().await; 110 | 111 | // Login as an admin. 112 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 113 | .await 114 | .expect("Login error."); 115 | 116 | let AuthTokens { 117 | access_token, 118 | refresh_token: _, 119 | } = tokens; 120 | 121 | // Add a test user. 122 | let id = Uuid::new_v4(); 123 | let username = "test"; 124 | let user = User { 125 | id, 126 | username: format!("test-{}-{}", username, id), 127 | email: format!("{}-{}@email.com", username, id), 128 | password_hash: "xyz123".to_string(), 129 | password_salt: "xyz123".to_string(), 130 | active: true, 131 | roles: UserRole::Customer.to_string(), 132 | created_at: None, 133 | updated_at: None, 134 | }; 135 | 136 | let _ = users::add(user.clone(), &access_token) 137 | .await 138 | .expect("User creation error."); 139 | 140 | // Add account for the user. 141 | let mut account = Account { 142 | id: Uuid::new_v4(), 143 | user_id: user.id, 144 | balance_cents: 0, 145 | created_at: None, 146 | updated_at: None, 147 | }; 148 | 149 | // Test for non existence of account. 150 | let result = accounts::get(account.id, &access_token).await; 151 | assert_api_error_status!(result, StatusCode::NOT_FOUND); 152 | 153 | // Add a new account. 154 | let account_added = accounts::add(account.clone(), &access_token) 155 | .await 156 | .expect("Account creation error."); 157 | assert!(account_added.updated_at.is_some()); 158 | assert!(account_added.created_at.is_some()); 159 | account.created_at = account_added.created_at; 160 | account.updated_at = account_added.updated_at; 161 | assert_eq!(account_added, account); 162 | 163 | // Fetch the added account. 164 | let account_fetched = accounts::get(account.id, &access_token) 165 | .await 166 | .expect("Account fetch error."); 167 | assert_eq!(account_fetched, account_added); 168 | 169 | // list existing accounts. 170 | let accounts = accounts::list(&access_token) 171 | .await 172 | .expect("Fetching the account list error."); 173 | assert!(accounts.contains(&account)); 174 | 175 | // Update account. 176 | account.balance_cents = 100; 177 | let account_updated = accounts::update(account.clone(), &access_token) 178 | .await 179 | .expect("Account update error."); 180 | assert_ne!(account_updated.updated_at, account.updated_at); 181 | account.updated_at = account_updated.updated_at; 182 | assert_eq!(account_updated, account); 183 | 184 | // Drop test database. 185 | test_db.drop().await.unwrap(); 186 | } 187 | -------------------------------------------------------------------------------- /src/api/handlers/auth_handlers.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::json; 4 | use sqlx::types::Uuid; 5 | 6 | use crate::{ 7 | api::{APIError, APIErrorCode, APIErrorEntry, APIErrorKind, version::APIVersion}, 8 | application::{ 9 | repository::user_repo, 10 | security::{ 11 | auth::{self, AuthError, JwtTokens}, 12 | jwt::{AccessClaims, ClaimsMethods, RefreshClaims}, 13 | }, 14 | service::token_service, 15 | state::SharedState, 16 | }, 17 | }; 18 | 19 | #[derive(Debug, Serialize, Deserialize)] 20 | pub struct LoginUser { 21 | username: String, 22 | password_hash: String, 23 | } 24 | 25 | #[derive(Debug, Serialize, Deserialize)] 26 | pub struct RevokeUser { 27 | user_id: Uuid, 28 | } 29 | 30 | #[tracing::instrument(level = tracing::Level::TRACE, name = "login", skip_all, fields(username=login.username))] 31 | pub async fn login_handler( 32 | api_version: APIVersion, 33 | State(state): State, 34 | Json(login): Json, 35 | ) -> Result { 36 | tracing::trace!("api version: {}", api_version); 37 | if let Ok(user) = user_repo::get_by_username(&login.username, &state).await { 38 | if user.active && user.password_hash == login.password_hash { 39 | tracing::trace!("access granted, user: {}", user.id); 40 | let tokens = auth::generate_tokens(user, &state.config); 41 | let response = tokens_to_response(tokens); 42 | return Ok(response); 43 | } 44 | } 45 | 46 | tracing::error!("access denied: {:#?}", login); 47 | Err(AuthError::WrongCredentials)? 48 | } 49 | 50 | pub async fn logout_handler( 51 | api_version: APIVersion, 52 | State(state): State, 53 | refresh_claims: RefreshClaims, 54 | ) -> Result { 55 | tracing::trace!("api version: {}", api_version); 56 | tracing::trace!("refresh_claims: {:?}", refresh_claims); 57 | auth::logout(refresh_claims, state).await?; 58 | Ok(()) 59 | } 60 | 61 | pub async fn refresh_handler( 62 | api_version: APIVersion, 63 | State(state): State, 64 | refresh_claims: RefreshClaims, 65 | ) -> Result { 66 | tracing::trace!("api version: {}", api_version); 67 | let new_tokens = auth::refresh(refresh_claims, state).await?; 68 | Ok(tokens_to_response(new_tokens)) 69 | } 70 | 71 | // Revoke all issued tokens until now. 72 | pub async fn revoke_all_handler( 73 | api_version: APIVersion, 74 | State(state): State, 75 | access_claims: AccessClaims, 76 | ) -> Result { 77 | tracing::trace!("api version: {}", api_version); 78 | access_claims.validate_role_admin()?; 79 | token_service::revoke_global(&state).await?; 80 | Ok(()) 81 | } 82 | 83 | // Revoke tokens issued to user until now. 84 | pub async fn revoke_user_handler( 85 | api_version: APIVersion, 86 | State(state): State, 87 | access_claims: AccessClaims, 88 | Json(revoke_user): Json, 89 | ) -> Result { 90 | tracing::trace!("api version: {}", api_version); 91 | if access_claims.sub != revoke_user.user_id.to_string() { 92 | // Only admin can revoke tokens of other users. 93 | access_claims.validate_role_admin()?; 94 | } 95 | tracing::trace!("revoke_user: {:?}", revoke_user); 96 | token_service::revoke_user_tokens(&revoke_user.user_id.to_string(), &state).await?; 97 | Ok(()) 98 | } 99 | 100 | pub async fn cleanup_handler( 101 | api_version: APIVersion, 102 | State(state): State, 103 | access_claims: AccessClaims, 104 | ) -> Result { 105 | tracing::trace!("api version: {}", api_version); 106 | access_claims.validate_role_admin()?; 107 | tracing::trace!("authentication details: {:#?}", access_claims); 108 | let deleted = auth::cleanup_revoked_and_expired(&access_claims, &state).await?; 109 | let json = json!({ 110 | "deleted_tokens": deleted, 111 | }); 112 | Ok(Json(json)) 113 | } 114 | 115 | fn tokens_to_response(jwt_tokens: JwtTokens) -> impl IntoResponse { 116 | let json = json!({ 117 | "access_token": jwt_tokens.access_token, 118 | "refresh_token": jwt_tokens.refresh_token, 119 | "token_type": "Bearer" 120 | }); 121 | 122 | tracing::trace!("JWT: generated response {:#?}", json); 123 | Json(json) 124 | } 125 | 126 | impl From for APIError { 127 | fn from(auth_error: AuthError) -> Self { 128 | let (status_code, code) = match auth_error { 129 | AuthError::WrongCredentials => ( 130 | StatusCode::UNAUTHORIZED, 131 | APIErrorCode::AuthenticationWrongCredentials, 132 | ), 133 | AuthError::MissingCredentials => ( 134 | StatusCode::BAD_REQUEST, 135 | APIErrorCode::AuthenticationMissingCredentials, 136 | ), 137 | AuthError::TokenCreationError => ( 138 | StatusCode::INTERNAL_SERVER_ERROR, 139 | APIErrorCode::AuthenticationTokenCreationError, 140 | ), 141 | AuthError::InvalidToken => ( 142 | StatusCode::BAD_REQUEST, 143 | APIErrorCode::AuthenticationInvalidToken, 144 | ), 145 | AuthError::Forbidden => (StatusCode::FORBIDDEN, APIErrorCode::AuthenticationForbidden), 146 | AuthError::RevokedTokensInactive => ( 147 | StatusCode::BAD_REQUEST, 148 | APIErrorCode::AuthenticationRevokedTokensInactive, 149 | ), 150 | AuthError::RedisError(_) => { 151 | (StatusCode::INTERNAL_SERVER_ERROR, APIErrorCode::RedisError) 152 | } 153 | AuthError::SQLxError(_) => ( 154 | StatusCode::INTERNAL_SERVER_ERROR, 155 | APIErrorCode::DatabaseError, 156 | ), 157 | }; 158 | 159 | let error = APIErrorEntry::new(&auth_error.to_string()) 160 | .code(code) 161 | .kind(APIErrorKind::AuthenticationError); 162 | 163 | Self { 164 | status: status_code.as_u16(), 165 | errors: vec![error], 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/api/server.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc, time::SystemTime}; 2 | 3 | use axum::{ 4 | Json, Router, 5 | body::Body, 6 | extract::{Query, Request}, 7 | http::{HeaderMap, Method, StatusCode}, 8 | middleware::{self, Next}, 9 | response::{IntoResponse, Response}, 10 | routing::{any, get}, 11 | }; 12 | use chrono::Utc; 13 | use serde_json::json; 14 | use tokio::{ 15 | net::TcpListener, 16 | signal::{ 17 | self, 18 | unix::{self, SignalKind}, 19 | }, 20 | }; 21 | use tower_http::cors::{Any, CorsLayer}; 22 | 23 | use crate::{ 24 | api::{ 25 | error::APIError, 26 | routes::{account_routes, auth_routes, transaction_routes, user_routes}, 27 | }, 28 | application::{security::jwt::AccessClaims, state::SharedState}, 29 | }; 30 | 31 | pub async fn start(state: SharedState) { 32 | // Build a CORS layer. 33 | // see https://docs.rs/tower-http/latest/tower_http/cors/index.html 34 | // for more details 35 | let cors_layer = CorsLayer::new().allow_origin(Any); 36 | // let cors_header_value = config.service_http_addr().parse::().unwrap(); 37 | // let cors_layer = CorsLayer::new() 38 | // .allow_origin(cors_header_value) 39 | // .allow_methods([ 40 | // Method::HEAD, 41 | // Method::GET, 42 | // Method::POST, 43 | // Method::PATCH, 44 | // Method::DELETE, 45 | // ]) 46 | // .allow_credentials(true) 47 | // .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]); 48 | 49 | // Build the router. 50 | let router = Router::new() 51 | .route("/", get(root_handler)) 52 | .route("/head", get(head_request_handler)) 53 | .route("/any", any(any_request_handler)) 54 | .route("/{version}/health", get(health_handler)) 55 | .route("/{version}/version", get(version_handler)) 56 | // Nesting authentication routes. 57 | .nest("/{version}/auth", auth_routes::routes()) 58 | // Nesting user routes. 59 | .nest("/{version}/users", user_routes::routes()) 60 | // Nesting account routes. 61 | .nest("/{version}/accounts", account_routes::routes()) 62 | // Nesting transaction routes. 63 | .nest("/{version}/transactions", transaction_routes::routes()) 64 | // Add a fallback service for handling routes to unknown paths. 65 | .fallback(error_404_handler) 66 | .with_state(Arc::clone(&state)) 67 | .layer(cors_layer) 68 | .layer(middleware::from_fn(logging_middleware)); 69 | 70 | // Build the listener. 71 | let addr = state.config.service_socket_addr(); 72 | let listener = TcpListener::bind(&addr).await.unwrap(); 73 | tracing::info!("listening on {}", addr); 74 | 75 | // Start the API service. 76 | axum::serve(listener, router) 77 | .with_graceful_shutdown(shutdown_signal()) 78 | .await 79 | .unwrap(); 80 | 81 | tracing::info!("server shutdown successfully."); 82 | } 83 | 84 | async fn shutdown_signal() { 85 | let ctrl_c = async { 86 | signal::ctrl_c() 87 | .await 88 | .expect("failed to install Ctrl+C handler"); 89 | }; 90 | 91 | #[cfg(unix)] 92 | let terminate = async { 93 | unix::signal(SignalKind::terminate()) 94 | .expect("failed to install signal handler") 95 | .recv() 96 | .await; 97 | }; 98 | 99 | #[cfg(not(unix))] 100 | let terminate = std::future::pending::<()>(); 101 | 102 | tokio::select! { 103 | _ = ctrl_c => {}, 104 | _ = terminate => {}, 105 | } 106 | 107 | tracing::info!("received termination signal, shutting down..."); 108 | } 109 | 110 | #[tracing::instrument(level = tracing::Level::TRACE, name = "axum", skip_all, fields(method=request.method().to_string(), uri=request.uri().to_string()))] 111 | pub async fn logging_middleware(request: Request, next: Next) -> Response { 112 | tracing::trace!( 113 | "received a {} request to {}", 114 | request.method(), 115 | request.uri() 116 | ); 117 | next.run(request).await 118 | } 119 | 120 | // Root handler. 121 | pub async fn root_handler(access_claims: AccessClaims) -> Result { 122 | if tracing::enabled!(tracing::Level::TRACE) { 123 | tracing::trace!("authentication details: {:#?}", access_claims); 124 | let timestamp = SystemTime::now() 125 | .duration_since(std::time::UNIX_EPOCH) 126 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 127 | .as_secs(); 128 | tracing::trace!("timestamp, std::time {}", timestamp); 129 | tracing::trace!("timestamp, chrono::Utc {}", Utc::now().timestamp() as usize); 130 | } 131 | Ok(Json(json!({"message": "Hello from Axum-Web!"}))) 132 | } 133 | 134 | // Health request handler. 135 | pub async fn health_handler() -> Result { 136 | Ok(Json(json!({"status": "healthy"}))) 137 | } 138 | 139 | // Version request handler. 140 | pub async fn version_handler() -> Result { 141 | let result = json!({ 142 | "name": env!("CARGO_PKG_NAME"), 143 | "version": env!("CARGO_PKG_VERSION"), 144 | }); 145 | Ok(Json(result)) 146 | } 147 | 148 | // A sample head request handler. 149 | // Using HEAD requests makes sense if processing (computing) the response body is costly. 150 | pub async fn head_request_handler(method: Method) -> Response { 151 | if method == Method::HEAD { 152 | tracing::debug!("HEAD method found"); 153 | return [("x-some-header", "header from HEAD")].into_response(); 154 | } 155 | ([("x-some-header", "header from GET")], "body from GET").into_response() 156 | } 157 | 158 | // A sample any request handler. 159 | pub async fn any_request_handler( 160 | method: Method, 161 | headers: HeaderMap, 162 | Query(params): Query>, 163 | request: Request, 164 | ) -> impl IntoResponse { 165 | if tracing::enabled!(tracing::Level::DEBUG) { 166 | tracing::debug!("method: {:?}", method); 167 | tracing::debug!("headers: {:?}", headers); 168 | tracing::debug!("params: {:?}", params); 169 | tracing::debug!("request: {:?}", request); 170 | } 171 | StatusCode::OK 172 | } 173 | 174 | // 404 handler. 175 | pub async fn error_404_handler(request: Request) -> impl IntoResponse { 176 | tracing::error!("route not found: {:?}", request); 177 | StatusCode::NOT_FOUND 178 | } 179 | -------------------------------------------------------------------------------- /src/application/service/token_service.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use redis::{AsyncCommands, RedisResult, aio::MultiplexedConnection}; 4 | use tokio::sync::MutexGuard; 5 | 6 | use crate::application::{ 7 | constants::*, 8 | security::jwt::{ClaimsMethods, RefreshClaims}, 9 | state::SharedState, 10 | }; 11 | 12 | pub async fn revoke_global(state: &SharedState) -> RedisResult<()> { 13 | let timestamp_now = chrono::Utc::now().timestamp() as usize; 14 | tracing::debug!("setting a timestamp for global revoke: {}", timestamp_now); 15 | state 16 | .redis 17 | .lock() 18 | .await 19 | .set(JWT_REDIS_REVOKE_GLOBAL_BEFORE_KEY, timestamp_now) 20 | .await 21 | } 22 | 23 | pub async fn revoke_user_tokens(user_id: &str, state: &SharedState) -> RedisResult<()> { 24 | let timestamp_now = chrono::Utc::now().timestamp() as usize; 25 | tracing::debug!( 26 | "adding a timestamp for user revoke, user:{}, timestamp: {}", 27 | user_id, 28 | timestamp_now 29 | ); 30 | state 31 | .redis 32 | .lock() 33 | .await 34 | .hset(JWT_REDIS_REVOKE_USER_BEFORE_KEY, user_id, timestamp_now) 35 | .await 36 | } 37 | 38 | async fn is_global_revoked( 39 | claims: &T, 40 | redis: &mut MutexGuard<'_, redis::aio::MultiplexedConnection>, 41 | ) -> RedisResult { 42 | // Check in global revoke. 43 | let opt_exp: Option = redis.get(JWT_REDIS_REVOKE_GLOBAL_BEFORE_KEY).await?; 44 | if let Some(exp) = opt_exp { 45 | let global_exp = exp.parse::().unwrap(); 46 | if global_exp >= claims.get_iat() { 47 | return Ok(true); 48 | } 49 | } 50 | Ok(false) 51 | } 52 | 53 | async fn is_user_revoked( 54 | claims: &T, 55 | redis: &mut MutexGuard<'_, redis::aio::MultiplexedConnection>, 56 | ) -> RedisResult { 57 | // Check in user revoke. 58 | let user_id = claims.get_sub(); 59 | let opt_exp: Option = redis 60 | .hget(JWT_REDIS_REVOKE_USER_BEFORE_KEY, user_id) 61 | .await?; 62 | if let Some(exp) = opt_exp { 63 | let global_exp = exp.parse::().unwrap(); 64 | if global_exp >= claims.get_iat() { 65 | return Ok(true); 66 | } 67 | } 68 | 69 | Ok(false) 70 | } 71 | 72 | async fn is_token_revoked( 73 | claims: &T, 74 | redis: &mut MutexGuard<'_, redis::aio::MultiplexedConnection>, 75 | ) -> RedisResult { 76 | // Check the token in revoked list. 77 | redis 78 | .hexists(JWT_REDIS_REVOKED_TOKENS_KEY, claims.get_jti()) 79 | .await 80 | } 81 | 82 | pub async fn is_revoked( 83 | claims: &T, 84 | state: &SharedState, 85 | ) -> RedisResult { 86 | let mut redis = state.redis.lock().await; 87 | 88 | let global_revoked = is_global_revoked(claims, &mut redis).await?; 89 | if global_revoked { 90 | tracing::error!("Access denied (globally revoked): {:#?}", claims); 91 | return Ok(true); 92 | } 93 | 94 | let user_revoked = is_user_revoked(claims, &mut redis).await?; 95 | if user_revoked { 96 | tracing::error!("Access denied (user revoked): {:#?}", claims); 97 | return Ok(true); 98 | } 99 | 100 | let token_revoked = is_token_revoked(claims, &mut redis).await?; 101 | if token_revoked { 102 | tracing::error!("Access denied (token revoked): {:#?}", claims); 103 | return Ok(true); 104 | } 105 | 106 | drop(redis); 107 | Ok(false) 108 | } 109 | 110 | pub async fn revoke_refresh_token(claims: &RefreshClaims, state: &SharedState) -> RedisResult<()> { 111 | // Adds refersh token and its paired access token into revoked list in Redis. 112 | // Tokens are tracked by JWT ID that handles the cases of reusing lost tokens and multi-device scenarios. 113 | 114 | let list_to_revoke = vec![&claims.jti, &claims.prf]; 115 | tracing::debug!("adding jwt tokens into revoked list: {:#?}", list_to_revoke); 116 | 117 | let mut redis = state.redis.lock().await; 118 | for claims_jti in list_to_revoke { 119 | let _: () = redis 120 | .hset(JWT_REDIS_REVOKED_TOKENS_KEY, claims_jti, claims.exp) 121 | .await?; 122 | } 123 | 124 | if tracing::enabled!(tracing::Level::TRACE) { 125 | log_revoked_tokens_count(&mut redis).await; 126 | } 127 | drop(redis); 128 | 129 | Ok(()) 130 | } 131 | 132 | pub async fn cleanup_expired(state: &SharedState) -> RedisResult { 133 | let timestamp_now = chrono::Utc::now().timestamp() as usize; 134 | 135 | let mut redis = state.redis.lock().await; 136 | 137 | let revoked_tokens: HashMap = 138 | redis.hgetall(JWT_REDIS_REVOKED_TOKENS_KEY).await?; 139 | 140 | let mut deleted = 0; 141 | for (key, exp) in revoked_tokens { 142 | match exp.parse::() { 143 | Ok(timestamp_exp) => { 144 | if timestamp_now > timestamp_exp { 145 | // Workaround for https://github.com/redis-rs/redis-rs/issues/1322 146 | let _: () = redis.hdel(JWT_REDIS_REVOKED_TOKENS_KEY, key).await?; 147 | deleted += 1; 148 | } 149 | } 150 | Err(e) => { 151 | tracing::error!("{}", e); 152 | } 153 | } 154 | } 155 | 156 | if tracing::enabled!(tracing::Level::TRACE) { 157 | log_revoked_tokens_count(&mut redis).await; 158 | } 159 | drop(redis); 160 | 161 | Ok(deleted) 162 | } 163 | 164 | pub async fn log_revoked_tokens_count(redis: &mut MultiplexedConnection) { 165 | let redis_result: RedisResult = redis.hlen(JWT_REDIS_REVOKED_TOKENS_KEY).await; 166 | match redis_result { 167 | Ok(revoked_tokens_count) => { 168 | tracing::debug!( 169 | "REDIS: count of revoked jwt tokens: {}", 170 | revoked_tokens_count 171 | ); 172 | } 173 | Err(e) => { 174 | tracing::error!("{}", e); 175 | } 176 | } 177 | } 178 | 179 | pub async fn log_revoked_tokens(redis: &mut MultiplexedConnection) { 180 | let redis_result: RedisResult> = 181 | redis.hgetall(JWT_REDIS_REVOKED_TOKENS_KEY).await; 182 | 183 | match redis_result { 184 | Ok(revoked_tokens) => { 185 | tracing::trace!("REDIS: list of revoked jwt tokens: {:#?}", revoked_tokens); 186 | } 187 | Err(e) => { 188 | tracing::error!("{}", e); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/endpoints.http: -------------------------------------------------------------------------------- 1 | ### Health check (public). 2 | GET http://127.0.0.1:8080/v1/health 3 | 4 | ### Version check (public). 5 | GET http://127.0.0.1:8080/v1/version 6 | 7 | ### Login. 8 | ### Note: keep the returned tokens to access the protected routes 9 | POST http://127.0.0.1:8080/v1/auth/login 10 | Content-type: application/json; charset=utf8 11 | 12 | { 13 | "username": "admin", 14 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51" 15 | } 16 | 17 | ### Root (protected) . 18 | ### Note: use the `access_token` in `Authorization` header. 19 | GET http://127.0.0.1:8080 20 | Content-type: application/json; charset=utf8 21 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2YTlkYjE4ZC1iZTE2LTQ2ZTUtOGM5Yy0yMjdjZGFjNWE0ZDYiLCJqdGkiOiJlNmEzODFhMy1lMjRlLTQ2YTAtOWY0YS04YTI2N2ViMjBjODMiLCJpYXQiOjE3MzU5ODU0NTcsImV4cCI6MTczNTk4OTA1NywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0._Fzanf_VsXO8fRpSlHSujnEGYmnCXtvbT9r7ujFSzbs 22 | 23 | ### Refresh tokens. 24 | ### Note: use the `refresh_token` in `Authorization` header. 25 | POST http://127.0.0.1:8080/v1/auth/refresh 26 | Content-type: application/json; charset=utf8 27 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2YTlkYjE4ZC1iZTE2LTQ2ZTUtOGM5Yy0yMjdjZGFjNWE0ZDYiLCJqdGkiOiJjNTk4MjliOC01Y2MyLTQ4ZDYtYjkzMy0yNTNhZWQ0OTBlYzAiLCJpYXQiOjE3MzU5ODUxMzgsImV4cCI6MTczNTk4ODczOCwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.uS5vlB5l4TL0xFqEwcHwJApsJPjFFuxwRnLVuhm2wyk 28 | 29 | ### Logout. 30 | ### Note: use the `refresh_token` in `Authorization` header. 31 | POST http://127.0.0.1:8080/v1/auth/logout 32 | Content-type: application/json; charset=utf8 33 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2YTlkYjE4ZC1iZTE2LTQ2ZTUtOGM5Yy0yMjdjZGFjNWE0ZDYiLCJqdGkiOiIwNWE2YjRhZi03N2Y1LTQzMWMtOTZkNC1mZDlhYTUzYzkzMTIiLCJpYXQiOjE3MzU5ODUzMzgsImV4cCI6MTc0Mzc2MTMzOCwicHJmIjoiMjRkMzc0NWEtMmI1ZS00OTBkLWFlYmUtNzVhMDc1NDEyMTY0IiwicGV4IjoxNzM1OTg4OTM4LCJ0eXAiOjEsInJvbGVzIjoiYWRtaW4ifQ.NVF-mdpK-b6cLEFFLj103dW5fmzmjV9gaNxAph6AXME 34 | 35 | ### Revoke tokens issued to the user. 36 | ### Note: use the `access_token` in `Authorization` header. 37 | POST http://127.0.0.1:8080/v1/auth/revoke-user 38 | Content-type: application/json; charset=utf8 39 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYzUxN2IwMC05NjhhLTRiOTUtYWY5NC1kZjBmZDI4NmZiNTEiLCJqdGkiOiJBVDo2NTZlZGI3OC03MDlhLTRjMTQtOWIyOC1mNDAwMzk0MjkxMjgiLCJpYXQiOjE3MDM4OTAxNzEsImV4cCI6MTcwMzg5Mzc3MX0.-pduSKuPUn2HF9dMVHyVswTtV59DabmgiNB5sf7M3qo 40 | 41 | { "user_id" : "617646a0-7437-48a0-bb03-a7aa830f8f81" } 42 | 43 | ### Revoke all issued tokens. 44 | ### Note: use the `access_token` in `Authorization` header. 45 | POST http://127.0.0.1:8080/v1/auth/revoke-all 46 | Content-type: application/json; charset=utf8 47 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2YTlkYjE4ZC1iZTE2LTQ2ZTUtOGM5Yy0yMjdjZGFjNWE0ZDYiLCJqdGkiOiJlNmEzODFhMy1lMjRlLTQ2YTAtOWY0YS04YTI2N2ViMjBjODMiLCJpYXQiOjE3MzU5ODU0NTcsImV4cCI6MTczNTk4OTA1NywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0._Fzanf_VsXO8fRpSlHSujnEGYmnCXtvbT9r7ujFSzbs 48 | 49 | ### Cleanup revoked tokens. 50 | ### Note: use the `access_token` in `Authorization` header. 51 | POST http://127.0.0.1:8080/v1/auth/cleanup 52 | Content-type: application/json; charset=utf8 53 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2YTlkYjE4ZC1iZTE2LTQ2ZTUtOGM5Yy0yMjdjZGFjNWE0ZDYiLCJqdGkiOiJmYzRiMTM2NC0zMTQ5LTQ1OWYtYmM1Ni05YTRlNTc2NDFjNTIiLCJpYXQiOjE3MzU5ODU1MTAsImV4cCI6MTczNTk4OTExMCwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.w4Q-fU1CcOoQxiimDbKcecAH05YCEYMrtQ6Pq8Sv8p4 54 | 55 | ### List users. 56 | ### Note: use the `access_token` in `Authorization` header. 57 | GET http://127.0.0.1:8080/v1/users 58 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxY2I2N2JjZC1mYTZiLTRlYTAtYWU4YS03YThhMDhlZWVkY2IiLCJqdGkiOiI2MWYzODU2Yy1iNTFjLTRkZjEtYTNmNC1iYWE0YmRjMDI1ZTkiLCJpYXQiOjE3MzU5OTM5OTAsImV4cCI6MTczNTk5NzU5MCwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.QlrSwP-w-OzdlKyClEVexlydjf0qJlibY1Rjf3PUqTU 59 | 60 | ### Get user by id. 61 | ### Note: use the `access_token` in `Authorization` header. 62 | GET http://127.0.0.1:8080/v1/users/617646a0-7437-48a0-bb03-a7aa830f8f81 63 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYzUxN2IwMC05NjhhLTRiOTUtYWY5NC1kZjBmZDI4NmZiNTEiLCJqdGkiOiJBVGUxODcxNjcwLTU3OTAtNDk0ZS1iMWQwLWYzOWM2NzJmYjg5YyIsImlhdCI6MTcwMzc3ODQwOSwiZXhwIjoxNzAzNzgyMDA5fQ.FWd0cyS5FfcmANp87kEVLt1lJtwgoe4d7rh49ZGKrC0 64 | 65 | ### Add a new user. 66 | ### Note: use the `access_token` in `Authorization` header. 67 | POST http://127.0.0.1:8080/v1/users 68 | Content-type: application/json; charset=utf8 69 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYzUxN2IwMC05NjhhLTRiOTUtYWY5NC1kZjBmZDI4NmZiNTEiLCJqdGkiOiJBVGUxODcxNjcwLTU3OTAtNDk0ZS1iMWQwLWYzOWM2NzJmYjg5YyIsImlhdCI6MTcwMzc3ODQwOSwiZXhwIjoxNzAzNzgyMDA5fQ.FWd0cyS5FfcmANp87kEVLt1lJtwgoe4d7rh49ZGKrC0 70 | 71 | { 72 | "id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 73 | "username": "admin2", 74 | "email": "admin2@admin.com", 75 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51", 76 | "password_salt": "pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF" 77 | } 78 | 79 | ### Update user. 80 | ### Note: use the `access_token` in `Authorization` header. 81 | PUT http://127.0.0.1:8080/v1/users/917646a0-7437-48a0-bb03-a7aa830f8f81 82 | Content-type: application/json; charset=utf8 83 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYzUxN2IwMC05NjhhLTRiOTUtYWY5NC1kZjBmZDI4NmZiNTEiLCJqdGkiOiJBVGUxODcxNjcwLTU3OTAtNDk0ZS1iMWQwLWYzOWM2NzJmYjg5YyIsImlhdCI6MTcwMzc3ODQwOSwiZXhwIjoxNzAzNzgyMDA5fQ.FWd0cyS5FfcmANp87kEVLt1lJtwgoe4d7rh49ZGKrC0 84 | 85 | { 86 | "id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 87 | "username": "admin21", 88 | "email": "admin21@admin.com", 89 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51", 90 | "password_salt": "pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF" 91 | } 92 | 93 | ### Delete user. 94 | ### Note: use the `access_token` in `Authorization` header. 95 | DELETE http://127.0.0.1:8080/v1/users/917646a0-7437-48a0-bb03-a7aa830f8f81 96 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYzUxN2IwMC05NjhhLTRiOTUtYWY5NC1kZjBmZDI4NmZiNTEiLCJqdGkiOiJBVGUxODcxNjcwLTU3OTAtNDk0ZS1iMWQwLWYzOWM2NzJmYjg5YyIsImlhdCI6MTcwMzc3ODQwOSwiZXhwIjoxNzAzNzgyMDA5fQ.FWd0cyS5FfcmANp87kEVLt1lJtwgoe4d7rh49ZGKrC0 97 | 98 | ### List accounts. 99 | ### Note: use the `access_token` in `Authorization` header. 100 | GET http://127.0.0.1:8080/v1/accounts 101 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiJjZTE3ODk2My0wZGY2LTQxOGYtOTUwMy02NjY1OGU3ZDA3ZTQiLCJpYXQiOjE3MzkxMDQ5NTMsImV4cCI6MTczOTEwODU1MywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.R5noU7ZIbmOWMev54Uk13Ykpkl02zFhP7C1XMEW-FgQ 102 | 103 | ### Get account by id. 104 | ### Note: use the `access_token` in `Authorization` header. 105 | GET http://127.0.0.1:8080/v1/accounts/9a7a8f34-a949-42e3-b552-e1818069003c 106 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiJjZTE3ODk2My0wZGY2LTQxOGYtOTUwMy02NjY1OGU3ZDA3ZTQiLCJpYXQiOjE3MzkxMDQ5NTMsImV4cCI6MTczOTEwODU1MywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.R5noU7ZIbmOWMev54Uk13Ykpkl02zFhP7C1XMEW-FgQ 107 | 108 | ### Add a new account. 109 | ### Note: use the `access_token` in `Authorization` header. 110 | POST http://127.0.0.1:8080/v1/accounts 111 | Content-type: application/json; charset=utf8 112 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiJjZTE3ODk2My0wZGY2LTQxOGYtOTUwMy02NjY1OGU3ZDA3ZTQiLCJpYXQiOjE3MzkxMDQ5NTMsImV4cCI6MTczOTEwODU1MywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.R5noU7ZIbmOWMev54Uk13Ykpkl02zFhP7C1XMEW-FgQ 113 | 114 | { 115 | "id": "72022566-44e2-44a4-bf07-485c6a56d506", 116 | "user_id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 117 | "balance_cents": 0 118 | } 119 | 120 | ### Update account. 121 | ### Note: use the `access_token` in `Authorization` header. 122 | PUT http://127.0.0.1:8080/v1/accounts/917646a0-7437-48a0-bb03-a7aa830f8f81 123 | Content-type: application/json; charset=utf8 124 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiJjZTE3ODk2My0wZGY2LTQxOGYtOTUwMy02NjY1OGU3ZDA3ZTQiLCJpYXQiOjE3MzkxMDQ5NTMsImV4cCI6MTczOTEwODU1MywidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.R5noU7ZIbmOWMev54Uk13Ykpkl02zFhP7C1XMEW-FgQ 125 | 126 | { 127 | "id": "72022566-44e2-44a4-bf07-485c6a56d506", 128 | "user_id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 129 | "balance_cents": 100 130 | } 131 | 132 | ### Transaction: transfer money. 133 | ### Note: use the `access_token` in `Authorization` header. 134 | POST http://127.0.0.1:8080/v1/transactions/transfer 135 | Content-type: application/json; charset=utf8 136 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiI5NDJlMzJlMy02NWQ2LTQwOGItODI3Ni0zMmQwOTQwM2Q4OWQiLCJpYXQiOjE3MzkxMTIwNjgsImV4cCI6MTczOTExNTY2OCwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.OzdumWL6WqS1cD21yfyGrgE8Gfs75ISvOF71WuRoxh4 137 | 138 | { 139 | "source_account_id": "9a7a8f34-a949-42e3-b552-e1818069003c", 140 | "destination_account_id": "94e331e9-d300-42db-a075-df6d49f81361", 141 | "amount_cents": 25 142 | } 143 | 144 | ### Get transaction by id. 145 | ### Note: use the `access_token` in `Authorization` header. 146 | GET http://127.0.0.1:8080/v1/transactions/bab1896d-ce16-44a2-8a3f-05a0602eea3d 147 | Content-type: application/json; charset=utf8 148 | Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1NTY5YTMyNi0zZTU3LTRiYjYtOTQ2Yy0xYmY3YjQwYjhjMzMiLCJqdGkiOiI5NDJlMzJlMy02NWQ2LTQwOGItODI3Ni0zMmQwOTQwM2Q4OWQiLCJpYXQiOjE3MzkxMTIwNjgsImV4cCI6MTczOTExNTY2OCwidHlwIjowLCJyb2xlcyI6ImFkbWluIn0.OzdumWL6WqS1cD21yfyGrgE8Gfs75ISvOF71WuRoxh4 149 | 150 | ### END. -------------------------------------------------------------------------------- /docs/api-docs.md: -------------------------------------------------------------------------------- 1 | # REST API Documentation 2 | 3 | ## Health Check (Public) 4 | 5 | **Endpoint:** `GET /v1/health` 6 | 7 | **Description:** Checks the health of the service. 8 | 9 | --- 10 | 11 | ## Version Check (Public) 12 | 13 | **Endpoint:** `GET /v1/version` 14 | 15 | **Description:** Retrieves the version of the service. 16 | 17 | --- 18 | 19 | ## Login 20 | 21 | **Endpoint:** `POST /v1/auth/login` 22 | 23 | **Description:** Authenticates a user and returns tokens. 24 | 25 | **Headers:** 26 | 27 | - `Content-Type: application/json; charset=utf8` 28 | 29 | **Request Body:** 30 | 31 | ```json 32 | { 33 | "username": "admin", 34 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51" 35 | } 36 | ``` 37 | 38 | --- 39 | 40 | ## Root (Protected) 41 | 42 | **Endpoint:** `GET /` 43 | 44 | **Description:** Accesses the root endpoint. 45 | 46 | **Headers:** 47 | 48 | - `Content-Type: application/json; charset=utf8` 49 | - `Authorization: Bearer ` 50 | 51 | --- 52 | 53 | ## Refresh Tokens 54 | 55 | **Endpoint:** `POST /v1/auth/refresh` 56 | 57 | **Description:** Refreshes the tokens. 58 | 59 | **Headers:** 60 | 61 | - `Content-Type: application/json; charset=utf8` 62 | - `Authorization: Bearer ` 63 | 64 | --- 65 | 66 | ## Logout 67 | 68 | **Endpoint:** `POST /v1/auth/logout` 69 | 70 | **Description:** Logs out the user. 71 | 72 | **Headers:** 73 | 74 | - `Content-Type: application/json; charset=utf8` 75 | - `Authorization: Bearer ` 76 | 77 | --- 78 | 79 | ## Revoke Tokens Issued to the User 80 | 81 | **Endpoint:** `POST /v1/auth/revoke-user` 82 | 83 | **Description:** Revokes tokens issued to the user. 84 | 85 | **Headers:** 86 | 87 | - `Content-Type: application/json; charset=utf8` 88 | - `Authorization: Bearer ` 89 | 90 | **Request Body:** 91 | 92 | ```json 93 | { "user_id" : "617646a0-7437-48a0-bb03-a7aa830f8f81" } 94 | ``` 95 | 96 | --- 97 | 98 | ## Revoke All Issued Tokens 99 | 100 | **Endpoint:** `POST /v1/auth/revoke-all` 101 | 102 | **Description:** Revokes all issued tokens. 103 | 104 | **Headers:** 105 | 106 | - `Content-Type: application/json; charset=utf8` 107 | - `Authorization: Bearer ` 108 | 109 | --- 110 | 111 | ## Cleanup Revoked Tokens 112 | 113 | **Endpoint:** `POST /v1/auth/cleanup` 114 | 115 | **Description:** Cleans up revoked tokens. 116 | 117 | **Headers:** 118 | 119 | - `Content-Type: application/json; charset=utf8` 120 | - `Authorization: Bearer ` 121 | 122 | --- 123 | 124 | ## List Users 125 | 126 | **Endpoint:** `GET /v1/users` 127 | 128 | **Description:** Lists all users. 129 | 130 | **Headers:** 131 | 132 | - `Authorization: Bearer ` 133 | 134 | --- 135 | 136 | ## Get User by ID 137 | 138 | **Endpoint:** `GET /v1/users/{user_id}` 139 | 140 | **Description:** Retrieves a user by ID. 141 | 142 | **Headers:** 143 | 144 | - `Authorization: Bearer ` 145 | 146 | --- 147 | 148 | ## Add a New User 149 | 150 | **Endpoint:** `POST /v1/users` 151 | 152 | **Description:** Adds a new user. 153 | 154 | **Headers:** 155 | 156 | - `Content-Type: application/json; charset=utf8` 157 | - `Authorization: Bearer ` 158 | 159 | **Request Body:** 160 | 161 | ```json 162 | { 163 | "id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 164 | "username": "admin2", 165 | "email": "admin2@admin.com", 166 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51", 167 | "password_salt": "pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF" 168 | } 169 | ``` 170 | 171 | --- 172 | 173 | ## Update User 174 | 175 | **Endpoint:** `PUT /v1/users/{user_id}` 176 | 177 | **Description:** Updates a user. 178 | 179 | **Headers:** 180 | 181 | - `Content-Type: application/json; charset=utf8` 182 | - `Authorization: Bearer ` 183 | 184 | **Request Body:** 185 | 186 | ```json 187 | { 188 | "id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 189 | "username": "admin21", 190 | "email": "admin21@admin.com", 191 | "password_hash": "7c44575b741f02d49c3e988ba7aa95a8fb6d90c0ef63a97236fa54bfcfbd9d51", 192 | "password_salt": "pjZKk6A8YtC8$9p&UIp62bv4PLwD7@dF" 193 | } 194 | ``` 195 | 196 | --- 197 | 198 | ## Delete User 199 | 200 | **Endpoint:** `DELETE /v1/users/{user_id}` 201 | 202 | **Description:** Deletes a user. 203 | 204 | **Headers:** 205 | 206 | - `Authorization: Bearer ` 207 | 208 | --- 209 | 210 | ## List Accounts 211 | 212 | **Endpoint:** `GET /v1/accounts` 213 | 214 | **Description:** Lists all accounts. 215 | 216 | **Headers:** 217 | 218 | - `Authorization: Bearer ` 219 | 220 | --- 221 | 222 | ## Get Account by ID 223 | 224 | **Endpoint:** `GET /v1/accounts/{account_id}` 225 | 226 | **Description:** Retrieves an account by ID. 227 | 228 | **Headers:** 229 | 230 | - `Authorization: Bearer ` 231 | 232 | --- 233 | 234 | ## Add a New Account 235 | 236 | **Endpoint:** `POST /v1/accounts` 237 | 238 | **Description:** Adds a new account. 239 | 240 | **Headers:** 241 | 242 | - `Content-Type: application/json; charset=utf8` 243 | - `Authorization: Bearer ` 244 | 245 | **Request Body:** 246 | 247 | ```json 248 | { 249 | "id": "72022566-44e2-44a4-bf07-485c6a56d506", 250 | "user_id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 251 | "balance_cents": 0 252 | } 253 | ``` 254 | 255 | --- 256 | 257 | ## Update Account 258 | 259 | **Endpoint:** `PUT /v1/accounts/{account_id}` 260 | 261 | **Description:** Updates an account. 262 | 263 | **Headers:** 264 | 265 | - `Content-Type: application/json; charset=utf8` 266 | - `Authorization: Bearer ` 267 | 268 | **Request Body:** 269 | 270 | ```json 271 | { 272 | "id": "72022566-44e2-44a4-bf07-485c6a56d506", 273 | "user_id": "917646a0-7437-48a0-bb03-a7aa830f8f81", 274 | "balance_cents": 100 275 | } 276 | ``` 277 | 278 | --- 279 | 280 | ## Transaction: Transfer Money 281 | 282 | **Endpoint:** `POST /v1/transactions/transfer` 283 | 284 | **Description:** Transfers money between accounts. 285 | 286 | **Headers:** 287 | 288 | - `Content-Type: application/json; charset=utf8` 289 | - `Authorization: Bearer ` 290 | 291 | **Request Body:** 292 | 293 | ```json 294 | { 295 | "source_account_id": "9a7a8f34-a949-42e3-b552-e1818069003c", 296 | "destination_account_id": "94e331e9-d300-42db-a075-df6d49f81361", 297 | "amount_cents": 25 298 | } 299 | ``` 300 | 301 | --- 302 | 303 | ## Get Transaction by ID 304 | 305 | **Endpoint:** `GET /v1/transactions/{transaction_id}` 306 | 307 | **Description:** Retrieves a transaction by ID. 308 | 309 | **Headers:** 310 | 311 | - `Content-Type: application/json; charset=utf8` 312 | - `Authorization: Bearer ` 313 | 314 | --- 315 | 316 | ## Errors 317 | 318 | ### The possible error codes and description 319 | 320 | - `authentication_wrong_credentials`: The provided credentials are incorrect. 321 | - `authentication_missing_credentials`: Required authentication credentials are missing. 322 | - `authentication_token_creation_error`: There was an error creating the authentication token. 323 | - `authentication_invalid_token`: The provided authentication token is invalid. 324 | - `authentication_revoked_tokens_inactive`: The provided token has been revoked and is inactive. 325 | - `authentication_forbidden`: The user does not have permission to access the requested resource. 326 | - `user_not_found`: The specified user was not found. 327 | - `transaction_not_found`: The specified transaction was not found. 328 | - `transfer_insufficient_funds`: The source account does not have sufficient funds for the transfer. 329 | - `transfer_source_account_not_found`: The source account for the transfer was not found. 330 | - `transfer_destination_account_not_found`: The destination account for the transfer was not found. 331 | - `transfer_accounts_are_same`: The source and destination accounts for the transfer are the same. 332 | - `resource_not_found`: The requested resource was not found. 333 | - `api_version_error`: There is an error with the API version. 334 | - `database_error`: There was an error with the database operation. 335 | - `redis_error`: There was an error with the Redis operation. 336 | 337 | ### The possible error kinds and description 338 | 339 | - `authentication_error`: An error occurred during the authentication process. 340 | - `resource_not_found`: The requested resource could not be found. 341 | - `validation_error`: There was a validation error with the provided data. 342 | - `database_error`: An error occurred with the database operation. 343 | - `redis_error`: An error occurred with the Redis operation. 344 | 345 | ### API error response samples 346 | 347 | ```json 348 | { 349 | "status": 404, 350 | "errors": [ 351 | { 352 | "code": "user_not_found", 353 | "kind": "resource_not_found", 354 | "message": "user not found: 12345", 355 | "description": "user with the ID '12345' does not exist in our records", 356 | "detail": { "user_id": "12345" }, 357 | "reason": "must be an existing user", 358 | "instance": "/api/v1/users/12345", 359 | "trace_id": "3d2b4f2d00694354a00522fe3bb86158", 360 | "timestamp": "2024-01-19T16:58:34.123+0000", 361 | "help": "please check if the user ID is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information", 362 | "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 363 | } 364 | ] 365 | } 366 | ``` 367 | 368 | ```json 369 | { 370 | "status": 422, 371 | "errors": [ 372 | { 373 | "code": "transfer_insufficient_funds", 374 | "kind": "validation_error", 375 | "message": "source account does not have sufficient funds for the transfer", 376 | "reason": "source account balance must be sufficient to cover the transfer amount", 377 | "instance": "/api/v1/transactions/transfer", 378 | "trace_id": "fbb9fdf5394d4abe8e42b49c3246310b", 379 | "timestamp": "2024-01-19T16:58:35.225+0000", 380 | "help": "please check the source account balance or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information", 381 | "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 382 | }, 383 | { 384 | "code": "transfer_destination_account_not_found", 385 | "kind": "validation_error", 386 | "message": "destination account not found: d424cfe9-c042-41db-9a8e-8da5715fea10", 387 | "detail": { "destination_account_id": "d424cfe9-c042-41db-9a8e-8da5715fea10" }, 388 | "reason": "must be an existing account", 389 | "instance": "/api/v1/transactions/transfer", 390 | "trace_id": "8a250eaa650943b085934771fb35ba54", 391 | "timestamp": "2024-01-19T16:59:03.124+0000", 392 | "help": "please check if the destination account ID is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information.", 393 | "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 394 | }, 395 | ] 396 | } 397 | ``` 398 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # Root options 13 | 14 | # The graph table configures how the dependency graph is constructed and thus 15 | # which crates the checks are performed against 16 | [graph] 17 | # If 1 or more target triples (and optionally, target_features) are specified, 18 | # only the specified targets will be checked when running `cargo deny check`. 19 | # This means, if a particular package is only ever used as a target specific 20 | # dependency, such as, for example, the `nix` crate only being used via the 21 | # `target_family = "unix"` configuration, that only having windows targets in 22 | # this list would mean the nix crate, as well as any of its exclusive 23 | # dependencies not shared by any other crates, would be ignored, as the target 24 | # list here is effectively saying which targets you are building for. 25 | targets = [ 26 | # The triple can be any string, but only the target triples built in to 27 | # rustc (as of 1.40) can be checked against actual config expressions 28 | #"x86_64-unknown-linux-musl", 29 | # You can also specify which target_features you promise are enabled for a 30 | # particular target. target_features are currently not validated against 31 | # the actual valid features supported by the target architecture. 32 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 33 | ] 34 | # When creating the dependency graph used as the source of truth when checks are 35 | # executed, this field can be used to prune crates from the graph, removing them 36 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 37 | # is pruned from the graph, all of its dependencies will also be pruned unless 38 | # they are connected to another crate in the graph that hasn't been pruned, 39 | # so it should be used with care. The identifiers are [Package ID Specifications] 40 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 41 | #exclude = [] 42 | # If true, metadata will be collected with `--all-features`. Note that this can't 43 | # be toggled off if true, if you want to conditionally enable `--all-features` it 44 | # is recommended to pass `--all-features` on the cmd line instead 45 | all-features = false 46 | # If true, metadata will be collected with `--no-default-features`. The same 47 | # caveat with `all-features` applies 48 | no-default-features = false 49 | # If set, these feature will be enabled when collecting metadata. If `--features` 50 | # is specified on the cmd line they will take precedence over this option. 51 | #features = [] 52 | 53 | # The output table provides options for how/if diagnostics are outputted 54 | [output] 55 | # When outputting inclusion graphs in diagnostics that include features, this 56 | # option can be used to specify the depth at which feature edges will be added. 57 | # This option is included since the graphs can be quite large and the addition 58 | # of features from the crate(s) to all of the graph roots can be far too verbose. 59 | # This option can be overridden via `--feature-depth` on the cmd line 60 | feature-depth = 1 61 | 62 | # This section is considered when running `cargo deny check advisories` 63 | # More documentation for the advisories section can be found here: 64 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 65 | [advisories] 66 | # The path where the advisory databases are cloned/fetched into 67 | #db-path = "$CARGO_HOME/advisory-dbs" 68 | # The url(s) of the advisory databases to use 69 | #db-urls = ["https://github.com/rustsec/advisory-db"] 70 | # A list of advisory IDs to ignore. Note that ignored advisories will still 71 | # output a note when they are encountered. 72 | ignore = [ 73 | #"RUSTSEC-0000-0000", 74 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 75 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 76 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 77 | ] 78 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 79 | # If this is false, then it uses a built-in git library. 80 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 81 | # See Git Authentication for more information about setting up git authentication. 82 | #git-fetch-with-cli = true 83 | 84 | # This section is considered when running `cargo deny check licenses` 85 | # More documentation for the licenses section can be found here: 86 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 87 | [licenses] 88 | # List of explicitly allowed licenses 89 | # See https://spdx.org/licenses/ for list of possible licenses 90 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 91 | allow = [ 92 | "MIT", 93 | "Apache-2.0", 94 | "OpenSSL", 95 | "Unicode-3.0", 96 | "MPL-2.0", 97 | "ISC", 98 | "BSD-3-Clause", 99 | "Zlib", 100 | "CDLA-Permissive-2.0", 101 | "BSL-1.0", 102 | #"Apache-2.0 WITH LLVM-exception", 103 | ] 104 | # The confidence threshold for detecting a license from license text. 105 | # The higher the value, the more closely the license text must be to the 106 | # canonical license text of a valid SPDX license file. 107 | # [possible values: any between 0.0 and 1.0]. 108 | confidence-threshold = 0.8 109 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 110 | # aren't accepted for every possible crate as with the normal allow list 111 | exceptions = [ 112 | # Each entry is the crate and version constraint, and its specific allow 113 | # list 114 | #{ allow = ["Zlib"], crate = "adler32" }, 115 | ] 116 | 117 | # Some crates don't have (easily) machine readable licensing information, 118 | # adding a clarification entry for it allows you to manually specify the 119 | # licensing information 120 | [[licenses.clarify]] 121 | # The package spec the clarification applies to 122 | crate = "ring" 123 | # The SPDX expression for the license requirements of the crate 124 | expression = "MIT AND ISC AND OpenSSL" 125 | # One or more files in the crate's source used as the "source of truth" for 126 | # the license expression. If the contents match, the clarification will be used 127 | # when running the license check, otherwise the clarification will be ignored 128 | # and the crate will be checked normally, which may produce warnings or errors 129 | # depending on the rest of your configuration 130 | license-files = [ 131 | # Each entry is a crate relative path, and the (opaque) hash of its contents 132 | { path = "LICENSE", hash = 0xbd0eed23 } 133 | ] 134 | 135 | [licenses.private] 136 | # If true, ignores workspace crates that aren't published, or are only 137 | # published to private registries. 138 | # To see how to mark a crate as unpublished (to the official registry), 139 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 140 | ignore = false 141 | # One or more private registries that you might publish crates to, if a crate 142 | # is only published to private registries, and ignore is true, the crate will 143 | # not have its license(s) checked 144 | registries = [ 145 | #"https://sekretz.com/registry 146 | ] 147 | 148 | # This section is considered when running `cargo deny check bans`. 149 | # More documentation about the 'bans' section can be found here: 150 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 151 | [bans] 152 | # Lint level for when multiple versions of the same crate are detected 153 | multiple-versions = "allow" 154 | # Lint level for when a crate version requirement is `*` 155 | wildcards = "allow" 156 | # The graph highlighting used when creating dotgraphs for crates 157 | # with multiple versions 158 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 159 | # * simplest-path - The path to the version with the fewest edges is highlighted 160 | # * all - Both lowest-version and simplest-path are used 161 | highlight = "all" 162 | # The default lint level for `default` features for crates that are members of 163 | # the workspace that is being checked. This can be overridden by allowing/denying 164 | # `default` on a crate-by-crate basis if desired. 165 | workspace-default-features = "allow" 166 | # The default lint level for `default` features for external crates that are not 167 | # members of the workspace. This can be overridden by allowing/denying `default` 168 | # on a crate-by-crate basis if desired. 169 | external-default-features = "allow" 170 | # List of crates that are allowed. Use with care! 171 | allow = [ 172 | #"ansi_term@0.11.0", 173 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 174 | ] 175 | # List of crates to deny 176 | deny = [ 177 | #"ansi_term@0.11.0", 178 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 179 | # Wrapper crates can optionally be specified to allow the crate when it 180 | # is a direct dependency of the otherwise banned crate 181 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 182 | ] 183 | 184 | # List of features to allow/deny 185 | # Each entry the name of a crate and a version range. If version is 186 | # not specified, all versions will be matched. 187 | #[[bans.features]] 188 | #crate = "reqwest" 189 | # Features to not allow 190 | #deny = ["json"] 191 | # Features to allow 192 | #allow = [ 193 | # "rustls", 194 | # "__rustls", 195 | # "__tls", 196 | # "hyper-rustls", 197 | # "rustls", 198 | # "rustls-pemfile", 199 | # "rustls-tls-webpki-roots", 200 | # "tokio-rustls", 201 | # "webpki-roots", 202 | #] 203 | # If true, the allowed features must exactly match the enabled feature set. If 204 | # this is set there is no point setting `deny` 205 | #exact = true 206 | 207 | # Certain crates/versions that will be skipped when doing duplicate detection. 208 | skip = [ 209 | #"ansi_term@0.11.0", 210 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 211 | ] 212 | # Similarly to `skip` allows you to skip certain crates during duplicate 213 | # detection. Unlike skip, it also includes the entire tree of transitive 214 | # dependencies starting at the specified crate, up to a certain depth, which is 215 | # by default infinite. 216 | skip-tree = [ 217 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 218 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 219 | ] 220 | 221 | # This section is considered when running `cargo deny check sources`. 222 | # More documentation about the 'sources' section can be found here: 223 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 224 | [sources] 225 | # Lint level for what to happen when a crate from a crate registry that is not 226 | # in the allow list is encountered 227 | unknown-registry = "warn" 228 | # Lint level for what to happen when a crate from a git repository that is not 229 | # in the allow list is encountered 230 | unknown-git = "warn" 231 | # List of URLs for allowed crate registries. Defaults to the crates.io index 232 | # if not specified. If it is specified but empty, no registries are allowed. 233 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 234 | # List of URLs for allowed Git repositories 235 | allow-git = [] 236 | 237 | [sources.allow-org] 238 | # github.com organizations to allow git sources for 239 | github = [] 240 | # gitlab.com organizations to allow git sources for 241 | gitlab = [] 242 | # bitbucket.org organizations to allow git sources for 243 | bitbucket = [] 244 | -------------------------------------------------------------------------------- /src/api/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter, Result}; 2 | 3 | use axum::{ 4 | Json, 5 | http::StatusCode, 6 | response::{IntoResponse, Response}, 7 | }; 8 | use chrono::{DateTime, Utc}; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | pub const API_DOCUMENT_URL: &str = 12 | "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md"; 13 | 14 | // API error response samples: 15 | // 16 | // { 17 | // "status": 404, 18 | // "errors": [ 19 | // { 20 | // "code": "user_not_found", 21 | // "kind": "resource_not_found", 22 | // "message": "user not found: 12345", 23 | // "description": "user with the ID '12345' does not exist in our records", 24 | // "detail": { "user_id": "12345" }, 25 | // "reason": "must be an existing user", 26 | // "instance": "/api/v1/users/12345", 27 | // "trace_id": "3d2b4f2d00694354a00522fe3bb86158", 28 | // "timestamp": "2024-01-19T16:58:34.123+0000", 29 | // "help": "please check if the user ID is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information", 30 | // "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 31 | // } 32 | // ] 33 | // } 34 | // 35 | // --- 36 | // 37 | // { 38 | // "status": 422, 39 | // "errors": [ 40 | // { 41 | // "code": "transfer_insufficient_funds", 42 | // "kind": "validation_error", 43 | // "message": "source account does not have sufficient funds for the transfer", 44 | // "reason": "source account balance must be sufficient to cover the transfer amount", 45 | // "instance": "/api/v1/transactions/transfer", 46 | // "trace_id": "fbb9fdf5394d4abe8e42b49c3246310b", 47 | // "timestamp": "2024-01-19T16:58:35.225+0000", 48 | // "help": "please check the source account balance or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information", 49 | // "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 50 | // }, 51 | // { 52 | // "code": "transfer_destination_account_not_found", 53 | // "kind": "validation_error", 54 | // "message": "destination account not found: d424cfe9-c042-41db-9a8e-8da5715fea10", 55 | // "detail": { "destination_account_id": "d424cfe9-c042-41db-9a8e-8da5715fea10" }, 56 | // "reason": "must be an existing account", 57 | // "instance": "/api/v1/transactions/transfer", 58 | // "trace_id": "8a250eaa650943b085934771fb35ba54", 59 | // "timestamp": "2024-01-19T16:59:03.124+0000", 60 | // "help": "please check if the destination account ID is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information.", 61 | // "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 62 | // }, 63 | // ] 64 | // } 65 | // 66 | // --- 67 | // 68 | // (Users endpoint can be extended to handle these validations) 69 | // 70 | // { 71 | // "status": 422, 72 | // "errors": [ 73 | // { 74 | // "code": "invalid_birthdate", 75 | // "kind": "validation_error", 76 | // "message": "user birthdate is not correct", 77 | // "description": "validation error in your request", 78 | // "detail": { "birthdate": "2050.02.30" }, 79 | // "reason": "must be a valid calendar date in the past", 80 | // "instance": "/api/v1/users/12345", 81 | // "trace_id": "8a250eaa650943b085934771fb35ba54", 82 | // "timestamp": "2024-01-19T16:59:03.124+0000", 83 | // "help": "please check if the user birthdate is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information.", 84 | // "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 85 | // }, 86 | // { 87 | // "code": "invalid_role", 88 | // "kind": "validation_error", 89 | // "message": "role not valid", 90 | // "description": "validation error in your request", 91 | // "detail": { role: "superadmin" }, 92 | // "reason": "allowed roles: ['customer', 'guest']", 93 | // "instance": "/api/v1/users/12345", 94 | // "trace_id": "e023ebc3ab3e4c02b08247d9c5f03aa8", 95 | // "timestamp": "2024-01-19T16:59:03.124+0000", 96 | // "help": "please check if the user role is correct or refer to our documentation at https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md#errors for more information", 97 | // "doc_url": "https://github.com/sheroz/axum-rest-api-sample/blob/main/docs/api-docs.md" 98 | // }, 99 | 100 | #[derive(Debug, Serialize, Deserialize)] 101 | pub struct APIError { 102 | pub status: u16, 103 | pub errors: Vec, 104 | } 105 | 106 | impl Display for APIError { 107 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 108 | let api_error = serde_json::to_string_pretty(&self).unwrap_or_default(); 109 | write!(f, "{}", api_error) 110 | } 111 | } 112 | 113 | #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] 114 | #[serde(rename_all = "snake_case")] 115 | pub enum APIErrorCode { 116 | AuthenticationWrongCredentials, 117 | AuthenticationMissingCredentials, 118 | AuthenticationTokenCreationError, 119 | AuthenticationInvalidToken, 120 | AuthenticationRevokedTokensInactive, 121 | AuthenticationForbidden, 122 | UserNotFound, 123 | TransactionNotFound, 124 | TransferInsufficientFunds, 125 | TransferSourceAccountNotFound, 126 | TransferDestinationAccountNotFound, 127 | TransferAccountsAreSame, 128 | ResourceNotFound, 129 | ApiVersionError, 130 | DatabaseError, 131 | RedisError, 132 | } 133 | 134 | impl Display for APIErrorCode { 135 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 136 | write!( 137 | f, 138 | "{}", 139 | serde_json::json!(self).as_str().unwrap_or_default() 140 | ) 141 | } 142 | } 143 | 144 | #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] 145 | #[serde(rename_all = "snake_case")] 146 | pub enum APIErrorKind { 147 | AuthenticationError, 148 | ResourceNotFound, 149 | ValidationError, 150 | DatabaseError, 151 | RedisError, 152 | } 153 | 154 | impl Display for APIErrorKind { 155 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 156 | write!( 157 | f, 158 | "{}", 159 | serde_json::json!(self).as_str().unwrap_or_default() 160 | ) 161 | } 162 | } 163 | 164 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 165 | pub struct APIErrorEntry { 166 | #[serde(skip_serializing_if = "Option::is_none")] 167 | pub code: Option, 168 | #[serde(skip_serializing_if = "Option::is_none")] 169 | pub kind: Option, 170 | pub message: String, 171 | #[serde(skip_serializing_if = "Option::is_none")] 172 | pub description: Option, 173 | #[serde(skip_serializing_if = "Option::is_none")] 174 | pub detail: Option, 175 | #[serde(skip_serializing_if = "Option::is_none")] 176 | pub reason: Option, 177 | #[serde(skip_serializing_if = "Option::is_none")] 178 | pub instance: Option, 179 | #[serde(skip_serializing_if = "Option::is_none")] 180 | pub trace_id: Option, 181 | pub timestamp: DateTime, 182 | #[serde(skip_serializing_if = "Option::is_none")] 183 | pub help: Option, 184 | #[serde(skip_serializing_if = "Option::is_none")] 185 | pub doc_url: Option, 186 | } 187 | 188 | impl APIErrorEntry { 189 | pub fn new(message: &str) -> Self { 190 | Self { 191 | message: message.to_owned(), 192 | timestamp: Utc::now(), 193 | ..Default::default() 194 | } 195 | } 196 | 197 | pub fn code(mut self, code: S) -> Self { 198 | self.code = Some(code.to_string()); 199 | self 200 | } 201 | 202 | pub fn kind(mut self, kind: S) -> Self { 203 | self.kind = Some(kind.to_string()); 204 | self 205 | } 206 | 207 | pub fn description(mut self, description: &str) -> Self { 208 | self.description = Some(description.to_owned()); 209 | self 210 | } 211 | 212 | pub fn detail(mut self, detail: serde_json::Value) -> Self { 213 | self.detail = Some(detail); 214 | self 215 | } 216 | 217 | pub fn reason(mut self, reason: &str) -> Self { 218 | self.reason = Some(reason.to_owned()); 219 | self 220 | } 221 | 222 | pub fn instance(mut self, instance: &str) -> Self { 223 | self.instance = Some(instance.to_owned()); 224 | self 225 | } 226 | 227 | pub fn trace_id(mut self) -> Self { 228 | // Generate a new trace id. 229 | let mut trace_id = uuid::Uuid::new_v4().to_string(); 230 | trace_id.retain(|c| c != '-'); 231 | self.trace_id = Some(trace_id); 232 | self 233 | } 234 | 235 | pub fn help(mut self, help: &str) -> Self { 236 | self.help = Some(help.to_owned()); 237 | self 238 | } 239 | 240 | pub fn doc_url(mut self) -> Self { 241 | self.doc_url = Some(API_DOCUMENT_URL.to_owned()); 242 | self 243 | } 244 | } 245 | 246 | impl From for APIErrorEntry { 247 | fn from(status_code: StatusCode) -> Self { 248 | let error_message = status_code.to_string(); 249 | let error_code = error_message.replace(' ', "_").to_lowercase(); 250 | Self::new(&error_message).code(error_code) 251 | } 252 | } 253 | 254 | impl From for APIErrorEntry { 255 | fn from(e: sqlx::Error) -> Self { 256 | // Do not disclose database-related internal specifics, except for debug builds. 257 | if cfg!(debug_assertions) { 258 | let (code, kind) = match e { 259 | sqlx::Error::RowNotFound => ( 260 | APIErrorCode::ResourceNotFound, 261 | APIErrorKind::ResourceNotFound, 262 | ), 263 | _ => (APIErrorCode::DatabaseError, APIErrorKind::DatabaseError), 264 | }; 265 | Self::new(&e.to_string()).code(code).kind(kind).trace_id() 266 | } else { 267 | // Build the entry with a trace id to find the exact error in the log when needed. 268 | let error_entry = Self::from(StatusCode::INTERNAL_SERVER_ERROR).trace_id(); 269 | let trace_id = error_entry.trace_id.as_deref().unwrap_or(""); 270 | // The error must be logged here. Otherwise, we would lose it. 271 | tracing::error!("SQLx error: {}, trace id: {}", e.to_string(), trace_id); 272 | error_entry 273 | } 274 | } 275 | } 276 | 277 | impl From for APIErrorEntry { 278 | fn from(e: redis::RedisError) -> Self { 279 | // Do not disclose Redis-related internal specifics, except for debug builds. 280 | if cfg!(debug_assertions) { 281 | Self::new(&e.to_string()) 282 | .code(APIErrorCode::RedisError) 283 | .kind(APIErrorKind::RedisError) 284 | .description(&format!("Redis error: {}", e)) 285 | .trace_id() 286 | } else { 287 | // Build the entry with a trace id to find the exact error in the log when needed. 288 | let error_entry = Self::from(StatusCode::INTERNAL_SERVER_ERROR).trace_id(); 289 | let trace_id = error_entry.trace_id.as_deref().unwrap_or(""); 290 | // The error must be logged here. Otherwise, we would lose it. 291 | tracing::error!("Redis error: {}, trace id: {}", e.to_string(), trace_id); 292 | error_entry 293 | } 294 | } 295 | } 296 | 297 | impl From<(StatusCode, Vec)> for APIError { 298 | fn from(error_from: (StatusCode, Vec)) -> Self { 299 | let (status_code, errors) = error_from; 300 | Self { 301 | status: status_code.as_u16(), 302 | errors, 303 | } 304 | } 305 | } 306 | 307 | impl From<(StatusCode, APIErrorEntry)> for APIError { 308 | fn from(error_from: (StatusCode, APIErrorEntry)) -> Self { 309 | let (status_code, error_entry) = error_from; 310 | Self { 311 | status: status_code.as_u16(), 312 | errors: vec![error_entry], 313 | } 314 | } 315 | } 316 | 317 | impl From for APIError { 318 | fn from(status_code: StatusCode) -> Self { 319 | Self { 320 | status: status_code.as_u16(), 321 | errors: vec![status_code.into()], 322 | } 323 | } 324 | } 325 | 326 | impl From for APIError { 327 | fn from(error: sqlx::Error) -> Self { 328 | let status_code = match error { 329 | sqlx::Error::RowNotFound => StatusCode::NOT_FOUND, 330 | _ => StatusCode::INTERNAL_SERVER_ERROR, 331 | }; 332 | Self { 333 | status: status_code.as_u16(), 334 | errors: vec![APIErrorEntry::from(error)], 335 | } 336 | } 337 | } 338 | 339 | impl From for APIError { 340 | fn from(error: redis::RedisError) -> Self { 341 | Self { 342 | status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), 343 | errors: vec![APIErrorEntry::from(error)], 344 | } 345 | } 346 | } 347 | 348 | impl IntoResponse for APIError { 349 | fn into_response(self) -> Response { 350 | tracing::error!("Error response: {:?}", self); 351 | let status_code = 352 | StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 353 | (status_code, Json(self)).into_response() 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /tests/transaction_tests.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use serial_test::serial; 3 | use uuid::Uuid; 4 | 5 | use axum_web::{ 6 | api::{APIErrorCode, APIErrorKind, handlers::transaction_handlers::TransactionError}, 7 | application::{ 8 | security::roles::UserRole, service::transaction_service::TransferValidationError, 9 | }, 10 | domain::models::{account::Account, user::User}, 11 | }; 12 | 13 | pub mod common; 14 | use common::{ 15 | TestError, accounts, 16 | auth::{self, AuthTokens}, 17 | constants::{TEST_ADMIN_PASSWORD_HASH, TEST_ADMIN_USERNAME}, 18 | test_app, transactions, users, 19 | }; 20 | 21 | // TODO: run transaction tests in parallel and remove `serial` dependencies. 22 | 23 | // Prepare accounts for Alice and Bob. 24 | async fn prepare_accounts(tokens: &AuthTokens) -> (Account, Account) { 25 | // Add user for Alice. 26 | let id = Uuid::new_v4(); 27 | let username = "alice"; 28 | let user_alice = User { 29 | id, 30 | username: format!("test-{}-{}", username, id), 31 | email: format!("{}-{}@email.com", username, id), 32 | password_hash: "xyz123".to_string(), 33 | password_salt: "xyz123".to_string(), 34 | active: true, 35 | roles: UserRole::Customer.to_string(), 36 | created_at: None, 37 | updated_at: None, 38 | }; 39 | 40 | let _ = users::add(user_alice.clone(), &tokens.access_token) 41 | .await 42 | .expect("User creation error."); 43 | 44 | // Add account for Alice. 45 | let account_alice = Account { 46 | id: Uuid::new_v4(), 47 | user_id: user_alice.id, 48 | balance_cents: 100, 49 | created_at: None, 50 | updated_at: None, 51 | }; 52 | 53 | let account_alice = accounts::add(account_alice.clone(), &tokens.access_token) 54 | .await 55 | .expect("Account creation error."); 56 | 57 | // Add user for Bob. 58 | let id = Uuid::new_v4(); 59 | let username = "bob"; 60 | let user_bob = User { 61 | id: Uuid::new_v4(), 62 | username: format!("test-{}-{}", username, id), 63 | email: format!("{}-{}@email.com", username, id), 64 | password_hash: "xyz123".to_string(), 65 | password_salt: "xyz123".to_string(), 66 | active: true, 67 | roles: UserRole::Customer.to_string(), 68 | created_at: None, 69 | updated_at: None, 70 | }; 71 | 72 | let _ = users::add(user_bob.clone(), &tokens.access_token) 73 | .await 74 | .expect("User creation error."); 75 | 76 | // Add account for Bob. 77 | let account_bob = Account { 78 | id: Uuid::new_v4(), 79 | user_id: user_bob.id, 80 | balance_cents: 100, 81 | created_at: None, 82 | updated_at: None, 83 | }; 84 | 85 | let account_bob = accounts::add(account_bob.clone(), &tokens.access_token) 86 | .await 87 | .expect("Account creation error."); 88 | 89 | (account_alice, account_bob) 90 | } 91 | 92 | #[serial] 93 | #[tokio::test] 94 | async fn transaction_unauthorized_test() { 95 | // Start api server. 96 | let test_db = test_app::run().await; 97 | 98 | // Try unauthorized access to transaction handlers. 99 | let wrong_access_token = "xyz"; 100 | let some_id = Uuid::new_v4(); 101 | let result = transactions::get(some_id, wrong_access_token).await; 102 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 103 | 104 | let result = transactions::transfer(some_id, some_id, 0, wrong_access_token).await; 105 | assert_api_error_status!(result, StatusCode::UNAUTHORIZED); 106 | 107 | // Drop test database. 108 | test_db.drop().await.unwrap(); 109 | } 110 | 111 | #[serial] 112 | #[tokio::test] 113 | async fn transaction_non_existing_test() { 114 | // Start api server. 115 | let test_db = test_app::run().await; 116 | 117 | // Login as an admin. 118 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 119 | .await 120 | .expect("Login error."); 121 | 122 | // Check for non existing transaction. 123 | let transaction_id = Uuid::new_v4(); 124 | let result = transactions::get(transaction_id, &tokens.access_token).await; 125 | 126 | assert!(result.is_err()); 127 | match result.err().unwrap() { 128 | TestError::APIError(api_error) => { 129 | assert_eq!(api_error.status, StatusCode::NOT_FOUND); 130 | assert_eq!(api_error.errors.len(), 1); 131 | 132 | let error_entry = api_error.errors[0].clone(); 133 | assert_eq!( 134 | error_entry.code, 135 | Some(APIErrorCode::TransactionNotFound.to_string()) 136 | ); 137 | assert_eq!( 138 | error_entry.kind, 139 | Some(APIErrorKind::ResourceNotFound.to_string()) 140 | ); 141 | assert_eq!( 142 | error_entry.message, 143 | TransactionError::TransactionNotFound(transaction_id).to_string() 144 | ); 145 | let json = error_entry.detail.unwrap(); 146 | assert_eq!(json["transaction_id"], transaction_id.to_string()); 147 | } 148 | _ => panic!("invalid transaction result"), 149 | } 150 | 151 | // Drop test database. 152 | test_db.drop().await.unwrap(); 153 | } 154 | 155 | #[serial] 156 | #[tokio::test] 157 | async fn transaction_transfer_test() { 158 | // Start api server. 159 | let test_db = test_app::run().await; 160 | 161 | // Login as an admin. 162 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 163 | .await 164 | .expect("Login error."); 165 | 166 | let (account_alice, account_bob) = prepare_accounts(&tokens).await; 167 | 168 | // Transfer money from Alice to Bob. 169 | let amount_cents = 25; 170 | let transaction = transactions::transfer( 171 | account_alice.id, 172 | account_bob.id, 173 | amount_cents, 174 | &tokens.access_token, 175 | ) 176 | .await 177 | .unwrap(); 178 | 179 | // Check for transaction details. 180 | assert_eq!(transaction.source_account_id, account_alice.id); 181 | assert_eq!(transaction.destination_account_id, account_bob.id); 182 | assert_eq!(transaction.amount_cents, amount_cents); 183 | let transaction_persisted = transactions::get(transaction.id, &tokens.access_token) 184 | .await 185 | .expect("Transaction fetch error."); 186 | assert_eq!(transaction_persisted, transaction); 187 | 188 | // Check for transfer results. 189 | let account_alice = accounts::get(account_alice.id, &tokens.access_token) 190 | .await 191 | .expect("Account fetch error."); 192 | assert_eq!(account_alice.balance_cents, 75); 193 | 194 | let account_bob = accounts::get(account_bob.id, &tokens.access_token) 195 | .await 196 | .expect("Account fetch error."); 197 | assert_eq!(account_bob.balance_cents, 125); 198 | 199 | // Transfer money from Bob to Alice. 200 | let amount_cents = 30; 201 | let transaction = transactions::transfer( 202 | account_bob.id, 203 | account_alice.id, 204 | amount_cents, 205 | &tokens.access_token, 206 | ) 207 | .await 208 | .expect("Transaction error."); 209 | 210 | // Check for transaction details. 211 | assert_eq!(transaction.source_account_id, account_bob.id); 212 | assert_eq!(transaction.destination_account_id, account_alice.id); 213 | assert_eq!(transaction.amount_cents, amount_cents); 214 | 215 | // Check for persisted transaction. 216 | let transaction_persisted = transactions::get(transaction.id, &tokens.access_token) 217 | .await 218 | .expect("Transaction fetch error."); 219 | assert_eq!(transaction_persisted, transaction); 220 | 221 | // Check for transfer results. 222 | let account_alice = accounts::get(account_alice.id, &tokens.access_token) 223 | .await 224 | .expect("Account fetch error."); 225 | assert_eq!(account_alice.balance_cents, 105); 226 | 227 | let account_bob = accounts::get(account_bob.id, &tokens.access_token) 228 | .await 229 | .expect("Account fetch error."); 230 | assert_eq!(account_bob.balance_cents, 95); 231 | 232 | // Drop test database. 233 | test_db.drop().await.unwrap(); 234 | } 235 | 236 | #[serial] 237 | #[tokio::test] 238 | async fn transfer_validate_invalid_accounts_test() { 239 | // Start api server. 240 | let test_db = test_app::run().await; 241 | 242 | // Login as an admin. 243 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 244 | .await 245 | .expect("Login error."); 246 | 247 | let source_account_id = Uuid::new_v4(); 248 | let destination_account_id = Uuid::new_v4(); 249 | let amount_cents = 100; 250 | 251 | let result = transactions::transfer( 252 | source_account_id, 253 | destination_account_id, 254 | amount_cents, 255 | &tokens.access_token, 256 | ) 257 | .await; 258 | 259 | assert!(result.is_err()); 260 | match result.err().unwrap() { 261 | TestError::APIError(api_error) => { 262 | assert_eq!(api_error.status, StatusCode::UNPROCESSABLE_ENTITY); 263 | assert_eq!(api_error.errors.len(), 2); 264 | 265 | let error_entry = api_error.errors[0].clone(); 266 | assert_eq!( 267 | error_entry.code, 268 | Some(APIErrorCode::TransferSourceAccountNotFound.to_string()) 269 | ); 270 | assert_eq!( 271 | error_entry.kind, 272 | Some(APIErrorKind::ValidationError.to_string()) 273 | ); 274 | assert_eq!( 275 | error_entry.message, 276 | TransferValidationError::SourceAccountNotFound(source_account_id).to_string() 277 | ); 278 | let json = error_entry.detail.unwrap(); 279 | assert_eq!(json["source_account_id"], source_account_id.to_string()); 280 | 281 | let error = api_error.errors[1].clone(); 282 | assert_eq!( 283 | error.code, 284 | Some(APIErrorCode::TransferDestinationAccountNotFound.to_string()) 285 | ); 286 | assert_eq!(error.kind, Some(APIErrorKind::ValidationError.to_string())); 287 | assert_eq!( 288 | error.message, 289 | TransferValidationError::DestinationAccountNotFound(destination_account_id) 290 | .to_string() 291 | ); 292 | let json = error.detail.unwrap(); 293 | assert_eq!( 294 | json["destination_account_id"], 295 | destination_account_id.to_string() 296 | ); 297 | } 298 | _ => panic!("invalid transfer result"), 299 | } 300 | 301 | // Drop test database. 302 | test_db.drop().await.unwrap(); 303 | } 304 | 305 | #[serial] 306 | #[tokio::test] 307 | async fn transfer_validate_distinct_accounts_test() { 308 | // Start api server. 309 | let test_db = test_app::run().await; 310 | 311 | // Login as an admin. 312 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 313 | .await 314 | .expect("Login error."); 315 | 316 | let source_account_id = Uuid::new_v4(); 317 | let destination_account_id = source_account_id; 318 | let amount_cents = 100; 319 | 320 | let result = transactions::transfer( 321 | source_account_id, 322 | destination_account_id, 323 | amount_cents, 324 | &tokens.access_token, 325 | ) 326 | .await; 327 | 328 | assert!(result.is_err()); 329 | match result.err().unwrap() { 330 | TestError::APIError(api_error) => { 331 | assert_eq!(api_error.status, StatusCode::UNPROCESSABLE_ENTITY); 332 | assert_eq!(api_error.errors.len(), 2); 333 | 334 | let error_entry = api_error.errors[0].clone(); 335 | assert_eq!( 336 | error_entry.code, 337 | Some(APIErrorCode::TransferSourceAccountNotFound.to_string()) 338 | ); 339 | assert_eq!( 340 | error_entry.kind, 341 | Some(APIErrorKind::ValidationError.to_string()) 342 | ); 343 | assert_eq!( 344 | error_entry.message, 345 | TransferValidationError::SourceAccountNotFound(source_account_id).to_string() 346 | ); 347 | let json = error_entry.detail.unwrap(); 348 | assert_eq!(json["source_account_id"], source_account_id.to_string()); 349 | 350 | let error = api_error.errors[1].clone(); 351 | assert_eq!( 352 | error.code, 353 | Some(APIErrorCode::TransferAccountsAreSame.to_string()) 354 | ); 355 | assert_eq!(error.kind, Some(APIErrorKind::ValidationError.to_string())); 356 | assert_eq!( 357 | error.message, 358 | TransferValidationError::AccountsAreSame.to_string() 359 | ); 360 | assert_eq!(error.detail, None); 361 | } 362 | _ => panic!("invalid transfer result"), 363 | } 364 | 365 | // Drop test database. 366 | test_db.drop().await.unwrap(); 367 | } 368 | 369 | #[serial] 370 | #[tokio::test] 371 | async fn transfer_validate_unsufficient_funds_test() { 372 | // Start api server. 373 | let test_db = test_app::run().await; 374 | 375 | // Login as an admin. 376 | let tokens = auth::login(TEST_ADMIN_USERNAME, TEST_ADMIN_PASSWORD_HASH) 377 | .await 378 | .expect("Login error."); 379 | 380 | let (account_alice, account_bob) = prepare_accounts(&tokens).await; 381 | let amount_cents = 200; 382 | let result = transactions::transfer( 383 | account_bob.id, 384 | account_alice.id, 385 | amount_cents, 386 | &tokens.access_token, 387 | ) 388 | .await; 389 | assert!(result.is_err()); 390 | match result.err().unwrap() { 391 | TestError::APIError(api_error) => { 392 | assert_eq!(api_error.status, StatusCode::UNPROCESSABLE_ENTITY); 393 | assert_eq!(api_error.errors.len(), 1); 394 | 395 | let error_entry = api_error.errors[0].clone(); 396 | assert_eq!( 397 | error_entry.code, 398 | Some(APIErrorCode::TransferInsufficientFunds.to_string()) 399 | ); 400 | assert_eq!( 401 | error_entry.kind, 402 | Some(APIErrorKind::ValidationError.to_string()) 403 | ); 404 | assert_eq!( 405 | error_entry.message, 406 | TransferValidationError::InsufficientFunds.to_string() 407 | ); 408 | assert_eq!(error_entry.detail, None); 409 | } 410 | _ => panic!("invalid transfer result"), 411 | } 412 | 413 | // Drop test database. 414 | test_db.drop().await.unwrap(); 415 | } 416 | --------------------------------------------------------------------------------