taler-rust

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

client.rs (9142B)


      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;
     18 
     19 use aws_lc_rs::{
     20     encoding::AsDer as _,
     21     rand::SystemRandom,
     22     signature::{EcdsaKeyPair, KeyPair as _},
     23 };
     24 use base64::{Engine as _, prelude::BASE64_STANDARD};
     25 use hyper::Method;
     26 use serde::{Deserialize, Serialize};
     27 use serde_json::{Value, json};
     28 
     29 use crate::magnet_api::{
     30     api::{ApiResult, MagnetRequest},
     31     oauth::{Token, TokenAuth},
     32     types::{
     33         Account, BalanceMini, Direction, Next, Order, PartnerList, SmsCodeSubmission, TokenInfo,
     34         TransactionPage, Tx,
     35     },
     36 };
     37 
     38 #[derive(Debug, Deserialize)]
     39 struct TxWrapper {
     40     #[serde(rename = "tranzakcio")]
     41     tx: Tx,
     42 }
     43 
     44 #[derive(Debug, Deserialize)]
     45 pub struct AccountWrapper {
     46     #[serde(rename = "bankszamla")]
     47     account: Account,
     48 }
     49 
     50 pub struct AuthClient<'a> {
     51     client: &'a http_client::Client,
     52     pub api_url: &'a url::Url,
     53     consumer: &'a Token,
     54 }
     55 
     56 impl<'a> AuthClient<'a> {
     57     pub fn new(
     58         client: &'a http_client::Client,
     59         api_url: &'a url::Url,
     60         consumer: &'a Token,
     61     ) -> Self {
     62         Self {
     63             client,
     64             api_url,
     65             consumer,
     66         }
     67     }
     68 
     69     fn request(
     70         &self,
     71         method: Method,
     72         path: impl Into<Cow<'static, str>>,
     73         access: Option<&'a Token>,
     74         verifier: Option<&'a str>,
     75     ) -> MagnetRequest<'_> {
     76         MagnetRequest::new(
     77             self.client,
     78             method,
     79             self.api_url,
     80             path,
     81             self.consumer,
     82             access,
     83             verifier,
     84         )
     85     }
     86 
     87     pub async fn token_request(&self) -> ApiResult<Token> {
     88         self.request(Method::GET, "/NetBankOAuth/token/request", None, None)
     89             .query("oauth_callback", "oob")
     90             .parse_url()
     91             .await
     92     }
     93 
     94     pub async fn token_access(
     95         &self,
     96         token_request: &Token,
     97         token_auth: &TokenAuth,
     98     ) -> ApiResult<Token> {
     99         self.request(
    100             Method::GET,
    101             "/NetBankOAuth/token/access",
    102             Some(token_request),
    103             Some(&token_auth.oauth_verifier),
    104         )
    105         .parse_url()
    106         .await
    107     }
    108 
    109     pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> {
    110         ApiClient {
    111             client: self.client,
    112             api_url: self.api_url,
    113             consumer: self.consumer,
    114             access,
    115         }
    116     }
    117 }
    118 
    119 pub struct ApiClient<'a> {
    120     pub client: &'a http_client::Client,
    121     api_url: &'a url::Url,
    122     consumer: &'a Token,
    123     access: &'a Token,
    124 }
    125 
    126 impl ApiClient<'_> {
    127     fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> MagnetRequest<'_> {
    128         MagnetRequest::new(
    129             self.client,
    130             method,
    131             self.api_url,
    132             path,
    133             self.consumer,
    134             Some(self.access),
    135             None,
    136         )
    137     }
    138 
    139     pub async fn token_info(&self) -> ApiResult<TokenInfo> {
    140         self.request(Method::GET, "/RESTApi/resources/v2/token")
    141             .parse_json()
    142             .await
    143     }
    144 
    145     pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> {
    146         self.request(Method::GET, "/RESTApi/resources/v2/kodszo/sms/token")
    147             .parse_json()
    148             .await
    149     }
    150 
    151     pub async fn perform_sca(&self, code: &str) -> ApiResult<()> {
    152         self.request(Method::PUT, "/RESTApi/resources/v2/token/SCA")
    153             .json(&json!({
    154                 "kodszo": code
    155             }))
    156             .parse_empty()
    157             .await
    158     }
    159 
    160     pub async fn upload_public_key(&self, key: &EcdsaKeyPair) -> ApiResult<Value> {
    161         let pub_key = key.public_key();
    162         let der = pub_key.as_der().unwrap(); // TODO error
    163         self.request(Method::POST, "/RESTApi/resources/v2/token/public-key")
    164             .json(&json!({
    165                 "keyData": BASE64_STANDARD.encode(der.as_ref())
    166             }))
    167             .parse_json()
    168             .await
    169     }
    170 
    171     pub async fn list_accounts(&self) -> ApiResult<PartnerList> {
    172         self.request(Method::GET, "/RESTApi/resources/v2/partnerszamla/0")
    173             .parse_json()
    174             .await
    175     }
    176 
    177     pub async fn account(&self, bban: &str) -> ApiResult<Account> {
    178         Ok(self
    179             .request(
    180                 Method::GET,
    181                 format!("/RESTApi/resources/v2/bankszamla/{bban}"),
    182             )
    183             .parse_json::<AccountWrapper>()
    184             .await?
    185             .account)
    186     }
    187 
    188     pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> {
    189         self.request(
    190             Method::GET,
    191             format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett"),
    192         )
    193         .parse_json()
    194         .await
    195     }
    196 
    197     pub async fn get_tx(&self, code: u64) -> ApiResult<Tx> {
    198         Ok(self
    199             .request(
    200                 Method::GET,
    201                 format!("/RESTApi/resources/v2/tranzakcio/{code}"),
    202             )
    203             .parse_json::<TxWrapper>()
    204             .await?
    205             .tx)
    206     }
    207 
    208     pub async fn page_tx(
    209         &self,
    210         direction: Direction,
    211         order: Order,
    212         limit: u16,
    213         bban: &str,
    214         next: &Option<Next>,
    215         sync: bool,
    216     ) -> ApiResult<TransactionPage> {
    217         let mut req = self.request(
    218             Method::GET,
    219             format!("/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}"),
    220         );
    221         if let Some(next) = next {
    222             req = req
    223                 .query("nextId", next.next_id)
    224                 .query("nextTipus", &next.next_type);
    225         }
    226         req.query("terheles", direction)
    227             .query("tranzakciofrissites", sync)
    228             .query("ascending", order == Order::Ascending)
    229             .parse_json()
    230             .await
    231     }
    232 
    233     pub async fn init_tx(
    234         &self,
    235         account_code: u64,
    236         amount: f64,
    237         subject: &str,
    238         date: &jiff::civil::Date,
    239         creditor_name: &str,
    240         creditor_bban: &str,
    241     ) -> ApiResult<Tx> {
    242         #[derive(Serialize)]
    243         struct Req<'a> {
    244             #[serde(rename = "bankszamlaKod")]
    245             account_code: u64,
    246             #[serde(rename = "osszeg")]
    247             amount: f64,
    248             #[serde(rename = "kozlemeny")]
    249             subject: &'a str,
    250             #[serde(rename = "ertekNap")]
    251             date: &'a jiff::civil::Date,
    252             #[serde(rename = "ellenpartner")]
    253             creditor_name: &'a str,
    254             #[serde(rename = "ellenszamla")]
    255             creditor_account: &'a str,
    256         }
    257 
    258         Ok(self
    259             .request(Method::POST, "/RESTApi/resources/v2/esetiatutalas")
    260             .json(&Req {
    261                 account_code,
    262                 amount,
    263                 subject,
    264                 date,
    265                 creditor_name,
    266                 creditor_account: creditor_bban,
    267             })
    268             .parse_json::<TxWrapper>()
    269             .await?
    270             .tx)
    271     }
    272 
    273     pub async fn submit_tx(
    274         &self,
    275         signing_key: &EcdsaKeyPair,
    276         bban: &str,
    277         tx_code: u64,
    278         amount: f64,
    279         date: &jiff::civil::Date,
    280         creditor_bban: &str,
    281     ) -> ApiResult<Tx> {
    282         #[derive(Serialize)]
    283         struct Req<'a> {
    284             #[serde(rename = "tranzakcioKod")]
    285             tx_code: u64,
    286             #[serde(rename = "forrasszamla")]
    287             debtor: &'a str,
    288             #[serde(rename = "ellenszamla")]
    289             creditor: &'a str,
    290             #[serde(rename = "osszeg")]
    291             amount: f64,
    292             #[serde(rename = "ertekNap")]
    293             date: &'a jiff::civil::Date,
    294             signature: &'a str,
    295         }
    296 
    297         let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};");
    298         let signature = signing_key
    299             .sign(&SystemRandom::new(), content.as_bytes())
    300             .unwrap();
    301         let encoded = BASE64_STANDARD.encode(signature.as_ref());
    302         Ok(self
    303             .request(Method::PUT, "/RESTApi/resources/v2/tranzakcio/alairas")
    304             .json(&Req {
    305                 tx_code,
    306                 debtor: bban,
    307                 creditor: creditor_bban,
    308                 amount,
    309                 date,
    310                 signature: &encoded,
    311             })
    312             .parse_json::<TxWrapper>()
    313             .await?
    314             .tx)
    315     }
    316 
    317     pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> {
    318         self.request(
    319             Method::DELETE,
    320             format!("/RESTApi/resources/v2/tranzakcio/{tx_code}"),
    321         )
    322         .parse_empty()
    323         .await
    324     }
    325 }