use serde::{de::DeserializeOwned, Serialize};
use std::time::Duration;
pub mod webhook;
pub mod events;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_API_URL: &str = "https://api.coffrify.com";
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("invalid api key (must start with 'cfy_')")]
InvalidApiKey,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("env var missing: {0}")]
EnvMissing(#[from] std::env::VarError),
#[error("api error: status={status} message={message}")]
Api { status: u16, message: String, code: Option<String>, body: Option<serde_json::Value> },
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct Client {
api_key: String,
api_url: String,
workspace_id: Option<String>,
http: reqwest::Client,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::with_options(api_key, None, None, None)
}
pub fn with_options(
api_key: impl Into<String>,
api_url: Option<String>,
workspace_id: Option<String>,
http: Option<reqwest::Client>,
) -> Result<Self> {
let api_key = api_key.into();
if !api_key.starts_with("cfy_") {
return Err(Error::InvalidApiKey);
}
let http = http.unwrap_or_else(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(format!("coffrify-rust/{}", VERSION))
.build()
.expect("reqwest client")
});
Ok(Self {
api_key,
api_url: api_url.unwrap_or_else(|| DEFAULT_API_URL.to_string()),
workspace_id,
http,
})
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.send::<T, ()>(reqwest::Method::GET, path, None).await
}
pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<T> {
self.send::<T, &B>(reqwest::Method::POST, path, Some(body)).await
}
pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.send::<T, ()>(reqwest::Method::DELETE, path, None).await
}
async fn send<T: DeserializeOwned, B: Serialize>(
&self,
method: reqwest::Method,
path: &str,
body: Option<B>,
) -> Result<T> {
let url = format!("{}/v1{}", self.api_url.trim_end_matches('/'), path);
let mut last_err: Option<reqwest::Error> = None;
for attempt in 1..=3u8 {
let mut req = self.http.request(method.clone(), &url)
.bearer_auth(&self.api_key)
.header("Accept", "application/json");
if let Some(ws) = &self.workspace_id {
req = req.header("X-Coffrify-Workspace-Id", ws);
}
if method != reqwest::Method::GET {
let key = format!("rust_{}", uuid::Uuid::new_v4().simple());
req = req.header("Idempotency-Key", key);
}
if let Some(b) = &body {
req = req.json(b);
}
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let raw = resp.text().await.unwrap_or_default();
let body_json: Option<serde_json::Value> = if raw.is_empty() {
None
} else {
serde_json::from_str(&raw).ok()
};
if (200..300).contains(&status) {
if raw.is_empty() {
return serde_json::from_str("null").map_err(Into::into);
}
return serde_json::from_str(&raw).map_err(Into::into);
}
let message = body_json
.as_ref()
.and_then(|v| v.get("message").or_else(|| v.get("error")).and_then(|s| s.as_str()))
.unwrap_or(&raw)
.to_string();
let code = body_json
.as_ref()
.and_then(|v| v.get("code").and_then(|s| s.as_str()))
.map(|s| s.to_string());
return Err(Error::Api { status, message, code, body: body_json });
}
Err(e) if attempt < 3 && (e.is_timeout() || e.is_connect()) => {
last_err = Some(e);
tokio::time::sleep(Duration::from_millis(500 * (1u64 << (attempt - 1)))).await;
}
Err(e) => return Err(e.into()),
}
}
Err(Error::Network(last_err.expect("network error after retries")))
}
pub async fn list_transfers(&self) -> Result<serde_json::Value> {
self.get("/transfers").await
}
pub async fn create_transfer(&self, body: &serde_json::Value) -> Result<serde_json::Value> {
self.post("/transfers", body).await
}
pub async fn list_webhooks(&self) -> Result<serde_json::Value> {
self.get("/webhooks").await
}
pub async fn webhook_events_catalog(&self) -> Result<serde_json::Value> {
self.get("/webhooks/events").await
}
pub async fn rotate_api_key(&self, id: &str, grace_days: u32) -> Result<serde_json::Value> {
self.post(&format!("/api-keys/{}/rotate", urlencoding::encode(id)), &serde_json::json!({ "grace_days": grace_days })).await
}
}
mod urlencoding {
pub fn encode(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!("%{:02X}", b)),
}
}
out
}
}