use reqwest::{Client, RequestBuilder, Response}; use serde::de::DeserializeOwned; use serde::Serialize; use tracing::debug; use crate::error::{HonchoError, Result}; const DEFAULT_BASE_URL: &str = "https://api.honcho.dev"; /// Honcho REST API client. /// /// Cheaply clone-able — the inner `reqwest::Client` holds an `Arc`. #[derive(Debug, Clone)] pub struct HonchoClient { pub(crate) http: Client, pub(crate) base_url: String, pub(crate) token: String, } impl HonchoClient { /// Create a client pointing at the production SaaS endpoint. pub fn new(token: impl Into) -> Self { Self::with_base_url(DEFAULT_BASE_URL, token) } /// Create a client pointing at a custom base URL (e.g. `http://localhost:8000`). pub fn with_base_url(base_url: impl Into, token: impl Into) -> Self { Self { http: Client::new(), base_url: base_url.into().trim_end_matches('/').to_owned(), token: token.into(), } } // ── internal helpers ────────────────────────────────────────────────── pub(crate) fn url(&self, path: &str) -> String { format!("{}{}", self.base_url, path) } /// Build a URL with optional query params (key-value pairs). pub(crate) fn url_with_query(&self, path: &str, params: &[(&str, String)]) -> String { if params.is_empty() { return self.url(path); } let qs: String = params .iter() .map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v))) .collect::>() .join("&"); format!("{}{}?{}", self.base_url, path, qs) } fn auth(&self, rb: RequestBuilder) -> RequestBuilder { rb.bearer_auth(&self.token) } pub(crate) async fn get(&self, path: &str) -> Result { let url = self.url(path); debug!("GET {url}"); let resp = self.auth(self.http.get(&url)).send().await?; parse_response(resp).await } pub(crate) async fn get_with_query( &self, path: &str, query: &[(&str, String)], ) -> Result { let url = self.url_with_query(path, query); debug!("GET {url}"); let resp = self.auth(self.http.get(&url)).send().await?; parse_response(resp).await } pub(crate) async fn post( &self, path: &str, body: &B, ) -> Result { let url = self.url(path); debug!("POST {url}"); let resp = self.auth(self.http.post(&url)).json(body).send().await?; parse_response(resp).await } pub(crate) async fn post_with_query( &self, path: &str, query: &[(&str, String)], body: &B, ) -> Result { let url = self.url_with_query(path, query); debug!("POST {url}"); let resp = self.auth(self.http.post(&url)).json(body).send().await?; parse_response(resp).await } pub(crate) async fn put( &self, path: &str, body: &B, ) -> Result { let url = self.url(path); debug!("PUT {url}"); let resp = self.auth(self.http.put(&url)).json(body).send().await?; parse_response(resp).await } pub(crate) async fn put_with_query( &self, path: &str, query: &[(&str, String)], body: &B, ) -> Result { let url = self.url_with_query(path, query); debug!("PUT {url}"); let resp = self.auth(self.http.put(&url)).json(body).send().await?; parse_response(resp).await } pub(crate) async fn delete_ok(&self, path: &str) -> Result<()> { let url = self.url(path); debug!("DELETE {url}"); let resp = self.auth(self.http.delete(&url)).send().await?; parse_no_content(resp).await } pub(crate) async fn delete_json(&self, path: &str, body: &B) -> Result<()> { let url = self.url(path); debug!("DELETE {url} (with body)"); let resp = self .auth(self.http.delete(&url)) .json(body) .send() .await?; parse_no_content(resp).await } } async fn parse_response(resp: Response) -> Result { let status = resp.status(); if status.is_success() { Ok(resp.json::().await?) } else { let code = status.as_u16(); let body = resp.text().await.unwrap_or_default(); Err(HonchoError::Http { status: code, body }) } } pub(crate) async fn parse_no_content(resp: Response) -> Result<()> { if resp.status().is_success() { Ok(()) } else { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); Err(HonchoError::Http { status, body }) } } /// Minimal percent-encoding for query param keys and values. fn urlencoding(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out }