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()
})
}
#[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())
}
}
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)>,
}
#[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 {
pub fn new(token: impl Into<String>, team: impl Into<String>) -> Result<Self, Error> {
Self::builder(token, team).build()
}
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,
}
}
pub async fn get(&self, name: &str) -> Result<String, Error> {
self.get_env(name, &self.default_env.clone()).await
}
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}\""
))
})
}
pub async fn get_all(&self) -> Result<HashMap<String, String>, Error> {
self.get_all_env(&self.default_env.clone()).await
}
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)
}
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;
}
unsafe { env::set_var(&k, &v) };
injected.push(k);
}
Ok(injected)
}
pub async fn clear_cache(&self) {
let mut state = self.state.lock().await;
state.cache.clear();
}
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 {
let base_ms = (100u64).saturating_mul(1u64 << attempt.min(10));
let capped = base_ms.min(2000);
let jitter = (attempt as u64 * 17) % 100; Duration::from_millis(capped + jitter)
}