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:
aecsocket
2025-12-08 20:34:41 +00:00
committed by GitHub
parent cfd2977c21
commit 9aa06fbc26
22 changed files with 1171 additions and 1151 deletions

View 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"))
}
}

View 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()
}
}

View 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)
}
}
}