1mod auth;
20mod jwt;
21
22pub use auth::{Auth, CcgConfig, OAuthConfig, RefreshTokenStore, TokenSource};
23pub use jwt::JwtConfig;
24
25use std::collections::HashMap;
26use std::time::Duration;
27
28const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
32
33#[derive(Debug)]
36pub struct Error(String);
37
38impl Error {
39 pub(crate) fn new(message: impl Into<String>) -> Error {
40 Error(message.into())
41 }
42}
43
44impl std::fmt::Display for Error {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 write!(f, "{}", self.0)
47 }
48}
49
50impl std::error::Error for Error {}
51
52impl From<serde_json::Error> for Error {
53 fn from(err: serde_json::Error) -> Self {
54 Error(err.to_string())
55 }
56}
57
58impl From<reqwest::Error> for Error {
59 fn from(err: reqwest::Error) -> Self {
60 Error(err.to_string())
61 }
62}
63
64pub struct Stream(Vec<u8>);
68
69impl Stream {
70 pub fn empty() -> Stream {
72 Stream(Vec::new())
73 }
74
75 pub fn from_bytes(bytes: Vec<u8>) -> Stream {
77 Stream(bytes)
78 }
79
80 pub fn into_bytes(self) -> Vec<u8> {
82 self.0
83 }
84}
85
86enum Body {
88 Bytes { content_type: String, data: Vec<u8> },
90 Multipart {
92 attributes: Vec<u8>,
93 file_name: String,
94 file: Vec<u8>,
95 },
96}
97
98pub struct Request {
101 method: String,
102 url: String,
103 headers: Vec<(String, String)>,
104 query: Vec<(String, String)>,
105 body: Option<Body>,
106}
107
108pub struct Response {
111 status: i64,
112 headers: Vec<(String, String)>,
113 body: Vec<u8>,
114}
115
116pub struct Client {
119 auth: Auth,
120 http: reqwest::Client,
121 base_urls: HashMap<String, String>,
122 max_retries: u32,
123 default_headers: Vec<(String, String)>,
124}
125
126impl Client {
127 pub fn new(auth: Auth) -> Client {
130 let http = reqwest::Client::builder()
131 .timeout(Duration::from_secs(60))
132 .build()
133 .unwrap_or_default();
134 Client {
135 auth,
136 http,
137 max_retries: 5,
138 base_urls: default_base_urls(),
139 default_headers: Vec::new(),
140 }
141 }
142
143 pub fn with_max_retries(mut self, n: u32) -> Client {
145 self.max_retries = n;
146 self
147 }
148
149 pub fn with_http_client(mut self, http: reqwest::Client) -> Client {
152 self.http = http;
153 self
154 }
155
156 pub fn with_header(mut self, name: &str, value: &str) -> Client {
162 self.default_headers
163 .retain(|(key, _)| !key.eq_ignore_ascii_case(name));
164 self.default_headers
165 .push((name.to_string(), value.to_string()));
166 self
167 }
168
169 pub fn with_base_url(mut self, name: &str, base: &str) -> Client {
171 self.base_urls
172 .insert(name.to_string(), base.trim_end_matches('/').to_string());
173 self
174 }
175
176 pub fn base_url(&self, name: &str) -> String {
178 self.base_urls.get(name).cloned().unwrap_or_default()
179 }
180
181 pub async fn access_token(&self) -> Result<String, Error> {
183 self.auth.access_token().await
184 }
185
186 pub fn new_request(&self, method: &str, url: &str) -> Request {
188 Request {
189 method: method.to_string(),
190 url: url.to_string(),
191 headers: Vec::new(),
192 query: Vec::new(),
193 body: None,
194 }
195 }
196
197 pub async fn fetch(&self, request: Request) -> Result<Response, Error> {
205 let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
206 Error::new(format!(
207 "gantryruntime: invalid method {:?}",
208 request.method
209 ))
210 })?;
211 let idempotent = method.is_idempotent();
212 let mut token = self.access_token().await?;
213 let mut refreshed = false;
214
215 for attempt in 0..=self.max_retries {
216 let mut builder = self
217 .http
218 .request(method.clone(), &request.url)
219 .query(&request.query)
220 .header("Authorization", format!("Bearer {token}"));
221 for (name, value) in &self.default_headers {
222 if !request
223 .headers
224 .iter()
225 .any(|(n, _)| n.eq_ignore_ascii_case(name))
226 {
227 builder = builder.header(name, value);
228 }
229 }
230 for (name, value) in &request.headers {
231 builder = builder.header(name, value);
232 }
233 builder = apply_body(builder, request.body.as_ref());
234
235 let response = match builder.send().await {
236 Ok(response) => response,
237 Err(err) => {
238 if attempt == self.max_retries || !idempotent {
241 return Err(err.into());
242 }
243 sleep(backoff(attempt)).await;
244 continue;
245 }
246 };
247 let response = read_response(response).await?;
248
249 if response.status == 401 && !refreshed && attempt < self.max_retries {
252 refreshed = true;
253 token = self.auth.force_refresh(&token).await?;
254 continue;
255 }
256 if should_retry(response.status, idempotent) && attempt < self.max_retries {
258 sleep(retry_delay(&response, attempt)).await;
259 continue;
260 }
261 return Ok(response);
262 }
263 Err(Error::new("gantryruntime: retries exhausted"))
264 }
265}
266
267fn default_base_urls() -> HashMap<String, String> {
270 [
271 ("api", "https://api.box.com/2.0"),
272 ("api_root", "https://api.box.com"),
273 ("upload", "https://upload.box.com/api/2.0"),
274 ("upload_session", "https://upload.box.com/api/2.0"),
275 ("oauth_authorize", "https://account.box.com/api/oauth2"),
276 ("download", "https://api.box.com/2.0"),
277 ]
278 .iter()
279 .map(|(k, v)| (k.to_string(), v.to_string()))
280 .collect()
281}
282
283fn apply_body(builder: reqwest::RequestBuilder, body: Option<&Body>) -> reqwest::RequestBuilder {
285 match body {
286 None => builder,
287 Some(Body::Bytes { content_type, data }) => builder
288 .header("Content-Type", content_type)
289 .body(data.clone()),
290 Some(Body::Multipart {
291 attributes,
292 file_name,
293 file,
294 }) => {
295 let boundary = multipart_boundary(attributes, file);
298 let payload = multipart_body(&boundary, attributes, file_name, file);
299 builder
300 .header(
301 "Content-Type",
302 format!("multipart/form-data; boundary={boundary}"),
303 )
304 .body(payload)
305 }
306 }
307}
308
309fn multipart_body(boundary: &str, attributes: &[u8], file_name: &str, file: &[u8]) -> Vec<u8> {
312 let mut out = Vec::new();
313 out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
314 out.extend_from_slice(b"Content-Disposition: form-data; name=\"attributes\"\r\n");
315 out.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
316 out.extend_from_slice(attributes);
317 out.extend_from_slice(b"\r\n");
318 out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
319 out.extend_from_slice(
320 format!(
321 "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
322 escape_filename(file_name)
323 )
324 .as_bytes(),
325 );
326 out.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
327 out.extend_from_slice(file);
328 out.extend_from_slice(b"\r\n");
329 out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
330 out
331}
332
333fn multipart_boundary(attributes: &[u8], file: &[u8]) -> String {
338 let mut seed = std::time::SystemTime::now()
339 .duration_since(std::time::UNIX_EPOCH)
340 .map(|d| d.as_nanos() as u64)
341 .unwrap_or(0x9e37_79b9_7f4a_7c15);
342 loop {
343 let candidate = format!("gantryruntimeXboundaryX{seed:016x}");
344 let needle = candidate.as_bytes();
345 if !contains_subslice(attributes, needle) && !contains_subslice(file, needle) {
346 return candidate;
347 }
348 seed = seed
349 .wrapping_mul(6364136223846793005)
350 .wrapping_add(1442695040888963407);
351 }
352}
353
354fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
356 !needle.is_empty()
357 && haystack.len() >= needle.len()
358 && haystack
359 .windows(needle.len())
360 .any(|window| window == needle)
361}
362
363fn escape_filename(name: &str) -> String {
367 let mut out = String::with_capacity(name.len());
368 for ch in name.chars() {
369 match ch {
370 '\r' | '\n' => {}
371 '\\' | '"' => {
372 out.push('\\');
373 out.push(ch);
374 }
375 _ => out.push(ch),
376 }
377 }
378 out
379}
380
381async fn read_response(response: reqwest::Response) -> Result<Response, Error> {
383 let status = response.status().as_u16() as i64;
384 let headers = response
385 .headers()
386 .iter()
387 .map(|(name, value)| {
388 (
389 name.as_str().to_string(),
390 value.to_str().unwrap_or_default().to_string(),
391 )
392 })
393 .collect();
394 let body = response.bytes().await?.to_vec();
395 Ok(Response {
396 status,
397 headers,
398 body,
399 })
400}
401
402fn should_retry(status: i64, idempotent: bool) -> bool {
406 status == 429 || (status >= 500 && idempotent)
407}
408
409fn backoff(attempt: u32) -> Duration {
411 let base = (500u64.saturating_mul(1u64 << attempt.min(20))).min(30_000);
412 Duration::from_millis(jitter(base))
413}
414
415fn retry_delay(response: &Response, attempt: u32) -> Duration {
419 let mut delay = backoff(attempt);
420 if let Some(value) = header_value(&response.headers, "retry-after") {
421 if let Ok(secs) = value.trim().parse::<u64>() {
422 delay = delay.max(Duration::from_secs(secs));
423 }
424 }
425 delay.min(MAX_RETRY_DELAY)
426}
427
428fn jitter(max: u64) -> u64 {
432 if max == 0 {
433 return 0;
434 }
435 let mut x = std::time::SystemTime::now()
436 .duration_since(std::time::UNIX_EPOCH)
437 .map(|d| d.as_nanos() as u64)
438 .unwrap_or(0x9e37_79b9_7f4a_7c15)
439 | 1;
440 x ^= x << 13;
441 x ^= x >> 7;
442 x ^= x << 17;
443 x % (max + 1)
444}
445
446async fn sleep(duration: Duration) {
447 tokio::time::sleep(duration).await;
448}
449
450fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
452 headers
453 .iter()
454 .find(|(key, _)| key.eq_ignore_ascii_case(name))
455 .map(|(_, value)| value.as_str())
456}
457
458pub fn with_header(mut request: Request, name: &str, value: &str) -> Request {
460 request
461 .headers
462 .retain(|(key, _)| !key.eq_ignore_ascii_case(name));
463 request.headers.push((name.to_string(), value.to_string()));
464 request
465}
466
467pub fn with_query(mut request: Request, name: &str, value: &str) -> Request {
469 request.query.push((name.to_string(), value.to_string()));
470 request
471}
472
473pub fn with_json_body(mut request: Request, body: &[u8]) -> Request {
475 request.body = Some(Body::Bytes {
476 content_type: "application/json".to_string(),
477 data: body.to_vec(),
478 });
479 request
480}
481
482pub fn with_form_body(mut request: Request, form: &[u8]) -> Request {
485 request.body = Some(Body::Bytes {
486 content_type: "application/x-www-form-urlencoded".to_string(),
487 data: form.to_vec(),
488 });
489 request
490}
491
492pub fn with_stream_body(mut request: Request, body: Stream, content_type: &str) -> Request {
494 request.body = Some(Body::Bytes {
495 content_type: content_type.to_string(),
496 data: body.into_bytes(),
497 });
498 request
499}
500
501pub fn with_multipart_body(
504 mut request: Request,
505 attributes: &[u8],
506 file_name: &str,
507 file: Stream,
508) -> Request {
509 request.body = Some(Body::Multipart {
510 attributes: attributes.to_vec(),
511 file_name: file_name.to_string(),
512 file: file.into_bytes(),
513 });
514 request
515}
516
517pub fn response_bytes(response: &Response) -> Result<Vec<u8>, Error> {
519 Ok(response.body.clone())
520}
521
522pub fn response_stream(response: &Response) -> Stream {
524 Stream::from_bytes(response.body.clone())
525}
526
527pub fn response_header(response: &Response, name: &str) -> String {
530 header_value(&response.headers, name)
531 .unwrap_or_default()
532 .to_string()
533}
534
535pub fn status_code(response: &Response) -> i64 {
537 response.status
538}