1use crate::ynab::errors::{Error, ErrorResponse};
2use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
3use std::fmt;
4use std::num::NonZeroU32;
5use std::sync::Arc;
6use std::time::Duration;
7
8pub struct Client {
10 base_url: reqwest::Url,
11 http_client: reqwest::Client,
12 limiter: Option<Arc<DefaultDirectRateLimiter>>,
13 api_key: String,
14 timeout: Option<Duration>,
15}
16
17impl fmt::Debug for Client {
18 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 f.debug_struct("Client")
20 .field("base_url", &self.base_url)
21 .field("api_key", &"[redacted]")
22 .finish()
23 }
24}
25
26impl Client {
27 pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
39 let api_key = api_key.into();
40 let http_client = Self::build_http_client(&api_key, None)?;
41 Ok(Self {
42 base_url: reqwest::Url::parse("https://api.ynab.com/v1")
43 .expect("hardcoded base URL is always valid"),
44 http_client,
45 limiter: None,
46 api_key,
47 timeout: None,
48 })
49 }
50
51 fn build_http_client(
52 api_key: &str,
53 timeout: Option<Duration>,
54 ) -> Result<reqwest::Client, Error> {
55 let mut headers = reqwest::header::HeaderMap::new();
56 headers.insert(
57 reqwest::header::AUTHORIZATION,
58 format!("Bearer {}", api_key)
59 .parse()
60 .expect("api key must be valid ASCII"),
61 );
62 let mut builder = reqwest::Client::builder().default_headers(headers);
63 if let Some(t) = timeout {
64 builder = builder.timeout(t);
65 }
66 builder.build().map_err(Into::into)
67 }
68
69 pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, Error> {
83 self.http_client = Self::build_http_client(&self.api_key, Some(timeout))?;
84 self.timeout = Some(timeout);
85 Ok(self)
86 }
87
88 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, Error> {
90 self.base_url = reqwest::Url::parse(base_url.as_ref())?;
91 Ok(self)
92 }
93
94 pub fn with_rate_limiter(
115 mut self,
116 requests_per_hour: usize,
117 burst_volume: Option<usize>,
118 ) -> Result<Self, Error> {
119 let requests = NonZeroU32::new(requests_per_hour as u32)
120 .ok_or_else(|| Error::InvalidRateLimit("requests_per_hour must be non-zero".into()))?;
121
122 let quota = match burst_volume {
123 None => Quota::per_hour(requests),
124 Some(burst) => {
125 let effective = (requests_per_hour as u32)
126 .checked_sub(burst as u32)
127 .ok_or_else(|| {
128 Error::InvalidRateLimit(
129 "requests_per_hour must be greater than burst_volume".into(),
130 )
131 })?;
132 let effective_rate = NonZeroU32::new(effective).ok_or_else(|| {
133 Error::InvalidRateLimit(
134 "requests_per_hour - burst_volume must be non-zero".into(),
135 )
136 })?;
137 let burst = NonZeroU32::new(burst as u32).ok_or_else(|| {
138 Error::InvalidRateLimit("burst_volume must be non-zero".into())
139 })?;
140 Quota::per_hour(effective_rate).allow_burst(burst)
141 }
142 };
143
144 self.limiter = Some(Arc::new(RateLimiter::direct(quota)));
145 Ok(self)
146 }
147
148 pub(crate) async fn get<T: serde::de::DeserializeOwned, Q: serde::ser::Serialize + ?Sized>(
149 &self,
150 endpoint: &str,
151 params: Option<&Q>,
152 ) -> Result<T, Error> {
153 if let Some(limiter) = &self.limiter {
154 limiter.until_ready().await;
155 }
156
157 let mut url = self.base_url.clone();
158 url.path_segments_mut()
159 .expect("base URL must be a valid base")
160 .extend(endpoint.split('/'));
161
162 let mut builder = self.http_client.get(url);
163 if let Some(p) = params {
164 builder = builder.query(p);
165 }
166 let res = builder.send().await?;
167 let status = res.status();
168
169 if !status.is_success() {
170 let err_body: ErrorResponse = res.json().await?;
171 return Err(Error::new_api_error(status, err_body.error));
172 }
173
174 res.json().await.map_err(Into::into)
175 }
176
177 pub(crate) async fn post<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
178 &self,
179 endpoint: &str,
180 body: B,
181 ) -> Result<T, Error> {
182 if let Some(limiter) = &self.limiter {
183 limiter.until_ready().await;
184 }
185 let mut url = self.base_url.clone();
186 url.path_segments_mut()
187 .expect("base URL must be a valid base")
188 .extend(endpoint.split('/'));
189
190 let res = self.http_client.post(url).json(&body).send().await?;
191 let status = res.status();
192
193 if !status.is_success() {
194 let err_body: ErrorResponse = res.json().await?;
195 return Err(Error::new_api_error(status, err_body.error));
196 }
197
198 res.json().await.map_err(Into::into)
199 }
200
201 pub(crate) async fn patch<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
202 &self,
203 endpoint: &str,
204 body: B,
205 ) -> Result<T, Error> {
206 if let Some(limiter) = &self.limiter {
207 limiter.until_ready().await;
208 }
209 let mut url = self.base_url.clone();
210 url.path_segments_mut()
211 .expect("base URL must be a valid base")
212 .extend(endpoint.split('/'));
213
214 let res = self.http_client.patch(url).json(&body).send().await?;
215 let status = res.status();
216
217 if !status.is_success() {
218 let err_body: ErrorResponse = res.json().await?;
219 return Err(Error::new_api_error(status, err_body.error));
220 }
221
222 res.json().await.map_err(Into::into)
223 }
224
225 pub(crate) async fn put<T: serde::de::DeserializeOwned, B: serde::ser::Serialize>(
226 &self,
227 endpoint: &str,
228 body: B,
229 ) -> Result<T, Error> {
230 if let Some(limiter) = &self.limiter {
231 limiter.until_ready().await;
232 }
233 let mut url = self.base_url.clone();
234 url.path_segments_mut()
235 .expect("base URL must be a valid base")
236 .extend(endpoint.split('/'));
237
238 let res = self.http_client.put(url).json(&body).send().await?;
239 let status = res.status();
240
241 if !status.is_success() {
242 let err_body: ErrorResponse = res.json().await?;
243 return Err(Error::new_api_error(status, err_body.error));
244 }
245
246 res.json().await.map_err(Into::into)
247 }
248
249 pub(crate) async fn delete<T: serde::de::DeserializeOwned>(
250 &self,
251 endpoint: &str,
252 ) -> Result<T, Error> {
253 if let Some(limiter) = &self.limiter {
254 limiter.until_ready().await;
255 }
256 let mut url = self.base_url.clone();
257 url.path_segments_mut()
258 .expect("base URL must be a valid base")
259 .extend(endpoint.split('/'));
260
261 let res = self.http_client.delete(url).send().await?;
262 let status = res.status();
263
264 if !status.is_success() {
265 let err_body: ErrorResponse = res.json().await?;
266 return Err(Error::new_api_error(status, err_body.error));
267 }
268
269 res.json().await.map_err(Into::into)
270 }
271}