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",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.86"
|
version = "0.1.86"
|
||||||
@ -179,6 +190,21 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "bank_core"
|
name = "bank_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -1141,9 +1167,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.14"
|
version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
@ -2147,18 +2173,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.11"
|
version = "2.0.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
|
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.11"
|
version = "2.0.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
|
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [ "bank_core" ]
|
members = [ "bank_client", "bank_core" ]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
chrono = { version = "0.4.40", features = ["serde"] }
|
chrono = { version = "0.4.40", features = ["serde"] }
|
||||||
@ -11,6 +11,8 @@ serde = { version = "1.0.218", features = ["derive"] }
|
|||||||
serde_json = "1.0.139"
|
serde_json = "1.0.139"
|
||||||
uuid = { version = "1.15.1", features = ["serde", "v7"] }
|
uuid = { version = "1.15.1", features = ["serde", "v7"] }
|
||||||
axum = { version = "0.8", default-features = false }
|
axum = { version = "0.8", default-features = false }
|
||||||
|
reqwest = { version = "0.12.15", features = ["json"] }
|
||||||
|
url = { version = "2.5.4", features = ["serde"] }
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "bankserver"
|
name = "bankserver"
|
||||||
@ -50,5 +52,5 @@ tracing-error = "0.2.1"
|
|||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
bank_core = { path = "./bank_core", features = ["axum"] }
|
bank_core = { path = "./bank_core", features = ["axum"] }
|
||||||
reqwest = { version = "0.12.15", features = ["json"] }
|
reqwest.workspace = true
|
||||||
url = { version = "2.5.4", features = ["serde"] }
|
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,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
|
|||||||
@ -11,15 +11,12 @@ use bank_core::{
|
|||||||
transaction::{FullTransaction, TransactionQuery},
|
transaction::{FullTransaction, TransactionQuery},
|
||||||
user::{User, UserAccounts, UserBalance},
|
user::{User, UserAccounts, UserBalance},
|
||||||
};
|
};
|
||||||
use concread::cowcell::asynch::CowCell;
|
|
||||||
use futures_util::lock::Mutex;
|
|
||||||
use garde::Validate;
|
use garde::Validate;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
use tracing::{error, instrument};
|
use tracing::instrument;
|
||||||
use url::Url;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user