1use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{
8 HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER,
9 USER_AGENT,
10};
11use reqwest::{Method, StatusCode};
12use serde::de::DeserializeOwned;
13use serde_json::{Map, Value};
14
15use crate::error::{ApiError, Error, ErrorBody};
16use crate::query::{self, Query};
17
18pub const DEFAULT_BASE_URL: &str = "https://api.truto.one";
20pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22pub const DEFAULT_MAX_RETRIES: u32 = 3;
24pub const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(10);
26pub const RETRY_STATUS_CODES: [u16; 7] = [408, 409, 425, 429, 502, 503, 504];
28
29const SDK_USER_AGENT: &str = concat!("truto-rust-sdk/", env!("CARGO_PKG_VERSION"));
30const BACKOFF_BASE: Duration = Duration::from_millis(500);
31const BACKOFF_CAP: Duration = Duration::from_secs(10);
32
33pub type RetryHook = Arc<dyn Fn(u32, Option<u16>) + Send + Sync>;
36pub type RateLimitHook = Arc<dyn Fn(u64, u32) + Send + Sync>;
39
40#[derive(Clone, Default)]
44pub struct RequestOptions {
45 pub headers: Vec<(String, String)>,
47 pub timeout: Option<Duration>,
49 pub max_retries: Option<u32>,
51 pub no_retry: bool,
53 pub retry_status_codes: Vec<u16>,
55 pub on_retry: Option<RetryHook>,
57 pub on_rate_limited: Option<RateLimitHook>,
59}
60
61impl RequestOptions {
62 pub fn new() -> Self {
64 Self::default()
65 }
66}
67
68impl fmt::Debug for RequestOptions {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_struct("RequestOptions")
71 .field("headers", &self.headers)
72 .field("timeout", &self.timeout)
73 .field("max_retries", &self.max_retries)
74 .field("no_retry", &self.no_retry)
75 .field("retry_status_codes", &self.retry_status_codes)
76 .field("on_retry", &self.on_retry.is_some())
77 .field("on_rate_limited", &self.on_rate_limited.is_some())
78 .finish()
79 }
80}
81
82struct Inner {
83 http: reqwest::Client,
84 token: String,
85 base_url: String,
86 timeout: Duration,
87 max_retries: u32,
88 default_retry_after: Duration,
89 on_retry: Option<RetryHook>,
90 on_rate_limited: Option<RateLimitHook>,
91}
92
93#[derive(Clone)]
102pub struct Client {
103 inner: Arc<Inner>,
104}
105
106impl fmt::Debug for Client {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.debug_struct("Client")
109 .field("base_url", &self.inner.base_url)
110 .field("timeout", &self.inner.timeout)
111 .field("max_retries", &self.inner.max_retries)
112 .finish_non_exhaustive()
113 }
114}
115
116impl Client {
117 pub fn new(token: impl Into<String>) -> Self {
124 Self::builder()
125 .token(token)
126 .build()
127 .expect("failed to build the truto client")
128 }
129
130 pub fn builder() -> ClientBuilder {
132 ClientBuilder::default()
133 }
134
135 pub fn base_url(&self) -> &str {
137 &self.inner.base_url
138 }
139
140 pub(crate) fn request<T>(&self, method: Method, path: impl Into<String>) -> crate::Request<T> {
141 crate::Request::new(self.clone(), method, path.into())
142 }
143
144 pub(crate) fn pager<T>(&self, path: impl Into<String>) -> crate::Pager<T> {
145 crate::Pager::new(self.clone(), path.into())
146 }
147
148 fn url(&self, path: &str, query: &Map<String, Value>) -> String {
149 let mut url = format!("{}/{}", self.inner.base_url, path.trim_start_matches('/'));
150 let qs = query::encode(query);
151 if !qs.is_empty() {
152 url.push('?');
153 url.push_str(&qs);
154 }
155 url
156 }
157
158 pub(crate) async fn execute(
160 &self,
161 call: &Call,
162 opts: &RequestOptions,
163 ) -> Result<Response, Error> {
164 let inner = &self.inner;
165 let url = self.url(&call.path, &call.query);
166 let body = match &call.body {
167 Some(v) => {
168 Some(serde_json::to_vec(v).map_err(|e| Error::InvalidRequest(e.to_string()))?)
169 }
170 None => None,
171 };
172 let mut headers = HeaderMap::new();
173 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
174 for (k, v) in &opts.headers {
175 let name = HeaderName::from_bytes(k.as_bytes())
176 .map_err(|e| Error::InvalidRequest(format!("invalid header name {k:?}: {e}")))?;
177 let value = HeaderValue::from_str(v).map_err(|e| {
178 Error::InvalidRequest(format!("invalid header value for {k:?}: {e}"))
179 })?;
180 headers.insert(name, value);
181 }
182 let auth = HeaderValue::from_str(&format!("Bearer {}", inner.token))
183 .map_err(|e| Error::InvalidRequest(format!("invalid token: {e}")))?;
184 headers.insert(AUTHORIZATION, auth);
185 headers.insert(USER_AGENT, HeaderValue::from_static(SDK_USER_AGENT));
186 if body.is_some() {
187 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
188 }
189
190 let timeout = opts.timeout.unwrap_or(inner.timeout);
191 let max_retries = if opts.no_retry {
192 0
193 } else {
194 opts.max_retries.unwrap_or(inner.max_retries)
195 };
196 let on_retry = opts.on_retry.as_ref().or(inner.on_retry.as_ref());
197 let on_rate_limited = opts
198 .on_rate_limited
199 .as_ref()
200 .or(inner.on_rate_limited.as_ref());
201 let retryable =
202 |s: u16| RETRY_STATUS_CODES.contains(&s) || opts.retry_status_codes.contains(&s);
203
204 let mut retry_count: u32 = 0;
205 loop {
206 let mut req = inner
207 .http
208 .request(call.method.clone(), &url)
209 .headers(headers.clone());
210 if let Some(b) = &body {
211 req = req.body(b.clone());
212 }
213
214 let attempt = async {
215 let resp = req.send().await?;
216 let status = resp.status();
217 let headers = resp.headers().clone();
218 let body = resp.bytes().await?.to_vec();
219 Ok::<_, reqwest::Error>(Response {
220 status,
221 headers,
222 body,
223 })
224 };
225
226 let resp = match tokio::time::timeout(timeout, attempt).await {
227 Err(_) => return Err(Error::Timeout),
228 Ok(Err(e)) if e.is_timeout() => return Err(Error::Timeout),
229 Ok(Err(e)) => {
230 if e.is_connect() && retry_count < max_retries {
231 tokio::time::sleep(backoff(retry_count)).await;
232 retry_count += 1;
233 if let Some(hook) = on_retry {
234 hook(retry_count, None);
235 }
236 continue;
237 }
238 return Err(Error::Transport(e));
239 }
240 Ok(Ok(resp)) => resp,
241 };
242
243 if resp.status.is_success() {
244 return Ok(resp);
245 }
246
247 let status = resp.status.as_u16();
248 if retryable(status) && retry_count < max_retries {
249 if status == 429 {
250 let wait = retry_after(&resp.headers).unwrap_or(inner.default_retry_after);
251 if let Some(hook) = on_rate_limited {
252 hook(wait.as_secs(), retry_count);
253 }
254 tokio::time::sleep(wait).await;
255 } else {
256 tokio::time::sleep(backoff(retry_count)).await;
257 }
258 retry_count += 1;
259 if let Some(hook) = on_retry {
260 hook(retry_count, Some(status));
261 }
262 continue;
263 }
264
265 return Err(api_error(resp));
266 }
267 }
268}
269
270#[derive(Default)]
272pub struct ClientBuilder {
273 token: Option<String>,
274 base_url: Option<String>,
275 timeout: Option<Duration>,
276 max_retries: Option<u32>,
277 default_retry_after: Option<Duration>,
278 http: Option<reqwest::Client>,
279 on_retry: Option<RetryHook>,
280 on_rate_limited: Option<RateLimitHook>,
281}
282
283impl fmt::Debug for ClientBuilder {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 f.debug_struct("ClientBuilder")
286 .field("base_url", &self.base_url)
287 .field("timeout", &self.timeout)
288 .field("max_retries", &self.max_retries)
289 .field("default_retry_after", &self.default_retry_after)
290 .finish_non_exhaustive()
291 }
292}
293
294impl ClientBuilder {
295 pub fn token(mut self, token: impl Into<String>) -> Self {
297 self.token = Some(token.into());
298 self
299 }
300
301 pub fn base_url(mut self, url: impl Into<String>) -> Self {
303 self.base_url = Some(url.into());
304 self
305 }
306
307 pub fn timeout(mut self, timeout: Duration) -> Self {
309 self.timeout = Some(timeout);
310 self
311 }
312
313 pub fn max_retries(mut self, n: u32) -> Self {
315 self.max_retries = Some(n);
316 self
317 }
318
319 pub fn default_retry_after(mut self, wait: Duration) -> Self {
321 self.default_retry_after = Some(wait);
322 self
323 }
324
325 pub fn http_client(mut self, http: reqwest::Client) -> Self {
327 self.http = Some(http);
328 self
329 }
330
331 pub fn on_retry(mut self, hook: impl Fn(u32, Option<u16>) + Send + Sync + 'static) -> Self {
333 self.on_retry = Some(Arc::new(hook));
334 self
335 }
336
337 pub fn on_rate_limited(mut self, hook: impl Fn(u64, u32) + Send + Sync + 'static) -> Self {
339 self.on_rate_limited = Some(Arc::new(hook));
340 self
341 }
342
343 pub fn build(self) -> Result<Client, Error> {
345 let token = self
346 .token
347 .filter(|t| !t.is_empty())
348 .ok_or_else(|| Error::InvalidRequest("an API token is required".into()))?;
349 let http = match self.http {
350 Some(http) => http,
351 None => reqwest::Client::builder()
352 .build()
353 .map_err(Error::Transport)?,
354 };
355 let base_url = self
356 .base_url
357 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
358 .trim_end_matches('/')
359 .to_string();
360 Ok(Client {
361 inner: Arc::new(Inner {
362 http,
363 token,
364 base_url,
365 timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
366 max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
367 default_retry_after: self.default_retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
368 on_retry: self.on_retry,
369 on_rate_limited: self.on_rate_limited,
370 }),
371 })
372 }
373}
374
375#[derive(Debug, Clone)]
377pub(crate) struct Call {
378 pub method: Method,
379 pub path: String,
380 pub query: Map<String, Value>,
381 pub body: Option<Value>,
382}
383
384impl Call {
385 pub(crate) fn new(method: Method, path: String, query: &Query, body: Option<Value>) -> Self {
386 Self {
387 method,
388 path,
389 query: query.as_map().clone(),
390 body,
391 }
392 }
393}
394
395pub(crate) struct Response {
397 pub status: StatusCode,
398 pub headers: HeaderMap,
399 pub body: Vec<u8>,
400}
401
402impl Response {
403 fn is_empty(&self) -> bool {
404 self.status == StatusCode::NO_CONTENT || self.body.iter().all(u8::is_ascii_whitespace)
405 }
406
407 fn is_json(&self) -> bool {
408 content_type(&self.headers).is_some_and(|c| c.contains("json"))
409 }
410
411 pub(crate) fn decode<T: DeserializeOwned>(&self) -> Result<T, Error> {
413 if self.is_empty() {
414 return decode_empty();
415 }
416 if !self.is_json() {
417 return Err(Error::decode(
418 format!(
419 "expected a JSON response, got content-type {:?}",
420 content_type(&self.headers).unwrap_or("")
421 ),
422 None,
423 ));
424 }
425 serde_json::from_slice(&self.body).map_err(|e| Error::decode(e.to_string(), Some(e)))
426 }
427}
428
429pub(crate) fn decode_empty<T: DeserializeOwned>() -> Result<T, Error> {
432 serde_json::from_value(Value::Null)
433 .or_else(|_| serde_json::from_value(Value::Object(Map::new())))
434 .map_err(|e| Error::decode(format!("empty response: {e}"), Some(e)))
435}
436
437pub(crate) fn content_type(headers: &HeaderMap) -> Option<&str> {
438 headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok())
439}
440
441fn api_error(resp: Response) -> Error {
442 let text = String::from_utf8_lossy(&resp.body).into_owned();
443 let body = if resp.is_json() {
444 match serde_json::from_slice(&resp.body) {
445 Ok(v) => ErrorBody::Json(v),
446 Err(_) => ErrorBody::Text(text),
447 }
448 } else {
449 ErrorBody::Text(text)
450 };
451 Error::Api(Box::new(ApiError {
452 status: resp.status.as_u16(),
453 status_text: resp.status.canonical_reason().unwrap_or("").to_string(),
454 body,
455 headers: resp.headers,
456 }))
457}
458
459fn retry_after(headers: &HeaderMap) -> Option<Duration> {
460 let v = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
461 v.parse::<u64>().ok().map(Duration::from_secs)
462}
463
464fn backoff(retry_count: u32) -> Duration {
466 let ceiling = BACKOFF_BASE
467 .saturating_mul(1u32 << retry_count.min(16))
468 .min(BACKOFF_CAP);
469 let millis = ceiling.as_millis() as u64;
470 Duration::from_millis(random_u64() % (millis + 1))
471}
472
473fn random_u64() -> u64 {
474 use std::collections::hash_map::RandomState;
475 use std::hash::{BuildHasher, Hasher};
476 let mut h = RandomState::new().build_hasher();
477 h.write_u128(
478 std::time::SystemTime::now()
479 .duration_since(std::time::UNIX_EPOCH)
480 .map(|d| d.as_nanos())
481 .unwrap_or(0),
482 );
483 h.finish()
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn backoff_stays_within_bounds() {
492 for n in 0..20 {
493 let d = backoff(n);
494 assert!(d <= BACKOFF_CAP);
495 if n == 0 {
496 assert!(d <= BACKOFF_BASE);
497 }
498 }
499 }
500
501 #[test]
502 fn builder_requires_token_and_trims_base_url() {
503 assert!(Client::builder().build().is_err());
504 let c = Client::builder()
505 .token("t")
506 .base_url("http://localhost:1/")
507 .build()
508 .unwrap();
509 assert_eq!(c.base_url(), "http://localhost:1");
510 }
511}