Files
openidea/crates/ide-http/src/env.rs

266 lines
10 KiB
Rust

//! Environments (`http-client.env.json`, `http-client.private.env.json`) and
//! `{{variable}}` substitution.
//!
//! ```json
//! { "$shared": { "version": "v1" }, "dev": { "host": "localhost:8080" } }
//! ```
//!
//! Lookup order for `{{name}}`: file variables (`@name = …`), the private
//! environment, the public environment, private `$shared`, public `$shared`.
//! Dynamic variables: `$uuid` / `$random.uuid`, `$timestamp`, `$isoTimestamp`,
//! `$randomInt` / `$random.integer`.
use ide_core::{IdeError, IdeResult};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
pub const PUBLIC_FILE: &str = "http-client.env.json";
pub const PRIVATE_FILE: &str = "http-client.private.env.json";
const SHARED: &str = "$shared";
type EnvMap = BTreeMap<String, BTreeMap<String, String>>;
/// The environment files that apply to a request file.
#[derive(Debug, Default, Clone)]
pub struct Environments {
public: EnvMap,
private: EnvMap,
}
/// Folders searched for env files: the request file's folder and its
/// ancestors up to (and including) `root`; `root` itself when the file is outside it.
fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(mut dir) = file.and_then(Path::parent) {
loop {
dirs.push(dir.to_path_buf());
if root.is_none_or(|r| dir == r || !dir.starts_with(r)) {
break;
}
match dir.parent() {
Some(p) => dir = p,
None => break,
}
}
}
if let Some(r) = root
&& !dirs.iter().any(|d| d == r)
{
dirs.push(r.to_path_buf());
}
dirs
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
let mut out = EnvMap::new();
let Some(envs) = value.as_object() else { return Ok(out) };
for (env, vars) in envs {
let Some(vars) = vars.as_object() else { continue };
let mut map = BTreeMap::new();
for (k, v) in vars {
let s = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
// Objects configure SSL / auth in IDEA; not variables.
_ => continue,
};
map.insert(k.clone(), s);
}
out.insert(env.clone(), map);
}
Ok(out)
}
fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult<EnvMap> {
for d in dirs {
let p = d.join(name);
if let Ok(text) = std::fs::read_to_string(&p) {
return parse_env(&text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)));
}
}
Ok(EnvMap::new())
}
impl Environments {
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
let dirs = search_dirs(file, root);
Ok(Self { public: read_first(&dirs, PUBLIC_FILE)?, private: read_first(&dirs, PRIVATE_FILE)? })
}
pub fn from_json(public: &str, private: &str) -> IdeResult<Self> {
let p = |s: &str| if s.trim().is_empty() { Ok(EnvMap::new()) } else { parse_env(s) };
Ok(Self { public: p(public)?, private: p(private)? })
}
/// Environment names (excluding `$shared`), sorted.
pub fn names(&self) -> Vec<String> {
let set: BTreeSet<&String> = self.public.keys().chain(self.private.keys()).filter(|k| *k != SHARED).collect();
set.into_iter().cloned().collect()
}
/// Variables of `env` merged in lookup order (first wins).
pub fn variables(&self, env: Option<&str>) -> HashMap<String, String> {
let mut out = HashMap::new();
let mut layer = |m: Option<&BTreeMap<String, String>>| {
for (k, v) in m.into_iter().flatten() {
out.entry(k.clone()).or_insert_with(|| v.clone());
}
};
if let Some(env) = env {
layer(self.private.get(env));
layer(self.public.get(env));
}
layer(self.private.get(SHARED));
layer(self.public.get(SHARED));
out
}
}
fn dynamic(name: &str) -> Option<String> {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
match name {
"$uuid" | "$random.uuid" => Some(uuid::Uuid::new_v4().to_string()),
"$timestamp" => Some(now.as_secs().to_string()),
"$isoTimestamp" => Some(iso8601(now.as_secs())),
"$randomInt" | "$random.integer" | "$random.integer()" => {
Some((uuid::Uuid::new_v4().as_u128() % 1000).to_string())
}
_ => None,
}
}
/// `secs` since the epoch as `YYYY-MM-DDTHH:MM:SSZ` (UTC).
fn iso8601(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// Civil-from-days (Howard Hinnant).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
/// Resolves `{{name}}` references. File variables may reference other
/// variables; unresolved names are collected in `missing` and left as written.
pub struct Resolver<'a> {
pub file_vars: &'a [(String, String)],
pub env: HashMap<String, String>,
}
impl Resolver<'_> {
fn lookup(&self, name: &str, depth: u32, missing: &mut BTreeSet<String>) -> Option<String> {
if let Some((_, v)) = self.file_vars.iter().rev().find(|(k, _)| k == name) {
return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() });
}
if let Some(v) = self.env.get(name) {
return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() });
}
dynamic(name)
}
fn substitute_inner(&self, text: &str, depth: u32, missing: &mut BTreeSet<String>) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
out.push_str(&rest[start..]);
return out;
};
let name = after[..end].trim();
match self.lookup(name, depth, missing) {
Some(v) => out.push_str(&v),
None => {
missing.insert(name.to_owned());
out.push_str(&rest[start..start + 2 + end + 2]);
}
}
rest = &after[end + 2..];
}
out.push_str(rest);
out
}
pub fn substitute(&self, text: &str, missing: &mut BTreeSet<String>) -> String {
self.substitute_inner(text, 0, missing)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_private_public_and_shared() {
let envs = Environments::from_json(
r#"{"$shared":{"v":"1","host":"shared"},"dev":{"host":"localhost","port":8080,"ssl":{"x":1}},"prod":{}}"#,
r#"{"dev":{"token":"secret"},"$shared":{"v":"2"}}"#,
)
.unwrap();
assert_eq!(envs.names(), vec!["dev", "prod"]);
let vars = envs.variables(Some("dev"));
assert_eq!(vars["host"], "localhost");
assert_eq!(vars["port"], "8080");
assert_eq!(vars["token"], "secret");
assert_eq!(vars["v"], "2");
assert!(!vars.contains_key("ssl"));
assert_eq!(envs.variables(None)["host"], "shared");
}
#[test]
fn substitutes_file_env_and_dynamic_variables() {
let file_vars = vec![("base".to_owned(), "https://{{host}}".to_owned())];
let env = HashMap::from([("host".to_owned(), "example.com".to_owned())]);
let r = Resolver { file_vars: &file_vars, env };
let mut missing = BTreeSet::new();
assert_eq!(r.substitute("{{ base }}/a?x={{nope}}", &mut missing), "https://example.com/a?x={{nope}}");
assert_eq!(missing.into_iter().collect::<Vec<_>>(), vec!["nope"]);
let mut missing = BTreeSet::new();
let id = r.substitute("{{$uuid}}", &mut missing);
assert_eq!(id.len(), 36);
assert!(missing.is_empty());
assert_eq!(r.substitute("{{unterminated", &mut missing), "{{unterminated");
}
#[test]
fn self_reference_terminates() {
let file_vars = vec![("a".to_owned(), "{{a}}".to_owned())];
let r = Resolver { file_vars: &file_vars, env: HashMap::new() };
let mut missing = BTreeSet::new();
assert_eq!(r.substitute("{{a}}", &mut missing), "{{a}}");
}
#[test]
fn iso_timestamps() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z");
}
#[test]
fn finds_env_files_in_ancestors_up_to_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("api/v1")).unwrap();
std::fs::write(root.join(PUBLIC_FILE), r#"{"dev":{"host":"root"}}"#).unwrap();
std::fs::write(root.join("api").join(PRIVATE_FILE), r#"{"dev":{"token":"t"}}"#).unwrap();
let envs = Environments::load(Some(&root.join("api/v1/a.http")), Some(root)).unwrap();
let vars = envs.variables(Some("dev"));
assert_eq!(vars["host"], "root");
assert_eq!(vars["token"], "t");
// A scratch file outside the project still sees the project's environments.
let other = tempfile::tempdir().unwrap();
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
}