Skip to main content

rust_ynab/ynab/
client.rs

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)]
10/// Client is the YNAB API client. Use Client::new() to create one.
11pub 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>, // in case we need to use this later on
17    pub(crate) timeout: Option<Duration>,
18}
19
20impl Client {
21    /// Creates a new client with the given Personal Access Token.
22    ///
23    /// # Examples
24    ///
25    /// ```no_run
26    /// use rust_ynab::Client;
27    ///
28    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
29    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?;
30    /// # Ok(()) }
31    /// ```
32    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    /// Sets the request timeout. Returns `self` for chaining.
58    ///
59    /// # Examples
60    ///
61    /// ```no_run
62    /// use rust_ynab::Client;
63    /// use std::time::Duration;
64    ///
65    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
66    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
67    ///     .with_timeout(Duration::from_secs(30))?;
68    /// # Ok(()) }
69    /// ```
70    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self, Error> {
71        self.timeout = Some(timeout);
72        Ok(self)
73    }
74
75    /// Overrides the base URL. Primarily useful for testing.
76    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    /// Configures a token bucket rate limiter on the client. Returns `self` for chaining.
82    ///
83    /// `requests_per_hour` is the total allowed requests per hour.
84    /// `burst_volume` optionally allows a number of requests to be made immediately
85    /// before throttling begins. The effective sustained rate becomes
86    /// `requests_per_hour - burst_volume` to account for burst consumption.
87    /// If `None`, no burst is allowed and the full rate is sustained evenly.
88    ///
89    /// # Examples
90    ///
91    /// ```no_run
92    /// use rust_ynab::Client;
93    /// use std::time::Duration;
94    ///
95    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
96    /// let client = Client::new(&std::env::var("YNAB_TOKEN")?)?
97    ///     .with_rate_limiter(200, Some(10))?  // 10 burst, then 190/hr
98    ///     .with_timeout(Duration::from_secs(30))?;
99    /// # Ok(()) }
100    /// ```
101    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    // shared: rate-limit wait + URL build + method selection
144    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    // shared: timeout + send + status check + error decode + body decode
158    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}