1use crate::ynab::errors::{Error, ErrorResponse};
2use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
3use reqwest::RequestBuilder;
4use secrecy::ExposeSecret;
5use std::num::NonZeroU32;
6use std::sync::Arc;
7use std::time::Duration;
8
9#[derive(Debug)]
10pub struct Client {
12 pub(crate) base_url: reqwest::Url,
13 pub(crate) http_client: reqwest::Client,
14 pub(crate) limiter: Option<Arc<DefaultDirectRateLimiter>>,
15 #[allow(dead_code)]
16 api_key: secrecy::SecretBox<String>, pub(crate) timeout: Option<Duration>,
18}
19
20impl Client {
21 pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
33 let api_key = secrecy::SecretBox::new(Box::new(api_key.into()));
34 let http_client = Self::build_http_client(api_key.expose_secret())?;
35 Ok(Self {
36 base_url: reqwest::Url::parse("https://api.ynab.com/v1")
37 .expect("hardcoded base URL is always valid"),
38 http_client,
39 limiter: None,
40 api_key,
41 timeout: None,
42 })
43 }
44
45 fn build_http_client(api_key: &str) -> Result<reqwest::Client, Error> {
46 let mut headers = reqwest::header::HeaderMap::new();
47 headers.insert(
48 reqwest::header::AUTHORIZATION,
49 format!("Bearer {}", api_key)
50 .parse()
51 .expect("api key must be valid ASCII"),
52 );
53 let builder = reqwest::Client::builder().default_headers(headers);
54 builder.build().map_err(Into::into)
55 }
56
57 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, Error> {
71 self.timeout = Some(timeout);
72 Ok(self)
73 }
74
75 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, Error> {
77 self.base_url = reqwest::Url::parse(base_url.as_ref())?;
78 Ok(self)
79 }
80
81 pub fn with_rate_limiter(
102 mut self,
103 requests_per_hour: usize,
104 burst_volume: Option<usize>,
105 ) -> Result<Self, Error> {
106 let requests = NonZeroU32::new(requests_per_hour as u32)
107 .ok_or_else(|| Error::InvalidRateLimit("requests_per_hour must be non-zero".into()))?;
108
109 let quota = match burst_volume {
110 None => Quota::per_hour(requests),
111 Some(burst) => {
112 let effective = (requests_per_hour as u32)
113 .checked_sub(burst as u32)
114 .ok_or_else(|| {
115 Error::InvalidRateLimit(
116 "requests_per_hour must be greater than burst_volume".into(),
117 )
118 })?;
119 let effective_rate = NonZeroU32::new(effective).ok_or_else(|| {
120 Error::InvalidRateLimit(
121 "requests_per_hour - burst_volume must be non-zero".into(),
122 )
123 })?;
124 let burst = NonZeroU32::new(burst as u32).ok_or_else(|| {
125 Error::InvalidRateLimit("burst_volume must be non-zero".into())
126 })?;
127 Quota::per_hour(effective_rate).allow_burst(burst)
128 }
129 };
130
131 self.limiter = Some(Arc::new(RateLimiter::direct(quota)));
132 Ok(self)
133 }
134
135 pub(crate) fn make_url(&self, endpoint: &str) -> reqwest::Url {
136 let mut url = self.base_url.clone();
137 url.path_segments_mut()
138 .expect("base URL must be a valid base")
139 .extend(endpoint.split('/').filter(|s| !s.is_empty()));
140 url
141 }
142
143 async fn prepare(&self, method: reqwest::Method, endpoint: &str) -> RequestBuilder {
145 if let Some(limiter) = &self.limiter {
146 let start = std::time::Instant::now();
147 limiter.until_ready().await;
148 tracing::debug!(
149 waited_ms = start.elapsed().as_millis() as u64,
150 "rate limiter delayed request"
151 );
152 }
153 let url = self.make_url(endpoint);
154 self.http_client.request(method, url)
155 }
156
157 async fn send_json<T: serde::de::DeserializeOwned>(
159 &self,
160 mut builder: RequestBuilder,
161 ) -> Result<T, Error> {
162 if let Some(t) = self.timeout {
163 builder = builder.timeout(t);
164 }
165 let start = std::time::Instant::now();
166 let res = builder.send().await?;
167 let status = res.status();
168 let elapsed_ms = start.elapsed().as_millis() as u64;
169
170 if !status.is_success() {
171 let err_body: ErrorResponse = res.json().await?;
172 tracing::warn!(status = status.as_u16(), elapsed_ms, error = %err_body.error, "YNAB API error");
173 return Err(Error::new_api_error(status, err_body.error));
174 }
175 tracing::debug!(status = status.as_u16(), elapsed_ms, "request succeeded");
176 res.json().await.map_err(Into::into)
177 }
178
179 #[tracing::instrument(skip(self, params), fields(endpoint = %endpoint))]
180 pub(crate) async fn get<T: serde::de::DeserializeOwned, Q: serde::ser::Serialize + ?Sized>(
181 &self,
182 endpoint: &str,
183 params: Option<&Q>,
184 ) -> Result<T, Error> {
185 let mut builder = self.prepare(reqwest::Method::GET, endpoint).await;
186 if let Some(p) = params {
187 builder = builder.query(p);
188 }
189 self.send_json(builder).await
190 }
191
192 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
193 pub(crate) async fn post<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
194 &self,
195 endpoint: &str,
196 body: B,
197 ) -> Result<T, Error> {
198 let builder = self
199 .prepare(reqwest::Method::POST, endpoint)
200 .await
201 .json(&body);
202 self.send_json(builder).await
203 }
204
205 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
206 pub(crate) async fn patch<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
207 &self,
208 endpoint: &str,
209 body: B,
210 ) -> Result<T, Error> {
211 let builder = self
212 .prepare(reqwest::Method::PATCH, endpoint)
213 .await
214 .json(&body);
215 self.send_json(builder).await
216 }
217
218 #[tracing::instrument(skip(self, body), fields(endpoint = %endpoint))]
219 pub(crate) async fn put<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
220 &self,
221 endpoint: &str,
222 body: B,
223 ) -> Result<T, Error> {
224 let builder = self
225 .prepare(reqwest::Method::PUT, endpoint)
226 .await
227 .json(&body);
228 self.send_json(builder).await
229 }
230
231 #[tracing::instrument(skip(self), fields(endpoint = %endpoint))]
232 pub(crate) async fn delete<T: serde::de::DeserializeOwned>(
233 &self,
234 endpoint: &str,
235 ) -> Result<T, Error> {
236 let builder = self.prepare(reqwest::Method::DELETE, endpoint).await;
237 self.send_json(builder).await
238 }
239}