client.rs (4739B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2025, 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::{borrow::Cow, time::Duration}; 18 19 use http_client::{ApiErr, ClientErr, builder::Req, headers::HeaderParser as _}; 20 use hyper::{ 21 Method, StatusCode, 22 header::{AUTHORIZATION, HeaderValue, RETRY_AFTER}, 23 }; 24 use jiff::{SignedDuration, Timestamp, fmt::rfc2822::DateTimeParser}; 25 use serde::de::DeserializeOwned; 26 use taler_common::types::amount::Currency; 27 use thiserror::Error; 28 29 use crate::{ 30 config::ApiEnv, 31 wise_api::types::{Balance, BalanceStatement, MultiCurrencyAccount, Profile}, 32 }; 33 34 #[derive(Error, Debug)] 35 pub enum WiseErr { 36 #[error("status {0}")] 37 Status(StatusCode), 38 #[error("status {0} '{1}'")] 39 StatusCause(StatusCode, String), 40 #[error("rate limited (retry after {0:?})")] 41 TooManyRequest(Option<Duration>), 42 #[error(transparent)] 43 Client(#[from] ClientErr), 44 } 45 pub type ApiResult<R> = std::result::Result<R, ApiErr<WiseErr>>; 46 47 /// Parse both forms of Retry-After. Past dates are already satisfied. 48 fn retry_after(value: &str) -> Option<Duration> { 49 let value = value.trim(); 50 let delay = if let Ok(seconds) = value.parse() { 51 Duration::from_secs(seconds) 52 } else { 53 let date = DateTimeParser::new().parse_timestamp(value).ok()?; 54 date.duration_since(Timestamp::now()) 55 .max(SignedDuration::ZERO) 56 .unsigned_abs() 57 }; 58 std::time::Instant::now().checked_add(delay).map(|_| delay) 59 } 60 61 pub struct Client<'a> { 62 base_url: &'a url::Url, 63 client: &'a http_client::Client, 64 token: HeaderValue, 65 } 66 67 impl<'a> Client<'a> { 68 pub fn new(client: &'a http_client::Client, env: ApiEnv, token: &str) -> Self { 69 Self { 70 client, 71 base_url: env.api(), 72 token: HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), 73 } 74 } 75 76 fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> Req { 77 Req::new(self.client, method, self.base_url, path) 78 } 79 80 async fn send<T: DeserializeOwned>(&self, req: Req) -> ApiResult<T> { 81 let (ctx, res) = req 82 .sensitive_header(AUTHORIZATION, self.token.clone()) 83 .send() 84 .await 85 .map_err(|(ctx, e)| ctx.wrap(e.into()))?; 86 match res.status() { 87 StatusCode::OK => { 88 // Parse json 89 res.json().await.map_err(|e| ctx.wrap(e.into())) 90 } 91 StatusCode::TOO_MANY_REQUESTS => Err(ctx.wrap(WiseErr::TooManyRequest(retry_after( 92 &res.str_header(RETRY_AFTER).unwrap_or_default(), 93 )))), 94 other => Err(ctx.wrap(WiseErr::Status(other))), 95 } 96 } 97 98 pub async fn profiles(&self) -> ApiResult<Vec<Profile>> { 99 Self::send(self, self.request(Method::GET, "/v2/profiles")).await 100 } 101 102 pub async fn standard_balances(&self, profile_id: u64) -> ApiResult<Vec<Balance>> { 103 Self::send( 104 self, 105 self.request( 106 Method::GET, 107 format!("/v4/profiles/{profile_id}/balances?types=STANDARD"), 108 ), 109 ) 110 .await 111 } 112 113 pub async fn borderless_accounts( 114 &self, 115 profile_id: u64, 116 ) -> ApiResult<Vec<MultiCurrencyAccount>> { 117 Self::send( 118 self, 119 self.request( 120 Method::GET, 121 format!("/v1/borderless-accounts?profileId={profile_id}"), 122 ), 123 ) 124 .await 125 } 126 127 pub async fn balance_statement( 128 &self, 129 profile_id: u64, 130 balance_id: u32, 131 currency: &Currency, 132 start: Timestamp, 133 end: Timestamp, 134 ) -> ApiResult<BalanceStatement> { 135 Self::send( 136 self, 137 self.request( 138 Method::GET, 139 format!("/v1/profiles/{profile_id}/balance-statements/{balance_id}/statement.json"), 140 ) 141 .query("type", "COMPACT") 142 .query("currency", currency) 143 .query("intervalStart", start) 144 .query("intervalEnd", end), 145 ) 146 .await 147 } 148 }