Skip to main content

rust_ynab/ynab/
client.rs

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
8/// Client is the YNAB API client. Use Client::new() to create one.
9pub 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    /// Creates a new client with the given Personal Access Token.
28    ///
29    /// # Examples
30    ///
31    /// ```no_run
32    /// use rust_ynab::Client;
33    ///
34    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
35    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
36    /// # Ok(()) }
37    /// ```
38    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    /// Sets the request timeout. Returns `self` for chaining.
70    ///
71    /// # Examples
72    ///
73    /// ```no_run
74    /// use rust_ynab::Client;
75    /// use std::time::Duration;
76    ///
77    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
78    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
79    ///     .with_timeout(Duration::from_secs(30))?;
80    /// # Ok(()) }
81    /// ```
82    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    /// Overrides the base URL. Primarily useful for testing.
89    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    /// Configures a token bucket rate limiter on the client. Returns `self` for chaining.
95    ///
96    /// `requests_per_hour` is the total allowed requests per hour.
97    /// `burst_volume` optionally allows a number of requests to be made immediately
98    /// before throttling begins. The effective sustained rate becomes
99    /// `requests_per_hour - burst_volume` to account for burst consumption.
100    /// If `None`, no burst is allowed and the full rate is sustained evenly.
101    ///
102    /// # Examples
103    ///
104    /// ```no_run
105    /// use rust_ynab::Client;
106    /// use std::time::Duration;
107    ///
108    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
109    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
110    ///     .with_rate_limiter(200, Some(10))?  // 10 burst, then 190/hr
111    ///     .with_timeout(Duration::from_secs(30))?;
112    /// # Ok(()) }
113    /// ```
114    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}