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)]
59 pub body_format: Option<String>,
60
61 #[serde(default, alias = "output")]
67 pub response_path: Option<String>,
68
69 #[serde(default)]
74 pub response_format: Option<String>,
75
76 #[serde(default = "default_timeout")]
78 pub timeout_ms: u64,
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
89#[serde(rename_all = "UPPERCASE")]
90pub enum HttpMethod {
91 #[default]
92 Get,
93 Post,
94 Put,
95 Patch,
96 Delete,
97}
98
99impl HttpMethod {
100 pub const ALL: &'static [HttpMethod] = &[
110 HttpMethod::Get,
111 HttpMethod::Post,
112 HttpMethod::Put,
113 HttpMethod::Patch,
114 HttpMethod::Delete,
115 ];
116
117 pub const fn as_str(&self) -> &'static str {
121 match self {
122 HttpMethod::Get => "GET",
123 HttpMethod::Post => "POST",
124 HttpMethod::Put => "PUT",
125 HttpMethod::Patch => "PATCH",
126 HttpMethod::Delete => "DELETE",
127 }
128 }
129
130 pub const fn is_idempotent(&self) -> bool {
137 match self {
138 HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => true,
139 HttpMethod::Post | HttpMethod::Patch => false,
140 }
141 }
142}
143
144impl std::fmt::Display for HttpMethod {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.write_str(self.as_str())
147 }
148}
149
150fn default_method() -> HttpMethod {
151 HttpMethod::Get
152}
153
154fn default_timeout() -> u64 {
155 30000
156}
157
158#[derive(Debug, Clone, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct EnrichConfig {
168 pub connector: String,
170
171 #[serde(default = "default_method")]
173 pub method: HttpMethod,
174
175 #[serde(default)]
177 pub path: Option<String>,
178
179 #[serde(default)]
182 pub path_logic: Option<Template>,
183
184 pub merge_path: String,
186
187 #[serde(default = "default_timeout")]
189 pub timeout_ms: u64,
190
191 #[serde(default)]
193 pub on_error: EnrichErrorAction,
194}
195
196fn resolve_string_field(
211 logic: &Option<Template>,
212 static_value: Option<String>,
213 ctx: &TaskContext<'_>,
214) -> Result<Option<String>> {
215 match logic {
216 Some(t) => Ok(Some(t.eval_to_plain_string(ctx)?)),
217 None => Ok(static_value),
218 }
219}
220
221fn resolve_value_field(
229 logic: &Option<Template>,
230 static_value: Option<Value>,
231 ctx: &TaskContext<'_>,
232) -> Result<Option<Value>> {
233 match logic {
234 Some(t) => Ok(Some(t.eval_into(ctx)?)),
235 None => Ok(static_value),
236 }
237}
238
239impl HttpCallConfig {
240 pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
249 resolve_string_field(&self.path_logic, self.path.clone(), ctx)
250 }
251
252 pub fn resolve_body(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
260 resolve_value_field(&self.body_logic, self.body.clone(), ctx)
261 }
262}
263
264impl EnrichConfig {
265 pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
272 resolve_string_field(&self.path_logic, self.path.clone(), ctx)
273 }
274}
275
276impl PublishKafkaConfig {
277 pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
287 resolve_string_field(&self.key_logic, None, ctx)
288 }
289
290 pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
304 resolve_value_field(&self.value_logic, None, ctx)
305 }
306}
307
308#[derive(Debug, Clone, Deserialize, Default)]
310#[serde(rename_all = "lowercase")]
311pub enum EnrichErrorAction {
312 #[default]
314 Fail,
315 Skip,
317}
318
319#[derive(Debug, Clone, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct PublishKafkaConfig {
327 pub connector: String,
329
330 pub topic: String,
332
333 #[serde(default)]
337 pub key_logic: Option<Template>,
338
339 #[serde(default)]
343 pub value_logic: Option<Template>,
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use serde_json::json;
350
351 #[test]
352 fn as_str_round_trips_through_deserialize() {
353 for m in HttpMethod::ALL {
356 let parsed: HttpMethod = serde_json::from_value(json!(m.as_str()))
357 .unwrap_or_else(|e| panic!("'{}' should deserialize: {e}", m.as_str()));
358 assert_eq!(parsed, *m);
359 }
360 }
361
362 #[test]
363 fn lowercase_method_is_rejected() {
364 assert!(serde_json::from_value::<HttpMethod>(json!("get")).is_err());
367 assert!(serde_json::from_value::<HttpMethod>(json!("Post")).is_err());
368 assert!(serde_json::from_value::<HttpMethod>(json!("HEAD")).is_err());
369 }
370
371 #[test]
372 fn all_covers_every_variant() {
373 for m in HttpMethod::ALL {
378 match m {
379 HttpMethod::Get
380 | HttpMethod::Post
381 | HttpMethod::Put
382 | HttpMethod::Patch
383 | HttpMethod::Delete => {}
384 }
385 }
386 assert_eq!(HttpMethod::ALL.len(), 5);
387 }
388
389 #[test]
390 fn is_idempotent_follows_rfc_9110() {
391 assert!(HttpMethod::Get.is_idempotent());
392 assert!(HttpMethod::Put.is_idempotent());
393 assert!(HttpMethod::Delete.is_idempotent());
394 assert!(!HttpMethod::Post.is_idempotent());
395 assert!(!HttpMethod::Patch.is_idempotent());
396 }
397
398 #[test]
399 fn display_matches_as_str() {
400 for m in HttpMethod::ALL {
401 assert_eq!(m.to_string(), m.as_str());
402 }
403 }
404
405 #[test]
406 fn default_method_is_get() {
407 assert_eq!(HttpMethod::default(), HttpMethod::Get);
408 assert_eq!(default_method(), HttpMethod::Get);
410 }
411
412 #[test]
413 fn format_fields_default_to_none() {
414 let cfg = http_config();
417 assert_eq!(cfg.body_format, None);
418 assert_eq!(cfg.response_format, None);
419 }
420
421 #[test]
422 fn format_values_are_data_not_api_surface() {
423 let cfg: HttpCallConfig = serde_json::from_value(json!({
427 "connector": "c",
428 "body_format": "form",
429 "response_format": "some-future-encoding",
430 }))
431 .expect("format values must parse as plain data");
432 assert_eq!(cfg.body_format.as_deref(), Some("form"));
433 assert_eq!(cfg.response_format.as_deref(), Some("some-future-encoding"));
434 }
435
436 #[test]
437 fn misspelled_format_field_is_rejected() {
438 let err = serde_json::from_value::<HttpCallConfig>(json!({
441 "connector": "c",
442 "body_fromat": "form",
443 }))
444 .expect_err("unknown field must be rejected");
445 let msg = err.to_string();
446 assert!(msg.contains("body_fromat"), "{msg}");
447 assert!(msg.contains("`body_format`"), "{msg}");
450 assert!(msg.contains("`response_format`"), "{msg}");
451 }
452
453 use crate::engine::functions::template::TemplateCompiler;
454 use crate::engine::message::Message;
455 use crate::engine::utils::set_nested_value;
456 use datavalue::OwnedDataValue;
457 use std::sync::Arc;
458
459 fn dv(v: serde_json::Value) -> OwnedDataValue {
460 OwnedDataValue::from(&v)
461 }
462
463 fn engine() -> Arc<datalogic_rs::Engine> {
464 Arc::new(
465 datalogic_rs::Engine::builder()
466 .with_templating(true)
467 .build(),
468 )
469 }
470
471 fn fresh_message() -> Message {
473 let mut m = Message::from_value(&json!({}));
474 set_nested_value(&mut m.context, "data.id", dv(json!("abc")));
475 set_nested_value(&mut m.context, "data.n", dv(json!(7)));
476 set_nested_value(&mut m.context, "data.obj", dv(json!({"a": 1})));
477 m
478 }
479
480 fn compile(dl: &Arc<datalogic_rs::Engine>, logic: serde_json::Value) -> Option<Template> {
483 let c = TemplateCompiler::new(Arc::clone(dl));
484 let mut t: Template = serde_json::from_value(logic).expect("Template::deserialize");
485 t.compile(&c, "test").expect("logic should compile");
486 Some(t)
487 }
488
489 fn http_config() -> HttpCallConfig {
490 serde_json::from_value(json!({ "connector": "c" })).unwrap()
491 }
492
493 fn enrich_config() -> EnrichConfig {
494 serde_json::from_value(json!({ "connector": "c", "merge_path": "data.out" })).unwrap()
495 }
496
497 fn kafka_config() -> PublishKafkaConfig {
498 serde_json::from_value(json!({ "connector": "c", "topic": "t" })).unwrap()
499 }
500
501 #[test]
502 fn http_resolve_path_covers_all_four_slot_combinations() {
503 let dl = engine();
504 let mut m = fresh_message();
505 let ctx = TaskContext::new(&mut m, &dl);
506
507 let mut cfg = http_config();
509 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
510 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
511
512 let mut cfg = http_config();
514 cfg.path = Some("/static".to_string());
515 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/static".to_string()));
516
517 assert_eq!(http_config().resolve_path(&ctx).unwrap(), None);
519
520 let mut cfg = http_config();
522 cfg.path = Some("/static".to_string());
523 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
524 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
525 }
526
527 #[test]
528 fn http_resolve_body_covers_all_four_slot_combinations() {
529 let dl = engine();
530 let mut m = fresh_message();
531 let ctx = TaskContext::new(&mut m, &dl);
532
533 let mut cfg = http_config();
534 cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
535 assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
536
537 let mut cfg = http_config();
538 cfg.body = Some(json!({"static": true}));
539 assert_eq!(
540 cfg.resolve_body(&ctx).unwrap(),
541 Some(json!({"static": true}))
542 );
543
544 assert_eq!(http_config().resolve_body(&ctx).unwrap(), None);
545
546 let mut cfg = http_config();
547 cfg.body = Some(json!({"static": true}));
548 cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
549 assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
550 }
551
552 #[test]
553 fn enrich_resolve_path_covers_all_four_slot_combinations() {
554 let dl = engine();
555 let mut m = fresh_message();
556 let ctx = TaskContext::new(&mut m, &dl);
557
558 let mut cfg = enrich_config();
559 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
560 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
561
562 let mut cfg = enrich_config();
563 cfg.path = Some("/lookup".to_string());
564 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/lookup".to_string()));
565
566 assert_eq!(enrich_config().resolve_path(&ctx).unwrap(), None);
567
568 let mut cfg = enrich_config();
569 cfg.path = Some("/lookup".to_string());
570 cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
571 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
572 }
573
574 #[test]
575 fn kafka_resolve_key_and_value() {
576 let dl = engine();
577 let mut m = fresh_message();
578 let ctx = TaskContext::new(&mut m, &dl);
579
580 let cfg = kafka_config();
582 assert_eq!(cfg.resolve_key(&ctx).unwrap(), None);
583 assert_eq!(cfg.resolve_value(&ctx).unwrap(), None);
584
585 let mut cfg = kafka_config();
586 cfg.key_logic = compile(&dl, json!({"var": "data.id"}));
587 cfg.value_logic = compile(&dl, json!({"var": "data.obj"}));
588 assert_eq!(cfg.resolve_key(&ctx).unwrap(), Some("abc".to_string()));
589 assert_eq!(cfg.resolve_value(&ctx).unwrap(), Some(json!({"a": 1})));
593 }
594
595 #[test]
596 fn path_resolution_coerces_non_strings_for_the_url() {
597 let dl = engine();
598 let mut m = fresh_message();
599 let ctx = TaskContext::new(&mut m, &dl);
600
601 let mut cfg = http_config();
603 cfg.path_logic = compile(&dl, json!({"var": "data.n"}));
604 assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("7".to_string()));
605
606 let mut cfg = http_config();
608 cfg.path_logic = compile(&dl, json!({"var": "data.obj"}));
609 assert_eq!(
610 cfg.resolve_path(&ctx).unwrap(),
611 Some("{\"a\":1}".to_string())
612 );
613 }
614
615 #[test]
616 fn a_failing_expression_propagates_instead_of_falling_back() {
617 let dl = engine();
618 let mut m = fresh_message();
619 let ctx = TaskContext::new(&mut m, &dl);
620
621 let mut cfg = http_config();
623 cfg.path = Some("/static".to_string());
624 cfg.path_logic = compile(&dl, json!({"+": ["abc", 1]}));
625
626 match cfg.resolve_path(&ctx) {
627 Err(crate::engine::error::DataflowError::LogicEvaluation(msg)) => {
628 assert!(!msg.is_empty());
629 }
630 other => panic!("expected LogicEvaluation, got {other:?}"),
631 }
632
633 let mut cfg = http_config();
635 cfg.body = Some(json!({"static": true}));
636 cfg.body_logic = compile(&dl, json!({"+": ["abc", 1]}));
637 assert!(cfg.resolve_body(&ctx).is_err());
638 }
639}