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