use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::errors::{LeashError, Result};
pub const ENV_CACHE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct Env {
inner: Arc<EnvInner>,
}
#[derive(Debug)]
struct EnvInner {
platform_url: String,
api_key: Option<String>,
http: reqwest::Client,
cache: Mutex<HashMap<String, Cached>>,
}
#[derive(Debug, Clone)]
struct Cached {
value: Option<String>,
expires_at: Instant,
}
impl Env {
pub(crate) fn new(platform_url: String, api_key: Option<String>, http: reqwest::Client) -> Self {
Self {
inner: Arc::new(EnvInner {
platform_url: platform_url.trim_end_matches('/').to_string(),
api_key,
http,
cache: Mutex::new(HashMap::new()),
}),
}
}
pub async fn get(&self, key: &str) -> Result<Option<String>> {
self.resolve(key, false).await
}
pub async fn get_fresh(&self, key: &str) -> Result<Option<String>> {
self.resolve(key, true).await
}
pub async fn get_many(&self, keys: &[&str]) -> Result<HashMap<String, Option<String>>> {
let mut out = HashMap::with_capacity(keys.len());
for key in keys {
let value = self.get(key).await?;
out.insert((*key).to_string(), value);
}
Ok(out)
}
async fn resolve(&self, key: &str, fresh: bool) -> Result<Option<String>> {
let now = Instant::now();
if !fresh {
if let Some(cached) = self.cache_get(key, now) {
return Ok(cached);
}
}
let value = self.fetch(key).await?;
self.cache_put(key, value.clone(), now + ENV_CACHE_TTL);
Ok(value)
}
fn cache_get(&self, key: &str, now: Instant) -> Option<Option<String>> {
let cache = self.inner.cache.lock().ok()?;
let entry = cache.get(key)?;
if entry.expires_at > now {
Some(entry.value.clone())
} else {
None
}
}
fn cache_put(&self, key: &str, value: Option<String>, expires_at: Instant) {
if let Ok(mut cache) = self.inner.cache.lock() {
cache.insert(
key.to_string(),
Cached {
value,
expires_at,
},
);
}
}
async fn fetch(&self, key: &str) -> Result<Option<String>> {
let api_key = self.inner.api_key.as_deref().ok_or_else(|| {
LeashError::Unauthorized {
message: "LEASH_API_KEY is required to call Env::get.".to_string(),
}
})?;
let url = format!(
"{}/api/apps/me/secrets/{}",
self.inner.platform_url,
percent_encode(key)
);
let resp = self
.inner
.http
.get(&url)
.bearer_auth(api_key)
.send()
.await?;
let status = resp.status();
let raw = resp.bytes().await?;
match status.as_u16() {
400 => Err(LeashError::UpstreamError {
status: 400,
message: format!(
"Invalid env-var key: '{key}'. Names must match /^[A-Za-z_][A-Za-z0-9_]*$/ and be \u{2264}100 chars."
),
}),
401 => Err(LeashError::Unauthorized {
message: "Missing or invalid LEASH_API_KEY.".to_string(),
}),
402 => {
let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
let required_plan = parsed
.as_ref()
.and_then(|v| v.get("requiredPlan"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let suffix = required_plan
.as_deref()
.map(|p| format!(" (requiredPlan: {p})"))
.unwrap_or_default();
Err(LeashError::UpgradeRequired {
message: format!("Env::get requires the Growth plan or above{suffix}."),
})
}
404 => Ok(None),
502 => {
let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
let msg = parsed
.as_ref()
.and_then(|v| v.get("error"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "Secret source resync failed on the platform side.".into());
Err(LeashError::UpstreamError {
status: 502,
message: msg,
})
}
s if s >= 400 => Err(LeashError::UpstreamError {
status: s,
message: format!("Unexpected response from platform: HTTP {s}"),
}),
_ => {
let body: SecretBody =
serde_json::from_slice(&raw).map_err(|_| LeashError::MalformedResponse {
message: format!("Platform returned unexpected shape for key '{key}'."),
})?;
Ok(Some(body.value))
}
}
}
}
#[derive(Debug, Deserialize)]
struct SecretBody {
value: String,
}
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_encodes_reserved_chars() {
assert_eq!(percent_encode("OPENAI_API_KEY"), "OPENAI_API_KEY");
assert_eq!(percent_encode("a/b"), "a%2Fb");
assert_eq!(percent_encode("a b"), "a%20b");
}
}