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