1use 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#[derive(Debug, Clone, serde::Serialize)]
38pub struct TemplateContext {
39 pub payload: JsonValue,
41 pub route: HashMap<String, String>,
43 pub query: HashMap<String, String>,
45 pub headers: HashMap<String, String>,
47 pub method: String,
49 pub path: String,
51 pub source_id: String,
53}
54
55pub struct TemplateEngine {
57 handlebars: Handlebars<'static>,
58}
59
60impl TemplateEngine {
61 pub fn new() -> Self {
63 let mut handlebars = Handlebars::new();
64 handlebars.set_strict_mode(false);
65
66 register_helpers(&mut handlebars);
68
69 Self { handlebars }
70 }
71
72 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 pub fn render_value(&self, template: &str, context: &TemplateContext) -> Result<JsonValue> {
84 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 let rendered = self.render_string(template, context)?;
93
94 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 pub fn process_mapping(
106 &self,
107 mapping: &WebhookMapping,
108 context: &TemplateContext,
109 source_id: &str,
110 ) -> Result<SourceChange> {
111 let operation = self.resolve_operation(mapping, context)?;
113
114 let effective_from = self.resolve_effective_from(mapping, context)?;
116
117 let element = self.build_element(mapping, context, source_id, effective_from)?;
119
120 match operation {
122 OperationType::Insert => Ok(SourceChange::Insert { element }),
123 OperationType::Update => Ok(SourceChange::Update { element }),
124 OperationType::Delete => {
125 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 fn resolve_operation(
137 &self,
138 mapping: &WebhookMapping,
139 context: &TemplateContext,
140 ) -> Result<OperationType> {
141 if let Some(ref op) = mapping.operation {
143 return Ok(op.clone());
144 }
145
146 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 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 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 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 let id = self.render_string(&template.id, context)?;
210 if id.is_empty() {
211 return Err(anyhow!("Template rendered empty ID"));
212 }
213
214 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 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 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 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 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 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
354fn register_helpers(handlebars: &mut Handlebars) {
356 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 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 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(¤t_time_millis().to_string())?;
407 Ok(())
408 },
409 ),
410 );
411
412 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(¶m.value().to_string());
428 }
429 }
430 out.write(&result)?;
431 Ok(())
432 },
433 ),
434 );
435
436 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 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
486fn 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 if !inner.contains(' ') && !inner.contains('#') && !inner.contains('/') {
493 return Some(inner.to_string());
494 }
495 }
496 None
497}
498
499fn 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
515fn context_to_json(context: &TemplateContext) -> JsonValue {
517 serde_json::to_value(context).unwrap_or(JsonValue::Null)
518}
519
520pub 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
549fn 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
557fn 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 let trimmed = value.trim();
565
566 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 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 if let Ok(num) = trimmed.parse::<i64>() {
582 let abs = num.unsigned_abs();
583 if abs < 10_000_000_000 {
585 return Ok(abs * 1000);
587 } else if abs < 10_000_000_000_000 {
588 return Ok(abs);
590 } else {
591 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
601fn 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 let result = engine.render_value("{{payload.value}}", &context).unwrap();
778 assert_eq!(result, JsonValue::Number(42.into()));
779
780 let result = engine
782 .render_value("{{payload.customer}}", &context)
783 .unwrap();
784 assert!(result.is_object());
785 assert_eq!(result["name"], "John");
786
787 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); 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); 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}