Skip to main content

dataflow_rs/engine/functions/
integration.rs

1use 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/// Configuration for the http_call integration function.
9///
10/// The actual HTTP implementation is provided by the service layer via AsyncFunctionHandler.
11/// This struct provides typed config validation and pre-compilation of JSONLogic expressions.
12///
13/// Unknown keys are rejected. A misspelled field previously parsed cleanly and
14/// was discarded, so an `http_call` task could make its request and silently
15/// throw the response away with no error at build time and none at dispatch.
16#[derive(Debug, Clone, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct HttpCallConfig {
19    /// Named connector reference (resolved by service layer)
20    pub connector: String,
21
22    /// HTTP method
23    #[serde(default = "default_method")]
24    pub method: HttpMethod,
25
26    /// Static path string
27    #[serde(default)]
28    pub path: Option<String>,
29
30    /// JSONLogic expression to compute path dynamically. Compiled once at
31    /// engine construction (`LogicCompiler`); read it through
32    /// [`HttpCallConfig::resolve_path`] rather than directly — that method
33    /// applies the static-`path` fallback and is the sanctioned read.
34    #[serde(default)]
35    pub path_logic: Option<Template>,
36
37    /// Static headers
38    #[serde(default)]
39    pub headers: HashMap<String, String>,
40
41    /// Static request body
42    #[serde(default)]
43    pub body: Option<Value>,
44
45    /// JSONLogic expression to compute body dynamically. Compiled once at
46    /// engine construction; read it through [`HttpCallConfig::resolve_body`].
47    #[serde(default)]
48    pub body_logic: Option<Template>,
49
50    /// JSONPath/dot-path to extract from response and merge into context.
51    ///
52    /// `output` is accepted as an alias, so a service layer can present one
53    /// destination-field name across its whole function catalogue. Supplying
54    /// both keys is a `duplicate field` error rather than a precedence rule.
55    #[serde(default, alias = "output")]
56    pub response_path: Option<String>,
57
58    /// Request timeout in milliseconds (default: 30000)
59    #[serde(default = "default_timeout")]
60    pub timeout_ms: u64,
61}
62
63/// HTTP methods supported by `http_call`.
64///
65/// This crate does not implement `http_call` — the transport is supplied by the
66/// service layer via `AsyncFunctionHandler` — so every consumer converts this
67/// into their own HTTP client's method type. [`HttpMethod::as_str`] is the
68/// intended bridge (e.g. `Method::from_bytes(m.as_str().as_bytes())`); the crate
69/// deliberately takes no HTTP-client dependency of its own.
70#[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    /// Every method a workflow may name in an `http_call` task.
83    ///
84    /// This is the vocabulary a service layer can validate its own
85    /// operator-facing method allow-lists against, instead of mirroring the
86    /// variant list by hand.
87    ///
88    /// Scoped narrowly to `http_call`: this is **not** a general list of HTTP
89    /// methods, and should not be reused to validate, say, inbound route
90    /// definitions, which may legitimately accept `HEAD` or `OPTIONS`.
91    pub const ALL: &'static [HttpMethod] = &[
92        HttpMethod::Get,
93        HttpMethod::Post,
94        HttpMethod::Put,
95        HttpMethod::Patch,
96        HttpMethod::Delete,
97    ];
98
99    /// Canonical uppercase token, identical to the spelling `Deserialize`
100    /// accepts — `from_value(json!(m.as_str()))` round-trips to `m` for every
101    /// variant.
102    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    /// Whether re-sending the request is safe (RFC 9110 idempotency), so a
113    /// caller may retry a timeout without risking a duplicate side effect.
114    ///
115    /// Written as an exhaustive `match` rather than a `matches!` so that adding
116    /// a variant is a compile error here rather than a silent classification as
117    /// non-idempotent.
118    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/// Configuration for the enrich integration function.
141///
142/// Enrichment calls an external service and merges the response into the message context.
143///
144/// Unknown keys are rejected, as for [`HttpCallConfig`]. Note that the
145/// destination field here is `merge_path` and takes **no** alias — only
146/// `HttpCallConfig::response_path` accepts `output`.
147#[derive(Debug, Clone, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct EnrichConfig {
150    /// Named connector reference
151    pub connector: String,
152
153    /// HTTP method for the enrichment call
154    #[serde(default = "default_method")]
155    pub method: HttpMethod,
156
157    /// Static path
158    #[serde(default)]
159    pub path: Option<String>,
160
161    /// JSONLogic expression to compute path dynamically. Compiled once at
162    /// engine construction; read it through [`EnrichConfig::resolve_path`].
163    #[serde(default)]
164    pub path_logic: Option<Template>,
165
166    /// Dot-path where enrichment data is merged into the message context
167    pub merge_path: String,
168
169    /// Request timeout in milliseconds (default: 30000)
170    #[serde(default = "default_timeout")]
171    pub timeout_ms: u64,
172
173    /// What to do on enrichment failure
174    #[serde(default)]
175    pub on_error: EnrichErrorAction,
176}
177
178/// Shared shape behind every `resolve_*` method below: `logic`, evaluated as a
179/// plain string, wins over `static_value` when both are set; `Ok(None)` when
180/// neither is.
181///
182/// A non-string result is coerced to its compact JSON form — `7` becomes `"7"`,
183/// `{"a":1}` becomes `"{\"a\":1}"` — because these values end up in a URL or a
184/// partition key. See [`crate::TaskContext::eval_to_plain_string`].
185///
186/// # Errors
187///
188/// Propagates [`crate::DataflowError::LogicEvaluation`] if `logic` fails to
189/// evaluate. It does **not** fall back to `static_value` on failure: a compiled
190/// expression that errors is a real problem, and silently substituting a
191/// different value would hide it.
192fn 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
203/// As [`resolve_string_field`], but for a `logic` field evaluated into a
204/// [`Value`] rather than coerced to a string — for fields (like a request body)
205/// where the caller wants the JSON shape, not a stringified one.
206///
207/// # Errors
208///
209/// As [`resolve_string_field`].
210fn 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    /// Resolve the request path: `path_logic` evaluated against the message
223    /// context when compiled, otherwise the static `path`. `Ok(None)` when
224    /// neither is set.
225    ///
226    /// # Errors
227    ///
228    /// As [`resolve_string_field`] — an evaluation failure propagates rather
229    /// than falling back to the static `path`.
230    pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
231        resolve_string_field(&self.path_logic, self.path.clone(), ctx)
232    }
233
234    /// Resolve the request body: `body_logic` when compiled, otherwise the
235    /// static `body`. `Ok(None)` when neither is set.
236    ///
237    /// # Errors
238    ///
239    /// As [`Self::resolve_path`] — an evaluation failure propagates rather than
240    /// falling back.
241    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    /// Resolve the enrichment path: `path_logic` when compiled, otherwise the
248    /// static `path`. `Ok(None)` when neither is set.
249    ///
250    /// # Errors
251    ///
252    /// As [`HttpCallConfig::resolve_path`].
253    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    /// Resolve the message key from `key_logic`. `Ok(None)` when it is not set —
260    /// there is no static key field, so a `None` key is the caller's to interpret
261    /// (Kafka treats a null key as "partition round-robin").
262    ///
263    /// Coerced to a plain string, matching [`HttpCallConfig::resolve_path`].
264    ///
265    /// # Errors
266    ///
267    /// As [`HttpCallConfig::resolve_path`].
268    pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
269        resolve_string_field(&self.key_logic, None, ctx)
270    }
271
272    /// Resolve the message value from `value_logic`. `Ok(None)` when it is not
273    /// set — the fallback (typically "serialize the whole message") stays the
274    /// caller's policy.
275    ///
276    /// Returns `Option<Value>`, **not** `Option<String>`, deliberately: a
277    /// producer that does `serde_json::to_string` unconditionally would put
278    /// different bytes on the wire for a string-valued payload than
279    /// [`Self::resolve_key`]'s plain-string coercion does. Keeping this as a
280    /// `Value` leaves that choice where it belongs.
281    ///
282    /// # Errors
283    ///
284    /// As [`HttpCallConfig::resolve_path`].
285    pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
286        resolve_value_field(&self.value_logic, None, ctx)
287    }
288}
289
290/// What to do when enrichment fails
291#[derive(Debug, Clone, Deserialize, Default)]
292#[serde(rename_all = "lowercase")]
293pub enum EnrichErrorAction {
294    /// Fail the task (default)
295    #[default]
296    Fail,
297    /// Skip enrichment and continue
298    Skip,
299}
300
301/// Configuration for the publish_kafka integration function.
302///
303/// The actual Kafka producer is provided by the service layer via AsyncFunctionHandler.
304///
305/// Unknown keys are rejected, as for [`HttpCallConfig`].
306#[derive(Debug, Clone, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct PublishKafkaConfig {
309    /// Named connector reference
310    pub connector: String,
311
312    /// Target topic name
313    pub topic: String,
314
315    /// JSONLogic expression to compute the message key. Compiled once at
316    /// engine construction; read it through
317    /// [`PublishKafkaConfig::resolve_key`].
318    #[serde(default)]
319    pub key_logic: Option<Template>,
320
321    /// JSONLogic expression to compute the message value. Compiled once at
322    /// engine construction; read it through
323    /// [`PublishKafkaConfig::resolve_value`].
324    #[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        // Ties `as_str` to `#[serde(rename_all = "UPPERCASE")]` rather than to a
336        // guess: the canonical token must be exactly what Deserialize accepts.
337        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        // Makes the round-trip above a real constraint — it would also be
347        // satisfied by a case-insensitive parse, which this rules out.
348        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        // Adding a variant makes this match non-exhaustive — a compile error,
356        // which is the reminder to extend `ALL`. `as_str` and `is_idempotent`
357        // are exhaustive matches so the compiler already guards those; `ALL` is
358        // hand-maintained and needs its own guard.
359        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        // `HttpCallConfig` relies on this via `default_method`.
391        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    /// A message with a few readable values in `data`.
413    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    /// Build a compiled `Template` for a slot, mirroring what `LogicCompiler`
422    /// does — so these tests exercise the same slot state the engine produces.
423    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        // logic present -> evaluated string
449        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        // logic absent, static path present
454        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        // both absent
459        assert_eq!(http_config().resolve_path(&ctx).unwrap(), None);
460
461        // both present -> logic wins
462        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        // No static fallback exists for either, so absent logic is Ok(None).
522        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        // `resolve_value` returns a Value, not a String — a producer that
531        // serializes unconditionally must not be forced through the key's
532        // plain-string coercion.
533        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        // A number becomes its digits, not "7" with quotes.
543        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        // A container becomes compact JSON.
548        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        // Static field is set, so a silent fallback would look like success.
563        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        // Same for body.
575        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}