Change object model and add crate Error type

This commit is contained in:
2026-05-03 14:17:50 +02:00
parent a9de66a49f
commit fa4cc722e2
12 changed files with 283 additions and 247 deletions
+120 -113
View File
@@ -1,66 +1,23 @@
use super::lookup::Lookup;
use super::apitypes;
use super::transform;
use super::types;
use crate::Error;
use crate::Transaction;
pub struct Ynab {
#[derive(Clone)]
pub struct Client {
token: String,
webclient: reqwest::Client,
}
impl Ynab {
pub fn new(token: &str) -> Ynab {
Ynab {
impl Client {
pub fn new(token: &str) -> Client {
Client {
token: token.to_owned(),
webclient: reqwest::Client::new(),
}
}
async fn resolve_plan(&self, plan: Lookup) -> Result<String, String> {
match plan {
Lookup::Id(id) => Ok(id),
Lookup::Name(name) => {
let url = "plans";
let res: types::YnabResponse<types::YnabPlanList> = serde_json::from_str(
&self
.get(url)
.await
.map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
let plans = res.data.plans;
plans
.into_iter()
.find(|p| p.name == name)
.map(|p| p.id)
.ok_or_else(|| "no matching plan found".to_string())
}
}
}
async fn resolve_account(&self, plan_id: &str, account: Lookup) -> Result<String, String> {
match account {
Lookup::Id(id) => Ok(id),
Lookup::Name(name) => {
let url = format!("plans/{}/accounts", plan_id);
let res: types::YnabResponse<types::YnabAccountList> = serde_json::from_str(
&self
.get(&url)
.await
.map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
let accounts = res.data.accounts;
accounts
.into_iter()
.find(|a| a.name == name)
.map(|a| a.id)
.ok_or_else(|| "no matching account found".to_string())
}
}
}
pub(crate) async fn get(&self, request: &str) -> Result<String, reqwest::Error> {
pub(crate) async fn get(&self, request: &str) -> Result<String, Error> {
const URL: &str = "https://api.ynab.com/v1/";
let request = format!("{}{}", URL, request);
let res = self
@@ -73,7 +30,7 @@ impl Ynab {
Ok(text)
}
async fn post(&self, request: &str, body: &str) -> Result<(), String> {
async fn post(&self, request: &str, body: &str) -> Result<(), Error> {
const URL: &str = "https://api.ynab.com/v1/";
let request = format!("{}{}", URL, request);
let res = self
@@ -83,92 +40,142 @@ impl Ynab {
.bearer_auth(&self.token)
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| format!("post error: {}", e))?;
.await?;
if res.status().is_success() {
println!(
"successful api response {}: {}",
res.status(),
res.text()
.await
.map_err(|e| format!("error retrieving api error: {}", e))?
);
// Maybe do something with the status here
// println!(
// "successful api response {}: {}",
// res.status(),
// res.text()
// .await
// .map_err(|e| format!("error retrieving api error: {}", e))?
// );
Ok(())
} else {
Err(format!(
Err(Error::Text(format!(
"api error: {}: {}",
res.status(),
res.text()
.await
.map_err(|e| format!("error retrieving api error: {}", e))?
))
res.text().await?
)))
}
}
pub async fn list_plans(&self) -> Result<Vec<String>, String> {
pub async fn list_plans(&self) -> Result<Vec<String>, Error> {
let url = String::from("plans");
let res: types::YnabResponse<types::YnabPlanList> = serde_json::from_str(
&self
.get(&url)
.await
.map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
let res: apitypes::Response<apitypes::PlanList> =
serde_json::from_str(&self.get(&url).await?)?;
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
}
pub async fn list_accounts(&self, plan: Lookup) -> Result<Vec<String>, String> {
let plan_id = self.resolve_plan(plan).await?;
let url = format!("plans/{}/accounts", plan_id);
let res: types::YnabResponse<types::YnabAccountList> = serde_json::from_str(
&self
.get(&url)
.await
.map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
pub fn plan_from_id(self, id: &str) -> Plan {
Plan::from_id(self, id)
}
pub async fn plan_from_name(self, name: &str) -> Result<Plan, Error> {
Plan::from_name(self, name).await
}
}
#[derive(Clone)]
pub struct Plan {
client: Client,
id: String,
}
impl Plan {
fn from_id(client: Client, id: &str) -> Plan {
Plan {
client,
id: id.to_owned(),
}
}
async fn from_name(client: Client, name: &str) -> Result<Plan, Error> {
let raw = client.get("plans").await?;
let res: apitypes::Response<apitypes::PlanList> = serde_json::from_str(&raw)?;
let id = res
.data
.plans
.into_iter()
.find(|p| p.name == name)
.map(|p| p.id)
.ok_or_else(|| Error::PlanNotFound(name.to_string()))?;
Ok(Plan { client, id })
}
pub fn account_from_id(&self, id: &str) -> Account {
Account::from_id(self, id)
}
pub async fn account_from_name(&self, name: &str) -> Result<Account, Error> {
Account::from_name(self, name).await
}
pub async fn list_accounts(&self) -> Result<Vec<String>, Error> {
let url = format!("plans/{}/accounts", self.id);
let raw = self.client.get(&url).await?;
let res: apitypes::Response<apitypes::AccountList> = serde_json::from_str(&raw)?;
Ok(res.data.accounts.into_iter().map(|p| p.name).collect())
}
pub async fn list_transactions(
&self,
plan: Lookup,
account: Option<Lookup>,
) -> Result<Vec<Transaction>, String> {
let plan_id = self.resolve_plan(plan).await?;
let account_id = match account {
Some(account) => Some(self.resolve_account(&plan_id, account).await?),
None => None,
};
account_id: Option<&str>,
) -> Result<Vec<Transaction>, Error> {
let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(30);
let since_date = since_date.format("%Y-%m-%d");
let url = format!("plans/{}/transactions?since_date={}", plan_id, since_date);
let raw = self
.get(&url)
.await
.map_err(|e| format!("request error {}", e))?;
let res: types::YnabResponse<types::YnabTransactionList> =
serde_json::from_str(&raw).map_err(|e| format!("parse error: {}", e))?;
let url = format!("plans/{}/transactions?since_date={}", self.id, since_date);
let raw = self.client.get(&url).await?;
let res: apitypes::Response<apitypes::TransactionList> = serde_json::from_str(&raw)?;
Ok(transform::from_ynab_transactions(
&res.data.transactions,
account_id.as_deref(),
account_id,
))
}
}
pub async fn upload(
&self,
transactions: &[Transaction],
plan: Lookup,
account: Lookup,
) -> Result<(), Box<dyn std::error::Error>> {
let plan_id = self.resolve_plan(plan).await?;
let account_id = self.resolve_account(&plan_id, account).await?;
let request = format!("plans/{}/transactions", plan_id);
let body = serde_json::to_string(&types::YnabTransactionList {
transactions: transform::to_ynab_transactions(transactions, &account_id),
pub struct Account {
plan: Plan,
id: String,
}
impl Account {
fn from_id(plan: &Plan, id: &str) -> Account {
Account {
plan: plan.clone(),
id: id.to_owned(),
}
}
async fn from_name(plan: &Plan, name: &str) -> Result<Account, Error> {
let raw = plan
.client
.get(&format!("plans/{}/accounts", &plan.id))
.await?;
let res: apitypes::Response<apitypes::AccountList> = serde_json::from_str(&raw)?;
let id = res
.data
.accounts
.into_iter()
.find(|p| p.name == name)
.map(|p| p.id)
.ok_or_else(|| Error::AccountNotFound(name.to_string()))?;
Ok(Account {
plan: plan.clone(),
id,
})
.map_err(|e| format!("transaction format error: {}", e))?;
self.post(&request, &body).await?;
}
pub async fn list_transactions(&self) -> Result<Vec<Transaction>, Error> {
self.plan.list_transactions(Some(&self.id)).await
}
pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> {
let request = format!("plans/{}/transactions", self.plan.id);
let body = serde_json::to_string(&apitypes::TransactionList {
transactions: transform::to_ynab_transactions(transactions, &self.id),
})?;
self.plan.client.post(&request, &body).await?;
Ok(())
}
}