taler-rust

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

client.rs (9195B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025 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 base64::{Engine as _, prelude::BASE64_STANDARD};
     20 use p256::{
     21     PublicKey,
     22     ecdsa::{DerSignature, SigningKey, signature::Signer as _},
     23     pkcs8::EncodePublicKey,
     24 };
     25 use reqwest::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 reqwest::Client,
     52     pub api_url: &'a reqwest::Url,
     53     consumer: &'a Token,
     54 }
     55 
     56 impl<'a> AuthClient<'a> {
     57     pub fn new(
     58         client: &'a reqwest::Client,
     59         api_url: &'a reqwest::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 reqwest::Client,
    121     api_url: &'a reqwest::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: &SigningKey) -> ApiResult<Value> {
    161         let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar());
    162         let der = public_key.to_public_key_der().unwrap().to_vec();
    163         self.request(Method::POST, "/RESTApi/resources/v2/token/public-key")
    164             .json(&json!({
    165                 "keyData": BASE64_STANDARD.encode(der)
    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(&[
    228                 ("tranzakciofrissites", sync),
    229                 ("ascending", order == Order::Ascending),
    230             ])
    231             .parse_json()
    232             .await
    233     }
    234 
    235     pub async fn init_tx(
    236         &self,
    237         account_code: u64,
    238         amount: f64,
    239         subject: &str,
    240         date: &jiff::civil::Date,
    241         creditor_name: &str,
    242         creditor_bban: &str,
    243     ) -> ApiResult<Tx> {
    244         #[derive(Serialize)]
    245         struct Req<'a> {
    246             #[serde(rename = "bankszamlaKod")]
    247             account_code: u64,
    248             #[serde(rename = "osszeg")]
    249             amount: f64,
    250             #[serde(rename = "kozlemeny")]
    251             subject: &'a str,
    252             #[serde(rename = "ertekNap")]
    253             date: &'a jiff::civil::Date,
    254             #[serde(rename = "ellenpartner")]
    255             creditor_name: &'a str,
    256             #[serde(rename = "ellenszamla")]
    257             creditor_account: &'a str,
    258         }
    259 
    260         Ok(self
    261             .request(Method::POST, "/RESTApi/resources/v2/esetiatutalas")
    262             .json(&Req {
    263                 account_code,
    264                 amount,
    265                 subject,
    266                 date,
    267                 creditor_name,
    268                 creditor_account: creditor_bban,
    269             })
    270             .parse_json::<TxWrapper>()
    271             .await?
    272             .tx)
    273     }
    274 
    275     pub async fn submit_tx(
    276         &self,
    277         signing_key: &SigningKey,
    278         bban: &str,
    279         tx_code: u64,
    280         amount: f64,
    281         date: &jiff::civil::Date,
    282         creditor_bban: &str,
    283     ) -> ApiResult<Tx> {
    284         #[derive(Serialize)]
    285         struct Req<'a> {
    286             #[serde(rename = "tranzakcioKod")]
    287             tx_code: u64,
    288             #[serde(rename = "forrasszamla")]
    289             debtor: &'a str,
    290             #[serde(rename = "ellenszamla")]
    291             creditor: &'a str,
    292             #[serde(rename = "osszeg")]
    293             amount: f64,
    294             #[serde(rename = "ertekNap")]
    295             date: &'a jiff::civil::Date,
    296             signature: &'a str,
    297         }
    298 
    299         let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};");
    300         let signature: DerSignature = signing_key.sign(content.as_bytes());
    301         let encoded = BASE64_STANDARD.encode(signature.as_bytes());
    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 }