From 54cec9ac0492d10b6dff20816f757ec5170e5cf0 Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Thu, 29 Feb 2024 21:41:11 +0100 Subject: [PATCH] Refactor to use one module per concept This makes code navigation easier. Signed-off-by: Jacob Kiers --- examples/list.rs | 2 + src/config.rs | 137 +++++++++++++++++ src/installation.rs | 0 src/lib.rs | 327 ++++++---------------------------------- src/monetary_account.rs | 37 +++++ src/payment.rs | 99 ++++++++++++ 6 files changed, 320 insertions(+), 282 deletions(-) create mode 100644 src/config.rs create mode 100644 src/installation.rs create mode 100644 src/monetary_account.rs create mode 100644 src/payment.rs diff --git a/examples/list.rs b/examples/list.rs index b57d9dd..6172ed4 100644 --- a/examples/list.rs +++ b/examples/list.rs @@ -5,8 +5,10 @@ fn main() -> Result<()> { let cfg = BunqConfig::load()?; let cfg = cfg.install()?; + let accs = cfg.monetary_accounts()?; println!("{:#?}", accs); + let acc = &accs[0]; let ps = cfg.payments(acc)?; println!("{:#?}", ps); diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4c4c03e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,137 @@ +use anyhow::anyhow; +use isahc::{RequestExt, ResponseExt}; +use isahc::http::StatusCode; +use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, LineEnding}; +use rsa::pkcs8::EncodePublicKey; +use rsa::RsaPrivateKey; +use serde::{Deserialize, Serialize}; +use crate::{BASE_URL, BunqClient, deserialize_retarded_response, sign}; + +#[derive(Serialize, Deserialize, Default)] +struct AppState { + token: String, + pem_private: String, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct BunqConfig { + api_key: String, + state: Option, +} + +impl BunqConfig { + pub fn load() -> anyhow::Result { + println!("Loading config file from {}", confy::get_configuration_file_path("bunq-rs", "bunq-rs")?.to_string_lossy()); + Ok(confy::load("bunq-rs", "bunq-rs")?) + } + pub fn save(&self) -> anyhow::Result<()> { + println!("Storing config file in {}", confy::get_configuration_file_path("bunq-rs", None)?.to_string_lossy()); + confy::store("bunq-rs", "bunq-rs", self)?; + Ok(()) + } + pub fn install(mut self) -> anyhow::Result { + let api_key = &self.api_key; + + let keypair = if let Some(state) = &self.state { + RsaPrivateKey::from_pkcs1_pem(&state.pem_private)? + } else { + let mut rng = rand::thread_rng(); + + let bits = 2048; + let keypair = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key"); + + let pem_public = keypair.to_public_key().to_public_key_pem(LineEnding::CRLF)?; + + let body = Installation { + client_public_key: &pem_public, + }; + let response = isahc::post( + format!("{}/v1/installation", BASE_URL), + serde_json::to_string(&body)?, + )? + .text()?; + let response: InstallationResponse = deserialize_retarded_response(&response)?.response; + let token = response.token.token; + + let body = DeviceServer { + description: "JK Test Server", + secret: api_key, + permitted_ips: &["77.175.86.236", "*"], + }; + let body = serde_json::to_string(&body)?; + let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE_URL)) + .header("X-Bunq-Client-Authentication", &token) + .body(body)? + .send()?; + + let response_text = response.text()?; + println!("{}", response_text); + + if response.status() != StatusCode::OK { + return Err(anyhow!(response_text)); + } + + self.state = Some(AppState { pem_private: keypair.to_pkcs1_pem(LineEnding::CRLF)?.to_string(), token }); + self.save()?; + + keypair + }; + let token = self.state.unwrap().token; + let body = SessionServer { secret: api_key }; + let body = serde_json::to_string(&body)?; + let sig = sign(&body, &keypair)?; + let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE_URL)) + .header("X-Bunq-Client-Authentication", &token) + .header("X-Bunq-Client-Signature", &sig) + .body(body)? + .send()? + .text()?; + let r: SessionServerResponse = deserialize_retarded_response(&response)?.response; + + Ok(BunqClient { + keypair, + token: r.token.token, + user_id: r.user_person.id, + }) + } +} + +#[derive(Serialize)] +pub(super) struct Installation<'a> { + pub(super) client_public_key: &'a str, +} + +#[derive(Deserialize)] +pub(super) struct Token { + pub(super) token: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct InstallationResponse { + pub(super) token: Token, +} + +#[derive(Serialize)] +pub(super) struct DeviceServer<'a> { + pub(super) description: &'a str, + pub(super) secret: &'a str, + pub(super) permitted_ips: &'a [&'a str], +} + +#[derive(Serialize)] +pub(super) struct SessionServer<'a> { + pub(super) secret: &'a str, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct SessionServerResponse { + pub(super) token: Token, + pub(super) user_person: UserPerson, +} + +#[derive(Deserialize)] +pub(super) struct UserPerson { + pub(super) id: i64, +} diff --git a/src/installation.rs b/src/installation.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs index b3ca44a..6daaeec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,61 +1,57 @@ -use anyhow::{anyhow, Result}; -use isahc::http::StatusCode; -use isahc::prelude::*; -use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}; +use anyhow::Result; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; -use rsa::pkcs8::{EncodePublicKey, LineEnding}; use rsa::signature::RandomizedSigner; use rsa::sha2::Sha256; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize}; -const BASE: &str = "https://api.bunq.com"; +mod config; +mod monetary_account; +mod payment; -#[derive(Serialize)] -struct Installation<'a> { - client_public_key: &'a str, -} +pub use config::BunqConfig; +pub use crate::monetary_account::MonetaryAccount; +pub use crate::payment::Payment; -#[derive(Deserialize)] -struct Token { +const BASE_URL: &str = "https://api.bunq.com"; + +pub struct BunqClient { token: String, + #[allow(dead_code)] // Required for signing bodies. Not used yet. + keypair: RsaPrivateKey, + user_id: i64, } -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct InstallationResponse { - token: Token, +impl BunqClient { + pub fn monetary_accounts(&self) -> Result> { + monetary_account::get(self) + } + + pub fn payments(&self, acc: &MonetaryAccount) -> Result> { + self.payments_from_to(acc, None, None) + } + pub fn payments_from_to( + &self, + acc: &MonetaryAccount, + from: Option, + to: Option, + ) -> anyhow::Result> { + payment::get_from_to(self, acc, from, to) + } } -#[derive(Serialize)] -struct DeviceServer<'a> { - description: &'a str, - secret: &'a str, - permitted_ips: &'a [&'a str], +#[derive(Deserialize, Debug)] +struct Pagination { + #[allow(dead_code)] // Used for refresh. Not implemented yet. + future_url: Option, + #[allow(dead_code)] // Used for finding newer items. Not necessary yet. + newer_url: Option, + older_url: Option, } -#[derive(Serialize)] -struct SessionServer<'a> { - secret: &'a str, -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct SessionServerResponse { - token: Token, - user_person: UserPerson, -} - -#[derive(Deserialize)] -struct UserPerson { - id: i64, -} - -fn sign(body: &str, key: &RsaPrivateKey) -> Result { - let signing_key = SigningKey::::new(key.clone()); - let mut rng = rand::thread_rng(); - let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes()); - Ok(base64::encode(signature.to_string())) +struct Response { + response: T, + pagination: Option, } #[derive(Deserialize)] @@ -65,11 +61,6 @@ struct RawResponse { pagination: Option, } -struct Response { - response: T, - pagination: Option, -} - impl RawResponse { fn decode_retarded(self) -> Result> { let mut map = serde_json::Map::new(); @@ -102,237 +93,9 @@ fn deserialize_normal_response(r: &str) -> Result, -} - -pub struct BunqConfigReady { - token: String, - keypair: RsaPrivateKey, - user_id: i64, -} - -impl BunqConfig { - pub fn load() -> Result { - println!("Loading config file from {}", confy::get_configuration_file_path("bunq-rs", "bunq-rs")?.to_string_lossy()); - Ok(confy::load("bunq-rs", "bunq-rs")?) - } - pub fn save(&self) -> Result<()> { - println!("Storing config file in {}", confy::get_configuration_file_path("bunq-rs", None)?.to_string_lossy()); - confy::store("bunq-rs", "bunq-rs", self)?; - Ok(()) - } - pub fn install(mut self) -> Result { - let api_key = &self.api_key; - - let keypair = if let Some(state) = &self.state { - RsaPrivateKey::from_pkcs1_pem(&*state.pem_private)? - } else { - let mut rng = rand::thread_rng(); - - let bits = 2048; - let keypair = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key"); - - let pem_public = keypair.to_public_key().to_public_key_pem(LineEnding::CRLF)?; - - let body = Installation { - client_public_key: &pem_public, - }; - let response = isahc::post( - format!("{}/v1/installation", BASE), - serde_json::to_string(&body)?, - )? - .text()?; - let response: InstallationResponse = deserialize_retarded_response(&response)?.response; - let token = response.token.token; - - let body = DeviceServer { - description: "awesome", - secret: &api_key, - permitted_ips: &["31.21.118.143", "*"], - }; - let body = serde_json::to_string(&body)?; - let mut response = isahc::http::Request::post(format!("{}/v1/device-server", BASE)) - .header("X-Bunq-Client-Authentication", &token) - .body(body)? - .send()?; - - let response_text = response.text()?; - println!("{}", response_text); - - if response.status() != StatusCode::OK { - return Err(anyhow!(response_text)); - } - - self.state = Some(AppState { pem_private: keypair.to_pkcs1_pem(LineEnding::CRLF)?.to_string(), token }); - self.save()?; - - keypair - }; - let token = self.state.unwrap().token; - let body = SessionServer { secret: &api_key }; - let body = serde_json::to_string(&body)?; - let sig = sign(&body, &keypair)?; - let response = isahc::http::Request::post(format!("{}/v1/session-server", BASE)) - .header("X-Bunq-Client-Authentication", &token) - .header("X-Bunq-Client-Signature", &sig) - .body(body)? - .send()? - .text()?; - let r: SessionServerResponse = deserialize_retarded_response(&response)?.response; - - Ok(BunqConfigReady { - keypair, - token: r.token.token, - user_id: r.user_person.id, - }) - } -} - -impl BunqConfigReady { - pub fn monetary_accounts(&self) -> Result> { - let response = isahc::http::Request::get(format!( - "{}/v1/user/{}/monetary-account", - BASE, self.user_id - )) - .header("X-Bunq-Client-Authentication", &self.token) - .body(())? - .send()? - .text()?; - - let accounts = deserialize_normal_response::>(&response)?; - - Ok(accounts.response) - } - pub fn payments(&self, acc: &MonetaryAccount) -> Result> { - self.payments_from_to(acc, None, None) - } - pub fn payments_from_to( - &self, - acc: &MonetaryAccount, - from: Option, - to: Option, - ) -> Result> { - let next_page = |url: &str| -> Result<(_, _)> { - let response = isahc::http::Request::get(url) - .header("X-Bunq-Client-Authentication", &self.token) - .body(())? - .send()? - .text()?; - let Response { - response, - pagination, - } = deserialize_normal_response::>(&response)?; - Ok(( - response.into_iter().map(|p| p.payment).collect(), - pagination, - )) - }; - - let account_id = match acc { - MonetaryAccount::MonetaryAccountBank(bank) => bank.id, - MonetaryAccount::MonetaryAccountSavings(savings) => savings.id, - }; - - let mut url = format!( - "/v1/user/{}/monetary-account/{}/payment", - self.user_id, account_id - ); - - if let Some(to) = to { - url = format!("{}?newer_id={}", url, to); - } - - let mut all = Vec::new(); - loop { - let (mut payments, pag) = next_page(&format!("{}{}", BASE, url))?; - all.append(&mut payments); - dbg!(&pag); - if let Some(Pagination { - older_url: Some(older_url), - .. - }) = pag - { - if let (Some(latest), Some(from)) = (all.last(), from) { - if latest.id <= from { - all = all.into_iter().filter(|p| p.id >= from).collect(); - break; - } - } - url = older_url; - } else { - break; - } - } - Ok(all) - } -} - -#[derive(Deserialize, Debug)] -pub struct LabelMonetaryAccount { - pub iban: Option, - pub display_name: String, - pub merchant_category_code: Option, -} - -#[derive(Deserialize, Debug)] -pub struct Amount { - pub value: String, - pub currency: String, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "PascalCase")] -struct PaymentPayment { - payment: Payment, -} - -#[derive(Deserialize, Debug)] -struct Pagination { - future_url: Option, - newer_url: Option, - older_url: Option, -} - -#[derive(Deserialize, Debug)] -pub struct Payment { - pub alias: LabelMonetaryAccount, - pub counterparty_alias: LabelMonetaryAccount, - pub amount: Amount, - pub balance_after_mutation: Amount, - pub created: String, - pub updated: String, - pub description: String, - pub id: i64, - pub monetary_account_id: i64, - #[serde(rename = "type")] - pub type_: String, - pub sub_type: String, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "PascalCase")] -pub enum MonetaryAccount { - MonetaryAccountBank(MonetaryAccountBank), - MonetaryAccountSavings(MonetaryAccountSavings), -} - -#[derive(Deserialize, Debug)] -pub struct MonetaryAccountBank { - pub id: i64, - pub description: String, -} - -#[derive(Deserialize, Debug)] -pub struct MonetaryAccountSavings { - pub id: i64, - pub description: String, +fn sign(body: &str, key: &RsaPrivateKey) -> Result { + let signing_key = SigningKey::::new(key.clone()); + let mut rng = rand::thread_rng(); + let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes()); + Ok(base64::encode(signature.to_string())) } diff --git a/src/monetary_account.rs b/src/monetary_account.rs new file mode 100644 index 0000000..5b22131 --- /dev/null +++ b/src/monetary_account.rs @@ -0,0 +1,37 @@ +use serde::Deserialize; +use isahc::{RequestExt, ResponseExt}; +use crate::{BASE_URL, BunqClient}; + +pub(super) fn get(client: &BunqClient) -> anyhow::Result> { + let response = isahc::http::Request::get(format!( + "{}/v1/user/{}/monetary-account", + BASE_URL, client.user_id + )) + .header("X-Bunq-Client-Authentication", &client.token) + .body(())? + .send()? + .text()?; + + let accounts = crate::deserialize_normal_response::>(&response)?; + + Ok(accounts.response) +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub enum MonetaryAccount { + MonetaryAccountBank(MonetaryAccountBank), + MonetaryAccountSavings(MonetaryAccountSavings), +} + +#[derive(Deserialize, Debug)] +pub struct MonetaryAccountBank { + pub id: i64, + pub description: String, +} + +#[derive(Deserialize, Debug)] +pub struct MonetaryAccountSavings { + pub id: i64, + pub description: String, +} diff --git a/src/payment.rs b/src/payment.rs new file mode 100644 index 0000000..c5f0459 --- /dev/null +++ b/src/payment.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use isahc::{RequestExt, ResponseExt}; +use serde::Deserialize; +use crate::{BASE_URL, BunqClient, MonetaryAccount, Pagination, Response}; + +pub(super) fn get_from_to(client: &BunqClient, + acc: &MonetaryAccount, + from: Option, + to: Option) -> Result> +{ + let next_page = |url: &str| -> Result<(_, _)> { + let response = isahc::http::Request::get(url) + .header("X-Bunq-Client-Authentication", &client.token) + .body(())? + .send()? + .text()?; + let Response { + response, + pagination, + } = crate::deserialize_normal_response::>(&response)?; + Ok(( + response.into_iter().map(|p| p.payment).collect(), + pagination, + )) + }; + + let account_id = match acc { + MonetaryAccount::MonetaryAccountBank(bank) => bank.id, + MonetaryAccount::MonetaryAccountSavings(savings) => savings.id, + }; + + let mut url = format!( + "/v1/user/{}/monetary-account/{}/payment", + client.user_id, account_id + ); + + if let Some(to) = to { + url = format!("{}?newer_id={}", url, to); + } + + let mut all = Vec::new(); + loop { + let (mut payments, pag) = next_page(&format!("{}{}", BASE_URL, url))?; + dbg!(&payments); + all.append(&mut payments); + dbg!(&pag); + if let Some(Pagination { + older_url: Some(older_url), + .. + }) = pag + { + if let (Some(latest), Some(from)) = (all.last(), from) { + if latest.id <= from { + all.retain(|p| p.id >= from); + break; + } + } + url = older_url; + } else { + break; + } + } + Ok(all) +} + +#[derive(Deserialize, Debug)] +pub struct LabelMonetaryAccount { + pub iban: Option, + pub display_name: String, + pub merchant_category_code: Option, +} + +#[derive(Deserialize, Debug)] +pub struct Amount { + pub value: String, + pub currency: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct PaymentPayment { + payment: Payment, +} + +#[derive(Deserialize, Debug)] +pub struct Payment { + pub alias: LabelMonetaryAccount, + pub counterparty_alias: LabelMonetaryAccount, + pub amount: Amount, + pub balance_after_mutation: Amount, + pub created: String, + pub updated: String, + pub description: String, + pub id: i64, + pub monetary_account_id: i64, + #[serde(rename = "type")] + pub type_: String, + pub sub_type: String, +} \ No newline at end of file