1use serde::{de::DeserializeOwned, Serialize};
24use std::time::Duration;
25
26pub mod webhook;
27pub mod events;
28
29pub const VERSION: &str = env!("CARGO_PKG_VERSION");
30pub const DEFAULT_API_URL: &str = "https://api.coffrify.com";
31
32#[derive(thiserror::Error, Debug)]
33pub enum Error {
34 #[error("invalid api key (must start with 'cfy_')")]
35 InvalidApiKey,
36 #[error("network error: {0}")]
37 Network(#[from] reqwest::Error),
38 #[error("env var missing: {0}")]
39 EnvMissing(#[from] std::env::VarError),
40 #[error("api error: status={status} message={message}")]
41 Api { status: u16, message: String, code: Option<String>, body: Option<serde_json::Value> },
42 #[error("serde: {0}")]
43 Serde(#[from] serde_json::Error),
44}
45
46pub type Result<T> = std::result::Result<T, Error>;
47
48#[derive(Debug, Clone)]
49pub struct Client {
50 api_key: String,
51 api_url: String,
52 workspace_id: Option<String>,
53 http: reqwest::Client,
54}
55
56impl Client {
57 pub fn new(api_key: impl Into<String>) -> Result<Self> {
58 Self::with_options(api_key, None, None, None)
59 }
60
61 pub fn with_options(
62 api_key: impl Into<String>,
63 api_url: Option<String>,
64 workspace_id: Option<String>,
65 http: Option<reqwest::Client>,
66 ) -> Result<Self> {
67 let api_key = api_key.into();
68 if !api_key.starts_with("cfy_") {
69 return Err(Error::InvalidApiKey);
70 }
71 let http = http.unwrap_or_else(|| {
72 reqwest::Client::builder()
73 .timeout(Duration::from_secs(30))
74 .user_agent(format!("coffrify-rust/{}", VERSION))
75 .build()
76 .expect("reqwest client")
77 });
78 Ok(Self {
79 api_key,
80 api_url: api_url.unwrap_or_else(|| DEFAULT_API_URL.to_string()),
81 workspace_id,
82 http,
83 })
84 }
85
86 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
88 self.send::<T, ()>(reqwest::Method::GET, path, None).await
89 }
90
91 pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<T> {
92 self.send::<T, &B>(reqwest::Method::POST, path, Some(body)).await
93 }
94
95 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
96 self.send::<T, ()>(reqwest::Method::DELETE, path, None).await
97 }
98
99 async fn send<T: DeserializeOwned, B: Serialize>(
100 &self,
101 method: reqwest::Method,
102 path: &str,
103 body: Option<B>,
104 ) -> Result<T> {
105 let url = format!("{}/v1{}", self.api_url.trim_end_matches('/'), path);
106 let mut last_err: Option<reqwest::Error> = None;
107
108 for attempt in 1..=3u8 {
109 let mut req = self.http.request(method.clone(), &url)
110 .bearer_auth(&self.api_key)
111 .header("Accept", "application/json");
112 if let Some(ws) = &self.workspace_id {
113 req = req.header("X-Coffrify-Workspace-Id", ws);
114 }
115 if method != reqwest::Method::GET {
116 let key = format!("rust_{}", uuid::Uuid::new_v4().simple());
117 req = req.header("Idempotency-Key", key);
118 }
119 if let Some(b) = &body {
120 req = req.json(b);
121 }
122 match req.send().await {
123 Ok(resp) => {
124 let status = resp.status().as_u16();
125 let raw = resp.text().await.unwrap_or_default();
126 let body_json: Option<serde_json::Value> = if raw.is_empty() {
127 None
128 } else {
129 serde_json::from_str(&raw).ok()
130 };
131 if (200..300).contains(&status) {
132 if raw.is_empty() {
133 return serde_json::from_str("null").map_err(Into::into);
135 }
136 return serde_json::from_str(&raw).map_err(Into::into);
137 }
138 let message = body_json
139 .as_ref()
140 .and_then(|v| v.get("message").or_else(|| v.get("error")).and_then(|s| s.as_str()))
141 .unwrap_or(&raw)
142 .to_string();
143 let code = body_json
144 .as_ref()
145 .and_then(|v| v.get("code").and_then(|s| s.as_str()))
146 .map(|s| s.to_string());
147 return Err(Error::Api { status, message, code, body: body_json });
148 }
149 Err(e) if attempt < 3 && (e.is_timeout() || e.is_connect()) => {
150 last_err = Some(e);
151 tokio::time::sleep(Duration::from_millis(500 * (1u64 << (attempt - 1)))).await;
152 }
153 Err(e) => return Err(e.into()),
154 }
155 }
156 Err(Error::Network(last_err.expect("network error after retries")))
157 }
158
159 pub async fn list_transfers(&self) -> Result<serde_json::Value> {
161 self.get("/transfers").await
162 }
163 pub async fn create_transfer(&self, body: &serde_json::Value) -> Result<serde_json::Value> {
164 self.post("/transfers", body).await
165 }
166 pub async fn list_webhooks(&self) -> Result<serde_json::Value> {
167 self.get("/webhooks").await
168 }
169 pub async fn webhook_events_catalog(&self) -> Result<serde_json::Value> {
170 self.get("/webhooks/events").await
171 }
172 pub async fn rotate_api_key(&self, id: &str, grace_days: u32) -> Result<serde_json::Value> {
173 self.post(&format!("/api-keys/{}/rotate", urlencoding::encode(id)), &serde_json::json!({ "grace_days": grace_days })).await
174 }
175}
176
177mod urlencoding {
178 pub fn encode(s: &str) -> String {
179 let mut out = String::with_capacity(s.len());
180 for b in s.bytes() {
181 match b {
182 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
183 _ => out.push_str(&format!("%{:02X}", b)),
184 }
185 }
186 out
187 }
188}