mirror of
https://git.dirksys.ovh/dirk/bankserver.git
synced 2025-12-20 02:59:20 +01:00
basic client implementation
This commit is contained in:
parent
4c0e44e542
commit
0c0828e3c8
38
Cargo.lock
generated
38
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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
15
bank_client/Cargo.toml
Normal 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
351
bank_client/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)]
|
||||
|
||||
@ -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::{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user