use std::collections::BTreeMap;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{Client, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::config::ResolvedConfig;
use crate::error::{error_for_status, Error, RequestContext};
use crate::version::SDK_VERSION;
#[derive(Debug)]
pub(crate) struct HttpTransport {
cfg: ResolvedConfig,
client: Client,
base_headers: HeaderMap,
}
pub(crate) type QueryParams = Vec<(&'static str, Option<String>)>;
impl HttpTransport {
pub(crate) fn new(cfg: ResolvedConfig) -> Result<Self, Error> {
let mut base_headers = HeaderMap::new();
base_headers.insert(
reqwest::header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", cfg.api_key))
.map_err(|e| Error::validation(format!("invalid api key header: {e}")))?,
);
base_headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/json"),
);
base_headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_str(&format!("invoance-rust/{SDK_VERSION}"))
.map_err(|e| Error::validation(format!("invalid user agent: {e}")))?,
);
for (k, v) in &cfg.extra_headers {
let name = HeaderName::from_bytes(k.as_bytes())
.map_err(|e| Error::validation(format!("invalid extra header name {k}: {e}")))?;
let value = HeaderValue::from_str(v).map_err(|e| {
Error::validation(format!("invalid extra header value for {k}: {e}"))
})?;
base_headers.insert(name, value);
}
let client = Client::builder()
.timeout(cfg.timeout)
.build()
.map_err(|e| Error::validation(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
cfg,
client,
base_headers,
})
}
fn build_url(&self, path: &str, params: Option<&QueryParams>) -> String {
let base = format!("{}/{}{}", self.cfg.base_url, self.cfg.api_version, path);
let Some(params) = params else { return base };
let mut pairs: Vec<(String, String)> = Vec::new();
for (k, v) in params {
if let Some(v) = v {
pairs.push(((*k).to_string(), v.clone()));
}
}
if pairs.is_empty() {
return base;
}
let qs = serde_urlencoded_lite(&pairs);
format!("{base}?{qs}")
}
pub(crate) async fn get<T: DeserializeOwned>(
&self,
path: &str,
params: Option<QueryParams>,
) -> Result<T, Error> {
let url = self.build_url(path, params.as_ref());
let ctx = RequestContext {
method: "GET".into(),
path: path.into(),
};
let resp = self
.send(
self.client.get(&url).headers(self.base_headers.clone()),
&ctx,
)
.await?;
self.handle_json(resp, ctx).await
}
pub(crate) async fn get_raw(&self, path: &str) -> Result<Value, Error> {
self.get::<Value>(path, None).await
}
pub(crate) async fn post<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&Value>,
idempotency_key: Option<&str>,
) -> Result<T, Error> {
let url = self.build_url(path, None);
let ctx = RequestContext {
method: "POST".into(),
path: path.into(),
};
let mut headers = self.base_headers.clone();
headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
if let Some(idem) = idempotency_key.or(self.cfg.idempotency_key.as_deref()) {
headers.insert(
HeaderName::from_static("idempotency-key"),
HeaderValue::from_str(idem)
.map_err(|e| Error::validation(format!("invalid idempotency key: {e}")))?,
);
}
let mut req = self.client.post(&url).headers(headers);
if let Some(b) = body {
req = req.json(b);
}
let resp = self.send(req, &ctx).await?;
self.handle_json(resp, ctx).await
}
pub(crate) async fn put<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&Value>,
) -> Result<T, Error> {
let url = self.build_url(path, None);
let ctx = RequestContext {
method: "PUT".into(),
path: path.into(),
};
let mut headers = self.base_headers.clone();
headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let mut req = self.client.put(&url).headers(headers);
if let Some(b) = body {
req = req.json(b);
}
let resp = self.send(req, &ctx).await?;
self.handle_json(resp, ctx).await
}
pub(crate) async fn patch<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&Value>,
) -> Result<T, Error> {
let url = self.build_url(path, None);
let ctx = RequestContext {
method: "PATCH".into(),
path: path.into(),
};
let mut headers = self.base_headers.clone();
headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let mut req = self.client.patch(&url).headers(headers);
if let Some(b) = body {
req = req.json(b);
}
let resp = self.send(req, &ctx).await?;
self.handle_json(resp, ctx).await
}
pub(crate) async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
let url = self.build_url(path, None);
let ctx = RequestContext {
method: "DELETE".into(),
path: path.into(),
};
let resp = self
.send(
self.client.delete(&url).headers(self.base_headers.clone()),
&ctx,
)
.await?;
self.handle_json(resp, ctx).await
}
pub(crate) async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, Error> {
let url = self.build_url(path, None);
let ctx = RequestContext {
method: "GET".into(),
path: path.into(),
};
let mut headers = self.base_headers.clone();
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
headers.remove(reqwest::header::CONTENT_TYPE);
let resp = self
.send(self.client.get(&url).headers(headers), &ctx)
.await?;
let status = resp.status().as_u16();
let retry_after = parse_retry_after(header_str(resp.headers(), "retry-after"));
let bytes = resp.bytes().await.map_err(|e| map_reqwest_error(e, &ctx))?;
if !(200..300).contains(&status) {
let body = serde_json::from_slice::<BTreeMap<String, Value>>(&bytes).ok();
error_for_status(status, body, Some(ctx), retry_after)?;
}
Ok(bytes.to_vec())
}
async fn send(
&self,
req: reqwest::RequestBuilder,
ctx: &RequestContext,
) -> Result<Response, Error> {
req.send().await.map_err(|e| map_reqwest_error(e, ctx))
}
async fn handle_json<T: DeserializeOwned>(
&self,
resp: Response,
ctx: RequestContext,
) -> Result<T, Error> {
let status = resp.status().as_u16();
let retry_after = parse_retry_after(header_str(resp.headers(), "retry-after"));
let text = resp.text().await.map_err(|e| map_reqwest_error(e, &ctx))?;
let body_map: Option<BTreeMap<String, Value>> = if text.trim().is_empty() {
None
} else {
serde_json::from_str::<BTreeMap<String, Value>>(&text).ok()
};
error_for_status(status, body_map, Some(ctx.clone()), retry_after)?;
if text.trim().is_empty() {
serde_json::from_str::<T>("null").map_err(|e| {
Error::validation(format!(
"empty response body could not be deserialized on {} {}: {e}",
ctx.method, ctx.path
))
})
} else {
serde_json::from_str::<T>(&text).map_err(|e| {
Error::validation(format!(
"failed to deserialize response on {} {}: {e}",
ctx.method, ctx.path
))
})
}
}
}
fn map_reqwest_error(err: reqwest::Error, ctx: &RequestContext) -> Error {
if err.is_timeout() {
Error::timeout(
format!("Request timed out on {} {}: {err}", ctx.method, ctx.path),
Some(ctx.clone()),
Some(Box::new(err)),
)
} else {
Error::network(
format!("Network failure on {} {}: {err}", ctx.method, ctx.path),
Some(ctx.clone()),
Some(Box::new(err)),
)
}
}
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok())
}
fn parse_retry_after(value: Option<&str>) -> Option<f64> {
let value = value?;
if let Ok(secs) = value.trim().parse::<f64>() {
if secs.is_finite() && secs >= 0.0 {
return Some(secs);
}
}
if let Some(ts) = parse_http_date_epoch(value) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
return Some((ts - now).max(0.0));
}
None
}
fn parse_http_date_epoch(s: &str) -> Option<f64> {
let s = s.trim();
let rest = s.split_once(", ").map(|(_, r)| r).unwrap_or(s);
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() < 5 {
return None;
}
let day: i64 = parts[0].parse().ok()?;
let month = match parts[1] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let year: i64 = parts[2].parse().ok()?;
let time: Vec<&str> = parts[3].split(':').collect();
if time.len() != 3 {
return None;
}
let hh: i64 = time[0].parse().ok()?;
let mm: i64 = time[1].parse().ok()?;
let ss: i64 = time[2].parse().ok()?;
Some(days_from_civil(year, month, day) as f64 * 86400.0 + (hh * 3600 + mm * 60 + ss) as f64)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn serde_urlencoded_lite(pairs: &[(String, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{}={}", encode_component(k), encode_component(v)))
.collect::<Vec<_>>()
.join("&")
}
fn encode_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push_str("%20"),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}