Skip to main content

coffrify/
lib.rs

1//! # Coffrify — Rust SDK
2//!
3//! Official Rust client for [Coffrify](https://coffrify.com), encrypted file transfer infrastructure.
4//!
5//! ## Quickstart
6//!
7//! ```no_run
8//! # async fn run() -> coffrify::Result<()> {
9//! let coffrify = coffrify::Client::new(std::env::var("COFFRIFY_API_KEY")?)?;
10//!
11//! let res: serde_json::Value = coffrify
12//!     .post("/transfers", serde_json::json!({
13//!         "files": [{"name": "report.pdf", "size": 1_240_000, "mime_type": "application/pdf"}],
14//!         "expires_in_hours": 72,
15//!         "max_downloads": 10,
16//!     }))
17//!     .await?;
18//!
19//! println!("{}", res["share_url"]);
20//! # Ok(()) }
21//! ```
22
23use 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    /// Low-level GET — deserialize the response into `T`.
87    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                            // Caller asked for T but server returned nothing — try Null
134                            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    // ── Convenience ─────────────────────────────────────────────────────
160    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}