dataflow_rs/engine/functions/
integration.rs1use crate::engine::error::Result;
2use crate::engine::functions::template::Template;
3use crate::engine::task_context::TaskContext;
4use serde::Deserialize;
5use serde_json::Value;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct HttpCallConfig {
19 pub connector: String,
21
22 #[serde(default = "default_method")]
24 pub method: HttpMethod,
25
26 #[serde(default)]
28 pub path: Option<String>,
29
30 #[serde(default)]
35 pub path_logic: Option<Template>,
36
37 #[serde(default)]
39 pub headers: HashMap<String, String>,
40
41 #[serde(default)]
43 pub body: Option<Value>,
44
45 #[serde(default)]
48 pub body_logic: Option<Template>,
49
50 #[serde(default, alias = "output")]
56 pub response_path: Option<String>,
57
58 #[serde(default = "default_timeout")]
60 pub timeout_ms: u64,
61}
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
71#[serde(rename_all = "UPPERCASE")]
72pub enum HttpMethod {
73 #[default]
74 Get,
75 Post,
76 Put,
77 Patch,
78 Delete,
79}
80
81impl HttpMethod {
82 pub const ALL: &'static [HttpMethod] = &[
92 HttpMethod::Get,
93 HttpMethod::Post,
94 HttpMethod::Put,
95 HttpMethod::Patch,
96 HttpMethod::Delete,
97 ];
98
99 pub const fn as_str(&self) -> &'static str {
103 match self {
104 HttpMethod::Get => "GET",
105 HttpMethod::Post => "POST",
106 HttpMethod::Put => "PUT",
107 HttpMethod::Patch => "PATCH",
108 HttpMethod::Delete => "DELETE",
109 }
110 }
111
112 pub const fn is_idempotent(&self) -> bool {
119 match self {
120 HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => true,
121 HttpMethod::Post | HttpMethod::Patch => false,
122 }
123 }
124}
125
126impl std::fmt::Display for HttpMethod {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.write_str(self.as_str())
129 }
130}
131
132fn default_method() -> HttpMethod {
133 HttpMethod::Get
134}
135
136fn default_timeout() -> u64 {
137 30000
138}
139
140#[derive(Debug, Clone, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct EnrichConfig {
150 pub connector: String,
152
153 #[serde(default = "default_method")]
155 pub method: HttpMethod,
156
157 #[serde(default)]
159 pub path: Option<String>,
160
161 #[serde(default)]
164 pub path_logic: Option<Template>,
165
166 pub merge_path: String,
168
169 #[serde(default = "default_timeout")]
171 pub timeout_ms: u64,
172
173 #[serde(default)]
175 pub on_error: EnrichErrorAction,
176}
177
178fn resolve_string_field(
193 logic: &Option<Template>,
194 static_value: Option<String>,
195 ctx: &TaskContext<'_>,
196) -> Result<Option<String>> {
197 match logic {
198 Some(t) => Ok(Some(t.eval_to_plain_string(ctx)?)),
199 None => Ok(static_value),
200 }
201}
202
203fn resolve_value_field(
211 logic: &Option<Template>,
212 static_value: Option<Value>,
213 ctx: &TaskContext<'_>,
214) -> Result<Option<Value>> {
215 match logic {
216 Some(t) => Ok(Some(t.eval_into(ctx)?)),
217 None => Ok(static_value),
218 }
219}
220
221impl HttpCallConfig {
222 pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
231 resolve_string_field(&self.path_logic, self.path.clone(), ctx)
232 }
233
234 pub fn resolve_body(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
242 resolve_value_field(&self.body_logic, self.body.clone(), ctx)
243 }
244}
245
246impl EnrichConfig {
247 pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
254 resolve_string_field(&self.path_logic, self.path.clone(), ctx)
255 }
256}
257
258impl PublishKafkaConfig {
259 pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
269 resolve_string_field(&self.key_logic, None, ctx)
270 }
271
272 pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
286 resolve_value_field(&self.value_logic, None, ctx)
287 }
288}
289
290#[derive(Debug, Clone, Deserialize, Default)]
292#[serde(rename_all = "lowercase")]
293pub enum EnrichErrorAction {
294 #[default]
296 Fail,
297 Skip,
299}
300
301#[derive(Debug, Clone, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct PublishKafkaConfig {
309 pub connector: String,
311
312 pub topic: String,
314
315 #[serde(default)]
319 pub key_logic: Option<Template>,
320
321 #[serde(default)]
325 pub value_logic: Option<Template>,
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use serde_json::json;
332
333 #[test]
334 fn as_str_round_trips_through_deserialize() {
335 for m in HttpMethod::ALL {
338 let parsed: HttpMethod = serde_json::from_value(json!(m.as_str()))
339 .unwrap_or_else(|e| panic!("'{}' should deserialize: {e}", m.as_str()));
340 assert_eq!(parsed, *m);
341 }
342 }
343
344 #[test]
345 fn lowercase_method_is_rejected() {
346 assert!(serde_json::from_value::<HttpMethod>(json!("get")).is_err());
349 assert!(serde_json::from_value::<HttpMethod>(json!("Post")).is_err());
350 assert!(serde_json::from_value::<HttpMethod>(json!("HEAD")).is_err());
351 }
352
353 #[test]
354 fn all_covers_every_variant() {
355 for m in HttpMethod::ALL {
360 match m {
361 HttpMethod::Get
362 | HttpMethod::Post
363 | HttpMethod::Put
364 | HttpMethod::Patch
365 | HttpMethod::Delete => {}
366 }
367 }
368 assert_eq!(HttpMethod::ALL.len(), 5);
369 }
370
371 #[test]
372 fn is_idempotent_follows_rfc_9110() {
373 assert!(HttpMethod::Get.is_idempotent());
374 assert!(HttpMethod::Put.is_idempotent());
375 assert!(HttpMethod::Delete.is_idempotent());
376 assert!(!HttpMethod::Post.is_idempotent());
377 assert!(!HttpMethod::Patch.is_idempotent());
378 }
379
380 #[test]
381 fn display_matches_as_str() {
382 for m in HttpMethod::ALL {
383 assert_eq!(m.to_string(), m.as_str());
384 }
385 }
386
387 #[test]
388 fn default_method_is_get() {
389 assert_eq!(HttpMethod::default(), HttpMethod::Get);
390 assert_eq!(default_method(), HttpMethod::Get);
392 }
393
394 use crate::engine::functions::template::TemplateCompiler;
395 use crate::engine::message::Message;
396 use crate::engine::utils::set_nested_value;
397 use datavalue::OwnedDataValue;
398 use std::sync::Arc;
399
400 fn dv(v: serde_json::Value) -> OwnedDataValue {
401 OwnedDataValue::from(&v)
402 }
403
404 fn engine() -> Arc<datalogic_rs::Engine> {
405 Arc::new(
406 datalogic_rs::Engine::builder()
407 .with_templating(true)
408 .build(),
409 )
410 }
411
412 fn fresh_message() -> Message {
414 let mut m = Message::from_value(&json!({}));
415 set_nested_value(&mut m.context, "data.id", dv(json!("abc")));
416 set_nested_value(&mut m.context, "data.n", dv(json!(7)));
417 set_nested_value(&mut m.context, "data.obj", dv(json!({"a": 1})));
418 m
419 }
420
421 fn compile(dl: &Arc<datalogic_rs::Engine>, logic: serde_json::Value) -> Option<Template> {
424 let c = TemplateCompiler::new(Arc::clone(dl));
425 let mut t: Template = serde_json::from_value(logic).expect("Template::deserialize");
426 t.compile(&c, "test").expect("logic should compile");
427 Some(t)
428 }
429
430 fn http_config() -> HttpCallConfig {
431 serde_json::from_value(json!({ "connector": "c" })).unwrap()
432 }
433
434 fn enrich_config() -> EnrichConfig {
435 serde_json::from_value(json!({ "connector": "c", "merge_path": "data.out" })).unwrap()
436 }
437
438 fn kafka_config() -> PublishKafkaConfig {
439 serde_json::from_value(json!({ "connector": "c", "topic": "t" })).unwrap()
440 }
441
442 #[test]
443 fn http_resolve_path_covers_all_four_slot_combinations() {
444 let dl = engine();
445 let mut m = fresh_message();
446 let ctx = TaskContext::new(&mut m, &dl);
447
448 let mut cfg = http_config();
450 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
451 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
452
453 let mut cfg = http_config();
455 cfg.path = Some("/static".to_string());
456 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/static".to_string()));
457
458 assert_eq!(http_config().resolve_path(&ctx).unwrap(), None);
460
461 let mut cfg = http_config();
463 cfg.path = Some("/static".to_string());
464 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
465 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
466 }
467
468 #[test]
469 fn http_resolve_body_covers_all_four_slot_combinations() {
470 let dl = engine();
471 let mut m = fresh_message();
472 let ctx = TaskContext::new(&mut m, &dl);
473
474 let mut cfg = http_config();
475 cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
476 assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
477
478 let mut cfg = http_config();
479 cfg.body = Some(json!({"static": true}));
480 assert_eq!(
481 cfg.resolve_body(&ctx).unwrap(),
482 Some(json!({"static": true}))
483 );
484
485 assert_eq!(http_config().resolve_body(&ctx).unwrap(), None);
486
487 let mut cfg = http_config();
488 cfg.body = Some(json!({"static": true}));
489 cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
490 assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
491 }
492
493 #[test]
494 fn enrich_resolve_path_covers_all_four_slot_combinations() {
495 let dl = engine();
496 let mut m = fresh_message();
497 let ctx = TaskContext::new(&mut m, &dl);
498
499 let mut cfg = enrich_config();
500 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
501 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
502
503 let mut cfg = enrich_config();
504 cfg.path = Some("/lookup".to_string());
505 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/lookup".to_string()));
506
507 assert_eq!(enrich_config().resolve_path(&ctx).unwrap(), None);
508
509 let mut cfg = enrich_config();
510 cfg.path = Some("/lookup".to_string());
511 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
512 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
513 }
514
515 #[test]
516 fn kafka_resolve_key_and_value() {
517 let dl = engine();
518 let mut m = fresh_message();
519 let ctx = TaskContext::new(&mut m, &dl);
520
521 let cfg = kafka_config();
523 assert_eq!(cfg.resolve_key(&ctx).unwrap(), None);
524 assert_eq!(cfg.resolve_value(&ctx).unwrap(), None);
525
526 let mut cfg = kafka_config();
527 cfg.key_logic = compile(&dl, json!({"var": "data.id"}));
528 cfg.value_logic = compile(&dl, json!({"var": "data.obj"}));
529 assert_eq!(cfg.resolve_key(&ctx).unwrap(), Some("abc".to_string()));
530 assert_eq!(cfg.resolve_value(&ctx).unwrap(), Some(json!({"a": 1})));
534 }
535
536 #[test]
537 fn path_resolution_coerces_non_strings_for_the_url() {
538 let dl = engine();
539 let mut m = fresh_message();
540 let ctx = TaskContext::new(&mut m, &dl);
541
542 let mut cfg = http_config();
544 cfg.path_logic = compile(&dl, json!({"var": "data.n"}));
545 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("7".to_string()));
546
547 let mut cfg = http_config();
549 cfg.path_logic = compile(&dl, json!({"var": "data.obj"}));
550 assert_eq!(
551 cfg.resolve_path(&ctx).unwrap(),
552 Some("{\"a\":1}".to_string())
553 );
554 }
555
556 #[test]
557 fn a_failing_expression_propagates_instead_of_falling_back() {
558 let dl = engine();
559 let mut m = fresh_message();
560 let ctx = TaskContext::new(&mut m, &dl);
561
562 let mut cfg = http_config();
564 cfg.path = Some("/static".to_string());
565 cfg.path_logic = compile(&dl, json!({"+": ["abc", 1]}));
566
567 match cfg.resolve_path(&ctx) {
568 Err(crate::engine::error::DataflowError::LogicEvaluation(msg)) => {
569 assert!(!msg.is_empty());
570 }
571 other => panic!("expected LogicEvaluation, got {other:?}"),
572 }
573
574 let mut cfg = http_config();
576 cfg.body = Some(json!({"static": true}));
577 cfg.body_logic = compile(&dl, json!({"+": ["abc", 1]}));
578 assert!(cfg.resolve_body(&ctx).is_err());
579 }
580}