Skip to main content

tango/
client.rs

1//! [`Client`], its `bon`-derived [`ClientBuilder`], and the inner shared state.
2//!
3//! Construct a `Client` via [`Client::builder`]. Required fields are enforced
4//! at compile time by `bon`'s type-state pattern, so `.build()` cannot be
5//! called without an `api_key` (or `TANGO_API_KEY` in the environment).
6
7use crate::error::{Error, Result};
8use crate::transport::{self, Body, RateLimitInfo};
9use bon::bon;
10use reqwest::header::HeaderMap;
11use serde::Serialize;
12use std::sync::{Arc, RwLock};
13use std::time::Duration;
14
15/// Default per-request timeout when none is set.
16pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17/// Default retry count when none is set.
18pub const DEFAULT_RETRIES: u32 = 3;
19/// Default initial retry backoff (doubles each attempt up to 10s) when none is set.
20pub const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_millis(250);
21
22/// Shared inner state. Held inside an `Arc` so `Client` is cheap to clone and
23/// always `Send + Sync`.
24pub(crate) struct ClientInner {
25    pub(crate) api_key: String,
26    pub(crate) base_url: String,
27    pub(crate) http: reqwest::Client,
28    pub(crate) timeout: Duration,
29    pub(crate) retries: u32,
30    pub(crate) retry_backoff: Duration,
31    pub(crate) user_agent: String,
32    rate_limit: RwLock<Option<RateLimitInfo>>,
33    last_headers: RwLock<Option<HeaderMap>>,
34}
35
36impl std::fmt::Debug for ClientInner {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("ClientInner")
39            .field("base_url", &self.base_url)
40            .field("timeout", &self.timeout)
41            .field("retries", &self.retries)
42            .field("retry_backoff", &self.retry_backoff)
43            .field("user_agent", &self.user_agent)
44            .field("api_key", &"<redacted>")
45            .finish_non_exhaustive()
46    }
47}
48
49impl ClientInner {
50    pub(crate) fn set_last_response(&self, headers: &HeaderMap) {
51        let info = RateLimitInfo::from_headers(headers);
52        if let Ok(mut guard) = self.rate_limit.write() {
53            *guard = Some(info);
54        }
55        if let Ok(mut guard) = self.last_headers.write() {
56            *guard = Some(headers.clone());
57        }
58    }
59}
60
61/// The Tango API client. Cheap to clone — all state is behind an `Arc`.
62///
63/// Construct with [`Client::builder`]. Call `.api_key(...)` on the builder, or
64/// set the `TANGO_API_KEY` environment variable and call [`Client::from_env`].
65///
66/// `Client` is `Send + Sync + Clone`. Share one instance across tasks.
67///
68/// # Example
69///
70/// ```no_run
71/// use tango::Client;
72/// # async fn run() -> tango::Result<()> {
73/// let client = Client::builder()
74///     .api_key("your-api-key")
75///     .build()?;
76/// let _agency = client.get_agency("9700", None).await?;
77/// # Ok(()) }
78/// ```
79#[derive(Clone)]
80pub struct Client {
81    pub(crate) inner: Arc<ClientInner>,
82}
83
84impl std::fmt::Debug for Client {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("Client")
87            .field("inner", &self.inner)
88            .finish()
89    }
90}
91
92#[bon]
93impl Client {
94    /// Start building a [`Client`].
95    ///
96    /// `api_key` is required (compile-time check via `bon`). Everything else
97    /// is optional with sensible defaults; see the individual setters.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`Error::Build`] if the underlying `reqwest::Client` fails to
102    /// construct (rare; usually means a bad rustls/TLS configuration).
103    #[builder(finish_fn = build)]
104    pub fn new(
105        /// The Tango API key. Falls back to `TANGO_API_KEY` if not provided
106        /// at the call site and the env var is set. An empty string falls
107        /// back too.
108        #[builder(into)]
109        api_key: Option<String>,
110        /// Base URL for the API. Falls back to `TANGO_BASE_URL`, then to
111        /// [`crate::DEFAULT_BASE_URL`].
112        #[builder(into)]
113        base_url: Option<String>,
114        /// Per-request timeout. Defaults to 30 seconds. `Duration::ZERO`
115        /// disables the deadline.
116        timeout: Option<Duration>,
117        /// Number of retries on retryable failures (5xx / 408 / 429 /
118        /// transport). The first attempt is not counted, so the total
119        /// attempt count is `retries + 1`. Defaults to 3.
120        retries: Option<u32>,
121        /// Initial backoff between retries; doubles each attempt, capped
122        /// at 10 seconds. The server's `Retry-After` header overrides
123        /// this. Defaults to 250ms.
124        retry_backoff: Option<Duration>,
125        /// Custom `User-Agent`. Defaults to `tango-rust/<version>`.
126        #[builder(into)]
127        user_agent: Option<String>,
128        /// Custom `reqwest::Client` for proxy/tracing injection. When
129        /// supplied, its built-in timeout is ignored — per-request
130        /// deadlines are applied here.
131        http_client: Option<reqwest::Client>,
132    ) -> Result<Self> {
133        let api_key = match api_key.filter(|s| !s.is_empty()) {
134            Some(k) => k,
135            None => std::env::var("TANGO_API_KEY").unwrap_or_default(),
136        };
137        let base_url = match base_url.filter(|s| !s.is_empty()) {
138            Some(u) => u,
139            None => std::env::var("TANGO_BASE_URL")
140                .ok()
141                .filter(|s| !s.is_empty())
142                .unwrap_or_else(|| crate::shapes::DEFAULT_BASE_URL.to_string()),
143        };
144        let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
145        let retries = retries.unwrap_or(DEFAULT_RETRIES);
146        let retry_backoff = retry_backoff.unwrap_or(DEFAULT_RETRY_BACKOFF);
147        let user_agent = user_agent
148            .filter(|s| !s.is_empty())
149            .unwrap_or_else(default_user_agent);
150        let http = match http_client {
151            Some(c) => c,
152            None => reqwest::Client::builder()
153                .build()
154                .map_err(|e| Error::Build(format!("build reqwest client: {e}")))?,
155        };
156
157        Ok(Self {
158            inner: Arc::new(ClientInner {
159                api_key,
160                base_url,
161                http,
162                timeout,
163                retries,
164                retry_backoff,
165                user_agent,
166                rate_limit: RwLock::new(None),
167                last_headers: RwLock::new(None),
168            }),
169        })
170    }
171}
172
173fn default_user_agent() -> String {
174    format!("tango-rust/{}", crate::VERSION)
175}
176
177impl Client {
178    /// Construct a client from `TANGO_API_KEY` and (optionally) `TANGO_BASE_URL`
179    /// in the environment, with all other settings at their defaults.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`Error::Build`] if the underlying HTTP client fails to construct.
184    pub fn from_env() -> Result<Self> {
185        Self::builder().build()
186    }
187
188    /// The resolved base URL the client will hit.
189    #[must_use]
190    pub fn base_url(&self) -> &str {
191        &self.inner.base_url
192    }
193
194    /// A snapshot of the rate-limit headers from the most recent response, or
195    /// `None` if no request has completed yet.
196    #[must_use]
197    pub fn rate_limit_info(&self) -> Option<RateLimitInfo> {
198        self.inner.rate_limit.read().ok().and_then(|g| g.clone())
199    }
200
201    /// The full response headers from the most recent completed request, or
202    /// `None` if no request has completed yet. Useful for inspecting
203    /// `X-Request-Id`, `X-Tango-Trace-Id`, etc.
204    #[must_use]
205    pub fn last_response_headers(&self) -> Option<HeaderMap> {
206        self.inner.last_headers.read().ok().and_then(|g| g.clone())
207    }
208
209    pub(crate) fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<reqwest::Url> {
210        let base = self.inner.base_url.trim_end_matches('/');
211        let path = if path.starts_with('/') {
212            path.to_string()
213        } else {
214            format!("/{path}")
215        };
216        let mut url = reqwest::Url::parse(&format!("{base}{path}"))
217            .map_err(|e| Error::Build(format!("parse url {base}{path}: {e}")))?;
218        if !query.is_empty() {
219            let mut pairs = url.query_pairs_mut();
220            for (k, v) in query {
221                pairs.append_pair(k, v);
222            }
223        }
224        Ok(url)
225    }
226
227    /// Internal: GET `path` with `query`, decode response as `T`.
228    pub(crate) async fn get_json<T: serde::de::DeserializeOwned>(
229        &self,
230        path: &str,
231        query: &[(String, String)],
232    ) -> Result<T> {
233        let url = self.build_url(path, query)?;
234        let bytes =
235            transport::send_with_retries(&self.inner, reqwest::Method::GET, url, Body::None)
236                .await?;
237        transport::decode_json(&bytes)
238    }
239
240    /// Internal: GET `path` with `query`, return raw bytes.
241    pub(crate) async fn get_bytes(
242        &self,
243        path: &str,
244        query: &[(String, String)],
245    ) -> Result<Vec<u8>> {
246        let url = self.build_url(path, query)?;
247        transport::send_with_retries(&self.inner, reqwest::Method::GET, url, Body::None).await
248    }
249
250    /// Internal: POST `path` with JSON `body`, decode response as `T`.
251    pub(crate) async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
252        &self,
253        path: &str,
254        body: &B,
255    ) -> Result<T> {
256        let url = self.build_url(path, &[])?;
257        let value = serde_json::to_value(body).map_err(Error::Decode)?;
258        let bytes = transport::send_with_retries(
259            &self.inner,
260            reqwest::Method::POST,
261            url,
262            Body::Json(&value),
263        )
264        .await?;
265        if bytes.is_empty() {
266            // Endpoints that legally return 204; T must be deserializable from
267            // `null` for this to work (e.g. `Option<X>` or `Value`).
268            return transport::decode_json::<T>(b"null");
269        }
270        transport::decode_json(&bytes)
271    }
272
273    /// Internal: PATCH `path` with JSON `body`, decode response as `T`.
274    pub(crate) async fn patch_json<B: Serialize, T: serde::de::DeserializeOwned>(
275        &self,
276        path: &str,
277        body: &B,
278    ) -> Result<T> {
279        let url = self.build_url(path, &[])?;
280        let value = serde_json::to_value(body).map_err(Error::Decode)?;
281        let bytes = transport::send_with_retries(
282            &self.inner,
283            reqwest::Method::PATCH,
284            url,
285            Body::Json(&value),
286        )
287        .await?;
288        if bytes.is_empty() {
289            return transport::decode_json::<T>(b"null");
290        }
291        transport::decode_json(&bytes)
292    }
293
294    /// Internal: DELETE `path` and discard the response.
295    pub(crate) async fn delete_no_content(&self, path: &str) -> Result<()> {
296        let url = self.build_url(path, &[])?;
297        transport::send_with_retries(&self.inner, reqwest::Method::DELETE, url, Body::None).await?;
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn builder_picks_up_env_var() {
308        // SAFETY: tests touch the process env. Run single-threaded if you
309        // care; for this assertion we just set and immediately read.
310        std::env::set_var("TANGO_API_KEY", "env-key");
311        let c = Client::builder().build().expect("build");
312        assert_eq!(c.inner.api_key, "env-key");
313        std::env::remove_var("TANGO_API_KEY");
314    }
315
316    #[test]
317    fn explicit_api_key_wins_over_env() {
318        std::env::set_var("TANGO_API_KEY", "env-key");
319        let c = Client::builder()
320            .api_key("explicit-key")
321            .build()
322            .expect("build");
323        assert_eq!(c.inner.api_key, "explicit-key");
324        std::env::remove_var("TANGO_API_KEY");
325    }
326
327    #[test]
328    fn default_base_url() {
329        let c = Client::builder().api_key("x").build().expect("build");
330        assert_eq!(c.base_url(), crate::shapes::DEFAULT_BASE_URL);
331    }
332
333    #[test]
334    fn build_url_joins_path_and_query() {
335        let c = Client::builder()
336            .api_key("x")
337            .base_url("https://example.test/".to_string())
338            .build()
339            .expect("build");
340        let url = c
341            .build_url(
342                "/api/contracts/",
343                &[("limit".into(), "25".into()), ("page".into(), "1".into())],
344            )
345            .expect("url");
346        let s = url.to_string();
347        assert!(s.starts_with("https://example.test/api/contracts/"));
348        assert!(s.contains("limit=25"));
349        assert!(s.contains("page=1"));
350    }
351
352    #[test]
353    fn build_url_handles_missing_leading_slash() {
354        let c = Client::builder().api_key("x").build().expect("build");
355        let url = c.build_url("api/version/", &[]).expect("url");
356        assert!(url.path().ends_with("/api/version/"));
357    }
358
359    #[test]
360    fn client_is_send_sync_clone() {
361        fn assert_send_sync<T: Send + Sync + Clone>() {}
362        assert_send_sync::<Client>();
363    }
364}