use std::io::Read;
use std::thread;
use std::time::Duration;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde_json::Value;
use std::io::Write;
use crate::error::{BilldogEngError, Result};
const GZIP_THRESHOLD_BYTES: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
}
#[derive(Debug, Clone)]
pub struct TransportConfig {
pub host: String,
pub request_timeout_ms: u64,
pub gzip: bool,
pub max_retries: u32,
pub enable_logging: bool,
}
pub struct RequestOptions<'a> {
pub path: &'a str,
pub body: Option<Value>,
pub headers: Vec<(String, String)>,
pub gzip: bool,
}
impl<'a> RequestOptions<'a> {
pub fn post(path: &'a str, body: Value) -> Self {
Self {
path,
body: Some(body),
headers: Vec::new(),
gzip: true,
}
}
pub fn header(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.headers.push((k.into(), v.into()));
self
}
pub fn gzip(mut self, gzip: bool) -> Self {
self.gzip = gzip;
self
}
}
#[derive(Clone)]
pub struct Transport {
config: TransportConfig,
agent: ureq::Agent,
}
impl Transport {
pub fn new(config: TransportConfig) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_millis(config.request_timeout_ms))
.build();
Self { config, agent }
}
fn log(&self, msg: &str) {
if self.config.enable_logging {
eprintln!("[BilldogEng] {msg}");
}
}
pub fn request(&self, options: &RequestOptions) -> Result<Value> {
let url = format!("{}{}", self.config.host, options.path);
let max_attempts = self.config.max_retries + 1;
let mut last_error: Option<BilldogEngError> = None;
for attempt in 0..max_attempts {
if attempt > 0 {
let backoff = 1000u64 * 2u64.pow(attempt - 1); self.log(&format!(
"retry {}/{} after {}ms → {}",
attempt, self.config.max_retries, backoff, options.path
));
thread::sleep(Duration::from_millis(backoff));
}
match self.attempt(&url, options) {
Ok(v) => return Ok(v),
Err(e) => {
let retryable = e.is_retryable();
last_error = Some(e.clone());
if !retryable || attempt == max_attempts - 1 {
return Err(e);
}
self.log(&format!(
"request failed (status {}), will retry: {}",
e.status, e.message
));
}
}
}
Err(last_error.unwrap_or_else(|| BilldogEngError::new("request failed", 0)))
}
fn attempt(&self, url: &str, options: &RequestOptions) -> Result<Value> {
let method = if options.body.is_some() {
Method::Post
} else {
Method::Get
};
let mut req = match method {
Method::Post => self.agent.post(url),
Method::Get => self.agent.get(url),
};
req = req
.set("Content-Type", "application/json")
.set("User-Agent", "billdogeng-rust");
for (k, v) in &options.headers {
req = req.set(k, v);
}
let response = if let Some(body) = &options.body {
let json = serde_json::to_vec(body)
.map_err(|e| BilldogEngError::new(format!("serialize error: {e}"), 0))?;
let use_gzip =
self.config.gzip && options.gzip && json.len() >= GZIP_THRESHOLD_BYTES;
if use_gzip {
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(&json)
.and_then(|_| enc.finish())
.map_err(|e| BilldogEngError::new(format!("gzip error: {e}"), 0))
.and_then(|gz| {
req.set("Content-Encoding", "gzip")
.send_bytes(&gz)
.map_err(map_ureq_error)
})
} else {
req.send_bytes(&json).map_err(map_ureq_error)
}
} else {
req.call().map_err(map_ureq_error)
};
let resp = response?;
let status = resp.status();
let mut text = String::new();
resp.into_reader()
.read_to_string(&mut text)
.map_err(|e| BilldogEngError::new(format!("read body error: {e}"), 0))?;
let parsed: Option<Value> = if text.is_empty() {
None
} else {
serde_json::from_str(&text).ok()
};
if !(200..300).contains(&status) {
let (code, message) = extract_envelope_error(&parsed);
return Err(BilldogEngError::new(
message.unwrap_or_else(|| format!("HTTP {status}")),
status,
)
.with_code(code)
.with_body(parsed));
}
Ok(unwrap_envelope(parsed))
}
}
fn map_ureq_error(err: ureq::Error) -> BilldogEngError {
match err {
ureq::Error::Status(status, resp) => {
let mut text = String::new();
let _ = resp.into_reader().read_to_string(&mut text);
let parsed: Option<Value> = if text.is_empty() {
None
} else {
serde_json::from_str(&text).ok()
};
let (code, message) = extract_envelope_error(&parsed);
BilldogEngError::new(message.unwrap_or_else(|| format!("HTTP {status}")), status)
.with_code(code)
.with_body(parsed)
}
ureq::Error::Transport(t) => {
BilldogEngError::new(format!("network error: {t}"), 0)
.with_code(Some("NETWORK_ERROR".to_string()))
}
}
}
fn extract_envelope_error(parsed: &Option<Value>) -> (Option<String>, Option<String>) {
if let Some(Value::Object(obj)) = parsed {
if let Some(Value::Object(err)) = obj.get("error") {
let code = err.get("code").and_then(|v| v.as_str()).map(String::from);
let message = err
.get("message")
.and_then(|v| v.as_str())
.map(String::from);
return (code, message);
}
}
(None, None)
}
fn unwrap_envelope(parsed: Option<Value>) -> Value {
match parsed {
Some(Value::Object(obj))
if obj.contains_key("data") && obj.contains_key("success") =>
{
obj.get("data").cloned().unwrap_or(Value::Null)
}
Some(v) => v,
None => Value::Null,
}
}