Skip to main content

box_open_sdk/runtime/
mod.rs

1// Code generated by box-gantry (vendored from runtimes/rust/gantryruntime/src/lib.rs). DO NOT EDIT.
2
3//! The hand-written runtime the generated Box Rust SDK ships against
4//! (TR-Rust.5). It implements the machine-readable runtime contract
5//! (`gantry-contract` V1): generated code calls only these declarations, and
6//! this crate supplies the behavior — a retrying async network layer (jittered
7//! backoff, 401 refresh, Retry-After), auth-token threading, request builders,
8//! and response accessors.
9//!
10//! It is the real implementation the compilable stubs stand in for during
11//! generation-time verification (FR-5.3). Because it satisfies the same
12//! signatures — `async fn` network entry points returning `Result<T, Error>`,
13//! per the Rust manifest axes — the generated SDK compiles against it unchanged
14//! (FR-5.2), which `crates/gantry-backend-rust/tests` enforces.
15//!
16//! Async threads cancellation through the future itself (no context parameter);
17//! dropping a `fetch` future cancels the in-flight request.
18
19mod auth;
20mod jwt;
21
22pub use auth::{Auth, CcgConfig, OAuthConfig, RefreshTokenStore};
23pub use jwt::JwtConfig;
24
25use std::collections::HashMap;
26use std::time::Duration;
27
28/// The safety ceiling on any single retry sleep, so a pathological server-sent
29/// `Retry-After` (hours, years) can never suspend `fetch` far past the caller's
30/// intent.
31const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
32
33/// A runtime error: a failed request, auth acquisition, or body decode. Opaque
34/// by design — the message carries the detail, the type stays stable.
35#[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
64/// A body stream. Buffered by construction (the Rust manifest's streaming axis
65/// is satisfied by full buffering here): keeping the bytes in hand makes both
66/// request retries and response replay safe.
67pub struct Stream(Vec<u8>);
68
69impl Stream {
70    /// An empty stream (e.g. an absent multipart file part).
71    pub fn empty() -> Stream {
72        Stream(Vec::new())
73    }
74
75    /// A stream over already-buffered bytes.
76    pub fn from_bytes(bytes: Vec<u8>) -> Stream {
77        Stream(bytes)
78    }
79
80    /// The buffered bytes.
81    pub fn into_bytes(self) -> Vec<u8> {
82        self.0
83    }
84}
85
86/// The body assembled by the `with_*` builders before `fetch` executes it.
87enum Body {
88    /// A byte body with a fixed content type (JSON, form, buffered stream).
89    Bytes { content_type: String, data: Vec<u8> },
90    /// A Box-style multipart body: an `attributes` JSON part plus a file part.
91    Multipart {
92        attributes: Vec<u8>,
93        file_name: String,
94        file: Vec<u8>,
95    },
96}
97
98/// The runtime-owned HTTP request envelope, assembled by the `with_*` builders
99/// before `fetch` executes it.
100pub struct Request {
101    method: String,
102    url: String,
103    headers: Vec<(String, String)>,
104    query: Vec<(String, String)>,
105    body: Option<Body>,
106}
107
108/// The runtime-owned HTTP response envelope. The body is read fully so it can
109/// be replayed as bytes or a stream and so retries stay safe.
110pub struct Response {
111    status: i64,
112    headers: Vec<(String, String)>,
113    body: Vec<u8>,
114}
115
116/// The runtime session: it holds the auth flow, HTTP client, base-URL
117/// configuration, and retry policy shared by every manager.
118pub struct Client {
119    auth: Auth,
120    http: reqwest::Client,
121    base_urls: HashMap<String, String>,
122    max_retries: u32,
123}
124
125impl Client {
126    /// Build a runtime session for an authentication flow, with the default
127    /// Box base URLs, a 60s HTTP timeout, and five retries.
128    pub fn new(auth: Auth) -> Client {
129        let http = reqwest::Client::builder()
130            .timeout(Duration::from_secs(60))
131            .build()
132            .unwrap_or_default();
133        Client {
134            auth,
135            http,
136            max_retries: 5,
137            base_urls: default_base_urls(),
138        }
139    }
140
141    /// Override how many times a retriable failure is retried (fluent).
142    pub fn with_max_retries(mut self, n: u32) -> Client {
143        self.max_retries = n;
144        self
145    }
146
147    /// Override one base-URL class for custom deployments (fluent).
148    pub fn with_base_url(mut self, name: &str, base: &str) -> Client {
149        self.base_urls
150            .insert(name.to_string(), base.trim_end_matches('/').to_string());
151        self
152    }
153
154    /// The configured base URL for a D-106 class, without a trailing slash.
155    pub fn base_url(&self, name: &str) -> String {
156        self.base_urls.get(name).cloned().unwrap_or_default()
157    }
158
159    /// A valid access token for the configured auth flow.
160    pub async fn access_token(&self) -> Result<String, Error> {
161        self.auth.access_token().await
162    }
163
164    /// Create a request envelope for a method and fully built URL.
165    pub fn new_request(&self, method: &str, url: &str) -> Request {
166        Request {
167            method: method.to_string(),
168            url: url.to_string(),
169            headers: Vec::new(),
170            query: Vec::new(),
171            body: None,
172        }
173    }
174
175    /// Execute the request with retries: exponential backoff + full jitter, a
176    /// single 401 token refresh, and Retry-After (as a floor) on 429/5xx.
177    ///
178    /// Retries are gated on idempotency: a 429 means the request was rate-limited
179    /// and never processed, so it retries for every method; a transport error or
180    /// 5xx may have already committed a write, so those retry only for idempotent
181    /// methods (GET/HEAD/PUT/DELETE/…). The 401 refresh applies to every method.
182    pub async fn fetch(&self, request: Request) -> Result<Response, Error> {
183        let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
184            Error::new(format!(
185                "gantryruntime: invalid method {:?}",
186                request.method
187            ))
188        })?;
189        let idempotent = method.is_idempotent();
190        let mut token = self.access_token().await?;
191        let mut refreshed = false;
192
193        for attempt in 0..=self.max_retries {
194            let mut builder = self
195                .http
196                .request(method.clone(), &request.url)
197                .query(&request.query)
198                .header("Authorization", format!("Bearer {token}"));
199            for (name, value) in &request.headers {
200                builder = builder.header(name, value);
201            }
202            builder = apply_body(builder, request.body.as_ref());
203
204            let response = match builder.send().await {
205                Ok(response) => response,
206                Err(err) => {
207                    // A transport error gives no response, so a non-idempotent
208                    // request may or may not have committed — don't replay it.
209                    if attempt == self.max_retries || !idempotent {
210                        return Err(err.into());
211                    }
212                    sleep(backoff(attempt)).await;
213                    continue;
214                }
215            };
216            let response = read_response(response).await?;
217
218            // A single force-refresh on 401: re-acquire past the token cache so
219            // the retry doesn't just resend the same rejected token.
220            if response.status == 401 && !refreshed {
221                refreshed = true;
222                token = self.auth.force_refresh(&token).await?;
223                continue;
224            }
225            // Back off exponentially on rate-limit / server errors.
226            if should_retry(response.status, idempotent) && attempt < self.max_retries {
227                sleep(retry_delay(&response, attempt)).await;
228                continue;
229            }
230            return Ok(response);
231        }
232        Err(Error::new("gantryruntime: retries exhausted"))
233    }
234}
235
236/// The default Box base URLs by D-106 class (custom deployments override any
237/// via `with_base_url`).
238fn default_base_urls() -> HashMap<String, String> {
239    [
240        ("api", "https://api.box.com/2.0"),
241        ("api_root", "https://api.box.com"),
242        ("upload", "https://upload.box.com/api/2.0"),
243        ("upload_session", "https://upload.box.com/api/2.0"),
244        ("oauth_authorize", "https://account.box.com/api/oauth2"),
245        ("download", "https://api.box.com/2.0"),
246    ]
247    .iter()
248    .map(|(k, v)| (k.to_string(), v.to_string()))
249    .collect()
250}
251
252/// Attach the assembled body (and its content type) to the request builder.
253fn apply_body(builder: reqwest::RequestBuilder, body: Option<&Body>) -> reqwest::RequestBuilder {
254    match body {
255        None => builder,
256        Some(Body::Bytes { content_type, data }) => builder
257            .header("Content-Type", content_type)
258            .body(data.clone()),
259        Some(Body::Multipart {
260            attributes,
261            file_name,
262            file,
263        }) => {
264            // A boundary that provably does not occur in either part, so the
265            // payload's own bytes can never split the framing.
266            let boundary = multipart_boundary(attributes, file);
267            let payload = multipart_body(&boundary, attributes, file_name, file);
268            builder
269                .header(
270                    "Content-Type",
271                    format!("multipart/form-data; boundary={boundary}"),
272                )
273                .body(payload)
274        }
275    }
276}
277
278/// Build a Box-style multipart/form-data body: an `attributes` JSON field plus
279/// a `file` part (G-7). `file_name` is escaped for the quoted header value.
280fn multipart_body(boundary: &str, attributes: &[u8], file_name: &str, file: &[u8]) -> Vec<u8> {
281    let mut out = Vec::new();
282    out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
283    out.extend_from_slice(b"Content-Disposition: form-data; name=\"attributes\"\r\n");
284    out.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
285    out.extend_from_slice(attributes);
286    out.extend_from_slice(b"\r\n");
287    out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
288    out.extend_from_slice(
289        format!(
290            "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
291            escape_filename(file_name)
292        )
293        .as_bytes(),
294    );
295    out.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
296    out.extend_from_slice(file);
297    out.extend_from_slice(b"\r\n");
298    out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
299    out
300}
301
302/// A multipart boundary guaranteed not to appear in either part — so no file
303/// content can forge a delimiter and split the framing. Derived from a
304/// nanosecond-seeded counter, re-rolled until it is collision-free (`mime/
305/// multipart` gets this from a random boundary; we verify explicitly).
306fn multipart_boundary(attributes: &[u8], file: &[u8]) -> String {
307    let mut seed = std::time::SystemTime::now()
308        .duration_since(std::time::UNIX_EPOCH)
309        .map(|d| d.as_nanos() as u64)
310        .unwrap_or(0x9e37_79b9_7f4a_7c15);
311    loop {
312        let candidate = format!("gantryruntimeXboundaryX{seed:016x}");
313        let needle = candidate.as_bytes();
314        if !contains_subslice(attributes, needle) && !contains_subslice(file, needle) {
315            return candidate;
316        }
317        seed = seed
318            .wrapping_mul(6364136223846793005)
319            .wrapping_add(1442695040888963407);
320    }
321}
322
323/// Whether `haystack` contains `needle` as a contiguous subslice.
324fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
325    !needle.is_empty()
326        && haystack.len() >= needle.len()
327        && haystack
328            .windows(needle.len())
329            .any(|window| window == needle)
330}
331
332/// Escape a filename for a quoted `Content-Disposition` value (RFC 6266): strip
333/// CR/LF so it can never inject header lines or a boundary, and backslash-escape
334/// `\` and `"` so a quote can't close the value early.
335fn escape_filename(name: &str) -> String {
336    let mut out = String::with_capacity(name.len());
337    for ch in name.chars() {
338        match ch {
339            '\r' | '\n' => {}
340            '\\' | '"' => {
341                out.push('\\');
342                out.push(ch);
343            }
344            _ => out.push(ch),
345        }
346    }
347    out
348}
349
350/// Read a response fully into the owned envelope.
351async fn read_response(response: reqwest::Response) -> Result<Response, Error> {
352    let status = response.status().as_u16() as i64;
353    let headers = response
354        .headers()
355        .iter()
356        .map(|(name, value)| {
357            (
358                name.as_str().to_string(),
359                value.to_str().unwrap_or_default().to_string(),
360            )
361        })
362        .collect();
363    let body = response.bytes().await?.to_vec();
364    Ok(Response {
365        status,
366        headers,
367        body,
368    })
369}
370
371/// Whether a response status warrants a retry for a request of this idempotency.
372/// A 429 (rate-limited, never processed) retries for any method; a 5xx may have
373/// committed a write, so it retries only for idempotent methods.
374fn should_retry(status: i64, idempotent: bool) -> bool {
375    status == 429 || (status >= 500 && idempotent)
376}
377
378/// Exponential backoff capped at 30s, with full jitter.
379fn backoff(attempt: u32) -> Duration {
380    let base = (500u64.saturating_mul(1u64 << attempt.min(20))).min(30_000);
381    Duration::from_millis(jitter(base))
382}
383
384/// The delay before the next retry: exponential backoff (so repeated 429s/5xx
385/// escalate), raised to a server-sent `Retry-After` as a floor when present, and
386/// clamped to [`MAX_RETRY_DELAY`] so a hostile header can't stall the client.
387fn retry_delay(response: &Response, attempt: u32) -> Duration {
388    let mut delay = backoff(attempt);
389    if let Some(value) = header_value(&response.headers, "retry-after") {
390        if let Ok(secs) = value.trim().parse::<u64>() {
391            delay = delay.max(Duration::from_secs(secs));
392        }
393    }
394    delay.min(MAX_RETRY_DELAY)
395}
396
397/// A cheap, dependency-free full-jitter source: a uniform value in `[0, max]`
398/// from a nanosecond-seeded xorshift (jitter only needs to decorrelate retries,
399/// not be cryptographic).
400fn jitter(max: u64) -> u64 {
401    if max == 0 {
402        return 0;
403    }
404    let mut x = std::time::SystemTime::now()
405        .duration_since(std::time::UNIX_EPOCH)
406        .map(|d| d.as_nanos() as u64)
407        .unwrap_or(0x9e37_79b9_7f4a_7c15)
408        | 1;
409    x ^= x << 13;
410    x ^= x >> 7;
411    x ^= x << 17;
412    x % (max + 1)
413}
414
415async fn sleep(duration: Duration) {
416    tokio::time::sleep(duration).await;
417}
418
419/// Case-insensitive header lookup (HTTP header names are case-insensitive).
420fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
421    headers
422        .iter()
423        .find(|(key, _)| key.eq_ignore_ascii_case(name))
424        .map(|(_, value)| value.as_str())
425}
426
427/// Return the request with the header set (replacing any prior value).
428pub fn with_header(mut request: Request, name: &str, value: &str) -> Request {
429    request
430        .headers
431        .retain(|(key, _)| !key.eq_ignore_ascii_case(name));
432    request.headers.push((name.to_string(), value.to_string()));
433    request
434}
435
436/// Return the request with the query parameter appended, encoded at send time.
437pub fn with_query(mut request: Request, name: &str, value: &str) -> Request {
438    request.query.push((name.to_string(), value.to_string()));
439    request
440}
441
442/// Return the request with the serialized JSON body and content type set.
443pub fn with_json_body(mut request: Request, body: &[u8]) -> Request {
444    request.body = Some(Body::Bytes {
445        content_type: "application/json".to_string(),
446        data: body.to_vec(),
447    });
448    request
449}
450
451/// Return the request with an application/x-www-form-urlencoded body (the
452/// OAuth2 token endpoints).
453pub fn with_form_body(mut request: Request, form: &[u8]) -> Request {
454    request.body = Some(Body::Bytes {
455        content_type: "application/x-www-form-urlencoded".to_string(),
456        data: form.to_vec(),
457    });
458    request
459}
460
461/// Return the request with a streaming body (buffered here).
462pub fn with_stream_body(mut request: Request, body: Stream, content_type: &str) -> Request {
463    request.body = Some(Body::Bytes {
464        content_type: content_type.to_string(),
465        data: body.into_bytes(),
466    });
467    request
468}
469
470/// Return the request with a Box-style multipart body: an `attributes` JSON
471/// part plus a file part (G-7).
472pub fn with_multipart_body(
473    mut request: Request,
474    attributes: &[u8],
475    file_name: &str,
476    file: Stream,
477) -> Request {
478    request.body = Some(Body::Multipart {
479        attributes: attributes.to_vec(),
480        file_name: file_name.to_string(),
481        file: file.into_bytes(),
482    });
483    request
484}
485
486/// Read the whole response body.
487pub fn response_bytes(response: &Response) -> Result<Vec<u8>, Error> {
488    Ok(response.body.clone())
489}
490
491/// The response body as a stream, for binary downloads (FR-7.4).
492pub fn response_stream(response: &Response) -> Stream {
493    Stream::from_bytes(response.body.clone())
494}
495
496/// A response header value, empty when absent (redirect Location, Retry-After
497/// surfacing).
498pub fn response_header(response: &Response, name: &str) -> String {
499    header_value(&response.headers, name)
500        .unwrap_or_default()
501        .to_string()
502}
503
504/// The response status code.
505pub fn status_code(response: &Response) -> i64 {
506    response.status
507}