mod auth;
mod jwt;
pub use auth::{Auth, CcgConfig, OAuthConfig, RefreshTokenStore};
pub use jwt::JwtConfig;
use std::collections::HashMap;
use std::time::Duration;
const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
#[derive(Debug)]
pub struct Error(String);
impl Error {
pub(crate) fn new(message: impl Into<String>) -> Error {
Error(message.into())
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for Error {}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Error(err.to_string())
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Error(err.to_string())
}
}
pub struct Stream(Vec<u8>);
impl Stream {
pub fn empty() -> Stream {
Stream(Vec::new())
}
pub fn from_bytes(bytes: Vec<u8>) -> Stream {
Stream(bytes)
}
pub fn into_bytes(self) -> Vec<u8> {
self.0
}
}
enum Body {
Bytes { content_type: String, data: Vec<u8> },
Multipart {
attributes: Vec<u8>,
file_name: String,
file: Vec<u8>,
},
}
pub struct Request {
method: String,
url: String,
headers: Vec<(String, String)>,
query: Vec<(String, String)>,
body: Option<Body>,
}
pub struct Response {
status: i64,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
pub struct Client {
auth: Auth,
http: reqwest::Client,
base_urls: HashMap<String, String>,
max_retries: u32,
}
impl Client {
pub fn new(auth: Auth) -> Client {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.unwrap_or_default();
Client {
auth,
http,
max_retries: 5,
base_urls: default_base_urls(),
}
}
pub fn with_max_retries(mut self, n: u32) -> Client {
self.max_retries = n;
self
}
pub fn with_base_url(mut self, name: &str, base: &str) -> Client {
self.base_urls
.insert(name.to_string(), base.trim_end_matches('/').to_string());
self
}
pub fn base_url(&self, name: &str) -> String {
self.base_urls.get(name).cloned().unwrap_or_default()
}
pub async fn access_token(&self) -> Result<String, Error> {
self.auth.access_token().await
}
pub fn new_request(&self, method: &str, url: &str) -> Request {
Request {
method: method.to_string(),
url: url.to_string(),
headers: Vec::new(),
query: Vec::new(),
body: None,
}
}
pub async fn fetch(&self, request: Request) -> Result<Response, Error> {
let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
Error::new(format!(
"gantryruntime: invalid method {:?}",
request.method
))
})?;
let idempotent = method.is_idempotent();
let mut token = self.access_token().await?;
let mut refreshed = false;
for attempt in 0..=self.max_retries {
let mut builder = self
.http
.request(method.clone(), &request.url)
.query(&request.query)
.header("Authorization", format!("Bearer {token}"));
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
builder = apply_body(builder, request.body.as_ref());
let response = match builder.send().await {
Ok(response) => response,
Err(err) => {
if attempt == self.max_retries || !idempotent {
return Err(err.into());
}
sleep(backoff(attempt)).await;
continue;
}
};
let response = read_response(response).await?;
if response.status == 401 && !refreshed {
refreshed = true;
token = self.auth.force_refresh(&token).await?;
continue;
}
if should_retry(response.status, idempotent) && attempt < self.max_retries {
sleep(retry_delay(&response, attempt)).await;
continue;
}
return Ok(response);
}
Err(Error::new("gantryruntime: retries exhausted"))
}
}
fn default_base_urls() -> HashMap<String, String> {
[
("api", "https://api.box.com/2.0"),
("api_root", "https://api.box.com"),
("upload", "https://upload.box.com/api/2.0"),
("upload_session", "https://upload.box.com/api/2.0"),
("oauth_authorize", "https://account.box.com/api/oauth2"),
("download", "https://api.box.com/2.0"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn apply_body(builder: reqwest::RequestBuilder, body: Option<&Body>) -> reqwest::RequestBuilder {
match body {
None => builder,
Some(Body::Bytes { content_type, data }) => builder
.header("Content-Type", content_type)
.body(data.clone()),
Some(Body::Multipart {
attributes,
file_name,
file,
}) => {
let boundary = multipart_boundary(attributes, file);
let payload = multipart_body(&boundary, attributes, file_name, file);
builder
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(payload)
}
}
}
fn multipart_body(boundary: &str, attributes: &[u8], file_name: &str, file: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(b"Content-Disposition: form-data; name=\"attributes\"\r\n");
out.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
out.extend_from_slice(attributes);
out.extend_from_slice(b"\r\n");
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
escape_filename(file_name)
)
.as_bytes(),
);
out.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
out.extend_from_slice(file);
out.extend_from_slice(b"\r\n");
out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
out
}
fn multipart_boundary(attributes: &[u8], file: &[u8]) -> String {
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9e37_79b9_7f4a_7c15);
loop {
let candidate = format!("gantryruntimeXboundaryX{seed:016x}");
let needle = candidate.as_bytes();
if !contains_subslice(attributes, needle) && !contains_subslice(file, needle) {
return candidate;
}
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
}
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty()
&& haystack.len() >= needle.len()
&& haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn escape_filename(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for ch in name.chars() {
match ch {
'\r' | '\n' => {}
'\\' | '"' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
async fn read_response(response: reqwest::Response) -> Result<Response, Error> {
let status = response.status().as_u16() as i64;
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
value.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let body = response.bytes().await?.to_vec();
Ok(Response {
status,
headers,
body,
})
}
fn should_retry(status: i64, idempotent: bool) -> bool {
status == 429 || (status >= 500 && idempotent)
}
fn backoff(attempt: u32) -> Duration {
let base = (500u64.saturating_mul(1u64 << attempt.min(20))).min(30_000);
Duration::from_millis(jitter(base))
}
fn retry_delay(response: &Response, attempt: u32) -> Duration {
let mut delay = backoff(attempt);
if let Some(value) = header_value(&response.headers, "retry-after") {
if let Ok(secs) = value.trim().parse::<u64>() {
delay = delay.max(Duration::from_secs(secs));
}
}
delay.min(MAX_RETRY_DELAY)
}
fn jitter(max: u64) -> u64 {
if max == 0 {
return 0;
}
let mut x = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9e37_79b9_7f4a_7c15)
| 1;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x % (max + 1)
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
pub fn with_header(mut request: Request, name: &str, value: &str) -> Request {
request
.headers
.retain(|(key, _)| !key.eq_ignore_ascii_case(name));
request.headers.push((name.to_string(), value.to_string()));
request
}
pub fn with_query(mut request: Request, name: &str, value: &str) -> Request {
request.query.push((name.to_string(), value.to_string()));
request
}
pub fn with_json_body(mut request: Request, body: &[u8]) -> Request {
request.body = Some(Body::Bytes {
content_type: "application/json".to_string(),
data: body.to_vec(),
});
request
}
pub fn with_form_body(mut request: Request, form: &[u8]) -> Request {
request.body = Some(Body::Bytes {
content_type: "application/x-www-form-urlencoded".to_string(),
data: form.to_vec(),
});
request
}
pub fn with_stream_body(mut request: Request, body: Stream, content_type: &str) -> Request {
request.body = Some(Body::Bytes {
content_type: content_type.to_string(),
data: body.into_bytes(),
});
request
}
pub fn with_multipart_body(
mut request: Request,
attributes: &[u8],
file_name: &str,
file: Stream,
) -> Request {
request.body = Some(Body::Multipart {
attributes: attributes.to_vec(),
file_name: file_name.to_string(),
file: file.into_bytes(),
});
request
}
pub fn response_bytes(response: &Response) -> Result<Vec<u8>, Error> {
Ok(response.body.clone())
}
pub fn response_stream(response: &Response) -> Stream {
Stream::from_bytes(response.body.clone())
}
pub fn response_header(response: &Response, name: &str) -> String {
header_value(&response.headers, name)
.unwrap_or_default()
.to_string()
}
pub fn status_code(response: &Response) -> i64 {
response.status
}