basic client implementation

This commit is contained in:
DSeeLP 2025-04-06 19:48:39 +02:00
parent 4c0e44e542
commit 0c0828e3c8
6 changed files with 413 additions and 13 deletions

38
Cargo.lock generated
View File

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

View File

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

15
bank_client/Cargo.toml Normal file
View File

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

351
bank_client/src/lib.rs Normal file
View File

@ -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<RequestBuilder, url::ParseError> {
let url = url.make_url(self.url.clone())?;
Ok(self.client.request(method, url))
}
pub fn get(&self, url: impl MakeUrl) -> Result<RequestBuilder, url::ParseError> {
self.make_request(Method::GET, url)
}
pub fn post(&self, url: impl MakeUrl) -> Result<RequestBuilder, url::ParseError> {
self.make_request(Method::POST, url)
}
pub fn put(&self, url: impl MakeUrl) -> Result<RequestBuilder, url::ParseError> {
self.make_request(Method::PUT, url)
}
async fn credentials_request(
&self,
path: &str,
username: &str,
password: &str,
) -> Result<TokenResponse, ClientError> {
let response = self
.client
.post(path)
.json(&json!({ "username": username, "password": password }))
.send()
.await?;
let body = handle_response::<TokenResponse>(response).await?;
Ok(body)
}
pub fn login(
&self,
username: &str,
password: &str,
) -> impl Future<Output = Result<TokenResponse, ClientError>> {
self.credentials_request("/api/login", username, password)
}
pub fn register(
&self,
username: &str,
password: &str,
) -> impl Future<Output = Result<TokenResponse, ClientError>> {
self.credentials_request("/api/register", username, password)
}
}
pub struct ApiClient {
client: HttpClient,
token: RwLock<Arc<str>>,
}
impl ApiClient {
async fn credentials_request(
&self,
fut: impl Future<Output = Result<TokenResponse, ClientError>>,
) -> 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<Output = Result<(), ClientError>> {
self.credentials_request(self.client.login(username, password))
}
pub fn register(
&self,
username: &str,
password: &str,
) -> impl Future<Output = Result<(), ClientError>> {
self.credentials_request(self.client.register(username, password))
}
async fn token(&self) -> Arc<str> {
(*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<T: DeserializeOwned>(
response: reqwest::Response,
) -> Result<T, ClientError> {
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::<T>(&bytes).map_err(ClientError::Json)
} else {
let mut error =
serde_json::from_slice::<ApiError<'static>>(&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<Pagination<User>, 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<Vec<UserAccountInfo>, ClientError> {
let response = request!(self.api, get, "/api/users/@me/accounts")
.send()
.await?;
let body = handle_response::<UserAccounts>(response).await?;
Ok(body.result)
}
pub async fn balance(&self) -> Result<u64, ClientError> {
let response = request!(self.api, get, "/api/users/@me/balance")
.send()
.await?;
let body = handle_response::<UserBalance>(response).await?;
Ok(body.balance)
}
pub async fn transactions(
&self,
direction: Option<Direction>,
limit: u64,
offset: u64,
) -> Result<Pagination<Transaction>, 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<Pagination<AccountInfo>, 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<Direction>,
limit: u64,
offset: u64,
) -> Result<Pagination<Transaction>, 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<Url, url::ParseError>;
}
impl MakeUrl for &'_ str {
fn make_url(self, base: Url) -> Result<Url, url::ParseError> {
base.join(self)
}
}
impl<'a> MakeUrl for &'a [&'a str] {
fn make_url(self, mut base: Url) -> Result<Url, url::ParseError> {
base.path_segments_mut().unwrap().extend(self);
Ok(base)
}
}
impl<'a, P, S, Q, K, V> MakeUrl for (P, Q)
where
Q: IntoIterator<Item = (K, V)>,
P: IntoIterator<Item = S>,
S: AsRef<str>,
K: AsRef<str>,
V: AsRef<str>,
{
fn make_url(self, mut base: Url) -> Result<Url, url::ParseError> {
base.path_segments_mut().unwrap().extend(self.0);
base.query_pairs_mut().extend_pairs(self.1);
Ok(base)
}
}
pub struct RequestUrl<P, Q> {
path: P,
pagination: RequestPagination,
extra_query: Q,
}
impl<'a, P: IntoIterator<Item = &'a str>> RequestUrl<P, ()> {
pub fn simple(
path: P,
limit: u64,
offset: u64,
) -> RequestUrl<P, std::iter::Empty<(String, String)>> {
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<P, Q>
where
P: IntoIterator<Item = &'a str>,
Q: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
fn make_url(self, mut base: Url) -> Result<Url, url::ParseError> {
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)
}
}

View File

@ -39,6 +39,15 @@ pub enum Direction {
Sent,
}
impl AsRef<str> 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)]

View File

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