1use 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
15pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17pub const DEFAULT_RETRIES: u32 = 3;
19pub const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_millis(250);
21
22pub(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#[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 #[builder(finish_fn = build)]
104 pub fn new(
105 #[builder(into)]
109 api_key: Option<String>,
110 #[builder(into)]
113 base_url: Option<String>,
114 timeout: Option<Duration>,
117 retries: Option<u32>,
121 retry_backoff: Option<Duration>,
125 #[builder(into)]
127 user_agent: Option<String>,
128 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 pub fn from_env() -> Result<Self> {
185 Self::builder().build()
186 }
187
188 #[must_use]
190 pub fn base_url(&self) -> &str {
191 &self.inner.base_url
192 }
193
194 #[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 #[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 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 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 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 return transport::decode_json::<T>(b"null");
269 }
270 transport::decode_json(&bytes)
271 }
272
273 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 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 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}