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) 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#[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 #[builder(finish_fn = build)]
105 pub fn new(
106 #[builder(into)]
110 api_key: Option<String>,
111 #[builder(into)]
114 base_url: Option<String>,
115 timeout: Option<Duration>,
118 retries: Option<u32>,
122 retry_backoff: Option<Duration>,
126 #[builder(into)]
128 user_agent: Option<String>,
129 http_client: Option<reqwest::Client>,
133 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 pub fn from_env() -> Result<Self> {
202 Self::builder().build()
203 }
204
205 #[must_use]
207 pub fn base_url(&self) -> &str {
208 &self.inner.base_url
209 }
210
211 #[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 #[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 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 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 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 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 return transport::decode_json::<T>(b"null");
304 }
305 transport::decode_json(&bytes)
306 }
307
308 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 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 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}