Skip to main content

mockd/
template.rs

1//! Minimal template rendering for mockd responses.
2//!
3//! Templates are expressions of the form `{{ namespace.key }}` embedded in
4//! string values inside a JSON response body. The supported namespaces are:
5//!
6//! - `path.<name>` — a captured path parameter, e.g. `{{path.id}}`.
7//! - `query.<name>` — a query parameter, e.g. `{{query.role}}`.
8//! - `header.<name>` — a request header, e.g. `{{header.x-tenant-id}}`.
9//! - `body.<json.path>` — a value extracted from the JSON request body using
10//!   dot navigation through objects and array indices, e.g.
11//!   `{{body.user.name}}` or `{{body.items.0.id}}`.
12//!
13//! In addition, the following helper functions are available (called without
14//! a namespace):
15//!
16//! - `{{uuid}}` — a fresh UUIDv4 string, e.g. `550e8400-e29b-41d4-a716-446655440000`.
17//! - `{{now}}` — the current UTC time as an ISO 8601 string, e.g.
18//!   `2024-01-15T12:34:56Z`.
19//! - `{{randomInt(min,max)}}` — a random integer in the inclusive range
20//!   `[min, max]`, e.g. `{{randomInt(1,100)}}`. Useful for generating IDs.
21//!
22//! ## Interpolation vs. coercion
23//!
24//! When a string value consists *exactly* of a single expression, the result is
25//! coerced into the most appropriate JSON type:
26//!
27//! - numeric strings become JSON numbers (`{{path.id}}` with `id = 42` → `42`),
28//! - `true` / `false` become booleans,
29//! - `null` becomes JSON null,
30//! - anything else stays a string.
31//!
32//! When an expression is part of a larger string, it is interpolated as text:
33//!
34//! `"user-{{path.id}}"` with `id = 42` → `"user-42"`.
35//!
36//! Unknown or missing variables resolve to an empty string during
37//! interpolation, and to JSON null when used as a whole-value coercion.
38//!
39//! Note: helper functions such as `{{uuid}}` produce a fresh value on every
40//! render and therefore never coerce to `null`.
41
42use std::collections::HashMap;
43
44use rand::Rng;
45use serde_json::Value;
46use time::macros::format_description;
47use time::OffsetDateTime;
48use uuid::Uuid;
49
50/// Lookup tables used while rendering templates.
51#[derive(Debug, Clone, Default)]
52pub struct TemplateContext {
53    /// Captured path parameters (e.g. `{"id": "42"}`).
54    pub path: HashMap<String, String>,
55    /// Query parameters.
56    pub query: HashMap<String, String>,
57    /// Request headers. Keys are expected to be lower-cased.
58    pub headers: HashMap<String, String>,
59    /// Parsed JSON request body (`Value::Null` when the body was not JSON).
60    pub body: Value,
61}
62
63impl TemplateContext {
64    /// Build an empty context.
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Resolve an expression to a string value.
70    ///
71    /// The expression may be:
72    /// - a helper function name (with or without arguments),
73    /// - `path.<key>`, `query.<key>`, `header.<key>`, or
74    /// - `body.<json.path>` (dot navigation through objects/arrays).
75    ///
76    /// Returns `None` when the expression is unknown or the value is absent.
77    /// Helper functions never return `None`.
78    pub fn lookup(&self, expression: &str) -> Option<String> {
79        // Helper functions take priority; they have no namespace prefix
80        // (or include parentheses for arguments).
81        if let Some(value) = lookup_function(expression) {
82            return Some(value);
83        }
84
85        let (namespace, rest) = expression.split_once('.')?;
86        match namespace {
87            "path" => self.path.get(rest).cloned(),
88            "query" => self.query.get(rest).cloned(),
89            "header" => self.header_lookup(rest),
90            "body" => lookup_body(&self.body, rest),
91            _ => None,
92        }
93    }
94
95    /// Case-insensitive header lookup. Path/query use exact keys.
96    fn header_lookup(&self, key: &str) -> Option<String> {
97        if let Some(v) = self.headers.get(key) {
98            return Some(v.clone());
99        }
100        let lower = key.to_ascii_lowercase();
101        self.headers.get(&lower).cloned()
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Helper functions
107// ---------------------------------------------------------------------------
108
109/// Resolve one of the built-in helper functions, or return `None` if `expr`
110/// does not name a function.
111fn lookup_function(expr: &str) -> Option<String> {
112    // Argument-taking form: `randomInt(min,max)`.
113    if let Some(args) = expr
114        .strip_prefix("randomInt(")
115        .and_then(|s| s.strip_suffix(')'))
116    {
117        let (lo, hi) = args.split_once(',')?;
118        let lo: i64 = lo.trim().parse().ok()?;
119        let hi: i64 = hi.trim().parse().ok()?;
120        if lo > hi {
121            return None;
122        }
123        let n = rand::thread_rng().gen_range(lo..=hi);
124        return Some(n.to_string());
125    }
126
127    // No-argument helpers.
128    match expr {
129        "uuid" => Some(Uuid::new_v4().to_string()),
130        "now" => Some(format_now_iso8601()),
131        "random" => {
132            let n: i64 = rand::thread_rng().gen();
133            Some(n.to_string())
134        }
135        _ => None,
136    }
137}
138
139/// Format the current UTC time as an ISO 8601 string (`YYYY-MM-DDTHH:MM:SSZ`).
140fn format_now_iso8601() -> String {
141    // The macro produces a `&'static [BorrowedFormatItem]` at compile time,
142    // so there is no per-call allocation and nothing to cache.
143    let format = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
144    OffsetDateTime::now_utc().format(format).unwrap_or_default()
145}
146
147/// Navigate `body` using dot-separated keys (object fields or array indices).
148fn lookup_body(body: &Value, path: &str) -> Option<String> {
149    let mut current = body;
150    for key in path.split('.') {
151        if key.is_empty() {
152            return None;
153        }
154        current = match current {
155            Value::Object(map) => map.get(key)?,
156            Value::Array(arr) => {
157                let idx: usize = key.parse().ok()?;
158                arr.get(idx)?
159            }
160            _ => return None,
161        };
162    }
163    Some(value_to_string(current))
164}
165
166/// Render a JSON value as a string suitable for template interpolation.
167fn value_to_string(v: &Value) -> String {
168    match v {
169        Value::String(s) => s.clone(),
170        Value::Number(n) => n.to_string(),
171        Value::Bool(b) => b.to_string(),
172        Value::Null => "null".to_string(),
173        // Objects and arrays are emitted as compact JSON.
174        other => other.to_string(),
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Rendering
180// ---------------------------------------------------------------------------
181
182/// Render every string value inside `value`, returning a new [`Value`].
183///
184/// Non-string values (numbers, booleans, arrays, objects, null) are returned
185/// unchanged, except that arrays and objects are traversed recursively.
186pub fn render(value: &Value, ctx: &TemplateContext) -> Value {
187    match value {
188        Value::String(s) => render_string(s, ctx),
189        Value::Array(items) => Value::Array(items.iter().map(|v| render(v, ctx)).collect()),
190        Value::Object(map) => {
191            let mut out = serde_json::Map::with_capacity(map.len());
192            for (k, v) in map {
193                out.insert(k.clone(), render(v, ctx));
194            }
195            Value::Object(out)
196        }
197        other => other.clone(),
198    }
199}
200
201/// Regex-free detection of a string that is *exactly* one template expression,
202/// possibly surrounded by whitespace.
203fn extract_single_expression(s: &str) -> Option<String> {
204    let trimmed = s.trim();
205    let inner = trimmed.strip_prefix("{{")?.strip_suffix("}}")?;
206    let expr = inner.trim();
207    // Must not contain another expression or closing markers in the middle.
208    if expr.contains("{{") || expr.contains("}}") {
209        return None;
210    }
211    Some(expr.to_string())
212}
213
214fn render_string(s: &str, ctx: &TemplateContext) -> Value {
215    // Whole-string expression: attempt type coercion.
216    if let Some(expr) = extract_single_expression(s) {
217        return match ctx.lookup(&expr) {
218            Some(raw) => coerce(&raw),
219            None => Value::Null,
220        };
221    }
222
223    // Otherwise: interpolate every `{{ ... }}` occurrence.
224    let mut out = String::with_capacity(s.len());
225    let mut rest = s;
226    while let Some(start) = rest.find("{{") {
227        out.push_str(&rest[..start]);
228        let after_open = &rest[start + 2..];
229        match after_open.find("}}") {
230            Some(end) => {
231                let expr = after_open[..end].trim();
232                if let Some(val) = ctx.lookup(expr) {
233                    out.push_str(&val);
234                }
235                rest = &after_open[end + 2..];
236            }
237            None => {
238                // Unbalanced `{{` — emit the rest verbatim.
239                out.push_str(&rest[start..]);
240                rest = "";
241            }
242        }
243    }
244    out.push_str(rest);
245    Value::String(out)
246}
247
248/// Coerce a raw string into the most appropriate JSON value.
249fn coerce(raw: &str) -> Value {
250    if raw.eq_ignore_ascii_case("true") {
251        return Value::Bool(true);
252    }
253    if raw.eq_ignore_ascii_case("false") {
254        return Value::Bool(false);
255    }
256    if raw.eq_ignore_ascii_case("null") {
257        return Value::Null;
258    }
259    if let Ok(n) = raw.parse::<i64>() {
260        return Value::from(n);
261    }
262    if let Ok(n) = raw.parse::<f64>() {
263        if n.is_finite() {
264            return serde_json::Number::from_f64(n)
265                .map(Value::Number)
266                .unwrap_or_else(|| Value::String(raw.to_string()));
267        }
268    }
269    Value::String(raw.to_string())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use serde_json::json;
276
277    fn ctx() -> TemplateContext {
278        let mut c = TemplateContext::new();
279        c.path.insert("id".into(), "42".into());
280        c.query.insert("role".into(), "admin".into());
281        c.headers.insert("x-tenant-id".into(), "tenant-a".into());
282        c
283    }
284
285    fn ctx_with_body() -> TemplateContext {
286        let mut c = ctx();
287        c.body = json!({
288            "user": {
289                "name": "alice",
290                "age": 30,
291                "roles": ["admin", "editor"],
292                "active": true,
293            },
294            "items": [
295                { "id": 1, "label": "first" },
296                { "id": 2, "label": "second" }
297            ]
298        });
299        c
300    }
301
302    #[test]
303    fn whole_string_number_is_coerced() {
304        let v = render(&json!("{{path.id}}"), &ctx());
305        assert_eq!(v, json!(42));
306    }
307
308    #[test]
309    fn whole_string_bool_is_coerced() {
310        let mut c = TemplateContext::new();
311        c.path.insert("flag".into(), "true".into());
312        let v = render(&json!("{{path.flag}}"), &c);
313        assert_eq!(v, json!(true));
314    }
315
316    #[test]
317    fn whole_string_null_is_coerced() {
318        let mut c = TemplateContext::new();
319        c.path.insert("nothing".into(), "null".into());
320        let v = render(&json!("{{path.nothing}}"), &c);
321        assert_eq!(v, Value::Null);
322    }
323
324    #[test]
325    fn whole_string_missing_is_null() {
326        let v = render(&json!("{{path.missing}}"), &ctx());
327        assert_eq!(v, Value::Null);
328    }
329
330    #[test]
331    fn interpolation_within_larger_string() {
332        let v = render(&json!("user-{{path.id}}"), &ctx());
333        assert_eq!(v, json!("user-42"));
334    }
335
336    #[test]
337    fn interpolation_multiple_expressions() {
338        let v = render(&json!("{{query.role}}@{{header.x-tenant-id}}"), &ctx());
339        assert_eq!(v, json!("admin@tenant-a"));
340    }
341
342    #[test]
343    fn header_lookup_is_case_insensitive() {
344        let v = render(&json!("{{header.X-Tenant-Id}}"), &ctx());
345        assert_eq!(v, json!("tenant-a"));
346    }
347
348    #[test]
349    fn renders_nested_objects_and_arrays() {
350        let body = json!({
351            "id": "{{path.id}}",
352            "label": "user-{{path.id}}",
353            "meta": {
354                "role": "{{query.role}}",
355                "tenant": "{{header.x-tenant-id}}"
356            },
357            "tags": ["{{query.role}}", "static"]
358        });
359        let v = render(&body, &ctx());
360        assert_eq!(
361            v,
362            json!({
363                "id": 42,
364                "label": "user-42",
365                "meta": {
366                    "role": "admin",
367                    "tenant": "tenant-a"
368                },
369                "tags": ["admin", "static"]
370            })
371        );
372    }
373
374    #[test]
375    fn leaves_non_string_values_untouched() {
376        let body = json!({"a": 1, "b": true, "c": null});
377        let v = render(&body, &ctx());
378        assert_eq!(v, body);
379    }
380
381    #[test]
382    fn unknown_namespace_yields_null() {
383        let v = render(&json!("{{cookie.sid}}"), &ctx());
384        assert_eq!(v, Value::Null);
385    }
386
387    #[test]
388    fn unbalanced_braces_emitted_verbatim() {
389        let v = render(&json!("value {{oops"), &ctx());
390        assert_eq!(v, json!("value {{oops"));
391    }
392
393    #[test]
394    fn empty_expression_resolves_to_empty_string_when_interpolated() {
395        // `{{}}` -> expr is empty -> lookup None -> interpolated as empty.
396        let v = render(&json!("a{{}}b"), &ctx());
397        assert_eq!(v, json!("ab"));
398    }
399
400    // -----------------------------------------------------------------
401    // body.* navigation
402    // -----------------------------------------------------------------
403
404    #[test]
405    fn body_lookup_object_field() {
406        let v = render(&json!("{{body.user.name}}"), &ctx_with_body());
407        assert_eq!(v, json!("alice"));
408    }
409
410    #[test]
411    fn body_lookup_number_is_coerced() {
412        let v = render(&json!("{{body.user.age}}"), &ctx_with_body());
413        assert_eq!(v, json!(30));
414    }
415
416    #[test]
417    fn body_lookup_array_index_then_field() {
418        let v = render(&json!("{{body.items.1.label}}"), &ctx_with_body());
419        assert_eq!(v, json!("second"));
420    }
421
422    #[test]
423    fn body_lookup_missing_path_is_null() {
424        let v = render(&json!("{{body.user.nope}}"), &ctx_with_body());
425        assert_eq!(v, Value::Null);
426    }
427
428    #[test]
429    fn body_lookup_interpolated_in_larger_string() {
430        let v = render(&json!("hello {{body.user.name}}!"), &ctx_with_body());
431        assert_eq!(v, json!("hello alice!"));
432    }
433
434    #[test]
435    fn body_lookup_when_body_is_null() {
436        // No JSON body -> Value::Null -> any body.* resolves to null.
437        let v = render(&json!("{{body.user.name}}"), &TemplateContext::new());
438        assert_eq!(v, Value::Null);
439    }
440
441    // -----------------------------------------------------------------
442    // helper functions
443    // -----------------------------------------------------------------
444
445    #[test]
446    fn uuid_renders_as_string() {
447        let v = render(&json!("{{uuid}}"), &TemplateContext::new());
448        let s = v.as_str().expect("uuid is a string");
449        // Sanity check: 36 chars with hyphens in the right positions.
450        assert_eq!(s.len(), 36);
451        assert_eq!(s.chars().filter(|&c| c == '-').count(), 4);
452    }
453
454    #[test]
455    fn uuid_is_unique_per_render() {
456        let a = render(&json!("{{uuid}}"), &TemplateContext::new());
457        let b = render(&json!("{{uuid}}"), &TemplateContext::new());
458        assert_ne!(a, b);
459    }
460
461    #[test]
462    fn uuid_within_larger_string() {
463        let v = render(&json!("id-{{uuid}}"), &TemplateContext::new());
464        let s = v.as_str().unwrap();
465        assert!(s.starts_with("id-"));
466        assert!(s.len() > 3);
467    }
468
469    #[test]
470    fn now_renders_as_iso8601_string() {
471        let v = render(&json!("{{now}}"), &TemplateContext::new());
472        let s = v.as_str().expect("now is a string");
473        // YYYY-MM-DDTHH:MM:SSZ -> 20 chars.
474        assert_eq!(s.len(), 20);
475        assert!(s.ends_with('Z'));
476    }
477
478    #[test]
479    fn random_int_within_bounds() {
480        for _ in 0..1000 {
481            let v = render(&json!("{{randomInt(1,10)}}"), &TemplateContext::new());
482            let n = v.as_i64().expect("randomInt yields a number");
483            assert!((1..=10).contains(&n));
484        }
485    }
486
487    #[test]
488    fn random_int_single_value() {
489        let v = render(&json!("{{randomInt(5,5)}}"), &TemplateContext::new());
490        assert_eq!(v, json!(5));
491    }
492
493    #[test]
494    fn random_int_inverted_range_resolves_to_null() {
495        // lo > hi -> lookup_function returns None -> coercion to null.
496        let v = render(&json!("{{randomInt(10,1)}}"), &TemplateContext::new());
497        assert_eq!(v, Value::Null);
498    }
499}