Refactor to use one module per concept

This makes code navigation easier.

Signed-off-by: Jacob Kiers <code@kiers.eu>
This commit is contained in:
Jacob Kiers 2024-02-29 21:41:11 +01:00
parent 2874d8dba0
commit 54cec9ac04
6 changed files with 320 additions and 282 deletions

View File

@ -5,8 +5,10 @@ fn main() -> Result<()> {
let cfg = BunqConfig::load()?; let cfg = BunqConfig::load()?;
let cfg = cfg.install()?; let cfg = cfg.install()?;
let accs = cfg.monetary_accounts()?; let accs = cfg.monetary_accounts()?;
println!("{:#?}", accs); println!("{:#?}", accs);
let acc = &accs[0]; let acc = &accs[0];
let ps = cfg.payments(acc)?; let ps = cfg.payments(acc)?;
println!("{:#?}", ps); println!("{:#?}", ps);

137
src/config.rs Normal file
View File

@ -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<AppState>,
}
impl BunqConfig {
pub fn load() -> anyhow::Result<BunqConfig> {
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<BunqClient> {
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,
}

0
src/installation.rs Normal file
View File

View File

@ -1,61 +1,57 @@
use anyhow::{anyhow, Result}; use anyhow::Result;
use isahc::http::StatusCode;
use isahc::prelude::*;
use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey};
use rsa::RsaPrivateKey; use rsa::RsaPrivateKey;
use rsa::pkcs1v15::SigningKey; use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::{EncodePublicKey, LineEnding};
use rsa::signature::RandomizedSigner; use rsa::signature::RandomizedSigner;
use rsa::sha2::Sha256; 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)] pub use config::BunqConfig;
struct Installation<'a> { pub use crate::monetary_account::MonetaryAccount;
client_public_key: &'a str, pub use crate::payment::Payment;
}
#[derive(Deserialize)] const BASE_URL: &str = "https://api.bunq.com";
struct Token {
pub struct BunqClient {
token: String, token: String,
#[allow(dead_code)] // Required for signing bodies. Not used yet.
keypair: RsaPrivateKey,
user_id: i64,
} }
#[derive(Deserialize)] impl BunqClient {
#[serde(rename_all = "PascalCase")] pub fn monetary_accounts(&self) -> Result<Vec<MonetaryAccount>> {
struct InstallationResponse { monetary_account::get(self)
token: Token, }
pub fn payments(&self, acc: &MonetaryAccount) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>,
) -> anyhow::Result<Vec<Payment>> {
payment::get_from_to(self, acc, from, to)
}
} }
#[derive(Serialize)] #[derive(Deserialize, Debug)]
struct DeviceServer<'a> { struct Pagination {
description: &'a str, #[allow(dead_code)] // Used for refresh. Not implemented yet.
secret: &'a str, future_url: Option<String>,
permitted_ips: &'a [&'a str], #[allow(dead_code)] // Used for finding newer items. Not necessary yet.
newer_url: Option<String>,
older_url: Option<String>,
} }
#[derive(Serialize)] struct Response<T> {
struct SessionServer<'a> { response: T,
secret: &'a str, pagination: Option<Pagination>,
}
#[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<String> {
let signing_key = SigningKey::<Sha256>::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()))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -65,11 +61,6 @@ struct RawResponse {
pagination: Option<Pagination>, pagination: Option<Pagination>,
} }
struct Response<T> {
response: T,
pagination: Option<Pagination>,
}
impl RawResponse { impl RawResponse {
fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> { fn decode_retarded<T: DeserializeOwned>(self) -> Result<Response<T>> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
@ -102,237 +93,9 @@ fn deserialize_normal_response<T: DeserializeOwned>(r: &str) -> Result<Response<
}) })
} }
#[derive(Serialize, Deserialize, Default)] fn sign(body: &str, key: &RsaPrivateKey) -> Result<String> {
struct AppState { let signing_key = SigningKey::<Sha256>::new(key.clone());
token: String,
pem_private: String,
}
#[derive(Serialize, Deserialize, Default)]
pub struct BunqConfig {
api_key: String,
state: Option<AppState>,
}
pub struct BunqConfigReady {
token: String,
keypair: RsaPrivateKey,
user_id: i64,
}
impl BunqConfig {
pub fn load() -> Result<BunqConfig> {
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<BunqConfigReady> {
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 mut rng = rand::thread_rng();
let signature = signing_key.sign_with_rng(&mut rng, body.as_bytes());
let bits = 2048; Ok(base64::encode(signature.to_string()))
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<Vec<MonetaryAccount>> {
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::<Vec<MonetaryAccount>>(&response)?;
Ok(accounts.response)
}
pub fn payments(&self, acc: &MonetaryAccount) -> Result<Vec<Payment>> {
self.payments_from_to(acc, None, None)
}
pub fn payments_from_to(
&self,
acc: &MonetaryAccount,
from: Option<i64>,
to: Option<i64>,
) -> Result<Vec<Payment>> {
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::<Vec<PaymentPayment>>(&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<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[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<String>,
newer_url: Option<String>,
older_url: Option<String>,
}
#[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,
} }

37
src/monetary_account.rs Normal file
View File

@ -0,0 +1,37 @@
use serde::Deserialize;
use isahc::{RequestExt, ResponseExt};
use crate::{BASE_URL, BunqClient};
pub(super) fn get(client: &BunqClient) -> anyhow::Result<Vec<MonetaryAccount>> {
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::<Vec<MonetaryAccount>>(&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,
}

99
src/payment.rs Normal file
View File

@ -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<i64>,
to: Option<i64>) -> Result<Vec<Payment>>
{
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::<Vec<PaymentPayment>>(&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<String>,
pub display_name: String,
pub merchant_category_code: Option<String>,
}
#[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,
}