coffrify 0.2.0

Official Rust SDK for Coffrify — encrypted file transfer infrastructure.
Documentation
//! # Coffrify — Rust SDK
//!
//! Official Rust client for [Coffrify](https://coffrify.com), encrypted file transfer infrastructure.
//!
//! ## Quickstart
//!
//! ```no_run
//! # async fn run() -> coffrify::Result<()> {
//! let coffrify = coffrify::Client::new(std::env::var("COFFRIFY_API_KEY")?)?;
//!
//! let res: serde_json::Value = coffrify
//!     .post("/transfers", serde_json::json!({
//!         "files": [{"name": "report.pdf", "size": 1_240_000, "mime_type": "application/pdf"}],
//!         "expires_in_hours": 72,
//!         "max_downloads": 10,
//!     }))
//!     .await?;
//!
//! println!("{}", res["share_url"]);
//! # Ok(()) }
//! ```

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,
        })
    }

    /// Low-level GET — deserialize the response into `T`.
    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() {
                            // Caller asked for T but server returned nothing — try Null
                            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")))
    }

    // ── Convenience ─────────────────────────────────────────────────────
    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
    }
}