diff --git a/Cargo.lock b/Cargo.lock index 441ec4c..a326967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.86" @@ -179,6 +190,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "bank_client" +version = "0.1.0" +dependencies = [ + "async-lock", + "bank_core", + "itoa", + "reqwest", + "serde", + "serde_json", + "thiserror", + "url", + "uuid", +] + [[package]] name = "bank_core" version = "0.1.0" @@ -1141,9 +1167,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" @@ -2147,18 +2173,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.11" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 61c39aa..998d1d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "bank_core" ] +members = [ "bank_client", "bank_core" ] [workspace.dependencies] chrono = { version = "0.4.40", features = ["serde"] } @@ -11,6 +11,8 @@ serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.139" uuid = { version = "1.15.1", features = ["serde", "v7"] } axum = { version = "0.8", default-features = false } +reqwest = { version = "0.12.15", features = ["json"] } +url = { version = "2.5.4", features = ["serde"] } [package] name = "bankserver" @@ -50,5 +52,5 @@ tracing-error = "0.2.1" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } uuid.workspace = true bank_core = { path = "./bank_core", features = ["axum"] } -reqwest = { version = "0.12.15", features = ["json"] } -url = { version = "2.5.4", features = ["serde"] } +reqwest.workspace = true +url.workspace = true diff --git a/bank_client/Cargo.toml b/bank_client/Cargo.toml new file mode 100644 index 0000000..c575928 --- /dev/null +++ b/bank_client/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "bank_client" +version = "0.1.0" +edition = "2024" + +[dependencies] +url.workspace = true +uuid.workspace = true +reqwest.workspace = true +bank_core = { path = "../bank_core" } +thiserror = "2.0.12" +serde_json.workspace = true +async-lock = "3.4.0" +serde.workspace = true +itoa = "1.0.15" diff --git a/bank_client/src/lib.rs b/bank_client/src/lib.rs new file mode 100644 index 0000000..d8876fa --- /dev/null +++ b/bank_client/src/lib.rs @@ -0,0 +1,351 @@ +use std::sync::Arc; + +use async_lock::RwLock; +use bank_core::{ + ApiError, TokenResponse, + account::{AccountInfo, UserAccountInfo}, + pagination::{Pagination, RequestPagination}, + transaction::{Direction, Transaction}, + user::{User, UserAccounts, UserBalance}, +}; +use reqwest::{Method, RequestBuilder, header::CONTENT_TYPE}; +use serde::de::DeserializeOwned; +use serde_json::json; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +pub struct HttpClient { + url: Url, + client: reqwest::Client, +} + +#[derive(Debug, Error)] +pub enum ClientError { + #[error("url: {_0}")] + Url(#[from] url::ParseError), + #[error("request: {_0}")] + Reqwest(#[from] reqwest::Error), + #[error("unexpected content type")] + UnexpectedContentType, + #[error("api: {_0:?}")] + Api(ApiError<'static>), + #[error("json: {_0:?}")] + Json(serde_json::Error), +} + +impl HttpClient { + fn make_request( + &self, + method: reqwest::Method, + url: impl MakeUrl, + ) -> Result { + let url = url.make_url(self.url.clone())?; + Ok(self.client.request(method, url)) + } + + pub fn get(&self, url: impl MakeUrl) -> Result { + self.make_request(Method::GET, url) + } + + pub fn post(&self, url: impl MakeUrl) -> Result { + self.make_request(Method::POST, url) + } + + pub fn put(&self, url: impl MakeUrl) -> Result { + self.make_request(Method::PUT, url) + } + + async fn credentials_request( + &self, + path: &str, + username: &str, + password: &str, + ) -> Result { + let response = self + .client + .post(path) + .json(&json!({ "username": username, "password": password })) + .send() + .await?; + let body = handle_response::(response).await?; + Ok(body) + } + + pub fn login( + &self, + username: &str, + password: &str, + ) -> impl Future> { + self.credentials_request("/api/login", username, password) + } + + pub fn register( + &self, + username: &str, + password: &str, + ) -> impl Future> { + self.credentials_request("/api/register", username, password) + } +} + +pub struct ApiClient { + client: HttpClient, + token: RwLock>, +} + +impl ApiClient { + async fn credentials_request( + &self, + fut: impl Future>, + ) -> Result<(), ClientError> { + let mut token = self.token.write().await; + let body = fut.await?; + *token = body.token.into(); + Ok(()) + } + + pub fn login( + &self, + username: &str, + password: &str, + ) -> impl Future> { + self.credentials_request(self.client.login(username, password)) + } + + pub fn register( + &self, + username: &str, + password: &str, + ) -> impl Future> { + self.credentials_request(self.client.register(username, password)) + } + + async fn token(&self) -> Arc { + (*self.token.read().await).clone() + } + + #[inline] + pub const fn users(&self) -> UsersApi { + UsersApi { api: self } + } + + #[inline] + pub const fn accounts(&self) -> AccountsApi { + AccountsApi { api: self } + } +} + +pub async fn handle_response( + response: reqwest::Response, +) -> Result { + let response = response.error_for_status()?; + let content_type = response.headers().get(CONTENT_TYPE).expect("content-type"); + if content_type != "application/json" { + return Err(ClientError::UnexpectedContentType); + } + let status = response.status(); + let bytes = response.bytes().await?; + if status.is_success() { + serde_json::from_slice::(&bytes).map_err(ClientError::Json) + } else { + let mut error = + serde_json::from_slice::>(&bytes).map_err(ClientError::Json)?; + error.status = status; + Err(ClientError::Api(error)) + } +} + +pub struct UsersApi<'a> { + api: &'a ApiClient, +} + +macro_rules! request { + ($api:expr, $method:ident, $path:expr) => {{ + let api = &$api; + api.client.$method($path)?.auth(api).await + }}; +} + +impl UsersApi<'_> { + pub async fn list(&self, limit: u64, offset: u64) -> Result, ClientError> { + let response = request!( + self.api, + get, + RequestUrl::simple(["api", "users"], limit, offset) + ) + .send() + .await?; + let body = handle_response(response).await?; + Ok(body) + } + + pub async fn accounts(&self) -> Result, ClientError> { + let response = request!(self.api, get, "/api/users/@me/accounts") + .send() + .await?; + let body = handle_response::(response).await?; + Ok(body.result) + } + + pub async fn balance(&self) -> Result { + let response = request!(self.api, get, "/api/users/@me/balance") + .send() + .await?; + let body = handle_response::(response).await?; + Ok(body.balance) + } + + pub async fn transactions( + &self, + direction: Option, + limit: u64, + offset: u64, + ) -> Result, ClientError> { + let response = request!( + self.api, + get, + RequestUrl { + path: ["api", "users", "@me", "transactions"], + pagination: RequestPagination { limit, offset }, + extra_query: direction.map(|dir| ("direction", dir)) + } + ) + .send() + .await?; + let body = handle_response(response).await?; + Ok(body) + } +} + +pub struct AccountsApi<'a> { + api: &'a ApiClient, +} + +impl AccountsApi<'_> { + pub async fn list( + &self, + limit: u64, + offset: u64, + ) -> Result, ClientError> { + let response = request!( + self.api, + get, + RequestUrl::simple(["api", "accounts"], limit, offset) + ) + .send() + .await?; + let body = handle_response(response).await?; + Ok(body) + } + + pub async fn transactions( + &self, + id: Uuid, + direction: Option, + limit: u64, + offset: u64, + ) -> Result, ClientError> { + let response = request!( + self.api, + get, + RequestUrl { + path: ["api", "accounts", &id.to_string(), "transactions"], + pagination: RequestPagination { limit, offset }, + extra_query: direction.map(|dir| ("direction", dir)) + } + ) + .send() + .await?; + let body = handle_response(response).await?; + Ok(body) + } +} + +trait RequestBuilderExt { + async fn auth(self, api: &ApiClient) -> Self; +} + +impl RequestBuilderExt for RequestBuilder { + async fn auth(self, api: &ApiClient) -> Self { + self.bearer_auth(api.token().await) + } +} + +pub trait MakeUrl { + fn make_url(self, base: Url) -> Result; +} + +impl MakeUrl for &'_ str { + fn make_url(self, base: Url) -> Result { + base.join(self) + } +} + +impl<'a> MakeUrl for &'a [&'a str] { + fn make_url(self, mut base: Url) -> Result { + base.path_segments_mut().unwrap().extend(self); + Ok(base) + } +} + +impl<'a, P, S, Q, K, V> MakeUrl for (P, Q) +where + Q: IntoIterator, + P: IntoIterator, + S: AsRef, + K: AsRef, + V: AsRef, +{ + fn make_url(self, mut base: Url) -> Result { + base.path_segments_mut().unwrap().extend(self.0); + base.query_pairs_mut().extend_pairs(self.1); + Ok(base) + } +} + +pub struct RequestUrl { + path: P, + pagination: RequestPagination, + extra_query: Q, +} + +impl<'a, P: IntoIterator> RequestUrl { + pub fn simple( + path: P, + limit: u64, + offset: u64, + ) -> RequestUrl> { + RequestUrl { + path, + pagination: RequestPagination { limit, offset }, + extra_query: std::iter::empty(), + } + } +} + +macro_rules! num { + ($num:expr) => { + itoa::Buffer::new().format($num) + }; +} + +impl<'a, P, Q, K, V> MakeUrl for RequestUrl +where + P: IntoIterator, + Q: IntoIterator, + K: AsRef, + V: AsRef, +{ + fn make_url(self, mut base: Url) -> Result { + base.path_segments_mut().unwrap().extend(self.path); + let mut query = base.query_pairs_mut(); + query.extend_pairs([ + ("limit", num!(self.pagination.limit)), + ("offset", num!(self.pagination.offset)), + ]); + query.extend_pairs(self.extra_query); + drop(query); + Ok(base) + } +} diff --git a/bank_core/src/transaction.rs b/bank_core/src/transaction.rs index 01997a7..ca6bdec 100644 --- a/bank_core/src/transaction.rs +++ b/bank_core/src/transaction.rs @@ -39,6 +39,15 @@ pub enum Direction { Sent, } +impl AsRef for Direction { + fn as_ref(&self) -> &str { + match self { + Direction::Received => "received", + Direction::Sent => "sent", + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[serde(untagged)] diff --git a/src/api/user.rs b/src/api/user.rs index ca9a4cf..59f8366 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -11,15 +11,12 @@ use bank_core::{ transaction::{FullTransaction, TransactionQuery}, user::{User, UserAccounts, UserBalance}, }; -use concread::cowcell::asynch::CowCell; -use futures_util::lock::Mutex; use garde::Validate; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::time::Instant; -use tracing::{error, instrument}; -use url::Url; +use tracing::instrument; use uuid::Uuid; use crate::{