use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::core::error::{
SsError, ERR_API_DECODE, ERR_API_STATUS, ERR_ITEM_NOT_FOUND, ERR_NETWORK, ERR_RATE_LIMITED,
ERR_SCAN_SUBMIT,
};
#[derive(Debug, Clone)]
pub struct ApiClient {
base: String,
http: reqwest::Client,
}
impl ApiClient {
pub fn new(base: String) -> Result<Self, SsError> {
let http = reqwest::Client::builder()
.use_rustls_tls()
.timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("saferskills/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| SsError::new(ERR_NETWORK, format!("Failed to build HTTP client: {e}")))?;
Ok(Self { base, http })
}
pub fn base(&self) -> &str {
&self.base
}
pub async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, SsError> {
let url = format!("{}{}", self.base, path);
let resp = self
.http
.get(&url)
.query(query)
.send()
.await
.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.status_error(status));
}
resp.json::<T>().await.map_err(|e| {
SsError::new(
ERR_API_DECODE,
format!("Failed to decode API response: {e}"),
)
.with_suggestion(
"This usually means the CLI is out of date — try `npx saferskills@latest`.",
)
})
}
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, SsError> {
let url = format!("{}{}", self.base, path);
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.status_error(status));
}
resp.bytes().await.map(|b| b.to_vec()).map_err(|e| {
SsError::new(
ERR_API_DECODE,
format!("Failed to read response bytes: {e}"),
)
})
}
pub async fn get_bytes_with_headers(
&self,
path: &str,
headers: &[(&str, &str)],
want: &[&str],
) -> Result<(Vec<u8>, Vec<Option<String>>), SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self.http.get(&url);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.agent_status_error(status));
}
let picked: Vec<Option<String>> = want
.iter()
.map(|h| {
resp.headers()
.get(*h)
.and_then(|v| v.to_str().ok())
.map(String::from)
})
.collect();
let body = resp
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| self.decode_error(e))?;
Ok((body, picked))
}
pub async fn post_text_for<T: DeserializeOwned>(
&self,
path: &str,
body: String,
content_type: &str,
headers: &[(&str, &str)],
) -> Result<T, SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self
.http
.post(&url)
.header("content-type", content_type)
.body(body);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
self.read_submit_body(resp).await
}
pub async fn post_json<B: Serialize>(&self, path: &str, body: &B) -> Result<(), SsError> {
let url = format!("{}{}", self.base, path);
let resp = self
.http
.post(&url)
.json(body)
.send()
.await
.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.status_error(status));
}
Ok(())
}
pub async fn post_for_status(
&self,
path: &str,
headers: &[(&str, &str)],
) -> Result<(), SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self.http.post(&url);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.agent_status_error(status));
}
Ok(())
}
pub async fn get_with_headers<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
headers: &[(&str, &str)],
) -> Result<T, SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self.http.get(&url).query(query);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
let status = resp.status();
if !status.is_success() {
return Err(self.status_error(status));
}
resp.json::<T>().await.map_err(|e| self.decode_error(e))
}
pub async fn post_json_for<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
headers: &[(&str, &str)],
) -> Result<T, SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self.http.post(&url).json(body);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
self.read_submit_body(resp).await
}
pub async fn post_multipart<T: DeserializeOwned>(
&self,
path: &str,
form: reqwest::multipart::Form,
headers: &[(&str, &str)],
) -> Result<T, SsError> {
let url = format!("{}{}", self.base, path);
let mut req = self.http.post(&url).multipart(form);
for (k, v) in headers {
req = req.header(*k, *v);
}
let resp = req.send().await.map_err(|e| self.transport_error(e))?;
self.read_submit_body(resp).await
}
async fn read_submit_body<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, SsError> {
let status = resp.status();
if status.as_u16() == 403 {
let reason = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| "forbidden".to_string());
return Err(SsError::new(
ERR_SCAN_SUBMIT,
format!("The scan was rejected by the API gate ({reason})."),
)
.with_suggestion(
"If this persists, the human-verification gate may require the web UI at \
https://saferskills.ai/scan.",
));
}
if !status.is_success() {
return Err(self.status_error(status));
}
resp.json::<T>().await.map_err(|e| self.decode_error(e))
}
fn decode_error(&self, e: reqwest::Error) -> SsError {
SsError::new(
ERR_API_DECODE,
format!("Failed to decode API response: {e}"),
)
.with_suggestion(
"This usually means the CLI is out of date — try `npx saferskills@latest`.",
)
}
fn transport_error(&self, e: reqwest::Error) -> SsError {
let detail = if e.is_timeout() {
"request timed out"
} else if e.is_connect() {
"could not connect"
} else {
"network error"
};
SsError::new(ERR_NETWORK, format!("{detail} talking to {}", self.base)).with_suggestion(
"Check your connection, or set SAFERSKILLS_API_URL to override the API origin.",
)
}
fn status_error(&self, status: reqwest::StatusCode) -> SsError {
match status.as_u16() {
404 => SsError::new(ERR_ITEM_NOT_FOUND, "Not found in the catalog."),
429 => SsError::new(ERR_RATE_LIMITED, "Rate limited by the API — retry shortly."),
code => SsError::new(ERR_API_STATUS, format!("API returned HTTP {code}.")),
}
}
fn agent_status_error(&self, status: reqwest::StatusCode) -> SsError {
match status.as_u16() {
403 => SsError::new(
ERR_SCAN_SUBMIT,
"The agent-scan run token was rejected (bad, expired, or already spent).",
),
404 => SsError::new(ERR_ITEM_NOT_FOUND, "Agent-scan run not found."),
410 => SsError::new(
ERR_API_STATUS,
"The assessment pack is no longer available (the run already submitted).",
),
429 => SsError::new(ERR_RATE_LIMITED, "Rate limited by the API — retry shortly."),
code => SsError::new(ERR_API_STATUS, format!("API returned HTTP {code}.")),
}
}
}