Skip to main content

drasi_source_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//! Template engine for webhook payload transformation.
16//!
17//! This module provides the HTTP-source-specific `TemplateContext` and delegates
18//! to the shared `SourceMappingEngine` from `drasi-source-mapping`.
19
20use crate::config::WebhookMapping;
21use anyhow::Result;
22use drasi_core::models::{ElementValue, SourceChange};
23use drasi_source_mapping::SourceMappingEngine;
24use serde_json::Value as JsonValue;
25use std::collections::HashMap;
26
27// Re-export json_to_element_value for backward compatibility
28pub use drasi_source_mapping::json_to_element_value;
29
30/// Template context containing all variables available in templates.
31///
32/// This is the HTTP-source-specific context. It gets serialized to JSON
33/// before being passed to the shared mapping engine.
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct TemplateContext {
36    /// Parsed payload body
37    pub payload: JsonValue,
38    /// Path parameters extracted from route
39    pub route: HashMap<String, String>,
40    /// Query parameters
41    pub query: HashMap<String, String>,
42    /// HTTP headers
43    pub headers: HashMap<String, String>,
44    /// HTTP method
45    pub method: String,
46    /// Request path
47    pub path: String,
48    /// Source ID
49    pub source_id: String,
50}
51
52/// Compiled template engine with pre-registered templates.
53///
54/// Delegates to `SourceMappingEngine` from `drasi-source-mapping`.
55pub struct TemplateEngine {
56    inner: SourceMappingEngine,
57}
58
59impl TemplateEngine {
60    /// Create a new template engine with custom helpers registered
61    pub fn new() -> Self {
62        Self {
63            inner: SourceMappingEngine::new(),
64        }
65    }
66
67    /// Render a template string with the given context
68    pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result<String> {
69        let json_context = context_to_json(context);
70        self.inner.render_string(template, &json_context)
71    }
72
73    /// Render a template and preserve the JSON value type
74    pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
75        let json_context = context_to_json(context);
76        self.inner.render_value(template, &json_context)
77    }
78
79    /// Process a webhook mapping and create a SourceChange
80    pub fn process_mapping(
81        &self,
82        mapping: &WebhookMapping,
83        context: &TemplateContext,
84        source_id: &str,
85    ) -> Result<SourceChange> {
86        let json_context = context_to_json(context);
87        self.inner
88            .process_mapping(mapping, &json_context, source_id)
89    }
90
91    /// Render a single property value (used internally by tests)
92    pub fn render_property_value(
93        &self,
94        value: &JsonValue,
95        context: &TemplateContext,
96    ) -> Result<ElementValue> {
97        // For property values that are template strings, render them
98        let json_context = context_to_json(context);
99        match value {
100            JsonValue::String(template) => {
101                let rendered = self.inner.render_value(template, &json_context)?;
102                json_to_element_value(&rendered)
103            }
104            other => json_to_element_value(other),
105        }
106    }
107}
108
109impl Default for TemplateEngine {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115/// Convert TemplateContext to JSON value for the shared engine
116fn context_to_json(context: &TemplateContext) -> JsonValue {
117    serde_json::to_value(context).unwrap_or(JsonValue::Null)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::config::{
124        ElementTemplate, ElementType, OperationType, TimestampFormat, WebhookMapping,
125    };
126    use drasi_core::models::{Element, ElementValue, SourceChange};
127
128    fn create_test_context() -> TemplateContext {
129        let payload = serde_json::json!({
130            "id": "123",
131            "name": "Test Event",
132            "value": 42,
133            "nested": {
134                "field": "nested_value"
135            },
136            "items": ["a", "b", "c"],
137            "customer": {
138                "name": "John",
139                "email": "john@example.com"
140            }
141        });
142
143        let mut route = HashMap::new();
144        route.insert("user_id".to_string(), "user_456".to_string());
145
146        let mut query = HashMap::new();
147        query.insert("filter".to_string(), "active".to_string());
148
149        let mut headers = HashMap::new();
150        headers.insert("X-Request-ID".to_string(), "req-789".to_string());
151
152        TemplateContext {
153            payload,
154            route,
155            query,
156            headers,
157            method: "POST".to_string(),
158            path: "/webhooks/test".to_string(),
159            source_id: "test-source".to_string(),
160        }
161    }
162
163    #[test]
164    fn test_simple_template_rendering() {
165        let engine = TemplateEngine::new();
166        let context = create_test_context();
167
168        let result = engine.render_string("{{payload.name}}", &context).unwrap();
169        assert_eq!(result, "Test Event");
170
171        let result = engine.render_string("{{route.user_id}}", &context).unwrap();
172        assert_eq!(result, "user_456");
173
174        let result = engine.render_string("{{method}}", &context).unwrap();
175        assert_eq!(result, "POST");
176    }
177
178    #[test]
179    fn test_nested_path_rendering() {
180        let engine = TemplateEngine::new();
181        let context = create_test_context();
182
183        let result = engine
184            .render_string("{{payload.nested.field}}", &context)
185            .unwrap();
186        assert_eq!(result, "nested_value");
187    }
188
189    #[test]
190    fn test_concatenation_template() {
191        let engine = TemplateEngine::new();
192        let context = create_test_context();
193
194        let result = engine
195            .render_string("event-{{payload.id}}-{{route.user_id}}", &context)
196            .unwrap();
197        assert_eq!(result, "event-123-user_456");
198    }
199
200    #[test]
201    fn test_lowercase_helper() {
202        let engine = TemplateEngine::new();
203        let context = create_test_context();
204
205        let result = engine
206            .render_string("{{lowercase payload.name}}", &context)
207            .unwrap();
208        assert_eq!(result, "test event");
209    }
210
211    #[test]
212    fn test_uppercase_helper() {
213        let engine = TemplateEngine::new();
214        let context = create_test_context();
215
216        let result = engine
217            .render_string("{{uppercase payload.name}}", &context)
218            .unwrap();
219        assert_eq!(result, "TEST EVENT");
220    }
221
222    #[test]
223    fn test_concat_helper() {
224        let engine = TemplateEngine::new();
225        let context = create_test_context();
226
227        let result = engine
228            .render_string("{{concat payload.id \"-\" route.user_id}}", &context)
229            .unwrap();
230        assert_eq!(result, "123-user_456");
231    }
232
233    #[test]
234    fn test_default_helper() {
235        let engine = TemplateEngine::new();
236        let context = create_test_context();
237
238        let result = engine
239            .render_string("{{default payload.missing \"fallback\"}}", &context)
240            .unwrap();
241        assert_eq!(result, "fallback");
242
243        let result = engine
244            .render_string("{{default payload.name \"fallback\"}}", &context)
245            .unwrap();
246        assert_eq!(result, "Test Event");
247    }
248
249    #[test]
250    fn test_json_helper() {
251        let engine = TemplateEngine::new();
252        let context = create_test_context();
253
254        let result = engine
255            .render_string("{{json payload.customer}}", &context)
256            .unwrap();
257        assert!(result.contains("\"name\":\"John\""));
258        assert!(result.contains("\"email\":\"john@example.com\""));
259    }
260
261    #[test]
262    fn test_render_value_preserves_types() {
263        let engine = TemplateEngine::new();
264        let context = create_test_context();
265
266        // Number should be preserved
267        let result = engine.render_value("{{payload.value}}", &context).unwrap();
268        assert_eq!(result, JsonValue::Number(42.into()));
269
270        // Object should be preserved
271        let result = engine
272            .render_value("{{payload.customer}}", &context)
273            .unwrap();
274        assert!(result.is_object());
275        assert_eq!(result["name"], "John");
276
277        // Array should be preserved
278        let result = engine.render_value("{{payload.items}}", &context).unwrap();
279        assert!(result.is_array());
280    }
281
282    #[test]
283    fn test_json_to_element_value() {
284        let json = serde_json::json!({
285            "string": "hello",
286            "number": 42,
287            "float": 3.15,
288            "bool": true,
289            "null": null,
290            "array": [1, 2, 3],
291            "object": {"key": "value"}
292        });
293
294        if let JsonValue::Object(obj) = json {
295            let string_val = json_to_element_value(&obj["string"]).unwrap();
296            assert!(matches!(string_val, ElementValue::String(_)));
297
298            let num_val = json_to_element_value(&obj["number"]).unwrap();
299            assert!(matches!(num_val, ElementValue::Integer(42)));
300
301            let bool_val = json_to_element_value(&obj["bool"]).unwrap();
302            assert!(matches!(bool_val, ElementValue::Bool(true)));
303
304            let null_val = json_to_element_value(&obj["null"]).unwrap();
305            assert!(matches!(null_val, ElementValue::Null));
306
307            let arr_val = json_to_element_value(&obj["array"]).unwrap();
308            assert!(matches!(arr_val, ElementValue::List(_)));
309
310            let obj_val = json_to_element_value(&obj["object"]).unwrap();
311            assert!(matches!(obj_val, ElementValue::Object(_)));
312        }
313    }
314
315    #[test]
316    fn test_process_mapping_insert() {
317        let engine = TemplateEngine::new();
318        let context = create_test_context();
319
320        let mapping = WebhookMapping {
321            when: None,
322            operation: Some(OperationType::Insert),
323            operation_from: None,
324            operation_map: None,
325            element_type: ElementType::Node,
326            effective_from: None,
327            template: ElementTemplate {
328                id: "event-{{payload.id}}".to_string(),
329                labels: vec!["Event".to_string(), "Test".to_string()],
330                properties: Some(serde_json::json!({
331                    "name": "{{payload.name}}",
332                    "value": "{{payload.value}}"
333                })),
334                from: None,
335                to: None,
336            },
337        };
338
339        let result = engine
340            .process_mapping(&mapping, &context, "test-source")
341            .unwrap();
342
343        match result {
344            SourceChange::Insert { element } => match element {
345                Element::Node {
346                    metadata,
347                    properties,
348                } => {
349                    assert_eq!(metadata.reference.element_id.as_ref(), "event-123");
350                    assert_eq!(metadata.labels.len(), 2);
351                    assert!(properties.get("name").is_some());
352                }
353                _ => panic!("Expected Node element"),
354            },
355            _ => panic!("Expected Insert operation"),
356        }
357    }
358
359    #[test]
360    fn test_process_mapping_relation() {
361        let engine = TemplateEngine::new();
362        let context = create_test_context();
363
364        let mapping = WebhookMapping {
365            when: None,
366            operation: Some(OperationType::Insert),
367            operation_from: None,
368            operation_map: None,
369            element_type: ElementType::Relation,
370            effective_from: None,
371            template: ElementTemplate {
372                id: "rel-{{payload.id}}".to_string(),
373                labels: vec!["LINKS_TO".to_string()],
374                properties: None,
375                from: Some("node-{{route.user_id}}".to_string()),
376                to: Some("node-{{payload.id}}".to_string()),
377            },
378        };
379
380        let result = engine
381            .process_mapping(&mapping, &context, "test-source")
382            .unwrap();
383
384        match result {
385            SourceChange::Insert { element } => match element {
386                Element::Relation {
387                    metadata,
388                    in_node,
389                    out_node,
390                    ..
391                } => {
392                    assert_eq!(metadata.reference.element_id.as_ref(), "rel-123");
393                    assert_eq!(out_node.element_id.as_ref(), "node-user_456");
394                    assert_eq!(in_node.element_id.as_ref(), "node-123");
395                }
396                _ => panic!("Expected Relation element"),
397            },
398            _ => panic!("Expected Insert operation"),
399        }
400    }
401
402    #[test]
403    fn test_process_mapping_with_operation_map() {
404        let engine = TemplateEngine::new();
405
406        let payload = serde_json::json!({
407            "id": "123",
408            "action": "created"
409        });
410
411        let context = TemplateContext {
412            payload,
413            route: HashMap::new(),
414            query: HashMap::new(),
415            headers: HashMap::new(),
416            method: "POST".to_string(),
417            path: "/events".to_string(),
418            source_id: "test".to_string(),
419        };
420
421        let mut operation_map = HashMap::new();
422        operation_map.insert("created".to_string(), OperationType::Insert);
423        operation_map.insert("updated".to_string(), OperationType::Update);
424        operation_map.insert("deleted".to_string(), OperationType::Delete);
425
426        let mapping = WebhookMapping {
427            when: None,
428            operation: None,
429            operation_from: Some("payload.action".to_string()),
430            operation_map: Some(operation_map),
431            element_type: ElementType::Node,
432            effective_from: None,
433            template: ElementTemplate {
434                id: "{{payload.id}}".to_string(),
435                labels: vec!["Event".to_string()],
436                properties: None,
437                from: None,
438                to: None,
439            },
440        };
441
442        let result = engine.process_mapping(&mapping, &context, "test").unwrap();
443        assert!(matches!(result, SourceChange::Insert { .. }));
444    }
445}