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    /// How the resolved body becomes request bytes (e.g. `"json"`, `"form"`,
51    /// `"text"`).
52    ///
53    /// The value is **data, not API surface**: this crate does not validate or
54    /// interpret it — the service layer owns the value table, its default for
55    /// `None`, and the encoding behaviour. That split is deliberate: field
56    /// *names* are fixed here by `deny_unknown_fields`, but a service layer can
57    /// grow new *values* (say, `"multipart"`) without touching this crate.
58    #[serde(default)]
59    pub body_format: Option<String>,
60
61    /// JSONPath/dot-path to extract from response and merge into context.
62    ///
63    /// `output` is accepted as an alias, so a service layer can present one
64    /// destination-field name across its whole function catalogue. Supplying
65    /// both keys is a `duplicate field` error rather than a precedence rule.
66    #[serde(default, alias = "output")]
67    pub response_path: Option<String>,
68
69    /// How response bytes become the captured value (e.g. `"json"`, `"text"`).
70    ///
71    /// As [`Self::body_format`]: data, not API surface — uninterpreted by this
72    /// crate, owned by the service layer.
73    #[serde(default)]
74    pub response_format: Option<String>,
75
76    /// Request timeout in milliseconds (default: 30000)
77    #[serde(default = "default_timeout")]
78    pub timeout_ms: u64,
79}
80
81/// HTTP methods supported by `http_call`.
82///
83/// This crate does not implement `http_call` — the transport is supplied by the
84/// service layer via `AsyncFunctionHandler` — so every consumer converts this
85/// into their own HTTP client's method type. [`HttpMethod::as_str`] is the
86/// intended bridge (e.g. `Method::from_bytes(m.as_str().as_bytes())`); the crate
87/// deliberately takes no HTTP-client dependency of its own.
88#[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    /// Every method a workflow may name in an `http_call` task.
101    ///
102    /// This is the vocabulary a service layer can validate its own
103    /// operator-facing method allow-lists against, instead of mirroring the
104    /// variant list by hand.
105    ///
106    /// Scoped narrowly to `http_call`: this is **not** a general list of HTTP
107    /// methods, and should not be reused to validate, say, inbound route
108    /// definitions, which may legitimately accept `HEAD` or `OPTIONS`.
109    pub const ALL: &'static [HttpMethod] = &[
110        HttpMethod::Get,
111        HttpMethod::Post,
112        HttpMethod::Put,
113        HttpMethod::Patch,
114        HttpMethod::Delete,
115    ];
116
117    /// Canonical uppercase token, identical to the spelling `Deserialize`
118    /// accepts — `from_value(json!(m.as_str()))` round-trips to `m` for every
119    /// variant.
120    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    /// Whether re-sending the request is safe (RFC 9110 idempotency), so a
131    /// caller may retry a timeout without risking a duplicate side effect.
132    ///
133    /// Written as an exhaustive `match` rather than a `matches!` so that adding
134    /// a variant is a compile error here rather than a silent classification as
135    /// non-idempotent.
136    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/// Configuration for the enrich integration function.
159///
160/// Enrichment calls an external service and merges the response into the message context.
161///
162/// Unknown keys are rejected, as for [`HttpCallConfig`]. Note that the
163/// destination field here is `merge_path` and takes **no** alias — only
164/// `HttpCallConfig::response_path` accepts `output`.
165#[derive(Debug, Clone, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct EnrichConfig {
168    /// Named connector reference
169    pub connector: String,
170
171    /// HTTP method for the enrichment call
172    #[serde(default = "default_method")]
173    pub method: HttpMethod,
174
175    /// Static path
176    #[serde(default)]
177    pub path: Option<String>,
178
179    /// JSONLogic expression to compute path dynamically. Compiled once at
180    /// engine construction; read it through [`EnrichConfig::resolve_path`].
181    #[serde(default)]
182    pub path_logic: Option<Template>,
183
184    /// Dot-path where enrichment data is merged into the message context
185    pub merge_path: String,
186
187    /// Request timeout in milliseconds (default: 30000)
188    #[serde(default = "default_timeout")]
189    pub timeout_ms: u64,
190
191    /// What to do on enrichment failure
192    #[serde(default)]
193    pub on_error: EnrichErrorAction,
194}
195
196/// Shared shape behind every `resolve_*` method below: `logic`, evaluated as a
197/// plain string, wins over `static_value` when both are set; `Ok(None)` when
198/// neither is.
199///
200/// A non-string result is coerced to its compact JSON form — `7` becomes `"7"`,
201/// `{"a":1}` becomes `"{\"a\":1}"` — because these values end up in a URL or a
202/// partition key. See [`crate::TaskContext::eval_to_plain_string`].
203///
204/// # Errors
205///
206/// Propagates [`crate::DataflowError::LogicEvaluation`] if `logic` fails to
207/// evaluate. It does **not** fall back to `static_value` on failure: a compiled
208/// expression that errors is a real problem, and silently substituting a
209/// different value would hide it.
210fn 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
221/// As [`resolve_string_field`], but for a `logic` field evaluated into a
222/// [`Value`] rather than coerced to a string — for fields (like a request body)
223/// where the caller wants the JSON shape, not a stringified one.
224///
225/// # Errors
226///
227/// As [`resolve_string_field`].
228fn 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    /// Resolve the request path: `path_logic` evaluated against the message
241    /// context when compiled, otherwise the static `path`. `Ok(None)` when
242    /// neither is set.
243    ///
244    /// # Errors
245    ///
246    /// As [`resolve_string_field`] — an evaluation failure propagates rather
247    /// than falling back to the static `path`.
248    pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
249        resolve_string_field(&self.path_logic, self.path.clone(), ctx)
250    }
251
252    /// Resolve the request body: `body_logic` when compiled, otherwise the
253    /// static `body`. `Ok(None)` when neither is set.
254    ///
255    /// # Errors
256    ///
257    /// As [`Self::resolve_path`] — an evaluation failure propagates rather than
258    /// falling back.
259    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    /// Resolve the enrichment path: `path_logic` when compiled, otherwise the
266    /// static `path`. `Ok(None)` when neither is set.
267    ///
268    /// # Errors
269    ///
270    /// As [`HttpCallConfig::resolve_path`].
271    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    /// Resolve the message key from `key_logic`. `Ok(None)` when it is not set —
278    /// there is no static key field, so a `None` key is the caller's to interpret
279    /// (Kafka treats a null key as "partition round-robin").
280    ///
281    /// Coerced to a plain string, matching [`HttpCallConfig::resolve_path`].
282    ///
283    /// # Errors
284    ///
285    /// As [`HttpCallConfig::resolve_path`].
286    pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
287        resolve_string_field(&self.key_logic, None, ctx)
288    }
289
290    /// Resolve the message value from `value_logic`. `Ok(None)` when it is not
291    /// set — the fallback (typically "serialize the whole message") stays the
292    /// caller's policy.
293    ///
294    /// Returns `Option<Value>`, **not** `Option<String>`, deliberately: a
295    /// producer that does `serde_json::to_string` unconditionally would put
296    /// different bytes on the wire for a string-valued payload than
297    /// [`Self::resolve_key`]'s plain-string coercion does. Keeping this as a
298    /// `Value` leaves that choice where it belongs.
299    ///
300    /// # Errors
301    ///
302    /// As [`HttpCallConfig::resolve_path`].
303    pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
304        resolve_value_field(&self.value_logic, None, ctx)
305    }
306}
307
308/// What to do when enrichment fails
309#[derive(Debug, Clone, Deserialize, Default)]
310#[serde(rename_all = "lowercase")]
311pub enum EnrichErrorAction {
312    /// Fail the task (default)
313    #[default]
314    Fail,
315    /// Skip enrichment and continue
316    Skip,
317}
318
319/// Configuration for the publish_kafka integration function.
320///
321/// The actual Kafka producer is provided by the service layer via AsyncFunctionHandler.
322///
323/// Unknown keys are rejected, as for [`HttpCallConfig`].
324#[derive(Debug, Clone, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct PublishKafkaConfig {
327    /// Named connector reference
328    pub connector: String,
329
330    /// Target topic name
331    pub topic: String,
332
333    /// JSONLogic expression to compute the message key. Compiled once at
334    /// engine construction; read it through
335    /// [`PublishKafkaConfig::resolve_key`].
336    #[serde(default)]
337    pub key_logic: Option<Template>,
338
339    /// JSONLogic expression to compute the message value. Compiled once at
340    /// engine construction; read it through
341    /// [`PublishKafkaConfig::resolve_value`].
342    #[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        // Ties `as_str` to `#[serde(rename_all = "UPPERCASE")]` rather than to a
354        // guess: the canonical token must be exactly what Deserialize accepts.
355        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        // Makes the round-trip above a real constraint — it would also be
365        // satisfied by a case-insensitive parse, which this rules out.
366        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        // Adding a variant makes this match non-exhaustive — a compile error,
374        // which is the reminder to extend `ALL`. `as_str` and `is_idempotent`
375        // are exhaustive matches so the compiler already guards those; `ALL` is
376        // hand-maintained and needs its own guard.
377        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        // `HttpCallConfig` relies on this via `default_method`.
409        assert_eq!(default_method(), HttpMethod::Get);
410    }
411
412    #[test]
413    fn format_fields_default_to_none() {
414        // Every pre-existing config deserializes unchanged: absent format
415        // fields are `None`, and what `None` means is the service layer's call.
416        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        // The crate accepts any string value — validation belongs to the
424        // service layer's value table, so a new encoding must not need a
425        // release of this crate.
426        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        // `deny_unknown_fields` covers the new names too: a typo fails at
439        // parse time instead of silently sending the default encoding.
440        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        // The expected-field list in the error names both format fields — the
448        // docs quote this text (integrations.md "Unknown fields are rejected").
449        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    /// A message with a few readable values in `data`.
472    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    /// Build a compiled `Template` for a slot, mirroring what `LogicCompiler`
481    /// does — so these tests exercise the same slot state the engine produces.
482    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        // logic present -> evaluated string
508        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        // logic absent, static path present
513        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        // both absent
518        assert_eq!(http_config().resolve_path(&ctx).unwrap(), None);
519
520        // both present -> logic wins
521        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        // No static fallback exists for either, so absent logic is Ok(None).
581        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        // `resolve_value` returns a Value, not a String — a producer that
590        // serializes unconditionally must not be forced through the key's
591        // plain-string coercion.
592        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        // A number becomes its digits, not "7" with quotes.
602        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        // A container becomes compact JSON.
607        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        // Static field is set, so a silent fallback would look like success.
622        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        // Same for body.
634        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}