Skip to main content

drasi_bootstrap_http/
template_engine.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Handlebars template engine for transforming API response items into
16//! Drasi graph elements.
17
18use anyhow::{anyhow, Result};
19use handlebars::Handlebars;
20use serde_json::Value as JsonValue;
21use std::collections::HashMap;
22
23/// Template context containing variables available in templates.
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct TemplateContext {
26    /// The current item being mapped.
27    pub item: JsonValue,
28    /// The index of the current item in the page.
29    pub index: usize,
30    /// Source ID.
31    pub source_id: String,
32}
33
34/// Compiled template engine with Handlebars.
35pub struct TemplateEngine {
36    handlebars: Handlebars<'static>,
37}
38
39impl Default for TemplateEngine {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl TemplateEngine {
46    /// Create a new template engine.
47    pub fn new() -> Self {
48        let mut handlebars = Handlebars::new();
49        handlebars.set_strict_mode(false);
50        Self { handlebars }
51    }
52
53    /// Render a template string with the given context.
54    pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result<String> {
55        self.handlebars
56            .render_template(template, context)
57            .map_err(|e| anyhow!("Template render error: {e}"))
58    }
59
60    /// Render a template and preserve the JSON value type.
61    ///
62    /// If the template is a simple variable reference like `{{item.field}}`,
63    /// this returns the original JSON value (preserving int/float/bool/string
64    /// types). Otherwise, it renders through Handlebars and returns a string.
65    pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
66        // If template is a simple reference, resolve directly to preserve type
67        if let Some(path) = extract_simple_path(template) {
68            let context_json = context_to_json(context);
69            if let Some(value) = resolve_path(&context_json, &path) {
70                return Ok(value.clone());
71            }
72        }
73
74        // Fall back to string rendering
75        let rendered = self.render_string(template, context)?;
76        if rendered.is_empty() {
77            Ok(JsonValue::Null)
78        } else {
79            Ok(JsonValue::String(rendered))
80        }
81    }
82
83    /// Render properties from a JSON value template specification.
84    /// Each value in the map is treated as a Handlebars template.
85    pub fn render_properties(
86        &self,
87        properties: &JsonValue,
88        context: &TemplateContext,
89    ) -> Result<HashMap<String, JsonValue>> {
90        match properties {
91            JsonValue::Object(map) => {
92                let mut result = HashMap::new();
93                for (key, value) in map {
94                    let rendered = self.render_property_value(value, context)?;
95                    result.insert(key.clone(), rendered);
96                }
97                Ok(result)
98            }
99            _ => Err(anyhow!("Properties must be a JSON object")),
100        }
101    }
102
103    /// Render a single property value.
104    fn render_property_value(
105        &self,
106        value: &JsonValue,
107        context: &TemplateContext,
108    ) -> Result<JsonValue> {
109        match value {
110            JsonValue::String(template) => self.render_value(template, context),
111            JsonValue::Object(map) => {
112                let mut result = serde_json::Map::new();
113                for (key, v) in map {
114                    let rendered = self.render_property_value(v, context)?;
115                    result.insert(key.clone(), rendered);
116                }
117                Ok(JsonValue::Object(result))
118            }
119            JsonValue::Array(arr) => {
120                let mut result = Vec::new();
121                for v in arr {
122                    let rendered = self.render_property_value(v, context)?;
123                    result.push(rendered);
124                }
125                Ok(JsonValue::Array(result))
126            }
127            // Non-string literals pass through unchanged
128            other => Ok(other.clone()),
129        }
130    }
131}
132
133/// Check if a template is a simple variable reference like `{{item.field}}`.
134/// Returns the path (e.g., `"item.field"`) if it is, None otherwise.
135fn extract_simple_path(template: &str) -> Option<String> {
136    let trimmed = template.trim();
137    if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
138        let inner = trimmed[2..trimmed.len() - 2].trim();
139        // Simple path: no spaces (helpers), no block markers, no extra braces
140        if !inner.contains(' ')
141            && !inner.contains('#')
142            && !inner.contains('/')
143            && !inner.contains('{')
144            && !inner.contains('}')
145        {
146            return Some(inner.to_string());
147        }
148    }
149    None
150}
151
152/// Resolve a dot-separated path in a JSON value.
153fn resolve_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
154    let mut current = value;
155    for part in path.split('.') {
156        current = match current {
157            JsonValue::Object(obj) => obj.get(part)?,
158            JsonValue::Array(arr) => {
159                let index: usize = part.parse().ok()?;
160                arr.get(index)?
161            }
162            _ => return None,
163        };
164    }
165    Some(current)
166}
167
168/// Convert TemplateContext to JSON for direct path resolution.
169fn context_to_json(context: &TemplateContext) -> JsonValue {
170    serde_json::to_value(context)
171        .expect("TemplateContext serialization should never fail (all fields are JSON-safe)")
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde_json::json;
178
179    #[test]
180    fn test_render_simple_template() {
181        let engine = TemplateEngine::new();
182        let context = TemplateContext {
183            item: json!({"id": "123", "name": "Alice"}),
184            index: 0,
185            source_id: "test-source".to_string(),
186        };
187
188        let result = engine.render_string("{{item.id}}", &context).unwrap();
189        assert_eq!(result, "123");
190
191        let result = engine.render_string("{{item.name}}", &context).unwrap();
192        assert_eq!(result, "Alice");
193    }
194
195    #[test]
196    fn test_render_value_preserves_types() {
197        let engine = TemplateEngine::new();
198        let context = TemplateContext {
199            item: json!({"id": "str-123", "count": 42, "rate": 3.15, "active": true}),
200            index: 0,
201            source_id: "test-source".to_string(),
202        };
203
204        // Simple references preserve original type
205        assert_eq!(
206            engine.render_value("{{item.id}}", &context).unwrap(),
207            json!("str-123")
208        );
209        assert_eq!(
210            engine.render_value("{{item.count}}", &context).unwrap(),
211            json!(42)
212        );
213        assert_eq!(
214            engine.render_value("{{item.rate}}", &context).unwrap(),
215            json!(3.15)
216        );
217        assert_eq!(
218            engine.render_value("{{item.active}}", &context).unwrap(),
219            json!(true)
220        );
221    }
222
223    #[test]
224    fn test_render_value_complex_template_returns_string() {
225        let engine = TemplateEngine::new();
226        let context = TemplateContext {
227            item: json!({"id": 42, "prefix": "user"}),
228            index: 0,
229            source_id: "test-source".to_string(),
230        };
231
232        // Complex templates always produce strings
233        let result = engine
234            .render_value("{{item.prefix}}-{{item.id}}", &context)
235            .unwrap();
236        assert_eq!(result, json!("user-42"));
237    }
238
239    #[test]
240    fn test_render_properties_preserves_types() {
241        let engine = TemplateEngine::new();
242        let context = TemplateContext {
243            item: json!({"id": "123", "name": "Alice", "age": 30}),
244            index: 0,
245            source_id: "test-source".to_string(),
246        };
247
248        let props = json!({
249            "name": "{{item.name}}",
250            "age": "{{item.age}}"
251        });
252
253        let result = engine.render_properties(&props, &context).unwrap();
254        assert_eq!(result["name"], json!("Alice"));
255        assert_eq!(result["age"], json!(30)); // preserved as integer
256    }
257
258    #[test]
259    fn test_render_missing_field() {
260        let engine = TemplateEngine::new();
261        let context = TemplateContext {
262            item: json!({"id": "123"}),
263            index: 0,
264            source_id: "test-source".to_string(),
265        };
266
267        // Non-strict mode: missing fields render as empty string
268        let result = engine
269            .render_string("{{item.nonexistent}}", &context)
270            .unwrap();
271        assert_eq!(result, "");
272    }
273
274    #[test]
275    fn test_extract_simple_path() {
276        assert_eq!(
277            extract_simple_path("{{item.id}}"),
278            Some("item.id".to_string())
279        );
280        assert_eq!(
281            extract_simple_path("{{item.nested.field}}"),
282            Some("item.nested.field".to_string())
283        );
284        // Complex templates are not simple paths
285        assert_eq!(extract_simple_path("{{item.a}}-{{item.b}}"), None);
286        assert_eq!(extract_simple_path("prefix-{{item.id}}"), None);
287        assert_eq!(extract_simple_path("static text"), None);
288    }
289
290    #[test]
291    fn test_string_value_not_coerced_to_int() {
292        let engine = TemplateEngine::new();
293        let context = TemplateContext {
294            item: json!({"id": "42"}), // String "42", not integer 42
295            index: 0,
296            source_id: "test-source".to_string(),
297        };
298
299        // Should stay as string since the original value is a string
300        let result = engine.render_value("{{item.id}}", &context).unwrap();
301        assert_eq!(result, json!("42"));
302        assert!(result.is_string());
303    }
304}