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}