calvery 0.3.0

Official Rust SDK for Calvery Vault secret manager
Documentation
//! Official Rust SDK for Calvery Vault secret manager.
//!
//! # Quickstart
//!
//! ```no_run
//! # #[tokio::main]
//! # async fn main() -> Result<(), calvery::Error> {
//! let client = calvery::Client::new("cvsm_xxx", "acme-corp")?;
//!
//! let db_url = client.get("DATABASE_URL").await?;
//! let all = client.get_all().await?;
//! client.inject(false).await?; // set env::set_var untuk semua secret
//! # Ok(())
//! # }
//! ```
//!
//! Builder untuk config non-default:
//!
//! ```no_run
//! # #[tokio::main]
//! # async fn main() -> Result<(), calvery::Error> {
//! let client = calvery::Client::builder("cvsm_xxx", "acme-corp")
//!     .base_url("https://api.calvery.xyz")
//!     .environment("staging")
//!     .cache_ttl(std::time::Duration::from_secs(60))
//!     .max_retries(5)
//!     .build()?;
//! # let _ = client;
//! # Ok(())
//! # }
//! ```

use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use thiserror::Error;
use tokio::sync::Mutex;

const DEFAULT_BASE_URL: &str = "https://api.calvery.xyz";
const DEFAULT_ENV: &str = "production";
const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRIES: u32 = 3;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");

fn uuid_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap()
    })
}

/// Error yang bisa di-return SDK. Gunakan `kind()` untuk distinguish kasus.
#[derive(Debug, Error)]
pub enum Error {
    #[error("config error: {0}")]
    Config(String),
    #[error("auth error: {0}")]
    Auth(String),
    #[error("not found: {0}")]
    NotFound(String),
    #[error("network error: {0}")]
    Network(String),
    #[error("server error ({status}): {message}")]
    Server { status: u16, message: String },
    #[error("decode error: {0}")]
    Decode(String),
}

impl From<reqwest::Error> for Error {
    fn from(e: reqwest::Error) -> Self {
        Error::Network(e.to_string())
    }
}

/// Builder untuk Client.
pub struct ClientBuilder {
    token: String,
    team: String,
    base_url: String,
    environment: String,
    cache_ttl: Duration,
    max_retries: u32,
    timeout: Duration,
}

impl ClientBuilder {
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into().trim_end_matches('/').to_string();
        self
    }

    pub fn environment(mut self, env: impl Into<String>) -> Self {
        self.environment = env.into();
        self
    }

    pub fn cache_ttl(mut self, d: Duration) -> Self {
        self.cache_ttl = d;
        self
    }

    pub fn max_retries(mut self, n: u32) -> Self {
        self.max_retries = n;
        self
    }

    pub fn timeout(mut self, d: Duration) -> Self {
        self.timeout = d;
        self
    }

    pub fn build(self) -> Result<Client, Error> {
        if self.token.is_empty() {
            return Err(Error::Config("token wajib".into()));
        }
        if self.team.is_empty() {
            return Err(Error::Config("team wajib (slug atau UUID)".into()));
        }
        let http = reqwest::Client::builder()
            .timeout(self.timeout)
            .user_agent(format!("calvery-rust/{}", SDK_VERSION))
            .build()
            .map_err(|e| Error::Config(format!("http client init: {e}")))?;
        Ok(Client {
            token: self.token,
            team_input: self.team,
            base_url: self.base_url,
            default_env: self.environment,
            cache_ttl: self.cache_ttl,
            max_retries: self.max_retries,
            http,
            state: Arc::new(Mutex::new(State {
                resolved_team_id: None,
                cache: HashMap::new(),
            })),
        })
    }
}

struct State {
    resolved_team_id: Option<String>,
    cache: HashMap<String, (HashMap<String, String>, Instant)>,
}

/// Calvery Vault client. Cheap to clone — shared state wrapped in Arc.
#[derive(Clone)]
pub struct Client {
    token: String,
    team_input: String,
    base_url: String,
    default_env: String,
    cache_ttl: Duration,
    max_retries: u32,
    http: reqwest::Client,
    state: Arc<Mutex<State>>,
}

impl Client {
    /// Create client dengan config default.
    pub fn new(token: impl Into<String>, team: impl Into<String>) -> Result<Self, Error> {
        Self::builder(token, team).build()
    }

    /// Start builder untuk config kustom.
    pub fn builder(token: impl Into<String>, team: impl Into<String>) -> ClientBuilder {
        ClientBuilder {
            token: token.into(),
            team: team.into(),
            base_url: DEFAULT_BASE_URL.into(),
            environment: DEFAULT_ENV.into(),
            cache_ttl: DEFAULT_CACHE_TTL,
            max_retries: DEFAULT_MAX_RETRIES,
            timeout: DEFAULT_TIMEOUT,
        }
    }

    /// Get satu secret by name (pakai default environment).
    pub async fn get(&self, name: &str) -> Result<String, Error> {
        self.get_env(name, &self.default_env.clone()).await
    }

    /// Get satu secret dengan environment eksplisit.
    pub async fn get_env(&self, name: &str, environment: &str) -> Result<String, Error> {
        let all = self.get_all_env(environment).await?;
        all.get(name).cloned().ok_or_else(|| {
            Error::NotFound(format!(
                "secret \"{name}\" tidak ditemukan di environment \"{environment}\""
            ))
        })
    }

    /// Get semua secret untuk default environment.
    pub async fn get_all(&self) -> Result<HashMap<String, String>, Error> {
        self.get_all_env(&self.default_env.clone()).await
    }

    /// Get semua secret untuk environment eksplisit.
    pub async fn get_all_env(&self, environment: &str) -> Result<HashMap<String, String>, Error> {
        {
            let state = self.state.lock().await;
            if let Some((data, expires)) = state.cache.get(environment) {
                if expires > &Instant::now() {
                    return Ok(data.clone());
                }
            }
        }

        let team_id = self.resolve_team_id().await?;
        let url = format!(
            "{}/api/v1/teams/{}/secrets/export?format=json&environment={}",
            self.base_url, team_id, environment
        );
        let res = self.do_with_retry(&url).await?;
        let data: HashMap<String, String> = res
            .json()
            .await
            .map_err(|e| Error::Decode(e.to_string()))?;

        if !self.cache_ttl.is_zero() {
            let mut state = self.state.lock().await;
            state
                .cache
                .insert(environment.to_string(), (data.clone(), Instant::now() + self.cache_ttl));
        }
        Ok(data)
    }

    /// Set semua secret ke std::env::set_var. Return list nama yang di-inject.
    /// Kalau `overwrite=false`, skip nama yang sudah ada di env.
    pub async fn inject(&self, overwrite: bool) -> Result<Vec<String>, Error> {
        let secrets = self.get_all().await?;
        let mut injected = Vec::with_capacity(secrets.len());
        for (k, v) in secrets {
            if !overwrite && env::var(&k).is_ok() {
                continue;
            }
            // Safety: single-threaded env mutation sudah umum di CI/boot
            // phase. Caller bertanggung jawab kalau concurrent.
            unsafe { env::set_var(&k, &v) };
            injected.push(k);
        }
        Ok(injected)
    }

    /// Clear local cache.
    pub async fn clear_cache(&self) {
        let mut state = self.state.lock().await;
        state.cache.clear();
    }

    // ── Internal ────────────────────────────────────────

    async fn resolve_team_id(&self) -> Result<String, Error> {
        {
            let state = self.state.lock().await;
            if let Some(id) = &state.resolved_team_id {
                return Ok(id.clone());
            }
        }

        if uuid_re().is_match(&self.team_input) {
            let mut state = self.state.lock().await;
            state.resolved_team_id = Some(self.team_input.clone());
            return Ok(self.team_input.clone());
        }

        let url = format!("{}/api/v1/teams", self.base_url);
        let res = self.do_with_retry(&url).await?;

        #[derive(Deserialize)]
        struct TeamList {
            teams: Vec<TeamEntry>,
        }
        #[derive(Deserialize)]
        struct TeamEntry {
            id: String,
            slug: String,
        }
        let body: TeamList = res.json().await.map_err(|e| Error::Decode(e.to_string()))?;
        for t in body.teams {
            if t.slug == self.team_input {
                let mut state = self.state.lock().await;
                state.resolved_team_id = Some(t.id.clone());
                return Ok(t.id);
            }
        }
        Err(Error::NotFound(format!(
            "team dengan slug \"{}\" tidak ditemukan di akun ini",
            self.team_input
        )))
    }

    async fn do_with_retry(&self, url: &str) -> Result<reqwest::Response, Error> {
        for attempt in 0..=self.max_retries {
            let req = self
                .http
                .get(url)
                .bearer_auth(&self.token)
                .build()
                .map_err(|e| Error::Network(e.to_string()))?;
            let res_result = self.http.execute(req).await;
            match res_result {
                Ok(res) => {
                    let status = res.status();
                    if status == 401 || status == 403 {
                        return Err(Error::Auth(read_error_msg(res).await));
                    }
                    if status.as_u16() >= 500 && attempt < self.max_retries {
                        tokio::time::sleep(backoff(attempt)).await;
                        continue;
                    }
                    if !status.is_success() {
                        return Err(Error::Server {
                            status: status.as_u16(),
                            message: read_error_msg(res).await,
                        });
                    }
                    return Ok(res);
                }
                Err(e) => {
                    if attempt < self.max_retries {
                        tokio::time::sleep(backoff(attempt)).await;
                        continue;
                    }
                    return Err(Error::Network(e.to_string()));
                }
            }
        }
        Err(Error::Network("unreachable".into()))
    }
}

async fn read_error_msg(res: reqwest::Response) -> String {
    let status = res.status();
    match res.json::<HashMap<String, serde_json::Value>>().await {
        Ok(m) => m
            .get("error")
            .and_then(|v| v.as_str())
            .map(String::from)
            .unwrap_or_else(|| status.canonical_reason().unwrap_or("error").to_string()),
        Err(_) => status.canonical_reason().unwrap_or("error").to_string(),
    }
}

fn backoff(attempt: u32) -> Duration {
    // Exponential 100ms, 200ms, 400ms, 800ms, cap 2s, plus small jitter.
    let base_ms = (100u64).saturating_mul(1u64 << attempt.min(10));
    let capped = base_ms.min(2000);
    let jitter = (attempt as u64 * 17) % 100; // deterministik tanpa rand dep
    Duration::from_millis(capped + jitter)
}