1use std::sync::Arc;
8use std::time::Duration;
9
10use bytes::Bytes;
11use http::{HeaderMap, HeaderValue, Method, StatusCode};
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14
15use crate::error::{ApiErrorCode, ApiErrorResponse, HttpClientError, MesaError};
16use crate::http_client::{HttpClient, HttpRequest, HttpResponse};
17
18const DEFAULT_BASE_URL: &str = "https://depot.mesa.dev/api/v1";
20
21const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24const DEFAULT_MAX_RETRIES: u32 = 3;
26
27const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
29
30const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
32
33#[derive(Debug, Clone)]
38pub struct ClientConfig {
39 pub base_url: String,
41 pub api_key: String,
43 pub timeout: Duration,
45 pub max_retries: u32,
47 pub initial_backoff: Duration,
49 pub max_backoff: Duration,
51 pub default_headers: HeaderMap,
53}
54
55#[derive(Debug)]
73pub struct ClientBuilder {
74 config: ClientConfig,
75}
76
77impl ClientBuilder {
78 pub fn new(api_key: impl Into<String>) -> Self {
80 Self {
81 config: ClientConfig {
82 base_url: DEFAULT_BASE_URL.to_owned(),
83 api_key: api_key.into(),
84 timeout: DEFAULT_TIMEOUT,
85 max_retries: DEFAULT_MAX_RETRIES,
86 initial_backoff: DEFAULT_INITIAL_BACKOFF,
87 max_backoff: DEFAULT_MAX_BACKOFF,
88 default_headers: HeaderMap::new(),
89 },
90 }
91 }
92
93 #[must_use]
95 pub fn base_url(mut self, url: impl Into<String>) -> Self {
96 self.config.base_url = url.into();
97 self
98 }
99
100 #[must_use]
102 pub fn timeout(mut self, timeout: Duration) -> Self {
103 self.config.timeout = timeout;
104 self
105 }
106
107 #[must_use]
109 pub fn max_retries(mut self, n: u32) -> Self {
110 self.config.max_retries = n;
111 self
112 }
113
114 #[must_use]
116 pub fn initial_backoff(mut self, d: Duration) -> Self {
117 self.config.initial_backoff = d;
118 self
119 }
120
121 #[must_use]
123 pub fn max_backoff(mut self, d: Duration) -> Self {
124 self.config.max_backoff = d;
125 self
126 }
127
128 #[must_use]
130 pub fn default_header(mut self, name: http::HeaderName, value: HeaderValue) -> Self {
131 self.config.default_headers.insert(name, value);
132 self
133 }
134
135 #[cfg(feature = "reqwest-client")]
137 #[must_use]
138 pub fn build(self) -> MesaClient<crate::backends::ReqwestClient> {
139 let http = crate::backends::ReqwestClient::new(self.config.timeout);
140 self.build_with(http)
141 }
142
143 #[must_use]
145 pub fn build_with<C: HttpClient>(self, http_client: C) -> MesaClient<C> {
146 MesaClient {
147 inner: Arc::new(ClientInner {
148 config: self.config,
149 http: http_client,
150 }),
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
168pub struct MesaClient<C: HttpClient> {
169 pub(crate) inner: Arc<ClientInner<C>>,
170}
171
172#[derive(Debug)]
174#[expect(unreachable_pub)]
175pub struct ClientInner<C: HttpClient> {
176 pub(crate) config: ClientConfig,
177 pub(crate) http: C,
178}
179
180impl<C: HttpClient> ClientInner<C> {
181 pub(crate) async fn request<T: DeserializeOwned>(
183 &self,
184 method: Method,
185 path: &str,
186 query: &[(&str, &str)],
187 body: Option<Bytes>,
188 ) -> Result<T, MesaError> {
189 let url = build_url(&self.config.base_url, path, query);
190 let response = self.send_with_retry(method, &url, body).await?;
191 serde_json::from_slice(&response.body).map_err(MesaError::from)
192 }
193
194 async fn send_with_retry(
196 &self,
197 method: Method,
198 url: &str,
199 body: Option<Bytes>,
200 ) -> Result<HttpResponse, MesaError> {
201 let max_attempts = self.config.max_retries + 1;
202 let mut last_error: Option<MesaError> = None;
203
204 for attempt in 0..max_attempts {
205 if attempt > 0 {
206 if let Some(ref err) = last_error
207 && !err.is_retryable()
208 {
209 break;
210 }
211
212 let backoff = compute_backoff(
213 attempt,
214 self.config.initial_backoff,
215 self.config.max_backoff,
216 );
217 std::thread::sleep(backoff);
218 }
219
220 let request = self.build_request(method.clone(), url, body.clone());
221 match self.http.send(request).await {
222 Ok(response) if response.status.is_success() => return Ok(response),
223 Ok(response) => {
224 let err = parse_api_error(response.status, &response.body);
225 last_error = Some(err);
226 }
227 Err(http_err) => {
228 last_error = Some(MesaError::HttpClient(http_err));
229 }
230 }
231 }
232
233 match last_error {
234 Some(err) if max_attempts > 1 && err.is_retryable() => {
235 Err(MesaError::RetriesExhausted {
236 attempts: max_attempts,
237 last_error: Box::new(err),
238 })
239 }
240 Some(err) => Err(err),
241 None => Err(MesaError::HttpClient(HttpClientError::Connection(
242 "no attempts made".to_owned(),
243 ))),
244 }
245 }
246
247 fn build_request(&self, method: Method, url: &str, body: Option<Bytes>) -> HttpRequest {
249 let mut headers = self.config.default_headers.clone();
250
251 if let Ok(auth) = HeaderValue::from_str(&format!("Bearer {}", self.config.api_key)) {
252 headers.insert(http::header::AUTHORIZATION, auth);
253 }
254
255 if body.is_some() {
256 headers.insert(
257 http::header::CONTENT_TYPE,
258 HeaderValue::from_static("application/json"),
259 );
260 }
261
262 headers.insert(
263 http::header::ACCEPT,
264 HeaderValue::from_static("application/json"),
265 );
266
267 HttpRequest {
268 method,
269 url: url.to_owned(),
270 headers,
271 body,
272 }
273 }
274}
275
276#[cfg(feature = "reqwest-client")]
278pub type Mesa = MesaClient<crate::backends::ReqwestClient>;
279
280#[cfg(feature = "reqwest-client")]
281impl Mesa {
282 pub fn new(api_key: impl Into<String>) -> Self {
284 ClientBuilder::new(api_key).build()
285 }
286}
287
288impl<C: HttpClient> MesaClient<C> {
289 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
291 ClientBuilder::new(api_key)
292 }
293
294 pub(crate) async fn request<T: DeserializeOwned>(
296 &self,
297 method: Method,
298 path: &str,
299 query: &[(&str, &str)],
300 body: Option<&(impl Serialize + Sync)>,
301 ) -> Result<T, MesaError> {
302 let json_body = match body {
303 Some(b) => Some(Bytes::from(serde_json::to_vec(b)?)),
304 None => None,
305 };
306 self.inner.request(method, path, query, json_body).await
307 }
308
309 #[must_use]
313 pub fn repos(&self, org: &str) -> crate::resources::ReposResource<'_, C> {
314 crate::resources::ReposResource::new(self, org.to_owned())
315 }
316
317 #[must_use]
319 pub fn branches(&self, org: &str, repo: &str) -> crate::resources::BranchesResource<'_, C> {
320 crate::resources::BranchesResource::new(self, org.to_owned(), repo.to_owned())
321 }
322
323 #[must_use]
325 pub fn commits(&self, org: &str, repo: &str) -> crate::resources::CommitsResource<'_, C> {
326 crate::resources::CommitsResource::new(self, org.to_owned(), repo.to_owned())
327 }
328
329 #[must_use]
331 pub fn content(&self, org: &str, repo: &str) -> crate::resources::ContentResource<'_, C> {
332 crate::resources::ContentResource::new(self, org.to_owned(), repo.to_owned())
333 }
334
335 #[must_use]
337 pub fn diffs(&self, org: &str, repo: &str) -> crate::resources::DiffsResource<'_, C> {
338 crate::resources::DiffsResource::new(self, org.to_owned(), repo.to_owned())
339 }
340
341 #[must_use]
343 pub fn admin(&self, org: &str) -> crate::resources::AdminResource<'_, C> {
344 crate::resources::AdminResource::new(self, org.to_owned())
345 }
346}
347
348fn build_url(base: &str, path: &str, query: &[(&str, &str)]) -> String {
350 let mut url = format!("{base}{path}");
351 if !query.is_empty() {
352 url.push('?');
353 for (i, (key, value)) in query.iter().enumerate() {
354 if i > 0 {
355 url.push('&');
356 }
357 url.push_str(key);
358 url.push('=');
359 url.push_str(&url_encode(value));
360 }
361 }
362 url
363}
364
365fn url_encode(s: &str) -> String {
367 let mut out = String::with_capacity(s.len());
368 for byte in s.bytes() {
369 match byte {
370 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
371 out.push(byte as char);
372 }
373 _ => {
374 out.push('%');
375 let high = byte >> 4;
377 let low = byte & 0x0F;
378 out.push(hex_digit(high));
379 out.push(hex_digit(low));
380 }
381 }
382 }
383 out
384}
385
386const fn hex_digit(nibble: u8) -> char {
388 match nibble {
389 0..=9 => (b'0' + nibble) as char,
390 _ => (b'A' + nibble - 10) as char,
391 }
392}
393
394#[expect(clippy::cast_possible_truncation)] fn compute_backoff(attempt: u32, initial: Duration, max: Duration) -> Duration {
397 let base = initial.saturating_mul(1 << attempt.min(16));
398 let capped = base.min(max);
399 let millis = capped.as_millis() as u64;
401 let jitter_millis = millis / 2 + simple_random_u64() % (millis / 2 + 1);
402 Duration::from_millis(jitter_millis)
403}
404
405#[expect(clippy::cast_possible_truncation)] fn simple_random_u64() -> u64 {
409 use std::time::SystemTime;
410 SystemTime::now()
411 .duration_since(SystemTime::UNIX_EPOCH)
412 .map_or(0, |d| d.as_nanos() as u64)
413}
414
415#[expect(unreachable_pub)]
417pub fn parse_api_error(status: StatusCode, body: &[u8]) -> MesaError {
418 match serde_json::from_slice::<ApiErrorResponse>(body) {
419 Ok(resp) => MesaError::Api {
420 status,
421 code: ApiErrorCode::from_code(&resp.error.code),
422 message: resp.error.message,
423 details: resp.error.details,
424 },
425 Err(_) => MesaError::Api {
426 status,
427 code: ApiErrorCode::Unknown(status.to_string()),
428 message: String::from_utf8_lossy(body).into_owned(),
429 details: serde_json::Value::Null,
430 },
431 }
432}