1use std::collections::HashMap;
43
44use rand::Rng;
45use serde_json::Value;
46use time::macros::format_description;
47use time::OffsetDateTime;
48use uuid::Uuid;
49
50#[derive(Debug, Clone, Default)]
52pub struct TemplateContext {
53 pub path: HashMap<String, String>,
55 pub query: HashMap<String, String>,
57 pub headers: HashMap<String, String>,
59 pub body: Value,
61}
62
63impl TemplateContext {
64 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn lookup(&self, expression: &str) -> Option<String> {
79 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 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
105fn lookup_function(expr: &str) -> Option<String> {
112 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 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
139fn format_now_iso8601() -> String {
141 let format = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
144 OffsetDateTime::now_utc().format(format).unwrap_or_default()
145}
146
147fn 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
166fn 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 other => other.to_string(),
175 }
176}
177
178pub 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
201fn 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 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 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 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 out.push_str(&rest[start..]);
240 rest = "";
241 }
242 }
243 }
244 out.push_str(rest);
245 Value::String(out)
246}
247
248fn 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 let v = render(&json!("a{{}}b"), &ctx());
397 assert_eq!(v, json!("ab"));
398 }
399
400 #[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 let v = render(&json!("{{body.user.name}}"), &TemplateContext::new());
438 assert_eq!(v, Value::Null);
439 }
440
441 #[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 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 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 let v = render(&json!("{{randomInt(10,1)}}"), &TemplateContext::new());
497 assert_eq!(v, Value::Null);
498 }
499}