Skip to main content

rust_ynab/ynab/
client.rs

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)]
9/// Client is the YNAB API client. Use Client::new() to create one.
10pub 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    /// Creates a new client with the given Personal Access Token.
20    ///
21    /// # Examples
22    ///
23    /// ```no_run
24    /// use rust_ynab::Client;
25    ///
26    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
27    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
28    /// # Ok(()) }
29    /// ```
30    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    /// Sets the request timeout. Returns `self` for chaining.
62    ///
63    /// # Examples
64    ///
65    /// ```no_run
66    /// use rust_ynab::Client;
67    /// use std::time::Duration;
68    ///
69    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
70    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
71    ///     .with_timeout(Duration::from_secs(30))?;
72    /// # Ok(()) }
73    /// ```
74    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    /// Overrides the base URL. Primarily useful for testing.
81    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    /// Configures a token bucket rate limiter on the client. Returns `self` for chaining.
87    ///
88    /// `requests_per_hour` is the total allowed requests per hour.
89    /// `burst_volume` optionally allows a number of requests to be made immediately
90    /// before throttling begins. The effective sustained rate becomes
91    /// `requests_per_hour - burst_volume` to account for burst consumption.
92    /// If `None`, no burst is allowed and the full rate is sustained evenly.
93    ///
94    /// # Examples
95    ///
96    /// ```no_run
97    /// use rust_ynab::Client;
98    /// use std::time::Duration;
99    ///
100    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
101    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
102    ///     .with_rate_limiter(200, Some(10))?  // 10 burst, then 190/hr
103    ///     .with_timeout(Duration::from_secs(30))?;
104    /// # Ok(()) }
105    /// ```
106    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}