mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 08:26:57 +00:00
266 lines
10 KiB
Rust
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"]);
|
|
}
|
|
}
|