Skip to main content

calvery/
lib.rs

1//! Official Rust SDK for Calvery Vault secret manager.
2//!
3//! # Quickstart
4//!
5//! ```no_run
6//! # #[tokio::main]
7//! # async fn main() -> Result<(), calvery::Error> {
8//! let client = calvery::Client::new("cvsm_xxx", "acme-corp")?;
9//!
10//! let db_url = client.get("DATABASE_URL").await?;
11//! let all = client.get_all().await?;
12//! client.inject(false).await?; // set env::set_var untuk semua secret
13//! # Ok(())
14//! # }
15//! ```
16//!
17//! Builder untuk config non-default:
18//!
19//! ```no_run
20//! # #[tokio::main]
21//! # async fn main() -> Result<(), calvery::Error> {
22//! let client = calvery::Client::builder("cvsm_xxx", "acme-corp")
23//!     .base_url("https://api.calvery.xyz")
24//!     .environment("staging")
25//!     .cache_ttl(std::time::Duration::from_secs(60))
26//!     .max_retries(5)
27//!     .build()?;
28//! # let _ = client;
29//! # Ok(())
30//! # }
31//! ```
32
33use regex::Regex;
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::env;
37use std::sync::{Arc, OnceLock};
38use std::time::{Duration, Instant};
39use thiserror::Error;
40use tokio::sync::Mutex;
41
42const DEFAULT_BASE_URL: &str = "https://api.calvery.xyz";
43const DEFAULT_ENV: &str = "production";
44const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30);
45const DEFAULT_MAX_RETRIES: u32 = 3;
46const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
47const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
48
49fn uuid_re() -> &'static Regex {
50    static RE: OnceLock<Regex> = OnceLock::new();
51    RE.get_or_init(|| {
52        Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap()
53    })
54}
55
56/// Error yang bisa di-return SDK. Gunakan `kind()` untuk distinguish kasus.
57#[derive(Debug, Error)]
58pub enum Error {
59    #[error("config error: {0}")]
60    Config(String),
61    #[error("auth error: {0}")]
62    Auth(String),
63    #[error("not found: {0}")]
64    NotFound(String),
65    #[error("network error: {0}")]
66    Network(String),
67    #[error("server error ({status}): {message}")]
68    Server { status: u16, message: String },
69    #[error("decode error: {0}")]
70    Decode(String),
71}
72
73impl From<reqwest::Error> for Error {
74    fn from(e: reqwest::Error) -> Self {
75        Error::Network(e.to_string())
76    }
77}
78
79/// Builder untuk Client.
80pub struct ClientBuilder {
81    token: String,
82    team: String,
83    base_url: String,
84    environment: String,
85    cache_ttl: Duration,
86    max_retries: u32,
87    timeout: Duration,
88}
89
90impl ClientBuilder {
91    pub fn base_url(mut self, url: impl Into<String>) -> Self {
92        self.base_url = url.into().trim_end_matches('/').to_string();
93        self
94    }
95
96    pub fn environment(mut self, env: impl Into<String>) -> Self {
97        self.environment = env.into();
98        self
99    }
100
101    pub fn cache_ttl(mut self, d: Duration) -> Self {
102        self.cache_ttl = d;
103        self
104    }
105
106    pub fn max_retries(mut self, n: u32) -> Self {
107        self.max_retries = n;
108        self
109    }
110
111    pub fn timeout(mut self, d: Duration) -> Self {
112        self.timeout = d;
113        self
114    }
115
116    pub fn build(self) -> Result<Client, Error> {
117        if self.token.is_empty() {
118            return Err(Error::Config("token wajib".into()));
119        }
120        if self.team.is_empty() {
121            return Err(Error::Config("team wajib (slug atau UUID)".into()));
122        }
123        let http = reqwest::Client::builder()
124            .timeout(self.timeout)
125            .user_agent(format!("calvery-rust/{}", SDK_VERSION))
126            .build()
127            .map_err(|e| Error::Config(format!("http client init: {e}")))?;
128        Ok(Client {
129            token: self.token,
130            team_input: self.team,
131            base_url: self.base_url,
132            default_env: self.environment,
133            cache_ttl: self.cache_ttl,
134            max_retries: self.max_retries,
135            http,
136            state: Arc::new(Mutex::new(State {
137                resolved_team_id: None,
138                cache: HashMap::new(),
139            })),
140        })
141    }
142}
143
144struct State {
145    resolved_team_id: Option<String>,
146    cache: HashMap<String, (HashMap<String, String>, Instant)>,
147}
148
149/// Calvery Vault client. Cheap to clone — shared state wrapped in Arc.
150#[derive(Clone)]
151pub struct Client {
152    token: String,
153    team_input: String,
154    base_url: String,
155    default_env: String,
156    cache_ttl: Duration,
157    max_retries: u32,
158    http: reqwest::Client,
159    state: Arc<Mutex<State>>,
160}
161
162impl Client {
163    /// Create client dengan config default.
164    pub fn new(token: impl Into<String>, team: impl Into<String>) -> Result<Self, Error> {
165        Self::builder(token, team).build()
166    }
167
168    /// Start builder untuk config kustom.
169    pub fn builder(token: impl Into<String>, team: impl Into<String>) -> ClientBuilder {
170        ClientBuilder {
171            token: token.into(),
172            team: team.into(),
173            base_url: DEFAULT_BASE_URL.into(),
174            environment: DEFAULT_ENV.into(),
175            cache_ttl: DEFAULT_CACHE_TTL,
176            max_retries: DEFAULT_MAX_RETRIES,
177            timeout: DEFAULT_TIMEOUT,
178        }
179    }
180
181    /// Get satu secret by name (pakai default environment).
182    pub async fn get(&self, name: &str) -> Result<String, Error> {
183        self.get_env(name, &self.default_env.clone()).await
184    }
185
186    /// Get satu secret dengan environment eksplisit.
187    pub async fn get_env(&self, name: &str, environment: &str) -> Result<String, Error> {
188        let all = self.get_all_env(environment).await?;
189        all.get(name).cloned().ok_or_else(|| {
190            Error::NotFound(format!(
191                "secret \"{name}\" tidak ditemukan di environment \"{environment}\""
192            ))
193        })
194    }
195
196    /// Get semua secret untuk default environment.
197    pub async fn get_all(&self) -> Result<HashMap<String, String>, Error> {
198        self.get_all_env(&self.default_env.clone()).await
199    }
200
201    /// Get semua secret untuk environment eksplisit.
202    pub async fn get_all_env(&self, environment: &str) -> Result<HashMap<String, String>, Error> {
203        {
204            let state = self.state.lock().await;
205            if let Some((data, expires)) = state.cache.get(environment) {
206                if expires > &Instant::now() {
207                    return Ok(data.clone());
208                }
209            }
210        }
211
212        let team_id = self.resolve_team_id().await?;
213        let url = format!(
214            "{}/api/v1/teams/{}/secrets/export?format=json&environment={}",
215            self.base_url, team_id, environment
216        );
217        let res = self.do_with_retry(&url).await?;
218        let data: HashMap<String, String> = res
219            .json()
220            .await
221            .map_err(|e| Error::Decode(e.to_string()))?;
222
223        if !self.cache_ttl.is_zero() {
224            let mut state = self.state.lock().await;
225            state
226                .cache
227                .insert(environment.to_string(), (data.clone(), Instant::now() + self.cache_ttl));
228        }
229        Ok(data)
230    }
231
232    /// Set semua secret ke std::env::set_var. Return list nama yang di-inject.
233    /// Kalau `overwrite=false`, skip nama yang sudah ada di env.
234    pub async fn inject(&self, overwrite: bool) -> Result<Vec<String>, Error> {
235        let secrets = self.get_all().await?;
236        let mut injected = Vec::with_capacity(secrets.len());
237        for (k, v) in secrets {
238            if !overwrite && env::var(&k).is_ok() {
239                continue;
240            }
241            // Safety: single-threaded env mutation sudah umum di CI/boot
242            // phase. Caller bertanggung jawab kalau concurrent.
243            unsafe { env::set_var(&k, &v) };
244            injected.push(k);
245        }
246        Ok(injected)
247    }
248
249    /// Clear local cache.
250    pub async fn clear_cache(&self) {
251        let mut state = self.state.lock().await;
252        state.cache.clear();
253    }
254
255    // ── Internal ────────────────────────────────────────
256
257    async fn resolve_team_id(&self) -> Result<String, Error> {
258        {
259            let state = self.state.lock().await;
260            if let Some(id) = &state.resolved_team_id {
261                return Ok(id.clone());
262            }
263        }
264
265        if uuid_re().is_match(&self.team_input) {
266            let mut state = self.state.lock().await;
267            state.resolved_team_id = Some(self.team_input.clone());
268            return Ok(self.team_input.clone());
269        }
270
271        let url = format!("{}/api/v1/teams", self.base_url);
272        let res = self.do_with_retry(&url).await?;
273
274        #[derive(Deserialize)]
275        struct TeamList {
276            teams: Vec<TeamEntry>,
277        }
278        #[derive(Deserialize)]
279        struct TeamEntry {
280            id: String,
281            slug: String,
282        }
283        let body: TeamList = res.json().await.map_err(|e| Error::Decode(e.to_string()))?;
284        for t in body.teams {
285            if t.slug == self.team_input {
286                let mut state = self.state.lock().await;
287                state.resolved_team_id = Some(t.id.clone());
288                return Ok(t.id);
289            }
290        }
291        Err(Error::NotFound(format!(
292            "team dengan slug \"{}\" tidak ditemukan di akun ini",
293            self.team_input
294        )))
295    }
296
297    async fn do_with_retry(&self, url: &str) -> Result<reqwest::Response, Error> {
298        for attempt in 0..=self.max_retries {
299            let req = self
300                .http
301                .get(url)
302                .bearer_auth(&self.token)
303                .build()
304                .map_err(|e| Error::Network(e.to_string()))?;
305            let res_result = self.http.execute(req).await;
306            match res_result {
307                Ok(res) => {
308                    let status = res.status();
309                    if status == 401 || status == 403 {
310                        return Err(Error::Auth(read_error_msg(res).await));
311                    }
312                    if status.as_u16() >= 500 && attempt < self.max_retries {
313                        tokio::time::sleep(backoff(attempt)).await;
314                        continue;
315                    }
316                    if !status.is_success() {
317                        return Err(Error::Server {
318                            status: status.as_u16(),
319                            message: read_error_msg(res).await,
320                        });
321                    }
322                    return Ok(res);
323                }
324                Err(e) => {
325                    if attempt < self.max_retries {
326                        tokio::time::sleep(backoff(attempt)).await;
327                        continue;
328                    }
329                    return Err(Error::Network(e.to_string()));
330                }
331            }
332        }
333        Err(Error::Network("unreachable".into()))
334    }
335}
336
337async fn read_error_msg(res: reqwest::Response) -> String {
338    let status = res.status();
339    match res.json::<HashMap<String, serde_json::Value>>().await {
340        Ok(m) => m
341            .get("error")
342            .and_then(|v| v.as_str())
343            .map(String::from)
344            .unwrap_or_else(|| status.canonical_reason().unwrap_or("error").to_string()),
345        Err(_) => status.canonical_reason().unwrap_or("error").to_string(),
346    }
347}
348
349fn backoff(attempt: u32) -> Duration {
350    // Exponential 100ms, 200ms, 400ms, 800ms, cap 2s, plus small jitter.
351    let base_ms = (100u64).saturating_mul(1u64 << attempt.min(10));
352    let capped = base_ms.min(2000);
353    let jitter = (attempt as u64 * 17) % 100; // deterministik tanpa rand dep
354    Duration::from_millis(capped + jitter)
355}