Skip to main content

leash_sdk/
env.rs

1//! Runtime env-var primitive — fetches values from the Leash platform.
2//!
3//! Mirrors `leash.env.get` / `leash.env.getMany` in the TS / Python / Go SDKs.
4//! Values are cached for 60 seconds per-instance; the cache is shared between
5//! [`Env::get`], [`Env::get_fresh`], and [`Env::get_many`].
6//!
7//! The Rust surface adopts two adaptations:
8//!
9//!   * `Result<Option<String>>` — `Ok(None)` for a 404 (key not declared
10//!     anywhere), `Err(_)` for everything else. Callers branch with
11//!     `if value.is_none()` instead of catching errors.
12//!   * Dedicated [`get_fresh`](Self::get_fresh) method for cache-bypass,
13//!     rather than an option-struct overload — gives type safety and is
14//!     equivalent to the TS `{ fresh: true }` option.
15
16use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20use serde::Deserialize;
21
22use crate::errors::{LeashError, Result};
23
24/// Default cache window — matches TS/Python/Go.
25pub const ENV_CACHE_TTL: Duration = Duration::from_secs(60);
26
27/// `leash.env()` — runtime env-var fetcher with an in-memory TTL cache.
28#[derive(Debug, Clone)]
29pub struct Env {
30    inner: Arc<EnvInner>,
31}
32
33#[derive(Debug)]
34struct EnvInner {
35    platform_url: String,
36    api_key: Option<String>,
37    http: reqwest::Client,
38    cache: Mutex<HashMap<String, Cached>>,
39}
40
41#[derive(Debug, Clone)]
42struct Cached {
43    value: Option<String>,
44    expires_at: Instant,
45}
46
47impl Env {
48    pub(crate) fn new(platform_url: String, api_key: Option<String>, http: reqwest::Client) -> Self {
49        Self {
50            inner: Arc::new(EnvInner {
51                platform_url: platform_url.trim_end_matches('/').to_string(),
52                api_key,
53                http,
54                cache: Mutex::new(HashMap::new()),
55            }),
56        }
57    }
58
59    /// Resolve a single env-var by name, using the TTL cache.
60    ///
61    /// Returns `Ok(None)` when the platform reports the key as not declared
62    /// or not found anywhere (HTTP 404). All other failures surface as
63    /// [`LeashError`].
64    pub async fn get(&self, key: &str) -> Result<Option<String>> {
65        self.resolve(key, false).await
66    }
67
68    /// Like [`Self::get`] but bypasses the cache read. The freshly-fetched
69    /// value is still written back to the cache.
70    ///
71    /// Equivalent to the TS `leash.env.get(key, { fresh: true })` option.
72    pub async fn get_fresh(&self, key: &str) -> Result<Option<String>> {
73        self.resolve(key, true).await
74    }
75
76    /// Resolve multiple env-vars sequentially, sharing the TTL cache.
77    ///
78    /// If any key fails (auth, plan, network), the whole call returns that
79    /// error — partial results are not surfaced (matches Go behaviour). Each
80    /// value is `Option<String>` so callers can distinguish "missing" (`None`)
81    /// from "empty string" (`Some("")`).
82    pub async fn get_many(&self, keys: &[&str]) -> Result<HashMap<String, Option<String>>> {
83        let mut out = HashMap::with_capacity(keys.len());
84        for key in keys {
85            let value = self.get(key).await?;
86            out.insert((*key).to_string(), value);
87        }
88        Ok(out)
89    }
90
91    async fn resolve(&self, key: &str, fresh: bool) -> Result<Option<String>> {
92        let now = Instant::now();
93        if !fresh {
94            if let Some(cached) = self.cache_get(key, now) {
95                return Ok(cached);
96            }
97        }
98
99        let value = self.fetch(key).await?;
100        self.cache_put(key, value.clone(), now + ENV_CACHE_TTL);
101        Ok(value)
102    }
103
104    fn cache_get(&self, key: &str, now: Instant) -> Option<Option<String>> {
105        let cache = self.inner.cache.lock().ok()?;
106        let entry = cache.get(key)?;
107        if entry.expires_at > now {
108            Some(entry.value.clone())
109        } else {
110            None
111        }
112    }
113
114    fn cache_put(&self, key: &str, value: Option<String>, expires_at: Instant) {
115        if let Ok(mut cache) = self.inner.cache.lock() {
116            cache.insert(
117                key.to_string(),
118                Cached {
119                    value,
120                    expires_at,
121                },
122            );
123        }
124    }
125
126    async fn fetch(&self, key: &str) -> Result<Option<String>> {
127        let api_key = self.inner.api_key.as_deref().ok_or_else(|| {
128            LeashError::Unauthorized {
129                message: "LEASH_API_KEY is required to call Env::get.".to_string(),
130            }
131        })?;
132
133        let url = format!(
134            "{}/api/apps/me/secrets/{}",
135            self.inner.platform_url,
136            percent_encode(key)
137        );
138
139        let resp = self
140            .inner
141            .http
142            .get(&url)
143            .bearer_auth(api_key)
144            .send()
145            .await?;
146
147        let status = resp.status();
148        let raw = resp.bytes().await?;
149
150        match status.as_u16() {
151            400 => Err(LeashError::UpstreamError {
152                status: 400,
153                message: format!(
154                    "Invalid env-var key: '{key}'. Names must match /^[A-Za-z_][A-Za-z0-9_]*$/ and be \u{2264}100 chars."
155                ),
156            }),
157            401 => Err(LeashError::Unauthorized {
158                message: "Missing or invalid LEASH_API_KEY.".to_string(),
159            }),
160            402 => {
161                let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
162                let required_plan = parsed
163                    .as_ref()
164                    .and_then(|v| v.get("requiredPlan"))
165                    .and_then(|v| v.as_str())
166                    .map(|s| s.to_string());
167                let suffix = required_plan
168                    .as_deref()
169                    .map(|p| format!(" (requiredPlan: {p})"))
170                    .unwrap_or_default();
171                Err(LeashError::UpgradeRequired {
172                    message: format!("Env::get requires the Growth plan or above{suffix}."),
173                })
174            }
175            // Adapted behaviour: 404 → Ok(None) so callers can branch with
176            // `if value.is_none()` instead of matching on a specific error.
177            404 => Ok(None),
178            502 => {
179                let parsed: Option<serde_json::Value> = serde_json::from_slice(&raw).ok();
180                let msg = parsed
181                    .as_ref()
182                    .and_then(|v| v.get("error"))
183                    .and_then(|v| v.as_str())
184                    .map(|s| s.to_string())
185                    .unwrap_or_else(|| "Secret source resync failed on the platform side.".into());
186                Err(LeashError::UpstreamError {
187                    status: 502,
188                    message: msg,
189                })
190            }
191            s if s >= 400 => Err(LeashError::UpstreamError {
192                status: s,
193                message: format!("Unexpected response from platform: HTTP {s}"),
194            }),
195            _ => {
196                let body: SecretBody =
197                    serde_json::from_slice(&raw).map_err(|_| LeashError::MalformedResponse {
198                        message: format!("Platform returned unexpected shape for key '{key}'."),
199                    })?;
200                Ok(Some(body.value))
201            }
202        }
203    }
204}
205
206#[derive(Debug, Deserialize)]
207struct SecretBody {
208    value: String,
209}
210
211/// Minimal percent-encoder for path segments — keeps unreserved chars,
212/// escapes everything else. Avoids a dep on `percent-encoding` for one call.
213fn percent_encode(input: &str) -> String {
214    let mut out = String::with_capacity(input.len());
215    for byte in input.bytes() {
216        match byte {
217            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
218                out.push(byte as char);
219            }
220            _ => {
221                out.push('%');
222                out.push_str(&format!("{byte:02X}"));
223            }
224        }
225    }
226    out
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn percent_encodes_reserved_chars() {
235        assert_eq!(percent_encode("OPENAI_API_KEY"), "OPENAI_API_KEY");
236        assert_eq!(percent_encode("a/b"), "a%2Fb");
237        assert_eq!(percent_encode("a b"), "a%20b");
238    }
239}