Skip to main content

lmn_core/request_template/
mod.rs

1pub mod definition;
2pub mod error;
3pub mod generator;
4mod generators;
5pub mod renderer;
6mod validators;
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use serde_json::Value;
12use tracing::instrument;
13
14pub use error::TemplateError;
15use generator::GeneratorContext;
16
17const METADATA_KEY: &str = "_lmn_metadata_templates";
18pub(crate) const ENV_PLACEHOLDER_PREFIX: &str = "ENV:";
19
20// ── Placeholder parsing ───────────────────────────────────────────────────────
21
22pub struct PlaceholderRef {
23    pub name: String,
24    pub once: bool,
25}
26
27/// Parses `{{name}}` or `{{name:once}}` from a string.
28/// Returns `None` if the string is not a placeholder.
29pub fn parse_placeholder(s: &str) -> Option<PlaceholderRef> {
30    let inner = s.trim().strip_prefix("{{")?.strip_suffix("}}")?;
31    if inner.is_empty() {
32        return None;
33    }
34    let (name, once) = match inner.strip_suffix(":once") {
35        Some(n) => (n, true),
36        None => (inner, false),
37    };
38    if name.is_empty() {
39        return None;
40    }
41    Some(PlaceholderRef {
42        name: name.to_string(),
43        once,
44    })
45}
46
47// ── ENV placeholder helpers ───────────────────────────────────────────────────
48
49/// Walks the body `Value` tree and collects the names of all placeholders
50/// whose name starts with `"ENV:"` (e.g. `"ENV:MY_TOKEN"`). Deduplicated.
51fn collect_env_placeholder_names(body: &Value) -> Vec<String> {
52    let mut names = Vec::new();
53    collect_env(body, &mut names);
54    names.sort();
55    names.dedup();
56    names
57}
58
59fn collect_env(value: &Value, names: &mut Vec<String>) {
60    match value {
61        Value::String(s) => {
62            if let Some(ph) = parse_placeholder(s)
63                && ph.name.starts_with(ENV_PLACEHOLDER_PREFIX)
64            {
65                names.push(ph.name);
66            }
67        }
68        Value::Object(map) => map.values().for_each(|v| collect_env(v, names)),
69        Value::Array(arr) => arr.iter().for_each(|v| collect_env(v, names)),
70        _ => {}
71    }
72}
73
74/// For each name like `"ENV:MY_TOKEN"`, reads the env var after the `ENV:` prefix.
75/// Returns `Err(TemplateError::MissingEnvVar)` if any variable is not set.
76/// Returns `Err(TemplateError::InvalidEnvVarName)` if the var name portion is empty.
77fn resolve_env_vars(names: &[String]) -> Result<HashMap<String, Value>, TemplateError> {
78    let mut map = HashMap::new();
79    for name in names {
80        let var_name = &name[ENV_PLACEHOLDER_PREFIX.len()..];
81        if var_name.is_empty() {
82            return Err(TemplateError::InvalidEnvVarName(name.to_string()));
83        }
84        match std::env::var(var_name) {
85            Ok(val) => {
86                map.insert(name.clone(), Value::String(val));
87            }
88            Err(_) => return Err(TemplateError::MissingEnvVar(var_name.to_string())),
89        }
90    }
91    Ok(map)
92}
93
94// ── Template ──────────────────────────────────────────────────────────────────
95
96pub struct Template {
97    body: Value,
98    context: GeneratorContext,
99}
100
101impl Template {
102    /// Reads, parses, and fully validates a template file.
103    /// Fails fast on any invalid configuration before any requests are made.
104    #[instrument(name = "lmn.template.parse", fields(path = %path.display()))]
105    pub fn parse(path: &Path) -> Result<Self, TemplateError> {
106        let content = std::fs::read_to_string(path)?;
107        let mut root: serde_json::Map<String, Value> = serde_json::from_str(&content)?;
108
109        // Extract metadata — it must not appear in request bodies
110        let metadata = root
111            .remove(METADATA_KEY)
112            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
113
114        let raw_defs: HashMap<String, definition::RawTemplateDef> =
115            serde_json::from_value(metadata)?;
116        let defs = definition::validate_all(raw_defs)?;
117
118        let body = Value::Object(root);
119
120        // All body placeholders must have definitions
121        renderer::validate_placeholders(&body, &defs)?;
122
123        // Object compositions must not form cycles
124        definition::check_circular_refs(&defs)?;
125
126        // Pre-resolve :once placeholders — same value reused across all requests
127        let ctx = GeneratorContext::new(defs);
128        let mut rng = rand::rng();
129
130        let once_values: HashMap<String, Value> = renderer::collect_once_placeholder_names(&body)
131            .into_iter()
132            .map(|name| {
133                let val = ctx.generate_by_name(&name, &mut rng);
134                (name, val)
135            })
136            .collect();
137
138        // Resolve ENV: placeholders — read from environment at startup (fail-closed)
139        let env_names = collect_env_placeholder_names(&body);
140        let env_values = resolve_env_vars(&env_names)?;
141
142        // Merge env values into once_values
143        let mut all_once_values = once_values;
144        all_once_values.extend(env_values);
145
146        Ok(Template {
147            body,
148            context: ctx.with_once_values(all_once_values),
149        })
150    }
151
152    /// Pre-generates `n` request bodies, each with independently rendered placeholders.
153    /// `:once` placeholders share the same value across all `n` bodies.
154    #[instrument(name = "lmn.template.render", skip(self), fields(n))]
155    pub fn pre_generate(&self, n: usize) -> Vec<String> {
156        let mut rng = rand::rng();
157        (0..n)
158            .map(|_| {
159                let rendered = renderer::render(&self.body, &self.context, &mut rng);
160                serde_json::to_string(&rendered).expect("rendered Value is always valid JSON")
161            })
162            .collect()
163    }
164
165    /// Generates a single request body on demand.
166    /// Thread-safe: each call creates its own RNG state, so concurrent VU tasks
167    /// can call this simultaneously without contention.
168    pub fn generate_one(&self) -> String {
169        let mut rng = rand::rng();
170        let rendered = renderer::render(&self.body, &self.context, &mut rng);
171        serde_json::to_string(&rendered).expect("rendered Value is always valid JSON")
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::io::Write;
179
180    fn write_temp(content: &str) -> tempfile::NamedTempFile {
181        let mut f = tempfile::NamedTempFile::new().unwrap();
182        f.write_all(content.as_bytes()).unwrap();
183        f
184    }
185
186    #[test]
187    fn parse_fails_on_missing_file() {
188        assert!(Template::parse(Path::new("nonexistent.json")).is_err());
189    }
190
191    #[test]
192    fn parse_fails_on_invalid_json() {
193        let f = write_temp("not json");
194        assert!(Template::parse(f.path()).is_err());
195    }
196
197    #[test]
198    fn parse_fails_on_unknown_placeholder() {
199        let f = write_temp(r#"{"field": "{{undefined}}"}"#);
200        assert!(Template::parse(f.path()).is_err());
201    }
202
203    #[test]
204    fn parse_fails_on_circular_reference() {
205        let f = write_temp(
206            r#"{
207            "field": "{{a}}",
208            "_lmn_metadata_templates": {
209                "a": { "type": "object", "composition": { "x": "{{b}}" } },
210                "b": { "type": "object", "composition": { "y": "{{a}}" } }
211            }
212        }"#,
213        );
214        assert!(Template::parse(f.path()).is_err());
215    }
216
217    #[test]
218    fn parse_succeeds_with_no_placeholders() {
219        let f = write_temp(r#"{"field": "static"}"#);
220        assert!(Template::parse(f.path()).is_ok());
221    }
222
223    // 9. generate_one returns valid JSON string
224    #[test]
225    fn generate_one_returns_valid_json() {
226        let f = write_temp(r#"{"field": "static", "value": 42}"#);
227        let template = Template::parse(f.path()).unwrap();
228        let result = template.generate_one();
229        // Must parse as valid JSON
230        let parsed: serde_json::Value =
231            serde_json::from_str(&result).expect("generate_one must return valid JSON");
232        assert_eq!(
233            parsed["field"],
234            serde_json::Value::String("static".to_string())
235        );
236        assert_eq!(parsed["value"], serde_json::json!(42));
237    }
238
239    #[test]
240    fn generate_one_is_independent_per_call() {
241        // Two calls should both produce valid JSON (no shared mutable state issues)
242        let f = write_temp(r#"{"field": "static"}"#);
243        let template = Template::parse(f.path()).unwrap();
244        let a = template.generate_one();
245        let b = template.generate_one();
246        assert!(serde_json::from_str::<serde_json::Value>(&a).is_ok());
247        assert!(serde_json::from_str::<serde_json::Value>(&b).is_ok());
248    }
249
250    #[test]
251    fn parse_env_placeholder_resolved_from_env() {
252        unsafe { std::env::set_var("LUMEN_TEST_TOKEN", "secret123") };
253        let f = write_temp(r#"{"token": "{{ENV:LUMEN_TEST_TOKEN}}"}"#);
254        let template = Template::parse(f.path()).unwrap();
255        let result = template.generate_one();
256        assert!(
257            result.contains("secret123"),
258            "expected 'secret123' in output, got: {result}"
259        );
260    }
261
262    #[test]
263    fn parse_env_placeholder_missing_var_is_error() {
264        // Ensure the var is definitely not set
265        unsafe { std::env::remove_var("LUMEN_NONEXISTENT_12345") };
266        let f = write_temp(r#"{"token": "{{ENV:LUMEN_NONEXISTENT_12345}}"}"#);
267        let result = Template::parse(f.path());
268        assert!(
269            result.is_err(),
270            "expected parse to fail for missing env var"
271        );
272        assert!(
273            matches!(result.err(), Some(TemplateError::MissingEnvVar(_))),
274            "expected MissingEnvVar error variant"
275        );
276    }
277
278    #[test]
279    fn parse_env_placeholder_no_def_required() {
280        // Template with ENV: placeholder and empty _lmn_metadata_templates should succeed
281        unsafe { std::env::set_var("LUMEN_TEST_TOKEN", "anyvalue") };
282        let f =
283            write_temp(r#"{"token": "{{ENV:LUMEN_TEST_TOKEN}}", "_lmn_metadata_templates": {}}"#);
284        assert!(Template::parse(f.path()).is_ok());
285    }
286
287    #[test]
288    fn parse_env_placeholder_with_once_suffix_resolves_correctly() {
289        // {{ENV:LUMEN_TEST_ONCE_TOKEN:once}} — the :once suffix is parsed away by
290        // parse_placeholder, leaving name = "ENV:LUMEN_TEST_ONCE_TOKEN". The collect_once
291        // path must skip it (since there is no generator def), and the ENV resolution path
292        // must still resolve it correctly.
293        unsafe { std::env::set_var("LUMEN_TEST_ONCE_TOKEN", "once_secret_value") };
294        let f = write_temp(r#"{"token": "{{ENV:LUMEN_TEST_ONCE_TOKEN:once}}"}"#);
295        let template = Template::parse(f.path()).unwrap();
296        let result = template.generate_one();
297        assert!(
298            result.contains("once_secret_value"),
299            "expected 'once_secret_value' in output, got: {result}"
300        );
301    }
302
303    #[test]
304    fn parse_env_placeholder_empty_var_name_is_error() {
305        // {{ENV:}} has an empty variable name and must produce InvalidEnvVarName
306        let f = write_temp(r#"{"token": "{{ENV:}}"}"#);
307        let result = Template::parse(f.path());
308        assert!(
309            result.is_err(),
310            "expected parse to fail for empty ENV var name"
311        );
312        assert!(
313            matches!(result.err(), Some(TemplateError::InvalidEnvVarName(_))),
314            "expected InvalidEnvVarName error variant"
315        );
316    }
317}