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::path::Path;
9
10use serde_json::Value;
11use tracing::instrument;
12
13pub use error::TemplateError;
14use generator::GeneratorContext;
15use renderer::{
16    CompiledTemplate, EnvPlaceholderHandler, GlobalPlaceholderHandler, PlaceholderHandler,
17};
18
19const METADATA_KEY: &str = "_lmn_metadata_templates";
20pub(crate) const ENV_PLACEHOLDER_PREFIX: &str = "ENV:";
21
22// ── Placeholder parsing ───────────────────────────────────────────────────────
23
24pub struct PlaceholderRef {
25    pub name: String,
26    pub global: bool,
27}
28
29/// Parses `{{name}}` or `{{name:global}}` from a string.
30/// Returns `None` if the string is not a placeholder.
31pub fn parse_placeholder(s: &str) -> Option<PlaceholderRef> {
32    let inner = s.trim().strip_prefix("{{")?.strip_suffix("}}")?;
33    if inner.is_empty() {
34        return None;
35    }
36    let (name, global) = match inner.strip_suffix(":global") {
37        Some(n) => (n, true),
38        None => (inner, false),
39    };
40    if name.is_empty() {
41        return None;
42    }
43    Some(PlaceholderRef {
44        name: name.to_string(),
45        global,
46    })
47}
48
49// ── Template ──────────────────────────────────────────────────────────────────
50
51pub struct Template {
52    compiled: CompiledTemplate,
53    context: GeneratorContext,
54}
55
56impl Template {
57    /// Reads, parses, and fully validates a template file.
58    /// Fails fast on any invalid configuration before any requests are made.
59    #[instrument(name = "lmn.template.parse", fields(path = %path.display()))]
60    pub fn parse(path: &Path) -> Result<Self, TemplateError> {
61        let content = std::fs::read_to_string(path)?;
62        let mut root: serde_json::Map<String, Value> = serde_json::from_str(&content)?;
63
64        // Extract metadata — it must not appear in request bodies
65        let metadata = root
66            .remove(METADATA_KEY)
67            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
68
69        let raw_defs: std::collections::HashMap<String, definition::RawTemplateDef> =
70            serde_json::from_value(metadata)?;
71        let defs = definition::validate_all(raw_defs)?;
72
73        let body = Value::Object(root);
74
75        // All body placeholders must have definitions
76        renderer::validate_placeholders(&body, &defs)?;
77
78        // Object compositions must not form cycles
79        definition::check_circular_refs(&defs)?;
80
81        let ctx = GeneratorContext::new(defs);
82
83        // Pre-resolve :global placeholders — same value reused across all requests.
84        let global_resolved = GlobalPlaceholderHandler.resolve(&body, &ctx)?;
85
86        // Resolve ENV: placeholders — read from environment at startup (fail-closed).
87        let env_resolved = EnvPlaceholderHandler.resolve(&body, &ctx)?;
88
89        // Merge all pre-resolved values and compile the body into segments.
90        let mut all_resolved = global_resolved;
91        all_resolved.extend(env_resolved);
92
93        let compiled = CompiledTemplate::compile(&body)?;
94        // body is dropped here — no longer needed after compile.
95
96        Ok(Template {
97            compiled,
98            context: ctx.with_resolved(all_resolved),
99        })
100    }
101
102    /// Generates a single request body on demand.
103    /// Thread-safe: each call creates its own RNG state, so concurrent VU tasks
104    /// can call this simultaneously without contention.
105    #[instrument(name = "lmn.template.generate_one", skip(self))]
106    pub fn generate_one(&self) -> Result<String, TemplateError> {
107        let mut rng = rand::rng();
108        self.compiled.render(&self.context, &mut rng)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::io::Write;
116
117    fn write_temp(content: &str) -> tempfile::NamedTempFile {
118        let mut f = tempfile::NamedTempFile::new().unwrap();
119        f.write_all(content.as_bytes()).unwrap();
120        f
121    }
122
123    #[test]
124    fn parse_fails_on_missing_file() {
125        assert!(Template::parse(Path::new("nonexistent.json")).is_err());
126    }
127
128    #[test]
129    fn parse_fails_on_invalid_json() {
130        let f = write_temp("not json");
131        assert!(Template::parse(f.path()).is_err());
132    }
133
134    #[test]
135    fn parse_fails_on_unknown_placeholder() {
136        let f = write_temp(r#"{"field": "{{undefined}}"}"#);
137        assert!(Template::parse(f.path()).is_err());
138    }
139
140    #[test]
141    fn parse_fails_on_circular_reference() {
142        let f = write_temp(
143            r#"{
144            "field": "{{a}}",
145            "_lmn_metadata_templates": {
146                "a": { "type": "object", "composition": { "x": "{{b}}" } },
147                "b": { "type": "object", "composition": { "y": "{{a}}" } }
148            }
149        }"#,
150        );
151        assert!(Template::parse(f.path()).is_err());
152    }
153
154    #[test]
155    fn parse_succeeds_with_no_placeholders() {
156        let f = write_temp(r#"{"field": "static"}"#);
157        assert!(Template::parse(f.path()).is_ok());
158    }
159
160    #[test]
161    fn generate_one_returns_valid_json() {
162        let f = write_temp(r#"{"field": "static", "value": 42}"#);
163        let template = Template::parse(f.path()).unwrap();
164        let result = template.generate_one().unwrap();
165        let parsed: serde_json::Value =
166            serde_json::from_str(&result).expect("generate_one must return valid JSON");
167        assert_eq!(
168            parsed["field"],
169            serde_json::Value::String("static".to_string())
170        );
171        assert_eq!(parsed["value"], serde_json::json!(42));
172    }
173
174    #[test]
175    fn generate_one_is_independent_per_call() {
176        let f = write_temp(r#"{"field": "static"}"#);
177        let template = Template::parse(f.path()).unwrap();
178        let a = template.generate_one().unwrap();
179        let b = template.generate_one().unwrap();
180        assert!(serde_json::from_str::<serde_json::Value>(&a).is_ok());
181        assert!(serde_json::from_str::<serde_json::Value>(&b).is_ok());
182    }
183
184    #[test]
185    fn parse_env_placeholder_resolved_from_env() {
186        unsafe { std::env::set_var("LUMEN_TEST_TOKEN", "secret123") };
187        let f = write_temp(r#"{"token": "{{ENV:LUMEN_TEST_TOKEN}}"}"#);
188        let template = Template::parse(f.path()).unwrap();
189        let result = template.generate_one().unwrap();
190        assert!(
191            result.contains("secret123"),
192            "expected 'secret123' in output, got: {result}"
193        );
194    }
195
196    #[test]
197    fn parse_env_placeholder_missing_var_is_error() {
198        unsafe { std::env::remove_var("LUMEN_NONEXISTENT_12345") };
199        let f = write_temp(r#"{"token": "{{ENV:LUMEN_NONEXISTENT_12345}}"}"#);
200        let result = Template::parse(f.path());
201        assert!(
202            result.is_err(),
203            "expected parse to fail for missing env var"
204        );
205        assert!(
206            matches!(result.err(), Some(TemplateError::MissingEnvVar(_))),
207            "expected MissingEnvVar error variant"
208        );
209    }
210
211    #[test]
212    fn parse_env_placeholder_no_def_required() {
213        unsafe { std::env::set_var("LUMEN_TEST_TOKEN", "anyvalue") };
214        let f =
215            write_temp(r#"{"token": "{{ENV:LUMEN_TEST_TOKEN}}", "_lmn_metadata_templates": {}}"#);
216        assert!(Template::parse(f.path()).is_ok());
217    }
218
219    #[test]
220    fn parse_env_placeholder_with_global_suffix_resolves_correctly() {
221        // {{ENV:LUMEN_TEST_GLOBAL_TOKEN:global}} — the :global suffix is parsed away by
222        // parse_placeholder, leaving name = "ENV:LUMEN_TEST_GLOBAL_TOKEN". The global
223        // handler skips it (ENV: prefix), and the ENV resolution path resolves it correctly.
224        unsafe { std::env::set_var("LUMEN_TEST_GLOBAL_TOKEN", "global_secret_value") };
225        let f = write_temp(r#"{"token": "{{ENV:LUMEN_TEST_GLOBAL_TOKEN:global}}"}"#);
226        let template = Template::parse(f.path()).unwrap();
227        let result = template.generate_one().unwrap();
228        assert!(
229            result.contains("global_secret_value"),
230            "expected 'global_secret_value' in output, got: {result}"
231        );
232    }
233
234    #[test]
235    fn parse_env_placeholder_empty_var_name_is_error() {
236        // {{ENV:}} has an empty variable name and must produce InvalidEnvVarName
237        let f = write_temp(r#"{"token": "{{ENV:}}"}"#);
238        let result = Template::parse(f.path());
239        assert!(
240            result.is_err(),
241            "expected parse to fail for empty ENV var name"
242        );
243        assert!(
244            matches!(result.err(), Some(TemplateError::InvalidEnvVarName(_))),
245            "expected InvalidEnvVarName error variant"
246        );
247    }
248}