You've already forked AstralRinth
forked from didirus/AstralRinth
Fix Mural payout status syncing (#4853)
* Fix Mural payout status syncing * Make Mural payout code more resilient * prepare sqlx * fix test
This commit is contained in:
80
packages/muralpay/src/client/error.rs
Normal file
80
packages/muralpay/src/client/error.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use {
|
||||
bytes::Bytes,
|
||||
derive_more::{Display, Error, From},
|
||||
serde::{Deserialize, Serialize},
|
||||
std::{collections::HashMap, fmt},
|
||||
uuid::Uuid,
|
||||
};
|
||||
|
||||
#[derive(Debug, Display, Error, From)]
|
||||
pub enum MuralError {
|
||||
#[display("API error")]
|
||||
Api(ApiError),
|
||||
#[display("request error")]
|
||||
Request(reqwest::Error),
|
||||
#[display("failed to decode response\n{json:?}")]
|
||||
#[from(skip)]
|
||||
Decode {
|
||||
source: serde_json::Error,
|
||||
json: Bytes,
|
||||
},
|
||||
#[display("failed to decode error response\n{json:?}")]
|
||||
#[from(skip)]
|
||||
DecodeError {
|
||||
source: serde_json::Error,
|
||||
json: Bytes,
|
||||
},
|
||||
}
|
||||
|
||||
pub type Result<T, E = MuralError> = std::result::Result<T, E>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiError {
|
||||
pub error_instance_id: Uuid,
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
#[serde(deserialize_with = "one_or_many")]
|
||||
#[serde(default)]
|
||||
pub details: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub params: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
|
||||
match OneOrMany::deserialize(deserializer)? {
|
||||
OneOrMany::One(s) => Ok(vec![s]),
|
||||
OneOrMany::Many(v) => Ok(v),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut lines = vec![self.message.clone()];
|
||||
|
||||
if !self.details.is_empty() {
|
||||
lines.push("details:".into());
|
||||
lines.extend(self.details.iter().map(|s| format!("- {s}")));
|
||||
}
|
||||
|
||||
if !self.params.is_empty() {
|
||||
lines.push("params:".into());
|
||||
lines.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}")));
|
||||
}
|
||||
|
||||
lines.push(format!("error name: {}", self.name));
|
||||
lines.push(format!("error instance id: {}", self.error_instance_id));
|
||||
|
||||
write!(f, "{}", lines.join("\n"))
|
||||
}
|
||||
}
|
||||
68
packages/muralpay/src/client/mock.rs
Normal file
68
packages/muralpay/src/client/mock.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! See [`MuralPayMock`].
|
||||
|
||||
use {
|
||||
crate::{
|
||||
Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, CreateCounterparty,
|
||||
CreatePayout, FiatAndRailCode, FiatFeeRequest, FiatPayoutFee, MuralError, Organization,
|
||||
OrganizationId, PayoutMethod, PayoutMethodDetails, PayoutMethodId, PayoutRequest,
|
||||
PayoutRequestId, PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse,
|
||||
TokenFeeRequest, TokenPayoutFee, UpdateCounterparty,
|
||||
transaction::{Transaction, TransactionId},
|
||||
},
|
||||
std::fmt::{self, Debug},
|
||||
};
|
||||
|
||||
macro_rules! impl_mock {
|
||||
(
|
||||
$(fn $fn:ident ( $( $ty:ty ),* ) -> $ret:ty);* $(;)?
|
||||
) => {
|
||||
/// Mock data returned by [`crate::MuralPay`].
|
||||
pub struct MuralPayMock {
|
||||
$(
|
||||
pub $fn: Box<dyn Fn($($ty),*) -> $ret + Send + Sync>,
|
||||
)*
|
||||
}
|
||||
|
||||
impl Default for MuralPayMock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
$(
|
||||
$fn: Box::new(|$(_: $ty),*| panic!("missing mock for `{}`", stringify!($fn))),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_mock! {
|
||||
fn get_all_accounts() -> Result<Vec<Account>, MuralError>;
|
||||
fn get_account(AccountId) -> Result<Account, MuralError>;
|
||||
fn create_account(&str, Option<&str>) -> Result<Account, MuralError>;
|
||||
fn search_payout_requests(Option<PayoutStatusFilter>, Option<SearchParams<PayoutRequestId>>) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, MuralError>;
|
||||
fn get_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
|
||||
fn get_fees_for_token_amount(&[TokenFeeRequest]) -> Result<Vec<TokenPayoutFee>, MuralError>;
|
||||
fn get_fees_for_fiat_amount(&[FiatFeeRequest]) -> Result<Vec<FiatPayoutFee>, MuralError>;
|
||||
fn create_payout_request(AccountId, Option<&str>, &[CreatePayout]) -> Result<PayoutRequest, MuralError>;
|
||||
fn execute_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
|
||||
fn cancel_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
|
||||
fn get_bank_details(&[FiatAndRailCode]) -> Result<BankDetailsResponse, MuralError>;
|
||||
fn search_payout_methods(CounterpartyId, Option<SearchParams<PayoutMethodId>>) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError>;
|
||||
fn get_payout_method(CounterpartyId, PayoutMethodId) -> Result<PayoutMethod, MuralError>;
|
||||
fn create_payout_method(CounterpartyId, &str, &PayoutMethodDetails) -> Result<PayoutMethod, MuralError>;
|
||||
fn delete_payout_method(CounterpartyId, PayoutMethodId) -> Result<(), MuralError>;
|
||||
fn search_organizations(SearchRequest) -> Result<SearchResponse<OrganizationId, Organization>, MuralError>;
|
||||
fn get_organization(OrganizationId) -> Result<Organization, MuralError>;
|
||||
fn search_counterparties(Option<SearchParams<CounterpartyId>>) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError>;
|
||||
fn get_counterparty(CounterpartyId) -> Result<Counterparty, MuralError>;
|
||||
fn create_counterparty(&CreateCounterparty) -> Result<Counterparty, MuralError>;
|
||||
fn update_counterparty(CounterpartyId, &UpdateCounterparty) -> Result<Counterparty, MuralError>;
|
||||
fn get_transaction(TransactionId) -> Result<Transaction, MuralError>;
|
||||
fn search_transactions(AccountId, Option<SearchParams<AccountId>>) -> Result<SearchResponse<AccountId, Account>, MuralError>;
|
||||
}
|
||||
|
||||
impl Debug for MuralPayMock {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.debug_struct("MuralPayMock").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
122
packages/muralpay/src/client/mod.rs
Normal file
122
packages/muralpay/src/client/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
mod error;
|
||||
pub use error::*;
|
||||
use {
|
||||
reqwest::{IntoUrl, RequestBuilder},
|
||||
secrecy::{ExposeSecret, SecretString},
|
||||
};
|
||||
|
||||
#[cfg(feature = "mock")]
|
||||
mod mock;
|
||||
#[cfg(feature = "mock")]
|
||||
pub use mock::MuralPayMock;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Client {
|
||||
pub http: reqwest::Client,
|
||||
pub api_url: String,
|
||||
pub api_key: SecretString,
|
||||
pub transfer_api_key: SecretString,
|
||||
#[cfg(feature = "mock")]
|
||||
pub mock: std::sync::Arc<arc_swap::ArcSwapOption<mock::MuralPayMock>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(
|
||||
api_url: impl Into<String>,
|
||||
api_key: impl Into<SecretString>,
|
||||
transfer_api_key: impl Into<SecretString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
api_url: api_url.into(),
|
||||
api_key: api_key.into(),
|
||||
transfer_api_key: transfer_api_key.into(),
|
||||
#[cfg(feature = "mock")]
|
||||
mock: std::sync::Arc::new(arc_swap::ArcSwapOption::empty()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a client which mocks responses.
|
||||
#[cfg(feature = "mock")]
|
||||
#[must_use]
|
||||
pub fn from_mock(mock: mock::MuralPayMock) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
api_url: String::new(),
|
||||
api_key: SecretString::from(String::new()),
|
||||
transfer_api_key: SecretString::from(String::new()),
|
||||
mock: std::sync::Arc::new(arc_swap::ArcSwapOption::from_pointee(mock)),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_req(&self, make_req: impl FnOnce() -> RequestBuilder) -> RequestBuilder {
|
||||
make_req()
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.header("accept", "application/json")
|
||||
.header("content-type", "application/json")
|
||||
}
|
||||
|
||||
pub(crate) fn http_get<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
|
||||
self.http_req(|| self.http.get(make_url(&self.api_url)))
|
||||
}
|
||||
|
||||
pub(crate) fn http_post<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
|
||||
self.http_req(|| self.http.post(make_url(&self.api_url)))
|
||||
}
|
||||
|
||||
pub(crate) fn http_put<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
|
||||
self.http_req(|| self.http.put(make_url(&self.api_url)))
|
||||
}
|
||||
|
||||
pub(crate) fn http_delete<U: IntoUrl>(
|
||||
&self,
|
||||
make_url: impl FnOnce(&str) -> U,
|
||||
) -> RequestBuilder {
|
||||
self.http_req(|| self.http.delete(make_url(&self.api_url)))
|
||||
}
|
||||
|
||||
pub async fn health(&self) -> reqwest::Result<()> {
|
||||
self.http_get(|base| format!("{base}/api/health"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RequestExt: Sized {
|
||||
#[must_use]
|
||||
fn transfer_auth(self, client: &Client) -> Self;
|
||||
|
||||
fn send_mural<T: DeserializeOwned>(
|
||||
self,
|
||||
) -> impl Future<Output = crate::Result<T>> + Send + Sync;
|
||||
}
|
||||
|
||||
const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key";
|
||||
|
||||
impl RequestExt for reqwest::RequestBuilder {
|
||||
fn transfer_auth(self, client: &Client) -> Self {
|
||||
self.header(
|
||||
HEADER_TRANSFER_API_KEY,
|
||||
client.transfer_api_key.expose_secret(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T> {
|
||||
let resp = self.send().await?;
|
||||
let status = resp.status();
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
let json = resp.bytes().await?;
|
||||
let err = serde_json::from_slice::<ApiError>(&json)
|
||||
.map_err(|source| MuralError::DecodeError { source, json })?;
|
||||
Err(MuralError::Api(err))
|
||||
} else {
|
||||
let json = resp.bytes().await?;
|
||||
let t = serde_json::from_slice::<T>(&json)
|
||||
.map_err(|source| MuralError::Decode { source, json })?;
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user