Implement logic
This commit is contained in:
20
firefly-client/Cargo.toml
Normal file
20
firefly-client/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "firefly-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rust_decimal = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-test = { workspace = true }
|
||||
126
firefly-client/src/client.rs
Normal file
126
firefly-client/src/client.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use reqwest::{Client, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
use tracing::instrument;
|
||||
use crate::models::{AccountArray, TransactionStore, TransactionArray, TransactionUpdate};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FireflyError {
|
||||
#[error("Request failed: {0}")]
|
||||
RequestFailed(#[from] reqwest::Error),
|
||||
#[error("API Error: {0}")]
|
||||
ApiError(String),
|
||||
#[error("URL Parse Error: {0}")]
|
||||
UrlParseError(#[from] url::ParseError),
|
||||
}
|
||||
|
||||
pub struct FireflyClient {
|
||||
base_url: Url,
|
||||
client: Client,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
impl FireflyClient {
|
||||
pub fn new(base_url: &str, access_token: &str) -> Result<Self, FireflyError> {
|
||||
Ok(Self {
|
||||
base_url: Url::parse(base_url)?,
|
||||
client: Client::new(),
|
||||
access_token: access_token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
||||
let mut url = self.base_url.join("/api/v1/accounts")?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("type", "asset");
|
||||
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn search_accounts(&self, query: &str) -> Result<AccountArray, FireflyError> {
|
||||
let mut url = self.base_url.join("/api/v1/search/accounts")?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("query", query)
|
||||
.append_pair("type", "asset")
|
||||
.append_pair("field", "all");
|
||||
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, transaction))]
|
||||
pub async fn store_transaction(&self, transaction: TransactionStore) -> Result<(), FireflyError> {
|
||||
let url = self.base_url.join("/api/v1/transactions")?;
|
||||
|
||||
let response = self.client.post(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.json(&transaction)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!("Store Transaction Failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_account_transactions(&self, account_id: &str, start: Option<&str>, end: Option<&str>) -> Result<TransactionArray, FireflyError> {
|
||||
let mut url = self.base_url.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
if let Some(s) = start {
|
||||
pairs.append_pair("start", s);
|
||||
}
|
||||
if let Some(e) = end {
|
||||
pairs.append_pair("end", e);
|
||||
}
|
||||
// Limit to 50, could be higher but safer to page if needed. For heuristic checks 50 is usually plenty per day range.
|
||||
pairs.append_pair("limit", "50");
|
||||
}
|
||||
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, update))]
|
||||
pub async fn update_transaction(&self, id: &str, update: TransactionUpdate) -> Result<(), FireflyError> {
|
||||
let url = self.base_url.join(&format!("/api/v1/transactions/{}", id))?;
|
||||
|
||||
let response = self.client.put(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.json(&update)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!("Update Transaction Failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_authenticated<T: DeserializeOwned>(&self, url: Url) -> Result<T, FireflyError> {
|
||||
let response = self.client.get(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!("API request failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
let data = response.json().await?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
2
firefly-client/src/lib.rs
Normal file
2
firefly-client/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
81
firefly-client/src/models.rs
Normal file
81
firefly-client/src/models.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountRead {
|
||||
pub id: String,
|
||||
pub attributes: Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub name: String,
|
||||
pub iban: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub account_type: String,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountArray {
|
||||
pub data: Vec<AccountRead>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionRead {
|
||||
pub id: String,
|
||||
pub attributes: Transaction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transaction {
|
||||
pub transactions: Vec<TransactionSplit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionSplit {
|
||||
pub date: String,
|
||||
pub amount: String,
|
||||
pub description: String,
|
||||
pub external_id: Option<String>,
|
||||
pub currency_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionArray {
|
||||
pub data: Vec<TransactionRead>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionSplitStore {
|
||||
#[serde(rename = "type")]
|
||||
pub transaction_type: String,
|
||||
pub date: String,
|
||||
pub amount: String,
|
||||
pub description: String,
|
||||
pub source_id: Option<String>,
|
||||
pub source_name: Option<String>,
|
||||
pub destination_id: Option<String>,
|
||||
pub destination_name: Option<String>,
|
||||
pub currency_code: Option<String>,
|
||||
pub foreign_amount: Option<String>,
|
||||
pub foreign_currency_code: Option<String>,
|
||||
pub external_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionStore {
|
||||
pub transactions: Vec<TransactionSplitStore>,
|
||||
pub apply_rules: Option<bool>,
|
||||
pub fire_webhooks: Option<bool>,
|
||||
pub error_if_duplicate_hash: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionUpdate {
|
||||
pub transactions: Vec<TransactionSplitUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransactionSplitUpdate {
|
||||
pub external_id: Option<String>,
|
||||
}
|
||||
62
firefly-client/tests/client_test.rs
Normal file
62
firefly-client/tests/client_test.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use firefly_client::client::FireflyClient;
|
||||
use firefly_client::models::{TransactionStore, TransactionSplitStore};
|
||||
use wiremock::matchers::{method, path, header};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use std::fs;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let fixture = fs::read_to_string("tests/fixtures/ff_accounts.json").unwrap();
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/search/accounts"))
|
||||
.and(header("Authorization", "Bearer my-token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(fixture))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
|
||||
let accounts = client.search_accounts("NL01").await.unwrap();
|
||||
|
||||
assert_eq!(accounts.data.len(), 1);
|
||||
assert_eq!(accounts.data[0].attributes.name, "Checking Account");
|
||||
assert_eq!(accounts.data[0].attributes.iban.as_deref(), Some("NL01BANK0123456789"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_transaction() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/transactions"))
|
||||
.and(header("Authorization", "Bearer my-token"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
|
||||
|
||||
let tx = TransactionStore {
|
||||
transactions: vec![TransactionSplitStore {
|
||||
transaction_type: "withdrawal".to_string(),
|
||||
date: "2023-01-01".to_string(),
|
||||
amount: "10.00".to_string(),
|
||||
description: "Test".to_string(),
|
||||
source_id: Some("1".to_string()),
|
||||
destination_name: Some("Shop".to_string()),
|
||||
currency_code: None,
|
||||
foreign_amount: None,
|
||||
foreign_currency_code: None,
|
||||
external_id: None,
|
||||
source_name: None,
|
||||
destination_id: None,
|
||||
}],
|
||||
apply_rules: None,
|
||||
fire_webhooks: None,
|
||||
error_if_duplicate_hash: None,
|
||||
};
|
||||
|
||||
let result = client.store_transaction(tx).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
22
firefly-client/tests/fixtures/ff_accounts.json
vendored
Normal file
22
firefly-client/tests/fixtures/ff_accounts.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"type": "accounts",
|
||||
"id": "2",
|
||||
"attributes": {
|
||||
"name": "Checking Account",
|
||||
"type": "asset",
|
||||
"iban": "NL01BANK0123456789"
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"total": 1,
|
||||
"count": 1,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"total_pages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user