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 the service layer).
20    ///
21    /// A JSONLogic expression, so one task can route by message content:
22    /// `{"if": [{"var": "data.is_eu"}, "eu_gateway", "us_gateway"]}`. The
23    /// static spelling `"payments_api"` is a literal and costs nothing. Read it
24    /// through [`Self::resolve_connector`].
25    pub connector: Template,
26
27    /// HTTP method
28    #[serde(default = "default_method")]
29    pub method: HttpMethod,
30
31    /// Request path, as a JSONLogic expression. Read it through
32    /// [`Self::resolve_path`].
33    ///
34    /// Back-compat: before 3.9 this was a static `String` with a separate
35    /// `path_logic` twin holding the expression, because a field could not be
36    /// both. It can now, so the two collapsed and `path_logic` is kept as an
37    /// alias so pre-3.9 definitions keep loading. Supplying both spellings is a
38    /// `duplicate field` error, not a precedence rule.
39    #[serde(default, alias = "path_logic")]
40    pub path: Option<Template>,
41
42    /// Request headers. Each value is a JSONLogic expression, so a header can
43    /// carry a secret or a computed value:
44    /// `{"Authorization": {"cat": ["Bearer ", {"secret": "api_token"}]}}`.
45    ///
46    /// A plain string value is a literal, so the static spelling is unchanged.
47    /// Header *names* stay static — a name is not a computed value, and keeping
48    /// them literal means no name can be swallowed as an operator.
49    #[serde(default)]
50    pub headers: HashMap<String, Template>,
51
52    /// Request body, as a JSONLogic expression. Read it through
53    /// [`Self::resolve_body`].
54    ///
55    /// Back-compat: as [`Self::path`], `body_logic` is kept as an alias for the
56    /// pre-3.9 spelling.
57    ///
58    /// A literal object body needs its keys escaped when they collide with an
59    /// operator name — `{"$cat": …}` for a body field actually called `cat` —
60    /// because the engine evaluates in templating mode. See [`Template`].
61    #[serde(default, alias = "body_logic")]
62    pub body: Option<Template>,
63
64    /// How the resolved body becomes request bytes (e.g. `"json"`, `"form"`,
65    /// `"text"`).
66    ///
67    /// The value is **data, not API surface**: this crate does not validate or
68    /// interpret it — the service layer owns the value table, its default for
69    /// `None`, and the encoding behaviour. That split is deliberate: field
70    /// *names* are fixed here by `deny_unknown_fields`, but a service layer can
71    /// grow new *values* (say, `"multipart"`) without touching this crate.
72    #[serde(default)]
73    pub body_format: Option<Template>,
74
75    /// JSONPath/dot-path to extract from response and merge into context.
76    ///
77    /// `output` is accepted as an alias, so a service layer can present one
78    /// destination-field name across its whole function catalogue. Supplying
79    /// both keys is a `duplicate field` error rather than a precedence rule.
80    #[serde(default, alias = "output")]
81    pub response_path: Option<Template>,
82
83    /// How response bytes become the captured value (e.g. `"json"`, `"text"`).
84    ///
85    /// As [`Self::body_format`]: data, not API surface — uninterpreted by this
86    /// crate, owned by the service layer.
87    #[serde(default)]
88    pub response_format: Option<Template>,
89
90    /// Request timeout in milliseconds (default: 30000). Read it through
91    /// [`Self::resolve_timeout_ms`].
92    #[serde(default = "default_timeout")]
93    pub timeout_ms: Template,
94}
95
96/// HTTP methods supported by `http_call`.
97///
98/// This crate does not implement `http_call` — the transport is supplied by the
99/// service layer via `AsyncFunctionHandler` — so every consumer converts this
100/// into their own HTTP client's method type. [`HttpMethod::as_str`] is the
101/// intended bridge (e.g. `Method::from_bytes(m.as_str().as_bytes())`); the crate
102/// deliberately takes no HTTP-client dependency of its own.
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
104#[serde(rename_all = "UPPERCASE")]
105pub enum HttpMethod {
106    #[default]
107    Get,
108    Post,
109    Put,
110    Patch,
111    Delete,
112}
113
114impl HttpMethod {
115    /// Every method a workflow may name in an `http_call` task.
116    ///
117    /// This is the vocabulary a service layer can validate its own
118    /// operator-facing method allow-lists against, instead of mirroring the
119    /// variant list by hand.
120    ///
121    /// Scoped narrowly to `http_call`: this is **not** a general list of HTTP
122    /// methods, and should not be reused to validate, say, inbound route
123    /// definitions, which may legitimately accept `HEAD` or `OPTIONS`.
124    pub const ALL: &'static [Self] = &[Self::Get, Self::Post, Self::Put, Self::Patch, Self::Delete];
125
126    /// Canonical uppercase token, identical to the spelling `Deserialize`
127    /// accepts — `from_value(json!(m.as_str()))` round-trips to `m` for every
128    /// variant.
129    pub const fn as_str(&self) -> &'static str {
130        match self {
131            Self::Get => "GET",
132            Self::Post => "POST",
133            Self::Put => "PUT",
134            Self::Patch => "PATCH",
135            Self::Delete => "DELETE",
136        }
137    }
138
139    /// Whether re-sending the request is safe (RFC 9110 idempotency), so a
140    /// caller may retry a timeout without risking a duplicate side effect.
141    ///
142    /// Written as an exhaustive `match` rather than a `matches!` so that adding
143    /// a variant is a compile error here rather than a silent classification as
144    /// non-idempotent.
145    pub const fn is_idempotent(&self) -> bool {
146        match self {
147            Self::Get | Self::Put | Self::Delete => true,
148            Self::Post | Self::Patch => false,
149        }
150    }
151}
152
153impl std::fmt::Display for HttpMethod {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.write_str(self.as_str())
156    }
157}
158
159fn default_method() -> HttpMethod {
160    HttpMethod::Get
161}
162
163fn default_timeout() -> Template {
164    Template::from(Value::from(DEFAULT_TIMEOUT_MS))
165}
166
167/// The `timeout_ms` default, as a number rather than a `Template`, for hosts
168/// that want the value without resolving an expression.
169pub const DEFAULT_TIMEOUT_MS: u64 = 30000;
170
171/// Configuration for the enrich integration function.
172///
173/// Enrichment calls an external service and merges the response into the message context.
174///
175/// Unknown keys are rejected, as for [`HttpCallConfig`]. Note that the
176/// destination field here is `merge_path` and takes **no** alias — only
177/// `HttpCallConfig::response_path` accepts `output`.
178#[derive(Debug, Clone, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct EnrichConfig {
181    /// Named connector reference. A JSONLogic expression, as
182    /// [`HttpCallConfig::connector`]; read it through
183    /// [`Self::resolve_connector`].
184    pub connector: Template,
185
186    /// HTTP method for the enrichment call
187    #[serde(default = "default_method")]
188    pub method: HttpMethod,
189
190    /// Enrichment path, as a JSONLogic expression. Read it through
191    /// [`Self::resolve_path`].
192    ///
193    /// Back-compat: `path_logic` is kept as an alias for the pre-3.9 spelling,
194    /// as on [`HttpCallConfig::path`].
195    #[serde(default, alias = "path_logic")]
196    pub path: Option<Template>,
197
198    /// Dot-path where enrichment data is merged into the message context, as a
199    /// JSONLogic expression. Read it through [`Self::resolve_merge_path`].
200    pub merge_path: Template,
201
202    /// Request timeout in milliseconds (default: 30000). Read it through
203    /// [`Self::resolve_timeout_ms`].
204    #[serde(default = "default_timeout")]
205    pub timeout_ms: Template,
206
207    /// What to do on enrichment failure
208    #[serde(default)]
209    pub on_error: EnrichErrorAction,
210}
211
212/// Shared shape behind the optional `resolve_*` methods below: evaluate the
213/// expression to a plain string when the field is set, `Ok(None)` when it is
214/// not.
215///
216/// A non-string result is coerced to its compact JSON form — `7` becomes `"7"`,
217/// `{"a":1}` becomes `"{\"a\":1}"` — because these values end up in a URL or a
218/// partition key. See [`crate::TaskContext::eval_to_plain_string`].
219///
220/// # Errors
221///
222/// Propagates [`crate::DataflowError::LogicEvaluation`] if the expression fails
223/// to evaluate. There is deliberately no fallback value: a compiled expression
224/// that errors is a real problem, and silently substituting something else
225/// would hide it.
226fn resolve_opt_string(field: &Option<Template>, ctx: &TaskContext<'_>) -> Result<Option<String>> {
227    field.as_ref().map(|t| t.resolve_string(ctx)).transpose()
228}
229
230/// As `resolve_opt_string`, but evaluated into a [`Value`] rather than
231/// coerced to a string — for fields (like a request body) where the caller
232/// wants the JSON shape, not a stringified one.
233///
234/// # Errors
235///
236/// As `resolve_opt_string`.
237fn resolve_opt_value(field: &Option<Template>, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
238    field.as_ref().map(|t| t.eval_into(ctx)).transpose()
239}
240
241impl HttpCallConfig {
242    /// Resolve the connector name for this message.
243    ///
244    /// # Errors
245    ///
246    /// As `resolve_opt_string`.
247    pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
248        self.connector.resolve_string(ctx)
249    }
250
251    /// Resolve the request path. `Ok(None)` when no path is configured.
252    ///
253    /// # Errors
254    ///
255    /// As `resolve_opt_string`.
256    pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
257        resolve_opt_string(&self.path, ctx)
258    }
259
260    /// Resolve every request header for this message.
261    ///
262    /// Header names are copied through unchanged; only the values are
263    /// expressions. A value that fails to evaluate fails the whole call rather
264    /// than sending the request with that header missing — a dropped
265    /// `Authorization` would otherwise surface as a confusing 401.
266    ///
267    /// # Errors
268    ///
269    /// As `resolve_opt_string`, for the first header value that fails.
270    pub fn resolve_headers(&self, ctx: &TaskContext<'_>) -> Result<HashMap<String, String>> {
271        self.headers
272            .iter()
273            .map(|(name, value)| Ok((name.clone(), value.resolve_string(ctx)?)))
274            .collect()
275    }
276
277    /// Resolve the request body. `Ok(None)` when no body is configured.
278    ///
279    /// # Errors
280    ///
281    /// As [`Self::resolve_path`].
282    pub fn resolve_body(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
283        resolve_opt_value(&self.body, ctx)
284    }
285
286    /// Resolve the body encoding name. `Ok(None)` when unset — what that means
287    /// is the service layer's call.
288    ///
289    /// # Errors
290    ///
291    /// As `resolve_opt_string`.
292    pub fn resolve_body_format(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
293        resolve_opt_string(&self.body_format, ctx)
294    }
295
296    /// Resolve the dot-path the response is captured to. `Ok(None)` when unset.
297    ///
298    /// # Errors
299    ///
300    /// As `resolve_opt_string`.
301    pub fn resolve_response_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
302        resolve_opt_string(&self.response_path, ctx)
303    }
304
305    /// Resolve the response decoding name. `Ok(None)` when unset.
306    ///
307    /// # Errors
308    ///
309    /// As `resolve_opt_string`.
310    pub fn resolve_response_format(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
311        resolve_opt_string(&self.response_format, ctx)
312    }
313
314    /// Resolve the request timeout in milliseconds.
315    ///
316    /// # Errors
317    ///
318    /// As [`Template::resolve_u64`] — a non-numeric result is a configuration
319    /// error, not something to silently default.
320    pub fn resolve_timeout_ms(&self, ctx: &TaskContext<'_>) -> Result<u64> {
321        self.timeout_ms.resolve_u64(ctx, "http_call timeout_ms")
322    }
323}
324
325impl EnrichConfig {
326    /// Resolve the connector name for this message.
327    ///
328    /// # Errors
329    ///
330    /// As `resolve_opt_string`.
331    pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
332        self.connector.resolve_string(ctx)
333    }
334
335    /// Resolve the enrichment path. `Ok(None)` when no path is configured.
336    ///
337    /// # Errors
338    ///
339    /// As [`HttpCallConfig::resolve_path`].
340    pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
341        resolve_opt_string(&self.path, ctx)
342    }
343
344    /// Resolve the dot-path enrichment data is merged into.
345    ///
346    /// # Errors
347    ///
348    /// As `resolve_opt_string`.
349    pub fn resolve_merge_path(&self, ctx: &TaskContext<'_>) -> Result<String> {
350        self.merge_path.resolve_string(ctx)
351    }
352
353    /// Resolve the request timeout in milliseconds.
354    ///
355    /// # Errors
356    ///
357    /// As [`HttpCallConfig::resolve_timeout_ms`].
358    pub fn resolve_timeout_ms(&self, ctx: &TaskContext<'_>) -> Result<u64> {
359        self.timeout_ms.resolve_u64(ctx, "enrich timeout_ms")
360    }
361}
362
363impl PublishKafkaConfig {
364    /// Resolve the connector name for this message.
365    ///
366    /// # Errors
367    ///
368    /// As `resolve_opt_string`.
369    pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
370        self.connector.resolve_string(ctx)
371    }
372
373    /// Resolve the target topic for this message.
374    ///
375    /// # Errors
376    ///
377    /// As `resolve_opt_string`.
378    pub fn resolve_topic(&self, ctx: &TaskContext<'_>) -> Result<String> {
379        self.topic.resolve_string(ctx)
380    }
381
382    /// Resolve the message key. `Ok(None)` when it is not set — Kafka treats a
383    /// null key as "partition round-robin", so a `None` key is the caller's to
384    /// interpret.
385    ///
386    /// Coerced to a plain string, matching [`HttpCallConfig::resolve_path`].
387    ///
388    /// # Errors
389    ///
390    /// As [`HttpCallConfig::resolve_path`].
391    pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
392        resolve_opt_string(&self.key, ctx)
393    }
394
395    /// Resolve the message value. `Ok(None)` when it is not set — the fallback
396    /// (typically "serialize the whole message") stays the caller's policy.
397    ///
398    /// Returns `Option<Value>`, **not** `Option<String>`, deliberately: a
399    /// producer that does `serde_json::to_string` unconditionally would put
400    /// different bytes on the wire for a string-valued payload than
401    /// [`Self::resolve_key`]'s plain-string coercion does. Keeping this as a
402    /// `Value` leaves that choice where it belongs.
403    ///
404    /// # Errors
405    ///
406    /// As [`HttpCallConfig::resolve_path`].
407    pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
408        resolve_opt_value(&self.value, ctx)
409    }
410}
411
412/// What to do when enrichment fails
413#[derive(Debug, Clone, Deserialize, Default)]
414#[serde(rename_all = "lowercase")]
415pub enum EnrichErrorAction {
416    /// Fail the task (default)
417    #[default]
418    Fail,
419    /// Skip enrichment and continue
420    Skip,
421}
422
423/// Configuration for the publish_kafka integration function.
424///
425/// The actual Kafka producer is provided by the service layer via AsyncFunctionHandler.
426///
427/// Unknown keys are rejected, as for [`HttpCallConfig`].
428#[derive(Debug, Clone, Deserialize)]
429#[serde(deny_unknown_fields)]
430pub struct PublishKafkaConfig {
431    /// Named connector reference. A JSONLogic expression, as
432    /// [`HttpCallConfig::connector`]; read it through
433    /// [`Self::resolve_connector`].
434    pub connector: Template,
435
436    /// Target topic name, as a JSONLogic expression — so one task can route by
437    /// message content, which is the ordinary Kafka pattern:
438    /// `{"cat": ["orders.", {"var": "data.region"}]}`. Read it through
439    /// [`Self::resolve_topic`].
440    pub topic: Template,
441
442    /// The message key, as a JSONLogic expression. Read it through
443    /// [`Self::resolve_key`].
444    ///
445    /// Back-compat: `key_logic` is kept as an alias for the pre-3.9 spelling.
446    /// This field was always an expression — the rename is for consistency with
447    /// the other configs, where the `_logic` suffix marked the twin of a static
448    /// field that no longer exists.
449    #[serde(default, alias = "key_logic")]
450    pub key: Option<Template>,
451
452    /// The message value, as a JSONLogic expression. Read it through
453    /// [`Self::resolve_value`].
454    ///
455    /// Back-compat: `value_logic` is kept as an alias, as for [`Self::key`].
456    #[serde(default, alias = "value_logic")]
457    pub value: Option<Template>,
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::engine::compiler::datalogic_engine_builder;
464    use crate::engine::functions::template::TemplateCompiler;
465    use crate::engine::message::Message;
466    use crate::engine::utils::set_nested_value;
467    use datavalue::OwnedDataValue;
468    use serde_json::json;
469    use std::sync::Arc;
470
471    #[test]
472    fn as_str_round_trips_through_deserialize() {
473        // Ties `as_str` to `#[serde(rename_all = "UPPERCASE")]` rather than to a
474        // guess: the canonical token must be exactly what Deserialize accepts.
475        for m in HttpMethod::ALL {
476            let parsed: HttpMethod = serde_json::from_value(json!(m.as_str()))
477                .unwrap_or_else(|e| panic!("'{}' should deserialize: {e}", m.as_str()));
478            assert_eq!(parsed, *m);
479        }
480    }
481
482    #[test]
483    fn lowercase_method_is_rejected() {
484        // Makes the round-trip above a real constraint — it would also be
485        // satisfied by a case-insensitive parse, which this rules out.
486        assert!(serde_json::from_value::<HttpMethod>(json!("get")).is_err());
487        assert!(serde_json::from_value::<HttpMethod>(json!("Post")).is_err());
488        assert!(serde_json::from_value::<HttpMethod>(json!("HEAD")).is_err());
489    }
490
491    #[test]
492    fn all_covers_every_variant() {
493        // Adding a variant makes this match non-exhaustive — a compile error,
494        // which is the reminder to extend `ALL`. `as_str` and `is_idempotent`
495        // are exhaustive matches so the compiler already guards those; `ALL` is
496        // hand-maintained and needs its own guard.
497        for m in HttpMethod::ALL {
498            match m {
499                HttpMethod::Get
500                | HttpMethod::Post
501                | HttpMethod::Put
502                | HttpMethod::Patch
503                | HttpMethod::Delete => {}
504            }
505        }
506        assert_eq!(HttpMethod::ALL.len(), 5);
507    }
508
509    #[test]
510    fn is_idempotent_follows_rfc_9110() {
511        assert!(HttpMethod::Get.is_idempotent());
512        assert!(HttpMethod::Put.is_idempotent());
513        assert!(HttpMethod::Delete.is_idempotent());
514        assert!(!HttpMethod::Post.is_idempotent());
515        assert!(!HttpMethod::Patch.is_idempotent());
516    }
517
518    #[test]
519    fn display_matches_as_str() {
520        for m in HttpMethod::ALL {
521            assert_eq!(m.to_string(), m.as_str());
522        }
523    }
524
525    #[test]
526    fn default_method_is_get() {
527        assert_eq!(HttpMethod::default(), HttpMethod::Get);
528        // `HttpCallConfig` relies on this via `default_method`.
529        assert_eq!(default_method(), HttpMethod::Get);
530    }
531
532    fn engine() -> Arc<datalogic_rs::Engine> {
533        Arc::new(datalogic_engine_builder().build())
534    }
535
536    fn dv(v: serde_json::Value) -> OwnedDataValue {
537        OwnedDataValue::from(&v)
538    }
539
540    /// A message with a few readable values in `data`.
541    fn fresh_message() -> Message {
542        let mut m = Message::from_value(&json!({}));
543        set_nested_value(&mut m.context, "data.id", dv(json!("abc")));
544        set_nested_value(&mut m.context, "data.n", dv(json!(7)));
545        set_nested_value(&mut m.context, "data.obj", dv(json!({"a": 1})));
546        m
547    }
548
549    /// Parse a config and compile every parameter, exactly as `LogicCompiler`
550    /// does — so these tests exercise the state the engine produces.
551    fn http_config(extra: serde_json::Value) -> HttpCallConfig {
552        let mut base = json!({ "connector": "c" });
553        let obj = base.as_object_mut().unwrap();
554        for (k, v) in extra.as_object().unwrap() {
555            obj.insert(k.clone(), v.clone());
556        }
557        let mut cfg: HttpCallConfig = serde_json::from_value(base).expect("config should parse");
558        let c = TemplateCompiler::new(engine());
559        cfg.connector.compile(&c, "connector").unwrap();
560        cfg.timeout_ms.compile(&c, "timeout_ms").unwrap();
561        for v in cfg.headers.values_mut() {
562            v.compile(&c, "header").unwrap();
563        }
564        for t in [
565            &mut cfg.path,
566            &mut cfg.body,
567            &mut cfg.body_format,
568            &mut cfg.response_path,
569            &mut cfg.response_format,
570        ]
571        .into_iter()
572        .flatten()
573        {
574            t.compile(&c, "field").unwrap();
575        }
576        cfg
577    }
578
579    #[test]
580    fn format_fields_default_to_none() {
581        // Every pre-existing config deserializes unchanged: absent format
582        // fields are `None`, and what `None` means is the service layer's call.
583        let cfg = http_config(json!({}));
584        assert!(cfg.body_format.is_none());
585        assert!(cfg.response_format.is_none());
586    }
587
588    #[test]
589    fn misspelled_format_field_is_rejected() {
590        // `deny_unknown_fields` covers the new names too: a typo fails at
591        // parse time instead of silently sending the default encoding.
592        let err = serde_json::from_value::<HttpCallConfig>(json!({
593            "connector": "c",
594            "body_fromat": "form",
595        }))
596        .expect_err("unknown field must be rejected");
597        let msg = err.to_string();
598        assert!(msg.contains("body_fromat"), "{msg}");
599        // The expected-field list in the error names both format fields — the
600        // docs quote this text (integrations.md "Unknown fields are rejected").
601        assert!(msg.contains("`body_format`"), "{msg}");
602        assert!(msg.contains("`response_format`"), "{msg}");
603    }
604
605    #[test]
606    fn the_pre_39_logic_spellings_still_deserialize() {
607        // The back-compat aliases. A workflow written against 3.8 must load
608        // unchanged — this is the whole reason the aliases exist.
609        let http: HttpCallConfig = serde_json::from_value(json!({
610            "connector": "c",
611            "path_logic": {"var": "data.id"},
612            "body_logic": {"var": "data.obj"},
613        }))
614        .expect("pre-3.9 http_call spelling must still load");
615        assert_eq!(http.path.unwrap().as_json(), &json!({"var": "data.id"}));
616        assert_eq!(http.body.unwrap().as_json(), &json!({"var": "data.obj"}));
617
618        let enrich: EnrichConfig = serde_json::from_value(json!({
619            "connector": "c",
620            "merge_path": "data.out",
621            "path_logic": {"var": "data.id"},
622        }))
623        .expect("pre-3.9 enrich spelling must still load");
624        assert_eq!(enrich.path.unwrap().as_json(), &json!({"var": "data.id"}));
625
626        let kafka: PublishKafkaConfig = serde_json::from_value(json!({
627            "connector": "c",
628            "topic": "t",
629            "key_logic": {"var": "data.id"},
630            "value_logic": {"var": "data.obj"},
631        }))
632        .expect("pre-3.9 publish_kafka spelling must still load");
633        assert_eq!(kafka.key.unwrap().as_json(), &json!({"var": "data.id"}));
634        assert_eq!(kafka.value.unwrap().as_json(), &json!({"var": "data.obj"}));
635    }
636
637    #[test]
638    fn supplying_both_spellings_is_a_duplicate_field_error() {
639        // Not a precedence rule — the same contract `response_path`/`output`
640        // has always had. An author who set both did not mean one to win.
641        let err = serde_json::from_value::<HttpCallConfig>(json!({
642            "connector": "c",
643            "path": "/a",
644            "path_logic": {"var": "data.id"},
645        }))
646        .expect_err("both spellings must be rejected");
647        assert!(err.to_string().contains("duplicate field"), "{err}");
648    }
649
650    #[test]
651    fn a_static_config_resolves_to_exactly_what_was_authored() {
652        // The static spelling of every parameter is a literal, so a 3.8-era
653        // config behaves identically — and folds, so it costs no evaluation.
654        let dl = engine();
655        let mut m = fresh_message();
656        let ctx = TaskContext::new(&mut m, &dl);
657
658        let cfg = http_config(json!({
659            "path": "/static",
660            "headers": {"X-Env": "prod"},
661            "body_format": "json",
662            "response_path": "data.out",
663        }));
664        assert_eq!(cfg.resolve_connector(&ctx).unwrap(), "c");
665        assert_eq!(cfg.resolve_path(&ctx).unwrap().as_deref(), Some("/static"));
666        assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Env"], "prod");
667        assert_eq!(
668            cfg.resolve_body_format(&ctx).unwrap().as_deref(),
669            Some("json")
670        );
671        assert_eq!(
672            cfg.resolve_response_path(&ctx).unwrap().as_deref(),
673            Some("data.out")
674        );
675        assert_eq!(cfg.resolve_timeout_ms(&ctx).unwrap(), DEFAULT_TIMEOUT_MS);
676
677        assert!(cfg.connector.is_constant(), "a literal connector must fold");
678        assert!(
679            cfg.timeout_ms.is_constant(),
680            "the default timeout must fold"
681        );
682    }
683
684    #[test]
685    fn every_parameter_can_be_computed_from_the_message() {
686        let dl = engine();
687        let mut m = fresh_message();
688        let ctx = TaskContext::new(&mut m, &dl);
689
690        let cfg = http_config(json!({
691            "connector": {"cat": ["gw_", {"var": "data.id"}]},
692            "path": {"cat": ["/orders/", {"var": "data.id"}]},
693            "headers": {"X-Request-Id": {"var": "data.id"}},
694            "body": {"var": "data.obj"},
695            "timeout_ms": {"var": "data.n"},
696        }));
697
698        assert_eq!(cfg.resolve_connector(&ctx).unwrap(), "gw_abc");
699        assert_eq!(
700            cfg.resolve_path(&ctx).unwrap().as_deref(),
701            Some("/orders/abc")
702        );
703        assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Request-Id"], "abc");
704        assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
705        assert_eq!(cfg.resolve_timeout_ms(&ctx).unwrap(), 7);
706    }
707
708    #[test]
709    fn an_escaped_body_key_is_sent_as_data_not_evaluated() {
710        // The reason `body` and `body_logic` could collapse at all: a literal
711        // body field named after an operator is now expressible.
712        let dl = engine();
713        let mut m = fresh_message();
714        let ctx = TaskContext::new(&mut m, &dl);
715
716        let cfg = http_config(json!({ "body": {"$cat": ["a", "b"]} }));
717        assert_eq!(
718            cfg.resolve_body(&ctx).unwrap(),
719            Some(json!({"cat": ["a", "b"]}))
720        );
721
722        // Unescaped, the same key is still the operator.
723        let cfg = http_config(json!({ "body": {"cat": ["a", "b"]} }));
724        assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!("ab")));
725    }
726
727    #[test]
728    fn header_values_are_coerced_to_plain_strings() {
729        // A header carries bytes, not JSON — a number must not arrive quoted.
730        let dl = engine();
731        let mut m = fresh_message();
732        let ctx = TaskContext::new(&mut m, &dl);
733
734        let cfg = http_config(json!({ "headers": {"X-Count": {"var": "data.n"}} }));
735        assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Count"], "7");
736    }
737
738    #[test]
739    fn a_failing_header_fails_the_call_rather_than_being_dropped() {
740        // A silently missing `Authorization` surfaces as a confusing 401.
741        let dl = engine();
742        let mut m = fresh_message();
743        let ctx = TaskContext::new(&mut m, &dl);
744
745        let cfg = http_config(json!({ "headers": {"X-Bad": {"+": ["abc", 1]}} }));
746        assert!(cfg.resolve_headers(&ctx).is_err());
747    }
748
749    #[test]
750    fn path_resolution_coerces_non_strings_for_the_url() {
751        let dl = engine();
752        let mut m = fresh_message();
753        let ctx = TaskContext::new(&mut m, &dl);
754
755        // A number becomes its digits, not "7" with quotes.
756        let cfg = http_config(json!({ "path": {"var": "data.n"} }));
757        assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("7".to_string()));
758
759        // A container becomes compact JSON.
760        let cfg = http_config(json!({ "path": {"var": "data.obj"} }));
761        assert_eq!(
762            cfg.resolve_path(&ctx).unwrap(),
763            Some("{\"a\":1}".to_string())
764        );
765    }
766
767    #[test]
768    fn a_failing_expression_propagates_instead_of_falling_back() {
769        let dl = engine();
770        let mut m = fresh_message();
771        let ctx = TaskContext::new(&mut m, &dl);
772
773        let cfg = http_config(json!({ "path": {"+": ["abc", 1]} }));
774        match cfg.resolve_path(&ctx) {
775            Err(crate::engine::error::DataflowError::LogicEvaluation(msg)) => {
776                assert!(!msg.is_empty());
777            }
778            other => panic!("expected LogicEvaluation, got {other:?}"),
779        }
780
781        let cfg = http_config(json!({ "body": {"+": ["abc", 1]} }));
782        assert!(cfg.resolve_body(&ctx).is_err());
783    }
784
785    #[test]
786    fn a_non_numeric_timeout_is_a_configuration_error() {
787        // `resolve_u64` refuses rather than defaulting: a `timeout_ms` whose
788        // path is missing resolves to null, and silently becoming 0 would make
789        // every request fail instantly with no explanation.
790        let dl = engine();
791        let mut m = fresh_message();
792        let ctx = TaskContext::new(&mut m, &dl);
793
794        let cfg = http_config(json!({ "timeout_ms": {"var": "data.nope"} }));
795        let err = cfg
796            .resolve_timeout_ms(&ctx)
797            .expect_err("a null timeout must be rejected");
798        assert!(err.to_string().contains("timeout_ms"), "{err}");
799    }
800
801    #[test]
802    fn enrich_and_kafka_resolve_their_own_parameters() {
803        let dl = engine();
804        let c = TemplateCompiler::new(engine());
805        let mut m = fresh_message();
806        let ctx = TaskContext::new(&mut m, &dl);
807
808        let mut enrich: EnrichConfig = serde_json::from_value(json!({
809            "connector": "lookup",
810            "path": {"cat": ["/users/", {"var": "data.id"}]},
811            "merge_path": {"cat": ["data.users.", {"var": "data.id"}]},
812        }))
813        .unwrap();
814        enrich.connector.compile(&c, "connector").unwrap();
815        enrich.merge_path.compile(&c, "merge_path").unwrap();
816        enrich.timeout_ms.compile(&c, "timeout_ms").unwrap();
817        enrich.path.as_mut().unwrap().compile(&c, "path").unwrap();
818
819        assert_eq!(enrich.resolve_connector(&ctx).unwrap(), "lookup");
820        assert_eq!(
821            enrich.resolve_path(&ctx).unwrap().as_deref(),
822            Some("/users/abc")
823        );
824        assert_eq!(enrich.resolve_merge_path(&ctx).unwrap(), "data.users.abc");
825        assert_eq!(enrich.resolve_timeout_ms(&ctx).unwrap(), DEFAULT_TIMEOUT_MS);
826
827        // Dynamic topic routing is the ordinary Kafka pattern and was
828        // impossible before 3.9.
829        let mut kafka: PublishKafkaConfig = serde_json::from_value(json!({
830            "connector": "bus",
831            "topic": {"cat": ["orders.", {"var": "data.id"}]},
832            "key": {"var": "data.id"},
833            "value": {"var": "data.obj"},
834        }))
835        .unwrap();
836        kafka.connector.compile(&c, "connector").unwrap();
837        kafka.topic.compile(&c, "topic").unwrap();
838        kafka.key.as_mut().unwrap().compile(&c, "key").unwrap();
839        kafka.value.as_mut().unwrap().compile(&c, "value").unwrap();
840
841        assert_eq!(kafka.resolve_connector(&ctx).unwrap(), "bus");
842        assert_eq!(kafka.resolve_topic(&ctx).unwrap(), "orders.abc");
843        assert_eq!(kafka.resolve_key(&ctx).unwrap().as_deref(), Some("abc"));
844        // A `Value`, not a `String` — a producer that serializes
845        // unconditionally must not be forced through the key's coercion.
846        assert_eq!(kafka.resolve_value(&ctx).unwrap(), Some(json!({"a": 1})));
847    }
848
849    #[test]
850    fn absent_optional_fields_resolve_to_none() {
851        let dl = engine();
852        let mut m = fresh_message();
853        let ctx = TaskContext::new(&mut m, &dl);
854
855        let cfg = http_config(json!({}));
856        assert_eq!(cfg.resolve_path(&ctx).unwrap(), None);
857        assert_eq!(cfg.resolve_body(&ctx).unwrap(), None);
858        assert_eq!(cfg.resolve_body_format(&ctx).unwrap(), None);
859        assert_eq!(cfg.resolve_response_path(&ctx).unwrap(), None);
860        assert_eq!(cfg.resolve_response_format(&ctx).unwrap(), None);
861        assert!(cfg.resolve_headers(&ctx).unwrap().is_empty());
862    }
863}