use crate::ynab::errors::{Error, ErrorResponse};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use reqwest::RequestBuilder;
use secrecy::ExposeSecret;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug)]
pub struct Client {
pub(crate) base_url: reqwest::Url,
pub(crate) http_client: reqwest::Client,
pub(crate) limiter: Option<Arc<DefaultDirectRateLimiter>>,
#[allow(dead_code)]
api_key: secrecy::SecretBox<String>, pub(crate) timeout: Option<Duration>,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
let api_key = secrecy::SecretBox::new(Box::new(api_key.into()));
let http_client = Self::build_http_client(api_key.expose_secret())?;
Ok(Self {
base_url: reqwest::Url::parse("https://api.ynab.com/v1")
.expect("hardcoded base URL is always valid"),
http_client,
limiter: None,
api_key,
timeout: None,
})
}
fn build_http_client(api_key: &str) -> Result<reqwest::Client, Error> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", api_key)
.parse()
.expect("api key must be valid ASCII"),
);
let builder = reqwest::Client::builder().default_headers(headers);
builder.build().map_err(Into::into)
}
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, Error> {
self.timeout = Some(timeout);
Ok(self)
}
pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, Error> {
self.base_url = reqwest::Url::parse(base_url.as_ref())?;
Ok(self)
}
pub fn with_rate_limiter(
mut self,
requests_per_hour: usize,
burst_volume: Option<usize>,
) -> Result<Self, Error> {
let requests = NonZeroU32::new(requests_per_hour as u32)
.ok_or_else(|| Error::InvalidRateLimit("requests_per_hour must be non-zero".into()))?;
let quota = match burst_volume {
None => Quota::per_hour(requests),
Some(burst) => {
let effective = (requests_per_hour as u32)
.checked_sub(burst as u32)
.ok_or_else(|| {
Error::InvalidRateLimit(
"requests_per_hour must be greater than burst_volume".into(),
)
})?;
let effective_rate = NonZeroU32::new(effective).ok_or_else(|| {
Error::InvalidRateLimit(
"requests_per_hour - burst_volume must be non-zero".into(),
)
})?;
let burst = NonZeroU32::new(burst as u32).ok_or_else(|| {
Error::InvalidRateLimit("burst_volume must be non-zero".into())
})?;
Quota::per_hour(effective_rate).allow_burst(burst)
}
};
self.limiter = Some(Arc::new(RateLimiter::direct(quota)));
Ok(self)
}
pub(crate) fn make_url(&self, endpoint: &str) -> reqwest::Url {
let mut url = self.base_url.clone();
url.path_segments_mut()
.expect("base URL must be a valid base")
.extend(endpoint.split('/').filter(|s| !s.is_empty()));
url
}
async fn prepare(&self, method: reqwest::Method, endpoint: &str) -> RequestBuilder {
if let Some(limiter) = &self.limiter {
let start = std::time::Instant::now();
limiter.until_ready().await;
tracing::debug!(
waited_ms = start.elapsed().as_millis() as u64,
"rate limiter delayed request"
);
}
let url = self.make_url(endpoint);
self.http_client.request(method, url)
}
async fn send_json<T: serde::de::DeserializeOwned>(
&self,
mut builder: RequestBuilder,
) -> Result<T, Error> {
if let Some(t) = self.timeout {
builder = builder.timeout(t);
}
let start = std::time::Instant::now();
let res = builder.send().await?;
let status = res.status();
let elapsed_ms = start.elapsed().as_millis() as u64;
if !status.is_success() {
let err_body: ErrorResponse = res.json().await?;
tracing::warn!(status = status.as_u16(), elapsed_ms, error = %err_body.error, "YNAB API error");
return Err(Error::new_api_error(status, err_body.error));
}
tracing::debug!(status = status.as_u16(), elapsed_ms, "request succeeded");
res.json().await.map_err(Into::into)
}
#[tracing::instrument(skip(self, params), fields(endpoint = %endpoint))]
pub(crate) async fn get<T: serde::de::DeserializeOwned, Q: serde::ser::Serialize + ?Sized>(
&self,
endpoint: &str,
params: Option<&Q>,
) -> Result<T, Error> {
let mut builder = self.prepare(reqwest::Method::GET, endpoint).await;
if let Some(p) = params {
builder = builder.query(p);
}
self.send_json(builder).await
}
#[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
pub(crate) async fn post<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
&self,
endpoint: &str,
body: B,
) -> Result<T, Error> {
let builder = self
.prepare(reqwest::Method::POST, endpoint)
.await
.json(&body);
self.send_json(builder).await
}
#[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
pub(crate) async fn patch<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
&self,
endpoint: &str,
body: B,
) -> Result<T, Error> {
let builder = self
.prepare(reqwest::Method::PATCH, endpoint)
.await
.json(&body);
self.send_json(builder).await
}
#[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
pub(crate) async fn put<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
&self,
endpoint: &str,
body: B,
) -> Result<T, Error> {
let builder = self
.prepare(reqwest::Method::PUT, endpoint)
.await
.json(&body);
self.send_json(builder).await
}
#[tracing::instrument(skip(self), fields(endpoint = %endpoint))]
pub(crate) async fn delete<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
) -> Result<T, Error> {
let builder = self.prepare(reqwest::Method::DELETE, endpoint).await;
self.send_json(builder).await
}
}