1use 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
27pub use drasi_source_mapping::json_to_element_value;
29
30#[derive(Debug, Clone, serde::Serialize)]
35pub struct TemplateContext {
36 pub payload: JsonValue,
38 pub route: HashMap<String, String>,
40 pub query: HashMap<String, String>,
42 pub headers: HashMap<String, String>,
44 pub method: String,
46 pub path: String,
48 pub source_id: String,
50}
51
52pub struct TemplateEngine {
56 inner: SourceMappingEngine,
57}
58
59impl TemplateEngine {
60 pub fn new() -> Self {
62 Self {
63 inner: SourceMappingEngine::new(),
64 }
65 }
66
67 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 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 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 pub fn render_property_value(
93 &self,
94 value: &JsonValue,
95 context: &TemplateContext,
96 ) -> Result<ElementValue> {
97 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
115fn 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 let result = engine.render_value("{{payload.value}}", &context).unwrap();
268 assert_eq!(result, JsonValue::Number(42.into()));
269
270 let result = engine
272 .render_value("{{payload.customer}}", &context)
273 .unwrap();
274 assert!(result.is_object());
275 assert_eq!(result["name"], "John");
276
277 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}