//! Template Resolver //! //! Resolves template variables in rule action parameters using context from //! event payloads, pack configuration, and system variables. //! //! Supports template syntax: `{{ source.path.to.value }}` //! //! ## Available Template Sources //! //! - `event.payload.*` — Fields from the event payload //! - `event.id` — The event's database ID //! - `event.trigger` — The trigger ref that generated the event //! - `event.created` — The event's creation timestamp //! - `pack.config.*` — Pack configuration values //! - `system.*` — System-provided variables (timestamp, rule info, etc.) //! //! ## Example //! //! ```rust //! use serde_json::json; //! use attune_common::template_resolver::{TemplateContext, resolve_templates}; //! //! let context = TemplateContext::new( //! json!({"service": "api-gateway"}), //! json!({}), //! json!({}), //! ) //! .with_event_id(42) //! .with_event_trigger("core.webhook") //! .with_event_created("2026-02-05T10:00:00Z"); //! //! let params = json!({ //! "message": "Error in {{ event.payload.service }}", //! "trigger": "{{ event.trigger }}", //! "event_id": "{{ event.id }}" //! }); //! //! let resolved = resolve_templates(¶ms, &context).unwrap(); //! assert_eq!(resolved["message"], "Error in api-gateway"); //! assert_eq!(resolved["trigger"], "core.webhook"); //! assert_eq!(resolved["event_id"], 42); //! ``` use anyhow::Result; use regex::Regex; use serde_json::Value as JsonValue; use std::sync::LazyLock; use tracing::{debug, warn}; /// Template context containing all available data sources for template resolution. /// /// The context is structured around three namespaces: /// - `event` — Event data including payload, id, trigger ref, and created timestamp /// - `pack.config` — Pack configuration values /// - `system` — System-provided variables #[derive(Debug, Clone)] pub struct TemplateContext { /// Event data (payload, id, trigger, created) — accessed as `event.*` pub event: JsonValue, /// Pack configuration — accessed as `pack.config.*` pub pack_config: JsonValue, /// System-provided variables — accessed as `system.*` pub system_vars: JsonValue, } impl TemplateContext { /// Create a new template context with an event payload. /// /// The payload is nested under `event.payload`. Use builder methods /// to add event metadata (`with_event_id`, `with_event_trigger`, `with_event_created`). pub fn new(event_payload: JsonValue, pack_config: JsonValue, system_vars: JsonValue) -> Self { let event = serde_json::json!({ "payload": event_payload, }); Self { event, pack_config, system_vars, } } /// Set the event ID in the context (accessible as `{{ event.id }}`). pub fn with_event_id(mut self, id: i64) -> Self { if let Some(obj) = self.event.as_object_mut() { obj.insert("id".to_string(), serde_json::json!(id)); } self } /// Set the trigger ref in the context (accessible as `{{ event.trigger }}`). pub fn with_event_trigger(mut self, trigger_ref: &str) -> Self { if let Some(obj) = self.event.as_object_mut() { obj.insert("trigger".to_string(), serde_json::json!(trigger_ref)); } self } /// Set the event created timestamp in the context (accessible as `{{ event.created }}`). pub fn with_event_created(mut self, created: &str) -> Self { if let Some(obj) = self.event.as_object_mut() { obj.insert("created".to_string(), serde_json::json!(created)); } self } /// Get a value from the context using a dotted path. /// /// Supports paths like: /// - `event.payload.field` — event payload data /// - `event.id` — event ID /// - `event.trigger` — trigger ref /// - `event.created` — creation timestamp /// - `pack.config.setting` — pack configuration /// - `system.timestamp` — system variables pub fn get_value(&self, path: &str) -> Option { let parts: Vec<&str> = path.split('.').collect(); if parts.is_empty() { return None; } // Determine the root source and how many path segments to skip let (root, skip_count) = match parts[0] { "event" => { // event.* paths navigate directly into the event JSON object // e.g. event.id, event.trigger, event.created, event.payload.field (&self.event, 1) } "pack" => { // pack.config.* paths if parts.len() < 2 || parts[1] != "config" { warn!("Invalid pack path: {}, expected 'pack.config.*'", path); return None; } (&self.pack_config, 2) } "system" => (&self.system_vars, 1), _ => { warn!("Unknown template source: {}", parts[0]); return None; } }; extract_nested_value(root, &parts[skip_count..]) } } /// Regex pattern to match template variables: {{ ... }} static TEMPLATE_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"\{\{\s*([^}]+?)\s*\}\}").expect("Failed to compile template regex") }); /// Resolve all template variables in a JSON value. /// /// Recursively processes objects and arrays, replacing template strings /// with values from the context. pub fn resolve_templates(value: &JsonValue, context: &TemplateContext) -> Result { match value { JsonValue::String(s) => resolve_string_template(s, context), JsonValue::Object(map) => { let mut resolved = serde_json::Map::new(); for (key, val) in map { resolved.insert(key.clone(), resolve_templates(val, context)?); } Ok(JsonValue::Object(resolved)) } JsonValue::Array(arr) => { let resolved: Result> = arr.iter().map(|v| resolve_templates(v, context)).collect(); Ok(JsonValue::Array(resolved?)) } // Pass through other types unchanged other => Ok(other.clone()), } } /// Resolve templates in a string value. /// /// If the string contains a single template that matches the entire string, /// returns the value with its original type (preserving numbers, booleans, etc). /// /// If the string contains multiple templates or mixed content, performs /// string interpolation. fn resolve_string_template(s: &str, context: &TemplateContext) -> Result { // Check if the entire string is a single template (for type preservation) if let Some(captures) = TEMPLATE_REGEX.captures(s) { let full_match = captures.get(0).unwrap(); if full_match.start() == 0 && full_match.end() == s.len() { // Single template - preserve type let path = captures.get(1).unwrap().as_str().trim(); debug!("Resolving single template: {}", path); return match context.get_value(path) { Some(value) => { debug!("Resolved {} -> {:?}", path, value); Ok(value) } None => { warn!("Template variable not found: {}", path); Ok(JsonValue::Null) } }; } } // Multiple templates or mixed content - perform string interpolation let mut result = s.to_string(); let mut any_replaced = false; for captures in TEMPLATE_REGEX.captures_iter(s) { let full_match = captures.get(0).unwrap().as_str(); let path = captures.get(1).unwrap().as_str().trim(); debug!("Resolving template in string: {}", path); match context.get_value(path) { Some(value) => { let replacement = value_to_string(&value); debug!("Resolved {} -> {}", path, replacement); result = result.replace(full_match, &replacement); any_replaced = true; } None => { warn!("Template variable not found: {}", path); result = result.replace(full_match, ""); } } } if any_replaced { debug!("String interpolation result: {}", result); } Ok(JsonValue::String(result)) } /// Extract a nested value from JSON using a path. fn extract_nested_value(root: &JsonValue, path: &[&str]) -> Option { if path.is_empty() { return Some(root.clone()); } let mut current = root; for part in path { match current { JsonValue::Object(map) => { current = map.get(*part)?; } JsonValue::Array(arr) => { // Try to parse part as array index if let Ok(index) = part.parse::() { current = arr.get(index)?; } else { return None; } } _ => return None, } } Some(current.clone()) } /// Convert a JSON value to a string for interpolation. fn value_to_string(value: &JsonValue) -> String { match value { JsonValue::String(s) => s.clone(), JsonValue::Number(n) => n.to_string(), JsonValue::Bool(b) => b.to_string(), JsonValue::Null => String::new(), JsonValue::Array(_) | JsonValue::Object(_) => { // For complex types, serialize as JSON serde_json::to_string(value).unwrap_or_default() } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn create_test_context() -> TemplateContext { TemplateContext::new( json!({ "service": "api-gateway", "message": "Connection timeout", "severity": "critical", "count": 42, "enabled": true, "metadata": { "host": "web-01", "port": 8080 }, "tags": ["production", "backend"] }), json!({ "api_token": "secret123", "alert_channel": "#incidents", "timeout": 30 }), json!({ "timestamp": "2026-01-17T15:30:00Z", "rule": { "id": 42, "ref": "test.rule" } }), ) .with_event_id(123) .with_event_trigger("core.error_event") .with_event_created("2026-01-17T15:30:00Z") } #[test] fn test_simple_string_substitution() { let context = create_test_context(); let template = json!({ "message": "Hello {{ event.payload.service }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["message"], "Hello api-gateway"); } #[test] fn test_single_template_type_preservation() { let context = create_test_context(); // Number let template = json!({"count": "{{ event.payload.count }}"}); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["count"], 42); // Boolean let template = json!({"enabled": "{{ event.payload.enabled }}"}); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["enabled"], true); } #[test] fn test_nested_object_access() { let context = create_test_context(); let template = json!({ "host": "{{ event.payload.metadata.host }}", "port": "{{ event.payload.metadata.port }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["host"], "web-01"); assert_eq!(result["port"], 8080); } #[test] fn test_array_access() { let context = create_test_context(); let template = json!({ "first_tag": "{{ event.payload.tags.0 }}", "second_tag": "{{ event.payload.tags.1 }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["first_tag"], "production"); assert_eq!(result["second_tag"], "backend"); } #[test] fn test_pack_config_reference() { let context = create_test_context(); let template = json!({ "token": "{{ pack.config.api_token }}", "channel": "{{ pack.config.alert_channel }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["token"], "secret123"); assert_eq!(result["channel"], "#incidents"); } #[test] fn test_system_variables() { let context = create_test_context(); let template = json!({ "timestamp": "{{ system.timestamp }}", "rule_id": "{{ system.rule.id }}", }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z"); assert_eq!(result["rule_id"], 42); } #[test] fn test_event_metadata_id() { let context = create_test_context(); let template = json!({ "event_id": "{{ event.id }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["event_id"], 123); } #[test] fn test_event_metadata_trigger() { let context = create_test_context(); let template = json!({ "trigger_ref": "{{ event.trigger }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["trigger_ref"], "core.error_event"); } #[test] fn test_event_metadata_created() { let context = create_test_context(); let template = json!({ "created_at": "{{ event.created }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["created_at"], "2026-01-17T15:30:00Z"); } #[test] fn test_event_metadata_in_interpolation() { let context = create_test_context(); let template = json!({ "summary": "Event {{ event.id }} from {{ event.trigger }} at {{ event.created }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!( result["summary"], "Event 123 from core.error_event at 2026-01-17T15:30:00Z" ); } #[test] fn test_missing_value_returns_null() { let context = create_test_context(); let template = json!({ "missing": "{{ event.payload.nonexistent }}" }); let result = resolve_templates(&template, &context).unwrap(); assert!(result["missing"].is_null()); } #[test] fn test_multiple_templates_in_string() { let context = create_test_context(); let template = json!({ "message": "Error in {{ event.payload.service }}: {{ event.payload.message }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!( result["message"], "Error in api-gateway: Connection timeout" ); } #[test] fn test_static_values_unchanged() { let context = create_test_context(); let template = json!({ "static": "This is static", "number": 123, "boolean": false }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["static"], "This is static"); assert_eq!(result["number"], 123); assert_eq!(result["boolean"], false); } #[test] fn test_nested_objects_and_arrays() { let context = create_test_context(); let template = json!({ "nested": { "field1": "{{ event.payload.service }}", "field2": "{{ pack.config.timeout }}" }, "array": [ "{{ event.payload.severity }}", "static value" ] }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["nested"]["field1"], "api-gateway"); assert_eq!(result["nested"]["field2"], 30); assert_eq!(result["array"][0], "critical"); assert_eq!(result["array"][1], "static value"); } #[test] fn test_empty_template_context() { let context = TemplateContext::new(json!({}), json!({}), json!({})); let template = json!({ "message": "{{ event.payload.missing }}" }); let result = resolve_templates(&template, &context).unwrap(); assert!(result["message"].is_null()); } #[test] fn test_whitespace_in_templates() { let context = create_test_context(); let template = json!({ "message": "{{ event.payload.service }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["message"], "api-gateway"); } #[test] fn test_complex_real_world_example() { let context = create_test_context(); let template = json!({ "channel": "{{ pack.config.alert_channel }}", "message": "🚨 Error in {{ event.payload.service }}: {{ event.payload.message }}", "severity": "{{ event.payload.severity }}", "details": { "host": "{{ event.payload.metadata.host }}", "count": "{{ event.payload.count }}", "tags": "{{ event.payload.tags }}", "event_id": "{{ event.id }}", "trigger": "{{ event.trigger }}" }, "timestamp": "{{ system.timestamp }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["channel"], "#incidents"); assert_eq!( result["message"], "🚨 Error in api-gateway: Connection timeout" ); assert_eq!(result["severity"], "critical"); assert_eq!(result["details"]["host"], "web-01"); assert_eq!(result["details"]["count"], 42); assert_eq!(result["details"]["event_id"], 123); assert_eq!(result["details"]["trigger"], "core.error_event"); assert_eq!(result["timestamp"], "2026-01-17T15:30:00Z"); } #[test] fn test_context_without_event_metadata() { // Context with only a payload — no id, trigger, or created let context = TemplateContext::new( json!({"service": "test"}), json!({}), json!({}), ); let template = json!({ "service": "{{ event.payload.service }}", "id": "{{ event.id }}", "trigger": "{{ event.trigger }}" }); let result = resolve_templates(&template, &context).unwrap(); assert_eq!(result["service"], "test"); // Missing metadata returns null assert!(result["id"].is_null()); assert!(result["trigger"].is_null()); } #[test] fn test_unknown_source() { let context = create_test_context(); let template = json!({ "value": "{{ unknown.field }}" }); let result = resolve_templates(&template, &context).unwrap(); assert!(result["value"].is_null()); } #[test] fn test_invalid_pack_path() { let context = create_test_context(); let template = json!({ "value": "{{ pack.invalid.field }}" }); let result = resolve_templates(&template, &context).unwrap(); assert!(result["value"].is_null()); } }