taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

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 }