diff --git a/src/ionos.rs b/src/ionos.rs index 4b0fd9d..8eb71ea 100644 --- a/src/ionos.rs +++ b/src/ionos.rs @@ -1 +1,62 @@ -// implemented in a later task +use crate::model::AppError; + +pub struct IonosClient { + base_url: String, + token: String, + http: reqwest::blocking::Client, +} + +fn expand_tilde(p: &str) -> String { + if let Some(rest) = p.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + return format!("{}/{}", home.to_string_lossy(), rest); + } + } + p.to_string() +} + +impl IonosClient { + pub fn new(base_url: &str, token_path: &str) -> Result { + let path = expand_tilde(token_path); + let token = std::fs::read_to_string(&path) + .map_err(|e| AppError::Config(format!("token {path}: {e}")))? + .trim().to_string(); + if token.is_empty() { + return Err(AppError::Config(format!("empty token at {path}"))); + } + let http = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| AppError::Ionos(format!("client: {e}")))?; + Ok(Self { base_url: base_url.trim_end_matches('/').to_string(), token, http }) + } + + /// POST with up to 3 retries on transport / 5xx, exponential backoff. + pub fn post_json( + &self, path: &str, body: &serde_json::Value, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let mut last = String::new(); + for attempt in 0..3 { + let r = self.http.post(&url) + .bearer_auth(&self.token) + .json(body) + .send(); + match r { + Ok(resp) if resp.status().is_success() => { + return resp.json::() + .map_err(|e| AppError::Ionos(format!("decode: {e}"))); + } + Ok(resp) => { + last = format!("HTTP {}", resp.status()); + if !resp.status().is_server_error() { + return Err(AppError::Ionos(last)); + } + } + Err(e) => last = format!("transport: {e}"), + } + std::thread::sleep(std::time::Duration::from_millis(300 * (1 << attempt))); + } + Err(AppError::Ionos(format!("giving up after retries: {last}"))) + } +} diff --git a/tests/ionos_tests.rs b/tests/ionos_tests.rs new file mode 100644 index 0000000..797d4de --- /dev/null +++ b/tests/ionos_tests.rs @@ -0,0 +1,25 @@ +use alpha_id::ionos::IonosClient; +use std::io::Write; + +fn mock_server(body: &'static str) -> (String, std::thread::JoinHandle<()>) { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let url = format!("http://{}", server.server_addr()); + let h = std::thread::spawn(move || { + if let Ok(req) = server.recv() { + let resp = tiny_http::Response::from_string(body); + req.respond(resp).ok(); + } + }); + (url, h) +} + +#[test] +fn posts_json_and_parses_response() { + let (url, h) = mock_server(r#"{"ok":true}"#); + let mut tf = tempfile::NamedTempFile::new().unwrap(); + write!(tf, "test-token-123").unwrap(); + let c = IonosClient::new(&url, tf.path().to_str().unwrap()).unwrap(); + let v: serde_json::Value = c.post_json("/ping", &serde_json::json!({"x":1})).unwrap(); + assert_eq!(v["ok"], true); + h.join().ok(); +}