1use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use bytes::Bytes;
8use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
9use reqwest::{Method, RequestBuilder};
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12use url::Url;
13
14use crate::error::{ApiError, Error, Problem, Result};
15use crate::generated::meta::DEFAULT_BASE_URL;
16use crate::sse::{EventStream, StreamOptions};
17use crate::util::encode_query_component;
18
19pub const NO_QUERY: Option<&()> = None;
21pub const NO_BODY: Option<&()> = None;
23
24const RETRYABLE: [u16; 7] = [408, 409, 429, 500, 502, 503, 504];
25
26#[derive(Debug, Clone, Default)]
32pub struct RequestOptions {
33 pub timeout: Option<Duration>,
35 pub max_retries: Option<u32>,
37 pub idempotency_key: Option<String>,
39 pub extra_headers: Vec<(String, String)>,
41 pub extra_query: Vec<(String, String)>,
43 pub stream: Option<StreamOptions>,
45}
46
47#[derive(Debug)]
49pub struct Request<'a, Q: ?Sized = (), B: ?Sized = ()> {
50 pub method: Method,
51 pub path: String,
52 pub query: Option<&'a Q>,
53 pub body: Option<&'a B>,
54 pub headers: Vec<(&'static str, String)>,
55 pub idempotent: bool,
57}
58
59pub(crate) struct Inner {
60 pub(crate) http: reqwest::Client,
61 pub(crate) base_url: Url,
62 pub(crate) api_key: String,
63 pub(crate) max_retries: u32,
64 pub(crate) timeout: Duration,
65 pub(crate) user_agent: String,
66 pub(crate) default_headers: HeaderMap,
67 pub(crate) sse_token_in_query: bool,
68}
69
70#[derive(Clone)]
81pub struct Client {
82 pub(crate) inner: Arc<Inner>,
83 pub(crate) options: RequestOptions,
86}
87
88impl std::fmt::Debug for Client {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("Client")
91 .field("base_url", &self.inner.base_url.as_str())
92 .field("max_retries", &self.inner.max_retries)
93 .field("timeout", &self.inner.timeout)
94 .field("options", &self.options)
95 .finish_non_exhaustive()
96 }
97}
98
99impl Client {
100 pub fn new(api_key: impl Into<String>) -> Result<Self> {
102 ClientBuilder::new().api_key(api_key).build()
103 }
104
105 pub fn from_env() -> Result<Self> {
108 let api_key = std::env::var("UARP_API_KEY")
113 .or_else(|_| std::env::var("SNAGA_API_KEY"))
114 .ok()
115 .filter(|key| !key.is_empty())
116 .ok_or_else(|| Error::Config("UARP_API_KEY is not set".into()))?;
117 let mut builder = ClientBuilder::new().api_key(api_key);
118 if let Ok(base) = std::env::var("UARP_BASE_URL") {
119 builder = builder.base_url(base);
120 }
121 builder.build()
122 }
123
124 pub fn builder() -> ClientBuilder {
125 ClientBuilder::new()
126 }
127
128 pub fn base_url(&self) -> &Url {
129 &self.inner.base_url
130 }
131
132 pub fn with_options(&self, options: RequestOptions) -> Client {
137 Client {
138 inner: self.inner.clone(),
139 options,
140 }
141 }
142
143 pub fn with_idempotency_key(&self, key: impl Into<String>) -> Client {
145 let mut options = self.options.clone();
146 options.idempotency_key = Some(key.into());
147 self.with_options(options)
148 }
149
150 pub fn with_timeout(&self, timeout: Duration) -> Client {
151 let mut options = self.options.clone();
152 options.timeout = Some(timeout);
153 self.with_options(options)
154 }
155
156 pub fn with_max_retries(&self, retries: u32) -> Client {
157 let mut options = self.options.clone();
158 options.max_retries = Some(retries);
159 self.with_options(options)
160 }
161
162 pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Client {
164 let mut options = self.options.clone();
165 options.extra_headers.push((name.into(), value.into()));
166 self.with_options(options)
167 }
168
169 pub fn with_query(&self, name: impl Into<String>, value: impl Into<String>) -> Client {
171 let mut options = self.options.clone();
172 options.extra_query.push((name.into(), value.into()));
173 self.with_options(options)
174 }
175
176 pub fn with_stream_options(&self, stream: StreamOptions) -> Client {
178 let mut options = self.options.clone();
179 options.stream = Some(stream);
180 self.with_options(options)
181 }
182
183 pub async fn request_json<Q, B, R>(&self, req: Request<'_, Q, B>) -> Result<R>
187 where
188 Q: Serialize + ?Sized + Sync,
189 B: Serialize + ?Sized + Sync,
190 R: DeserializeOwned,
191 {
192 let body = req.body;
193 let response = self
194 .run(&req, &move |builder: RequestBuilder| match body {
195 Some(value) => Ok(builder.json(value)),
196 None => Ok(builder),
197 })
198 .await?;
199 decode_json(response).await
200 }
201
202 pub async fn request_empty<Q, B>(&self, req: Request<'_, Q, B>) -> Result<()>
204 where
205 Q: Serialize + ?Sized + Sync,
206 B: Serialize + ?Sized + Sync,
207 {
208 let body = req.body;
209 self.run(&req, &move |builder: RequestBuilder| match body {
210 Some(value) => Ok(builder.json(value)),
211 None => Ok(builder),
212 })
213 .await?;
214 Ok(())
215 }
216
217 pub async fn request_bytes<Q, B>(&self, req: Request<'_, Q, B>) -> Result<Bytes>
219 where
220 Q: Serialize + ?Sized + Sync,
221 B: Serialize + ?Sized + Sync,
222 {
223 let body = req.body;
224 let response = self
225 .run(&req, &move |builder: RequestBuilder| match body {
226 Some(value) => Ok(builder.json(value)),
227 None => Ok(builder),
228 })
229 .await?;
230 response.bytes().await.map_err(Error::Connection)
231 }
232
233 pub async fn request_text<Q, B>(&self, req: Request<'_, Q, B>) -> Result<String>
240 where
241 Q: Serialize + ?Sized + Sync,
242 B: Serialize + ?Sized + Sync,
243 {
244 let body = req.body;
245 let response = self
246 .run(&req, &move |builder: RequestBuilder| match body {
247 Some(value) => Ok(builder.json(value)),
248 None => Ok(builder),
249 })
250 .await?;
251 response.text().await.map_err(Error::Connection)
252 }
253
254 pub async fn request_multipart<Q, R, F>(
256 &self,
257 req: Request<'_, Q, ()>,
258 make_form: F,
259 ) -> Result<R>
260 where
261 Q: Serialize + ?Sized + Sync,
262 R: DeserializeOwned,
263 F: Fn() -> Result<reqwest::multipart::Form> + Send + Sync,
264 {
265 let response = self
266 .run(&req, &move |builder: RequestBuilder| {
267 Ok(builder.multipart(make_form()?))
268 })
269 .await?;
270 decode_json(response).await
271 }
272
273 pub fn request_stream<Q>(
275 &self,
276 path: &str,
277 query: Option<&Q>,
278 headers: Vec<(&'static str, String)>,
279 ) -> EventStream
280 where
281 Q: Serialize + ?Sized,
282 {
283 let options = self.options.stream.clone().unwrap_or_default();
284 let mut headers: Vec<(String, String)> = headers
285 .into_iter()
286 .map(|(name, value)| (name.to_string(), value))
287 .collect();
288 headers.extend(self.options.extra_headers.iter().cloned());
289 let url = self.build_url(path, query).map(|mut url| {
290 if self.inner.sse_token_in_query && !self.inner.api_key.is_empty() {
293 url.query_pairs_mut()
294 .append_pair("token", &self.inner.api_key);
295 }
296 url
297 });
298 EventStream::new(self.inner.clone(), url, headers, options)
299 }
300
301 pub async fn raw<R: DeserializeOwned>(
303 &self,
304 method: Method,
305 path: &str,
306 body: Option<&serde_json::Value>,
307 ) -> Result<R> {
308 let idempotent = method != Method::GET && path.starts_with("/api/v1");
309 self.request_json(Request {
310 method,
311 path: path.to_string(),
312 query: NO_QUERY,
313 body,
314 headers: Vec::new(),
315 idempotent,
316 })
317 .await
318 }
319
320 async fn run<Q, B>(
323 &self,
324 req: &Request<'_, Q, B>,
325 apply_body: &(dyn Fn(RequestBuilder) -> Result<RequestBuilder> + Send + Sync),
326 ) -> Result<reqwest::Response>
327 where
328 Q: Serialize + ?Sized + Sync,
329 B: Serialize + ?Sized + Sync,
330 {
331 let url = self.build_url(&req.path, req.query)?;
332 let idempotency_key = req.idempotent.then(|| {
333 self.options
334 .idempotency_key
335 .clone()
336 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
337 });
338 let retryable_method = req.method == Method::GET || req.method == Method::HEAD;
339 let can_retry = retryable_method || idempotency_key.is_some();
340
341 let retries = self.options.max_retries.unwrap_or(self.inner.max_retries);
342 let timeout = self.options.timeout.unwrap_or(self.inner.timeout);
343
344 let mut attempt: u32 = 0;
345 loop {
346 let mut builder = self
347 .inner
348 .http
349 .request(req.method.clone(), url.clone())
350 .timeout(timeout)
351 .header(reqwest::header::ACCEPT, "application/json")
352 .header(reqwest::header::USER_AGENT, &self.inner.user_agent)
353 .headers(self.inner.default_headers.clone());
354
355 if !self.inner.api_key.is_empty() {
362 builder = builder.header(
363 reqwest::header::AUTHORIZATION,
364 format!("Bearer {}", self.inner.api_key),
365 );
366 }
367
368 for (name, value) in &req.headers {
369 builder = builder.header(*name, value);
370 }
371 for (name, value) in &self.options.extra_headers {
372 builder = builder.header(name.as_str(), value);
373 }
374 if let Some(key) = &idempotency_key {
375 builder = builder.header("Idempotency-Key", key);
376 }
377 builder = apply_body(builder)?;
378
379 match builder.send().await {
380 Ok(response) if response.status().is_success() => return Ok(response),
381 Ok(response) => {
382 let status = response.status().as_u16();
383 let headers = collect_headers(response.headers());
384 let retry_after = parse_retry_after(&headers);
385 let should_retry = RETRYABLE.contains(&status)
386 && headers.get("x-should-retry").map(String::as_str) != Some("false")
387 && can_retry
388 && attempt < retries;
389 if !should_retry {
390 let problem = read_problem(response).await;
391 return Err(ApiError {
392 status,
393 problem,
394 headers,
395 }
396 .into());
397 }
398 let wait = retry_after.unwrap_or_else(|| backoff(attempt));
399 attempt += 1;
400 tokio::time::sleep(wait.min(Duration::from_secs(60))).await;
401 }
402 Err(err) => {
403 let mapped = if err.is_timeout() {
404 Error::Timeout
405 } else {
406 Error::Connection(err)
407 };
408 if !can_retry || attempt >= retries {
409 return Err(mapped);
410 }
411 let wait = backoff(attempt);
412 attempt += 1;
413 tokio::time::sleep(wait).await;
414 }
415 }
416 }
417 }
418
419 fn build_url<Q: Serialize + ?Sized>(&self, path: &str, query: Option<&Q>) -> Result<Url> {
420 let mut url = self
421 .inner
422 .base_url
423 .join(path.trim_start_matches('/'))
424 .map_err(|err| Error::Config(format!("invalid path {path}: {err}")))?;
425 if let Some(query) = query {
426 let form =
430 serde_urlencoded::to_string(query).map_err(|err| Error::Encode(err.to_string()))?;
431 let encoded = form_urlencoded::parse(form.as_bytes())
432 .map(|(name, value)| {
433 format!(
434 "{}={}",
435 encode_query_component(name.as_ref()),
436 encode_query_component(value.as_ref())
437 )
438 })
439 .collect::<Vec<_>>()
440 .join("&");
441 if !encoded.is_empty() {
442 url.set_query(Some(&encoded));
443 }
444 }
445 for (name, value) in &self.options.extra_query {
446 let pair = format!(
447 "{}={}",
448 encode_query_component(name),
449 encode_query_component(value)
450 );
451 let joined = match url.query() {
452 Some(existing) if !existing.is_empty() => format!("{existing}&{pair}"),
453 _ => pair,
454 };
455 url.set_query(Some(&joined));
456 }
457 Ok(url)
458 }
459}
460
461#[derive(Debug)]
463pub struct ClientBuilder {
464 api_key: Option<String>,
465 base_url: String,
466 timeout: Duration,
467 max_retries: u32,
468 user_agent: Option<String>,
469 default_headers: HeaderMap,
470 http: Option<reqwest::Client>,
471 sse_token_in_query: bool,
472}
473
474impl Default for ClientBuilder {
475 fn default() -> Self {
476 Self::new()
477 }
478}
479
480impl ClientBuilder {
481 pub fn new() -> Self {
482 Self {
483 api_key: None,
484 base_url: DEFAULT_BASE_URL.to_string(),
485 timeout: Duration::from_secs(60),
486 max_retries: 2,
487 user_agent: None,
488 default_headers: HeaderMap::new(),
489 http: None,
490 sse_token_in_query: false,
491 }
492 }
493
494 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
495 self.api_key = Some(api_key.into());
496 self
497 }
498
499 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
500 self.base_url = base_url.into();
501 self
502 }
503
504 pub fn timeout(mut self, timeout: Duration) -> Self {
506 self.timeout = timeout;
507 self
508 }
509
510 pub fn max_retries(mut self, max_retries: u32) -> Self {
512 self.max_retries = max_retries;
513 self
514 }
515
516 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
518 self.user_agent = Some(user_agent.into());
519 self
520 }
521
522 pub fn default_header(mut self, name: &str, value: &str) -> Result<Self> {
523 let name = HeaderName::from_bytes(name.as_bytes())
524 .map_err(|err| Error::Config(format!("invalid header name: {err}")))?;
525 let value = HeaderValue::from_str(value)
526 .map_err(|err| Error::Config(format!("invalid header value: {err}")))?;
527 self.default_headers.insert(name, value);
528 Ok(self)
529 }
530
531 pub fn http_client(mut self, http: reqwest::Client) -> Self {
533 self.http = Some(http);
534 self
535 }
536
537 pub fn sse_token_in_query(mut self, enabled: bool) -> Self {
539 self.sse_token_in_query = enabled;
540 self
541 }
542
543 pub fn build(self) -> Result<Client> {
544 let api_key = self.api_key.ok_or_else(|| {
545 Error::Config("missing API key: call .api_key(...) or Client::from_env()".into())
546 })?;
547 let mut base = self.base_url.trim_end_matches('/').to_string();
549 base.push('/');
550 let base_url =
551 Url::parse(&base).map_err(|err| Error::Config(format!("invalid base URL: {err}")))?;
552
553 let sdk_agent = format!("uarp-sdk-rust/{}", env!("CARGO_PKG_VERSION"));
554 let user_agent = match self.user_agent {
555 Some(extra) => format!("{sdk_agent} {extra}"),
556 None => sdk_agent,
557 };
558
559 let http = match self.http {
560 Some(http) => http,
561 None => reqwest::Client::builder()
562 .build()
563 .map_err(|err| Error::Config(format!("could not build HTTP client: {err}")))?,
564 };
565
566 Ok(Client {
567 options: RequestOptions::default(),
568 inner: Arc::new(Inner {
569 http,
570 base_url,
571 api_key,
572 max_retries: self.max_retries,
573 timeout: self.timeout,
574 user_agent,
575 default_headers: self.default_headers,
576 sse_token_in_query: self.sse_token_in_query,
577 }),
578 })
579 }
580}
581
582async fn decode_json<R: DeserializeOwned>(response: reqwest::Response) -> Result<R> {
585 let bytes = response.bytes().await.map_err(Error::Connection)?;
586 let slice: &[u8] = if bytes.is_empty() { b"null" } else { &bytes };
588 serde_json::from_slice(slice).map_err(Error::Decode)
589}
590
591const PROBLEM_KEYS: [&str; 6] = ["type", "title", "status", "detail", "correlationId", "errors"];
594
595pub(crate) fn problem_from_slice(bytes: &[u8]) -> Problem {
607 let raw = || String::from_utf8_lossy(bytes).into_owned();
608 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
609 return Problem { detail: Some(raw()), ..Problem::default() };
610 };
611 let Some(object) = value.as_object() else {
612 return Problem { detail: Some(raw()), ..Problem::default() };
613 };
614 if PROBLEM_KEYS.iter().any(|k| object.contains_key(*k)) {
615 if let Ok(problem) = serde_json::from_slice::<Problem>(bytes) {
616 return problem;
617 }
618 }
619 let message = object
620 .get("error")
621 .and_then(|e| {
622 e.as_str()
623 .map(str::to_owned)
624 .or_else(|| e.get("message").and_then(|m| m.as_str()).map(str::to_owned))
625 })
626 .or_else(|| object.get("message").and_then(|m| m.as_str()).map(str::to_owned));
627 Problem { detail: Some(message.unwrap_or_else(raw)), ..Problem::default() }
628}
629
630async fn read_problem(response: reqwest::Response) -> Problem {
631 match response.bytes().await {
632 Ok(bytes) if !bytes.is_empty() => problem_from_slice(&bytes),
633 _ => Problem::default(),
634 }
635}
636
637pub(crate) fn collect_headers(headers: &HeaderMap) -> HashMap<String, String> {
638 headers
639 .iter()
640 .filter_map(|(name, value)| {
641 Some((
642 name.as_str().to_ascii_lowercase(),
643 value.to_str().ok()?.to_string(),
644 ))
645 })
646 .collect()
647}
648
649fn parse_retry_after(headers: &HashMap<String, String>) -> Option<Duration> {
650 let raw = headers.get("retry-after")?;
651 raw.parse::<f64>()
652 .ok()
653 .filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
654 .map(Duration::from_secs_f64)
655}
656
657pub(crate) fn backoff(attempt: u32) -> Duration {
659 let base = 500u64.saturating_mul(1u64 << attempt.min(4)).min(8_000);
660 let jitter = (uuid::Uuid::new_v4().as_u128() as u64) % (base / 2 + 1);
662 Duration::from_millis(base / 2 + jitter)
663}
664
665#[cfg(test)]
666mod problem_decoding_tests {
667 use super::problem_from_slice;
668
669 #[test]
673 fn bare_error_key_keeps_its_message() {
674 let p = problem_from_slice(br#"{"error": "Insufficient role: owner required"}"#);
675 assert_eq!(p.detail.as_deref(), Some("Insufficient role: owner required"));
676 }
677
678 #[test]
679 fn nested_error_message_keeps_its_message() {
680 let p = problem_from_slice(br#"{"error": {"message": "Upstream error"}}"#);
681 assert_eq!(p.detail.as_deref(), Some("Upstream error"));
682 }
683
684 #[test]
685 fn real_problem_document_is_used_as_is() {
686 let p = problem_from_slice(
687 br#"{"type":"about:blank","title":"Not Found","status":404,"detail":"no such agent"}"#,
688 );
689 assert_eq!(p.title.as_deref(), Some("Not Found"));
690 assert_eq!(p.detail.as_deref(), Some("no such agent"));
691 assert_eq!(p.status, Some(404));
692 }
693
694 #[test]
695 fn non_json_body_is_not_thrown_away() {
696 let p = problem_from_slice(b"<html><body>502 Bad Gateway</body></html>");
697 assert!(p.detail.unwrap().contains("Bad Gateway"));
698 }
699}