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//! Uses Handlebars templates to transform webhook payloads into
18//! Drasi source change events.
19
20use crate::config::{
21    EffectiveFromConfig, ElementTemplate, ElementType, OperationType, TimestampFormat,
22    WebhookMapping,
23};
24use anyhow::{anyhow, Result};
25use drasi_core::models::{
26    Element, ElementMetadata, ElementPropertyMap, ElementReference, ElementValue, SourceChange,
27};
28use handlebars::{
29    Context, Handlebars, Helper, HelperResult, Output, RenderContext, RenderErrorReason,
30};
31use ordered_float::OrderedFloat;
32use serde_json::Value as JsonValue;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36/// Template context containing all variables available in templates
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct TemplateContext {
39    /// Parsed payload body
40    pub payload: JsonValue,
41    /// Path parameters extracted from route
42    pub route: HashMap<String, String>,
43    /// Query parameters
44    pub query: HashMap<String, String>,
45    /// HTTP headers
46    pub headers: HashMap<String, String>,
47    /// HTTP method
48    pub method: String,
49    /// Request path
50    pub path: String,
51    /// Source ID
52    pub source_id: String,
53}
54
55/// Compiled template engine with pre-registered templates
56pub struct TemplateEngine {
57    handlebars: Handlebars<'static>,
58}
59
60impl TemplateEngine {
61    /// Create a new template engine with custom helpers registered
62    pub fn new() -> Self {
63        let mut handlebars = Handlebars::new();
64        handlebars.set_strict_mode(false);
65
66        // Register custom helpers
67        register_helpers(&mut handlebars);
68
69        Self { handlebars }
70    }
71
72    /// Render a template string with the given context
73    pub fn render_string(&self, template: &str, context: &TemplateContext) -> Result<String> {
74        self.handlebars
75            .render_template(template, context)
76            .map_err(|e| anyhow!("Template render error: {e}"))
77    }
78
79    /// Render a template and preserve the JSON value type
80    ///
81    /// If the template is a simple variable reference like `{{payload.field}}`,
82    /// this returns the original JSON value. Otherwise, it returns the rendered string.
83    pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
84        // Check if this is a simple variable reference
85        if let Some(path) = extract_simple_path(template) {
86            if let Some(value) = resolve_path(&context_to_json(context), &path) {
87                return Ok(value.clone());
88            }
89        }
90
91        // Fall back to string rendering
92        let rendered = self.render_string(template, context)?;
93
94        // Try to parse as JSON, otherwise return as string
95        if rendered.is_empty() {
96            Ok(JsonValue::Null)
97        } else if let Ok(parsed) = serde_json::from_str::<JsonValue>(&rendered) {
98            Ok(parsed)
99        } else {
100            Ok(JsonValue::String(rendered))
101        }
102    }
103
104    /// Process a webhook mapping and create a SourceChange
105    pub fn process_mapping(
106        &self,
107        mapping: &WebhookMapping,
108        context: &TemplateContext,
109        source_id: &str,
110    ) -> Result<SourceChange> {
111        // Determine the operation
112        let operation = self.resolve_operation(mapping, context)?;
113
114        // Get effective_from timestamp
115        let effective_from = self.resolve_effective_from(mapping, context)?;
116
117        // Build the element based on type
118        let element = self.build_element(mapping, context, source_id, effective_from)?;
119
120        // Create the appropriate SourceChange
121        match operation {
122            OperationType::Insert => Ok(SourceChange::Insert { element }),
123            OperationType::Update => Ok(SourceChange::Update { element }),
124            OperationType::Delete => {
125                // For delete, we only need metadata
126                let metadata = match element {
127                    Element::Node { metadata, .. } => metadata,
128                    Element::Relation { metadata, .. } => metadata,
129                };
130                Ok(SourceChange::Delete { metadata })
131            }
132        }
133    }
134
135    /// Resolve the operation type from mapping configuration
136    fn resolve_operation(
137        &self,
138        mapping: &WebhookMapping,
139        context: &TemplateContext,
140    ) -> Result<OperationType> {
141        // If static operation is defined, use it
142        if let Some(ref op) = mapping.operation {
143            return Ok(op.clone());
144        }
145
146        // Otherwise, extract from payload using operation_from
147        let op_path = mapping
148            .operation_from
149            .as_ref()
150            .ok_or_else(|| anyhow!("No operation or operation_from specified"))?;
151
152        let op_map = mapping
153            .operation_map
154            .as_ref()
155            .ok_or_else(|| anyhow!("operation_map required when using operation_from"))?;
156
157        // Resolve the path value
158        let context_json = context_to_json(context);
159        let value = resolve_path(&context_json, op_path)
160            .ok_or_else(|| anyhow!("operation_from path '{op_path}' not found in context"))?;
161
162        let value_str = match value {
163            JsonValue::String(s) => s.clone(),
164            JsonValue::Number(n) => n.to_string(),
165            JsonValue::Bool(b) => b.to_string(),
166            _ => return Err(anyhow!("operation_from value must be a string or number")),
167        };
168
169        op_map
170            .get(&value_str)
171            .cloned()
172            .ok_or_else(|| anyhow!("No operation mapping found for value '{value_str}'"))
173    }
174
175    /// Resolve effective_from timestamp
176    fn resolve_effective_from(
177        &self,
178        mapping: &WebhookMapping,
179        context: &TemplateContext,
180    ) -> Result<u64> {
181        let Some(ref config) = mapping.effective_from else {
182            return Ok(current_time_millis());
183        };
184
185        let (template, format) = match config {
186            EffectiveFromConfig::Simple(t) => (t.as_str(), None),
187            EffectiveFromConfig::Explicit { value, format } => (value.as_str(), Some(format)),
188        };
189
190        let rendered = self.render_string(template, context)?;
191        if rendered.is_empty() {
192            return Ok(current_time_millis());
193        }
194
195        parse_timestamp(&rendered, format)
196    }
197
198    /// Build an Element from the template
199    fn build_element(
200        &self,
201        mapping: &WebhookMapping,
202        context: &TemplateContext,
203        source_id: &str,
204        effective_from: u64,
205    ) -> Result<Element> {
206        let template = &mapping.template;
207
208        // Render ID
209        let id = self.render_string(&template.id, context)?;
210        if id.is_empty() {
211            return Err(anyhow!("Template rendered empty ID"));
212        }
213
214        // Render labels
215        let labels: Result<Vec<Arc<str>>> = template
216            .labels
217            .iter()
218            .map(|l| {
219                let rendered = self.render_string(l, context)?;
220                Ok(Arc::from(rendered.as_str()))
221            })
222            .collect();
223        let labels = labels?;
224
225        // Build metadata
226        let metadata = ElementMetadata {
227            reference: ElementReference {
228                source_id: Arc::from(source_id),
229                element_id: Arc::from(id.as_str()),
230            },
231            labels: Arc::from(labels),
232            effective_from,
233        };
234
235        // Render properties
236        let properties = self.render_properties(template, context)?;
237
238        match mapping.element_type {
239            ElementType::Node => Ok(Element::Node {
240                metadata,
241                properties,
242            }),
243            ElementType::Relation => {
244                let from_template = template
245                    .from
246                    .as_ref()
247                    .ok_or_else(|| anyhow!("Relation template missing 'from' field"))?;
248                let to_template = template
249                    .to
250                    .as_ref()
251                    .ok_or_else(|| anyhow!("Relation template missing 'to' field"))?;
252
253                let from_id = self.render_string(from_template, context)?;
254                let to_id = self.render_string(to_template, context)?;
255
256                Ok(Element::Relation {
257                    metadata,
258                    properties,
259                    in_node: ElementReference {
260                        source_id: Arc::from(source_id),
261                        element_id: Arc::from(to_id.as_str()),
262                    },
263                    out_node: ElementReference {
264                        source_id: Arc::from(source_id),
265                        element_id: Arc::from(from_id.as_str()),
266                    },
267                })
268            }
269        }
270    }
271
272    /// Render properties from template
273    fn render_properties(
274        &self,
275        template: &ElementTemplate,
276        context: &TemplateContext,
277    ) -> Result<ElementPropertyMap> {
278        let mut props = ElementPropertyMap::new();
279
280        let Some(ref prop_value) = template.properties else {
281            return Ok(props);
282        };
283
284        match prop_value {
285            JsonValue::Object(obj) => {
286                for (key, value) in obj {
287                    let rendered = self.render_property_value(value, context)?;
288                    props.insert(key, rendered);
289                }
290            }
291            JsonValue::String(template_str) => {
292                // Single template that should resolve to an object
293                let rendered = self.render_value(template_str, context)?;
294                if let JsonValue::Object(obj) = rendered {
295                    for (key, value) in obj {
296                        props.insert(&key, json_to_element_value(&value)?);
297                    }
298                }
299            }
300            _ => {
301                return Err(anyhow!("Properties must be an object or a template string"));
302            }
303        }
304
305        Ok(props)
306    }
307
308    /// Render a single property value
309    fn render_property_value(
310        &self,
311        value: &JsonValue,
312        context: &TemplateContext,
313    ) -> Result<ElementValue> {
314        match value {
315            JsonValue::String(template) => {
316                let rendered = self.render_value(template, context)?;
317                json_to_element_value(&rendered)
318            }
319            JsonValue::Number(n) => {
320                if let Some(i) = n.as_i64() {
321                    Ok(ElementValue::Integer(i))
322                } else if let Some(f) = n.as_f64() {
323                    Ok(ElementValue::Float(OrderedFloat(f)))
324                } else {
325                    Err(anyhow!("Invalid number"))
326                }
327            }
328            JsonValue::Bool(b) => Ok(ElementValue::Bool(*b)),
329            JsonValue::Null => Ok(ElementValue::Null),
330            JsonValue::Array(arr) => {
331                let items: Result<Vec<_>> = arr
332                    .iter()
333                    .map(|v| self.render_property_value(v, context))
334                    .collect();
335                Ok(ElementValue::List(items?))
336            }
337            JsonValue::Object(obj) => {
338                let mut map = ElementPropertyMap::new();
339                for (k, v) in obj {
340                    map.insert(k, self.render_property_value(v, context)?);
341                }
342                Ok(ElementValue::Object(map))
343            }
344        }
345    }
346}
347
348impl Default for TemplateEngine {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354/// Register custom Handlebars helpers
355fn register_helpers(handlebars: &mut Handlebars) {
356    // lowercase helper
357    handlebars.register_helper(
358        "lowercase",
359        Box::new(
360            |h: &Helper,
361             _: &Handlebars,
362             _: &Context,
363             _: &mut RenderContext,
364             out: &mut dyn Output|
365             -> HelperResult {
366                let param = h
367                    .param(0)
368                    .ok_or(RenderErrorReason::ParamNotFoundForIndex("lowercase", 0))?;
369                let value = param.value().as_str().unwrap_or("");
370                out.write(&value.to_lowercase())?;
371                Ok(())
372            },
373        ),
374    );
375
376    // uppercase helper
377    handlebars.register_helper(
378        "uppercase",
379        Box::new(
380            |h: &Helper,
381             _: &Handlebars,
382             _: &Context,
383             _: &mut RenderContext,
384             out: &mut dyn Output|
385             -> HelperResult {
386                let param = h
387                    .param(0)
388                    .ok_or(RenderErrorReason::ParamNotFoundForIndex("uppercase", 0))?;
389                let value = param.value().as_str().unwrap_or("");
390                out.write(&value.to_uppercase())?;
391                Ok(())
392            },
393        ),
394    );
395
396    // now helper - returns current timestamp in milliseconds
397    handlebars.register_helper(
398        "now",
399        Box::new(
400            |_: &Helper,
401             _: &Handlebars,
402             _: &Context,
403             _: &mut RenderContext,
404             out: &mut dyn Output|
405             -> HelperResult {
406                out.write(&current_time_millis().to_string())?;
407                Ok(())
408            },
409        ),
410    );
411
412    // concat helper
413    handlebars.register_helper(
414        "concat",
415        Box::new(
416            |h: &Helper,
417             _: &Handlebars,
418             _: &Context,
419             _: &mut RenderContext,
420             out: &mut dyn Output|
421             -> HelperResult {
422                let mut result = String::new();
423                for param in h.params() {
424                    if let Some(s) = param.value().as_str() {
425                        result.push_str(s);
426                    } else {
427                        result.push_str(&param.value().to_string());
428                    }
429                }
430                out.write(&result)?;
431                Ok(())
432            },
433        ),
434    );
435
436    // default helper
437    handlebars.register_helper(
438        "default",
439        Box::new(
440            |h: &Helper,
441             _: &Handlebars,
442             _: &Context,
443             _: &mut RenderContext,
444             out: &mut dyn Output|
445             -> HelperResult {
446                let value = h.param(0).map(|p| p.value());
447                let default = h.param(1).map(|p| p.value());
448
449                let output = match value {
450                    Some(v) if !v.is_null() && v.as_str() != Some("") => v,
451                    _ => default.unwrap_or(&JsonValue::Null),
452                };
453
454                if let Some(s) = output.as_str() {
455                    out.write(s)?;
456                } else {
457                    out.write(&output.to_string())?;
458                }
459                Ok(())
460            },
461        ),
462    );
463
464    // json helper - serialize value to JSON string
465    handlebars.register_helper(
466        "json",
467        Box::new(
468            |h: &Helper,
469             _: &Handlebars,
470             _: &Context,
471             _: &mut RenderContext,
472             out: &mut dyn Output|
473             -> HelperResult {
474                let param = h
475                    .param(0)
476                    .ok_or(RenderErrorReason::ParamNotFoundForIndex("json", 0))?;
477                let json_str =
478                    serde_json::to_string(param.value()).unwrap_or_else(|_| "null".to_string());
479                out.write(&json_str)?;
480                Ok(())
481            },
482        ),
483    );
484}
485
486/// Extract a simple variable path from a template like `{{payload.field}}`
487fn extract_simple_path(template: &str) -> Option<String> {
488    let trimmed = template.trim();
489    if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
490        let inner = trimmed[2..trimmed.len() - 2].trim();
491        // Check if it's a simple path (no spaces, no helpers)
492        if !inner.contains(' ') && !inner.contains('#') && !inner.contains('/') {
493            return Some(inner.to_string());
494        }
495    }
496    None
497}
498
499/// Resolve a dot-separated path in a JSON value
500fn resolve_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
501    let mut current = value;
502    for part in path.split('.') {
503        current = match current {
504            JsonValue::Object(obj) => obj.get(part)?,
505            JsonValue::Array(arr) => {
506                let index: usize = part.parse().ok()?;
507                arr.get(index)?
508            }
509            _ => return None,
510        };
511    }
512    Some(current)
513}
514
515/// Convert TemplateContext to JSON value for path resolution
516fn context_to_json(context: &TemplateContext) -> JsonValue {
517    serde_json::to_value(context).unwrap_or(JsonValue::Null)
518}
519
520/// Convert JSON value to ElementValue
521pub fn json_to_element_value(value: &JsonValue) -> Result<ElementValue> {
522    match value {
523        JsonValue::Null => Ok(ElementValue::Null),
524        JsonValue::Bool(b) => Ok(ElementValue::Bool(*b)),
525        JsonValue::Number(n) => {
526            if let Some(i) = n.as_i64() {
527                Ok(ElementValue::Integer(i))
528            } else if let Some(f) = n.as_f64() {
529                Ok(ElementValue::Float(OrderedFloat(f)))
530            } else {
531                Err(anyhow!("Invalid number value"))
532            }
533        }
534        JsonValue::String(s) => Ok(ElementValue::String(Arc::from(s.as_str()))),
535        JsonValue::Array(arr) => {
536            let items: Result<Vec<_>> = arr.iter().map(json_to_element_value).collect();
537            Ok(ElementValue::List(items?))
538        }
539        JsonValue::Object(obj) => {
540            let mut map = ElementPropertyMap::new();
541            for (k, v) in obj {
542                map.insert(k, json_to_element_value(v)?);
543            }
544            Ok(ElementValue::Object(map))
545        }
546    }
547}
548
549/// Get current time in milliseconds
550fn current_time_millis() -> u64 {
551    std::time::SystemTime::now()
552        .duration_since(std::time::UNIX_EPOCH)
553        .map(|d| d.as_millis() as u64)
554        .unwrap_or(0)
555}
556
557/// Parse a timestamp string into milliseconds since epoch
558fn parse_timestamp(value: &str, format: Option<&TimestampFormat>) -> Result<u64> {
559    if let Some(fmt) = format {
560        return parse_with_format(value, fmt);
561    }
562
563    // Auto-detect format
564    let trimmed = value.trim();
565
566    // Try ISO 8601 first (contains 'T' or '-')
567    if trimmed.contains('T') || (trimmed.contains('-') && !trimmed.starts_with('-')) {
568        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
569            return Ok(dt.timestamp_millis() as u64);
570        }
571        // Try without timezone
572        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
573            return Ok(dt.and_utc().timestamp_millis() as u64);
574        }
575        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") {
576            return Ok(dt.and_utc().timestamp_millis() as u64);
577        }
578    }
579
580    // Try parsing as number
581    if let Ok(num) = trimmed.parse::<i64>() {
582        let abs = num.unsigned_abs();
583        // Heuristic based on magnitude
584        if abs < 10_000_000_000 {
585            // Seconds (before year 2286)
586            return Ok(abs * 1000);
587        } else if abs < 10_000_000_000_000 {
588            // Milliseconds
589            return Ok(abs);
590        } else {
591            // Nanoseconds
592            return Ok(abs / 1_000_000);
593        }
594    }
595
596    Err(anyhow!(
597        "Unable to parse timestamp '{value}'. Expected ISO 8601 or Unix timestamp"
598    ))
599}
600
601/// Parse timestamp with explicit format
602fn parse_with_format(value: &str, format: &TimestampFormat) -> Result<u64> {
603    match format {
604        TimestampFormat::Iso8601 => {
605            let dt = chrono::DateTime::parse_from_rfc3339(value.trim())
606                .map_err(|e| anyhow!("Invalid ISO 8601 timestamp: {e}"))?;
607            Ok(dt.timestamp_millis() as u64)
608        }
609        TimestampFormat::UnixSeconds => {
610            let secs: i64 = value
611                .trim()
612                .parse()
613                .map_err(|e| anyhow!("Invalid Unix seconds: {e}"))?;
614            Ok((secs * 1000) as u64)
615        }
616        TimestampFormat::UnixMillis => {
617            let millis: u64 = value
618                .trim()
619                .parse()
620                .map_err(|e| anyhow!("Invalid Unix milliseconds: {e}"))?;
621            Ok(millis)
622        }
623        TimestampFormat::UnixNanos => {
624            let nanos: u64 = value
625                .trim()
626                .parse()
627                .map_err(|e| anyhow!("Invalid Unix nanoseconds: {e}"))?;
628            Ok(nanos / 1_000_000)
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::config::{ElementTemplate, WebhookMapping};
637
638    fn create_test_context() -> TemplateContext {
639        let payload = serde_json::json!({
640            "id": "123",
641            "name": "Test Event",
642            "value": 42,
643            "nested": {
644                "field": "nested_value"
645            },
646            "items": ["a", "b", "c"],
647            "customer": {
648                "name": "John",
649                "email": "john@example.com"
650            }
651        });
652
653        let mut route = HashMap::new();
654        route.insert("user_id".to_string(), "user_456".to_string());
655
656        let mut query = HashMap::new();
657        query.insert("filter".to_string(), "active".to_string());
658
659        let mut headers = HashMap::new();
660        headers.insert("X-Request-ID".to_string(), "req-789".to_string());
661
662        TemplateContext {
663            payload,
664            route,
665            query,
666            headers,
667            method: "POST".to_string(),
668            path: "/webhooks/test".to_string(),
669            source_id: "test-source".to_string(),
670        }
671    }
672
673    #[test]
674    fn test_simple_template_rendering() {
675        let engine = TemplateEngine::new();
676        let context = create_test_context();
677
678        let result = engine.render_string("{{payload.name}}", &context).unwrap();
679        assert_eq!(result, "Test Event");
680
681        let result = engine.render_string("{{route.user_id}}", &context).unwrap();
682        assert_eq!(result, "user_456");
683
684        let result = engine.render_string("{{method}}", &context).unwrap();
685        assert_eq!(result, "POST");
686    }
687
688    #[test]
689    fn test_nested_path_rendering() {
690        let engine = TemplateEngine::new();
691        let context = create_test_context();
692
693        let result = engine
694            .render_string("{{payload.nested.field}}", &context)
695            .unwrap();
696        assert_eq!(result, "nested_value");
697    }
698
699    #[test]
700    fn test_concatenation_template() {
701        let engine = TemplateEngine::new();
702        let context = create_test_context();
703
704        let result = engine
705            .render_string("event-{{payload.id}}-{{route.user_id}}", &context)
706            .unwrap();
707        assert_eq!(result, "event-123-user_456");
708    }
709
710    #[test]
711    fn test_lowercase_helper() {
712        let engine = TemplateEngine::new();
713        let context = create_test_context();
714
715        let result = engine
716            .render_string("{{lowercase payload.name}}", &context)
717            .unwrap();
718        assert_eq!(result, "test event");
719    }
720
721    #[test]
722    fn test_uppercase_helper() {
723        let engine = TemplateEngine::new();
724        let context = create_test_context();
725
726        let result = engine
727            .render_string("{{uppercase payload.name}}", &context)
728            .unwrap();
729        assert_eq!(result, "TEST EVENT");
730    }
731
732    #[test]
733    fn test_concat_helper() {
734        let engine = TemplateEngine::new();
735        let context = create_test_context();
736
737        let result = engine
738            .render_string("{{concat payload.id \"-\" route.user_id}}", &context)
739            .unwrap();
740        assert_eq!(result, "123-user_456");
741    }
742
743    #[test]
744    fn test_default_helper() {
745        let engine = TemplateEngine::new();
746        let context = create_test_context();
747
748        let result = engine
749            .render_string("{{default payload.missing \"fallback\"}}", &context)
750            .unwrap();
751        assert_eq!(result, "fallback");
752
753        let result = engine
754            .render_string("{{default payload.name \"fallback\"}}", &context)
755            .unwrap();
756        assert_eq!(result, "Test Event");
757    }
758
759    #[test]
760    fn test_json_helper() {
761        let engine = TemplateEngine::new();
762        let context = create_test_context();
763
764        let result = engine
765            .render_string("{{json payload.customer}}", &context)
766            .unwrap();
767        assert!(result.contains("\"name\":\"John\""));
768        assert!(result.contains("\"email\":\"john@example.com\""));
769    }
770
771    #[test]
772    fn test_render_value_preserves_types() {
773        let engine = TemplateEngine::new();
774        let context = create_test_context();
775
776        // Number should be preserved
777        let result = engine.render_value("{{payload.value}}", &context).unwrap();
778        assert_eq!(result, JsonValue::Number(42.into()));
779
780        // Object should be preserved
781        let result = engine
782            .render_value("{{payload.customer}}", &context)
783            .unwrap();
784        assert!(result.is_object());
785        assert_eq!(result["name"], "John");
786
787        // Array should be preserved
788        let result = engine.render_value("{{payload.items}}", &context).unwrap();
789        assert!(result.is_array());
790    }
791
792    #[test]
793    fn test_json_to_element_value() {
794        let json = serde_json::json!({
795            "string": "hello",
796            "number": 42,
797            "float": 3.15,
798            "bool": true,
799            "null": null,
800            "array": [1, 2, 3],
801            "object": {"key": "value"}
802        });
803
804        if let JsonValue::Object(obj) = json {
805            let string_val = json_to_element_value(&obj["string"]).unwrap();
806            assert!(matches!(string_val, ElementValue::String(_)));
807
808            let num_val = json_to_element_value(&obj["number"]).unwrap();
809            assert!(matches!(num_val, ElementValue::Integer(42)));
810
811            let bool_val = json_to_element_value(&obj["bool"]).unwrap();
812            assert!(matches!(bool_val, ElementValue::Bool(true)));
813
814            let null_val = json_to_element_value(&obj["null"]).unwrap();
815            assert!(matches!(null_val, ElementValue::Null));
816
817            let arr_val = json_to_element_value(&obj["array"]).unwrap();
818            assert!(matches!(arr_val, ElementValue::List(_)));
819
820            let obj_val = json_to_element_value(&obj["object"]).unwrap();
821            assert!(matches!(obj_val, ElementValue::Object(_)));
822        }
823    }
824
825    #[test]
826    fn test_parse_timestamp_iso8601() {
827        let result = parse_timestamp("2024-01-15T10:30:00Z", None).unwrap();
828        assert!(result > 0);
829
830        let result =
831            parse_timestamp("2024-01-15T10:30:00Z", Some(&TimestampFormat::Iso8601)).unwrap();
832        assert!(result > 0);
833    }
834
835    #[test]
836    fn test_parse_timestamp_unix_seconds() {
837        let result = parse_timestamp("1705315800", None).unwrap();
838        assert_eq!(result, 1705315800000); // Converted to millis
839
840        let result = parse_timestamp("1705315800", Some(&TimestampFormat::UnixSeconds)).unwrap();
841        assert_eq!(result, 1705315800000);
842    }
843
844    #[test]
845    fn test_parse_timestamp_unix_millis() {
846        let result = parse_timestamp("1705315800000", None).unwrap();
847        assert_eq!(result, 1705315800000);
848
849        let result = parse_timestamp("1705315800000", Some(&TimestampFormat::UnixMillis)).unwrap();
850        assert_eq!(result, 1705315800000);
851    }
852
853    #[test]
854    fn test_parse_timestamp_unix_nanos() {
855        let result = parse_timestamp("1705315800000000000", None).unwrap();
856        assert_eq!(result, 1705315800000); // Converted to millis
857
858        let result =
859            parse_timestamp("1705315800000000000", Some(&TimestampFormat::UnixNanos)).unwrap();
860        assert_eq!(result, 1705315800000);
861    }
862
863    #[test]
864    fn test_process_mapping_insert() {
865        let engine = TemplateEngine::new();
866        let context = create_test_context();
867
868        let mapping = WebhookMapping {
869            when: None,
870            operation: Some(OperationType::Insert),
871            operation_from: None,
872            operation_map: None,
873            element_type: ElementType::Node,
874            effective_from: None,
875            template: ElementTemplate {
876                id: "event-{{payload.id}}".to_string(),
877                labels: vec!["Event".to_string(), "Test".to_string()],
878                properties: Some(serde_json::json!({
879                    "name": "{{payload.name}}",
880                    "value": "{{payload.value}}"
881                })),
882                from: None,
883                to: None,
884            },
885        };
886
887        let result = engine
888            .process_mapping(&mapping, &context, "test-source")
889            .unwrap();
890
891        match result {
892            SourceChange::Insert { element } => match element {
893                Element::Node {
894                    metadata,
895                    properties,
896                } => {
897                    assert_eq!(metadata.reference.element_id.as_ref(), "event-123");
898                    assert_eq!(metadata.labels.len(), 2);
899                    assert!(properties.get("name").is_some());
900                }
901                _ => panic!("Expected Node element"),
902            },
903            _ => panic!("Expected Insert operation"),
904        }
905    }
906
907    #[test]
908    fn test_process_mapping_relation() {
909        let engine = TemplateEngine::new();
910        let context = create_test_context();
911
912        let mapping = WebhookMapping {
913            when: None,
914            operation: Some(OperationType::Insert),
915            operation_from: None,
916            operation_map: None,
917            element_type: ElementType::Relation,
918            effective_from: None,
919            template: ElementTemplate {
920                id: "rel-{{payload.id}}".to_string(),
921                labels: vec!["LINKS_TO".to_string()],
922                properties: None,
923                from: Some("node-{{route.user_id}}".to_string()),
924                to: Some("node-{{payload.id}}".to_string()),
925            },
926        };
927
928        let result = engine
929            .process_mapping(&mapping, &context, "test-source")
930            .unwrap();
931
932        match result {
933            SourceChange::Insert { element } => match element {
934                Element::Relation {
935                    metadata,
936                    in_node,
937                    out_node,
938                    ..
939                } => {
940                    assert_eq!(metadata.reference.element_id.as_ref(), "rel-123");
941                    assert_eq!(out_node.element_id.as_ref(), "node-user_456");
942                    assert_eq!(in_node.element_id.as_ref(), "node-123");
943                }
944                _ => panic!("Expected Relation element"),
945            },
946            _ => panic!("Expected Insert operation"),
947        }
948    }
949
950    #[test]
951    fn test_process_mapping_with_operation_map() {
952        let engine = TemplateEngine::new();
953
954        let payload = serde_json::json!({
955            "id": "123",
956            "action": "created"
957        });
958
959        let context = TemplateContext {
960            payload,
961            route: HashMap::new(),
962            query: HashMap::new(),
963            headers: HashMap::new(),
964            method: "POST".to_string(),
965            path: "/events".to_string(),
966            source_id: "test".to_string(),
967        };
968
969        let mut operation_map = HashMap::new();
970        operation_map.insert("created".to_string(), OperationType::Insert);
971        operation_map.insert("updated".to_string(), OperationType::Update);
972        operation_map.insert("deleted".to_string(), OperationType::Delete);
973
974        let mapping = WebhookMapping {
975            when: None,
976            operation: None,
977            operation_from: Some("payload.action".to_string()),
978            operation_map: Some(operation_map),
979            element_type: ElementType::Node,
980            effective_from: None,
981            template: ElementTemplate {
982                id: "{{payload.id}}".to_string(),
983                labels: vec!["Event".to_string()],
984                properties: None,
985                from: None,
986                to: None,
987            },
988        };
989
990        let result = engine.process_mapping(&mapping, &context, "test").unwrap();
991        assert!(matches!(result, SourceChange::Insert { .. }));
992    }
993
994    #[test]
995    fn test_extract_simple_path() {
996        assert_eq!(
997            extract_simple_path("{{payload.id}}"),
998            Some("payload.id".to_string())
999        );
1000        assert_eq!(
1001            extract_simple_path("{{ payload.id }}"),
1002            Some("payload.id".to_string())
1003        );
1004        assert_eq!(extract_simple_path("{{#if condition}}"), None);
1005        assert_eq!(extract_simple_path("prefix-{{id}}"), None);
1006        assert_eq!(extract_simple_path("{{lowercase name}}"), None);
1007    }
1008}