1use 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#[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
79pub 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#[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 pub fn new(token: impl Into<String>, team: impl Into<String>) -> Result<Self, Error> {
165 Self::builder(token, team).build()
166 }
167
168 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 pub async fn get(&self, name: &str) -> Result<String, Error> {
183 self.get_env(name, &self.default_env.clone()).await
184 }
185
186 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 pub async fn get_all(&self) -> Result<HashMap<String, String>, Error> {
198 self.get_all_env(&self.default_env.clone()).await
199 }
200
201 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 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 unsafe { env::set_var(&k, &v) };
244 injected.push(k);
245 }
246 Ok(injected)
247 }
248
249 pub async fn clear_cache(&self) {
251 let mut state = self.state.lock().await;
252 state.cache.clear();
253 }
254
255 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 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; Duration::from_millis(capped + jitter)
355}