Skip to main content

dataflow_rs/engine/functions/
config.rs

1use crate::engine::error::Result;
2use crate::engine::executor::ArenaContext;
3use crate::engine::functions::filter::FilterConfig;
4use crate::engine::functions::integration::{EnrichConfig, HttpCallConfig, PublishKafkaConfig};
5use crate::engine::functions::log::LogConfig;
6use crate::engine::functions::map::MapConfig;
7use crate::engine::functions::parse::{
8    ParseConfig, execute_parse_json_in_arena, execute_parse_xml,
9};
10use crate::engine::functions::publish::{PublishConfig, execute_publish_json, execute_publish_xml};
11use crate::engine::functions::validation::ValidationConfig;
12use crate::engine::message::{Change, Message};
13use crate::engine::task_outcome::TaskOutcome;
14use datalogic_rs::Engine;
15use serde::de::DeserializeOwned;
16use serde::{Deserialize, Deserializer};
17use serde_json::Value;
18use std::any::Any;
19use std::sync::Arc;
20
21/// Pre-parsed typed input for a `FunctionConfig::Custom` task. Populated by
22/// the engine at `Engine::new()` time by calling the registered
23/// `AsyncFunctionHandler::parse_input` for the named function. Cached as
24/// `Arc<dyn Any>` so the dispatch path can hand it to the handler with a
25/// single `downcast_ref` (O(1)) and zero per-message deserialization cost.
26///
27/// The wrapper exists because `dyn Any` does not implement `Debug`, which
28/// would otherwise prevent `#[derive(Debug)]` on `FunctionConfig`.
29#[derive(Clone)]
30pub struct CompiledCustomInput(pub Arc<dyn Any + Send + Sync>);
31
32impl CompiledCustomInput {
33    /// Borrow the inner value as `&(dyn Any + Send + Sync)` for handoff to
34    /// `DynAsyncFunctionHandler::dyn_execute`.
35    #[inline]
36    pub fn as_any(&self) -> &(dyn Any + Send + Sync) {
37        &*self.0
38    }
39}
40
41impl std::fmt::Debug for CompiledCustomInput {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str("CompiledCustomInput(<opaque>)")
44    }
45}
46
47/// Enum containing all possible function configurations.
48///
49/// Deserialization dispatches on the `name` field: each known built-in
50/// (`map`, `validate`, `parse_json`, …) parses its `input` strictly into the
51/// matching typed config and errors with a clear envelope (`config for
52/// function 'map': missing field 'mappings'`). Unknown names fall through
53/// to [`FunctionConfig::Custom`], which preserves the raw input for a
54/// user-registered handler to consume at engine construction time.
55#[derive(Debug, Clone)]
56pub enum FunctionConfig {
57    Map {
58        name: MapName,
59        input: MapConfig,
60    },
61    Validation {
62        name: ValidationName,
63        input: ValidationConfig,
64    },
65    ParseJson {
66        name: ParseJsonName,
67        input: ParseConfig,
68    },
69    ParseXml {
70        name: ParseXmlName,
71        input: ParseConfig,
72    },
73    PublishJson {
74        name: PublishJsonName,
75        input: PublishConfig,
76    },
77    PublishXml {
78        name: PublishXmlName,
79        input: PublishConfig,
80    },
81    Filter {
82        name: FilterName,
83        input: FilterConfig,
84    },
85    Log {
86        name: LogName,
87        input: LogConfig,
88    },
89    HttpCall {
90        name: HttpCallName,
91        input: HttpCallConfig,
92    },
93    Enrich {
94        name: EnrichName,
95        input: EnrichConfig,
96    },
97    PublishKafka {
98        name: PublishKafkaName,
99        input: PublishKafkaConfig,
100    },
101    /// For custom or unknown functions, store raw input and a slot for the
102    /// pre-parsed typed value populated at engine construction time.
103    Custom {
104        name: String,
105        input: Value,
106        /// Pre-parsed `<RegisteredHandler as AsyncFunctionHandler>::Input`,
107        /// boxed as `dyn Any`. Set by the engine after handler registration;
108        /// `None` on initial deserialization. `FunctionConfig` is
109        /// deserialize-only — round-tripping a workflow through JSON
110        /// re-parses on the next `Engine::new()` call.
111        compiled_input: Option<CompiledCustomInput>,
112    },
113}
114
115#[derive(Debug, Clone, Deserialize)]
116#[serde(rename_all = "lowercase")]
117pub enum MapName {
118    Map,
119}
120
121#[derive(Debug, Clone, Deserialize, PartialEq)]
122#[serde(rename_all = "lowercase")]
123pub enum ValidationName {
124    Validation,
125    Validate,
126}
127
128#[derive(Debug, Clone, Deserialize, PartialEq)]
129#[serde(rename_all = "snake_case")]
130pub enum ParseJsonName {
131    ParseJson,
132}
133
134#[derive(Debug, Clone, Deserialize, PartialEq)]
135#[serde(rename_all = "snake_case")]
136pub enum ParseXmlName {
137    ParseXml,
138}
139
140#[derive(Debug, Clone, Deserialize, PartialEq)]
141#[serde(rename_all = "snake_case")]
142pub enum PublishJsonName {
143    PublishJson,
144}
145
146#[derive(Debug, Clone, Deserialize, PartialEq)]
147#[serde(rename_all = "snake_case")]
148pub enum PublishXmlName {
149    PublishXml,
150}
151
152#[derive(Debug, Clone, Deserialize, PartialEq)]
153#[serde(rename_all = "lowercase")]
154pub enum FilterName {
155    Filter,
156}
157
158#[derive(Debug, Clone, Deserialize, PartialEq)]
159#[serde(rename_all = "lowercase")]
160pub enum LogName {
161    Log,
162}
163
164#[derive(Debug, Clone, Deserialize, PartialEq)]
165#[serde(rename_all = "snake_case")]
166pub enum HttpCallName {
167    HttpCall,
168}
169
170#[derive(Debug, Clone, Deserialize, PartialEq)]
171#[serde(rename_all = "snake_case")]
172pub enum EnrichName {
173    Enrich,
174}
175
176#[derive(Debug, Clone, Deserialize, PartialEq)]
177#[serde(rename_all = "snake_case")]
178pub enum PublishKafkaName {
179    PublishKafka,
180}
181
182/// Every function name that [`FunctionConfig`]'s deserializer resolves to a
183/// typed built-in variant instead of [`FunctionConfig::Custom`].
184///
185/// Used in error messages and as the discriminator for [`FunctionConfig`]
186/// deserialization. Kept in one place so adding a new built-in updates the
187/// dispatch, the error suggestion list, and the docs in lockstep.
188///
189/// Public so a service layer that gates workflow authoring on a closed
190/// function set can derive that set rather than copy it. Membership here is
191/// **not** the same fact as "this engine can run it" — see
192/// [`builtin_function_kind`], and prefer it for that question.
193///
194/// # Stability
195///
196/// Names are only added in a minor release and only removed in a major one, so
197/// a caller may treat a name that appears here as durable. **Ordering is not
198/// meaningful** and may change without notice; treat this as a set. Note that
199/// `validation` and `validate` are both present and both resolve to
200/// [`FunctionConfig::Validation`].
201pub const BUILTIN_FUNCTION_NAMES: &[&str] = &[
202    "map",
203    "validation",
204    "validate",
205    "parse_json",
206    "parse_xml",
207    "publish_json",
208    "publish_xml",
209    "filter",
210    "log",
211    "http_call",
212    "enrich",
213    "publish_kafka",
214];
215
216/// How a built-in function reaches an implementation.
217///
218/// This is the programmatic form of the distinction
219/// `docs/src/built-in-functions/integrations.md` draws in prose: some built-ins
220/// this crate executes itself, and for others it only supplies a config schema.
221///
222/// Deliberately **not** `#[non_exhaustive]`. A caller matching on this is
223/// usually deciding whether to accept a workflow definition, and if a third
224/// kind is ever added that decision needs revisiting — a compile error at every
225/// match site is the correct signal, not a silent fall-through to a `_` arm.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum BuiltinKind {
228    /// Executed by this crate. Needs no registration; an engine can always run it.
229    SelfContained,
230    /// Deserializes into a typed built-in variant — so `Engine::new` accepts it
231    /// without complaint — but dispatches to a handler registered under the same
232    /// name, and fails with [`crate::DataflowError::FunctionNotFound`] on the
233    /// first message if none is registered.
234    ///
235    /// `http_call`, `enrich` and `publish_kafka` are these. A validator that
236    /// treats them like [`BuiltinKind::SelfContained`] will green-light a
237    /// workflow that builds cleanly and then fails every request.
238    RequiresHandler,
239}
240
241/// Classify `name` as a built-in function.
242///
243/// `None` means it is not a built-in: it lands in [`FunctionConfig::Custom`] and
244/// needs `Engine::builder().register(name, handler)`.
245///
246/// Matching is exact, the same as the deserializer dispatch — `"HTTP_CALL"` and
247/// `"htttp_call"` are both `None`.
248///
249/// ```
250/// use dataflow_rs::{BuiltinKind, builtin_function_kind};
251///
252/// assert_eq!(builtin_function_kind("map"), Some(BuiltinKind::SelfContained));
253/// assert_eq!(builtin_function_kind("enrich"), Some(BuiltinKind::RequiresHandler));
254/// assert_eq!(builtin_function_kind("my_handler"), None);
255/// ```
256pub fn builtin_function_kind(name: &str) -> Option<BuiltinKind> {
257    match name {
258        "map" | "validation" | "validate" | "parse_json" | "parse_xml" | "publish_json"
259        | "publish_xml" | "filter" | "log" => Some(BuiltinKind::SelfContained),
260        "http_call" | "enrich" | "publish_kafka" => Some(BuiltinKind::RequiresHandler),
261        _ => None,
262    }
263}
264
265/// Whether `name` deserializes to a typed built-in variant at all.
266///
267/// Equivalent to `builtin_function_kind(name).is_some()`. This answers "is this
268/// a name the crate special-cases", **not** "can this engine run it" — a
269/// [`BuiltinKind::RequiresHandler`] name returns `true` here whether or not a
270/// handler is registered.
271#[inline]
272pub fn is_builtin_function(name: &str) -> bool {
273    builtin_function_kind(name).is_some()
274}
275
276/// Parse a `serde_json::Value` into a typed config, wrapping any error in a
277/// "config for function '<func>': …" envelope. Strips the trailing
278/// `" at line 0 column 0"` that `serde_json::from_value` always appends
279/// (since the source `Value` has no source-text location); the outer
280/// deserializer re-attaches the real source location when this error
281/// bubbles up to e.g. `Workflow::from_json`.
282fn parse_function_input<T, E>(func: &str, input: Value) -> std::result::Result<T, E>
283where
284    T: DeserializeOwned,
285    E: serde::de::Error,
286{
287    serde_json::from_value::<T>(input).map_err(|err| {
288        let raw = err.to_string();
289        let trimmed = raw
290            .rsplit_once(" at line ")
291            .map(|(head, _)| head)
292            .unwrap_or(&raw);
293        E::custom(format!("config for function '{func}': {trimmed}"))
294    })
295}
296
297impl<'de> Deserialize<'de> for FunctionConfig {
298    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
299    where
300        D: Deserializer<'de>,
301    {
302        // Tag-only intermediate. Format-agnostic: works for any deserializer
303        // that produces `String`/`serde_json::Value`. The strict typed parse
304        // happens in the dispatch below.
305        #[derive(Deserialize)]
306        struct Raw {
307            name: String,
308            input: Value,
309        }
310
311        let Raw { name, input } = Raw::deserialize(deserializer)?;
312
313        Ok(match name.as_str() {
314            "map" => FunctionConfig::Map {
315                name: MapName::Map,
316                input: parse_function_input("map", input)?,
317            },
318            "validate" => FunctionConfig::Validation {
319                name: ValidationName::Validate,
320                input: parse_function_input("validate", input)?,
321            },
322            "validation" => FunctionConfig::Validation {
323                name: ValidationName::Validation,
324                input: parse_function_input("validation", input)?,
325            },
326            "parse_json" => FunctionConfig::ParseJson {
327                name: ParseJsonName::ParseJson,
328                input: parse_function_input("parse_json", input)?,
329            },
330            "parse_xml" => FunctionConfig::ParseXml {
331                name: ParseXmlName::ParseXml,
332                input: parse_function_input("parse_xml", input)?,
333            },
334            "publish_json" => FunctionConfig::PublishJson {
335                name: PublishJsonName::PublishJson,
336                input: parse_function_input("publish_json", input)?,
337            },
338            "publish_xml" => FunctionConfig::PublishXml {
339                name: PublishXmlName::PublishXml,
340                input: parse_function_input("publish_xml", input)?,
341            },
342            "filter" => FunctionConfig::Filter {
343                name: FilterName::Filter,
344                input: parse_function_input("filter", input)?,
345            },
346            "log" => FunctionConfig::Log {
347                name: LogName::Log,
348                input: parse_function_input("log", input)?,
349            },
350            "http_call" => FunctionConfig::HttpCall {
351                name: HttpCallName::HttpCall,
352                input: parse_function_input("http_call", input)?,
353            },
354            "enrich" => FunctionConfig::Enrich {
355                name: EnrichName::Enrich,
356                input: parse_function_input("enrich", input)?,
357            },
358            "publish_kafka" => FunctionConfig::PublishKafka {
359                name: PublishKafkaName::PublishKafka,
360                input: parse_function_input("publish_kafka", input)?,
361            },
362            _ => FunctionConfig::Custom {
363                name,
364                input,
365                compiled_input: None,
366            },
367        })
368    }
369}
370
371/// Refresh the arena's `"data"` slot on `Ok`, then return `result` unchanged.
372/// Shared by the three built-ins (`parse_xml`, `publish_json`, `publish_xml`)
373/// that write through `set_nested_value` on the owned context rather than the
374/// arena — the arena cache would otherwise miss that write for the rest of the
375/// sync stretch. On `Err` the context didn't change, so the cache is already
376/// in sync and is left alone.
377fn refresh_data_on_success(
378    message: &Message,
379    arena_ctx: &mut ArenaContext<'_>,
380    result: Result<(TaskOutcome, Vec<Change>)>,
381) -> Result<(TaskOutcome, Vec<Change>)> {
382    if result.is_ok() {
383        arena_ctx.refresh_for_path(&message.context, "data");
384    }
385    result
386}
387
388impl FunctionConfig {
389    /// Get the function name for this configuration
390    pub fn function_name(&self) -> &str {
391        match self {
392            FunctionConfig::Map { .. } => "map",
393            FunctionConfig::Validation { .. } => "validate",
394            FunctionConfig::ParseJson { .. } => "parse_json",
395            FunctionConfig::ParseXml { .. } => "parse_xml",
396            FunctionConfig::PublishJson { .. } => "publish_json",
397            FunctionConfig::PublishXml { .. } => "publish_xml",
398            FunctionConfig::Filter { .. } => "filter",
399            FunctionConfig::Log { .. } => "log",
400            FunctionConfig::HttpCall { .. } => "http_call",
401            FunctionConfig::Enrich { .. } => "enrich",
402            FunctionConfig::PublishKafka { .. } => "publish_kafka",
403            FunctionConfig::Custom { name, .. } => name,
404        }
405    }
406
407    /// Whether this is a synchronous built-in. Synchronous built-ins can share
408    /// a single `ArenaContext` lifetime across consecutive tasks within a
409    /// workflow without crossing any `.await` point.
410    ///
411    /// Must match the variants handled in [`Self::try_execute_in_arena`]; the
412    /// debug assertion below ties the two together so they can't drift.
413    /// The connector this task references, if any.
414    ///
415    /// The three integration variants return their typed `connector` field
416    /// verbatim — including an empty string. Whether an empty connector name is
417    /// acceptable is a validation question for the host, not this accessor's.
418    ///
419    /// [`FunctionConfig::Custom`] returns `input["connector"]` when that key
420    /// holds a string. That is the convention for service-registered integration
421    /// handlers, mirroring the three built-in schemas; a `Custom` input whose
422    /// `connector` key means something else is a false positive, and the
423    /// convention is the only contract available.
424    ///
425    /// Usable without a [`crate::Task`]: `FunctionConfig` deserializes from a
426    /// bare `{"name": .., "input": ..}` object, so a caller holding only a task's
427    /// `function` value does not need to satisfy `Task`'s required `id` and
428    /// `name`.
429    ///
430    /// The match is exhaustive on purpose — a future connector-bearing config
431    /// cannot be silently omitted.
432    pub fn connector(&self) -> Option<&str> {
433        match self {
434            FunctionConfig::HttpCall { input, .. } => Some(&input.connector),
435            FunctionConfig::Enrich { input, .. } => Some(&input.connector),
436            FunctionConfig::PublishKafka { input, .. } => Some(&input.connector),
437            FunctionConfig::Custom { input, .. } => input.get("connector").and_then(Value::as_str),
438            FunctionConfig::Map { .. }
439            | FunctionConfig::Validation { .. }
440            | FunctionConfig::ParseJson { .. }
441            | FunctionConfig::ParseXml { .. }
442            | FunctionConfig::PublishJson { .. }
443            | FunctionConfig::PublishXml { .. }
444            | FunctionConfig::Filter { .. }
445            | FunctionConfig::Log { .. } => None,
446        }
447    }
448
449    pub fn is_sync_builtin(&self) -> bool {
450        matches!(
451            self,
452            FunctionConfig::Map { .. }
453                | FunctionConfig::Validation { .. }
454                | FunctionConfig::ParseJson { .. }
455                | FunctionConfig::ParseXml { .. }
456                | FunctionConfig::PublishJson { .. }
457                | FunctionConfig::PublishXml { .. }
458                | FunctionConfig::Filter { .. }
459                | FunctionConfig::Log { .. }
460        )
461    }
462
463    /// If this config is a sync built-in, execute it against the supplied
464    /// arena context and return `Some(result)`. Otherwise return `None` —
465    /// the workflow executor uses that as the signal to break the sync
466    /// stretch and dispatch the task on the async path instead.
467    ///
468    /// `mapping_snapshots` is only consulted by the `Map` variant — when
469    /// `Some`, the map function pushes a `serde_json::Value` snapshot of the
470    /// context before each mapping (for the trace surface). All other
471    /// variants ignore it. Pass `None` from the production path.
472    ///
473    /// This is the single source of truth for the sync-stretch dispatch:
474    /// adding a new sync built-in only requires adding an arm here (and the
475    /// matching variant to `is_sync_builtin` above).
476    pub(crate) fn try_execute_in_arena<'arena>(
477        &'arena self,
478        message: &mut Message,
479        arena_ctx: &mut ArenaContext<'arena>,
480        engine: &Arc<Engine>,
481        mapping_snapshots: Option<&mut Vec<Value>>,
482    ) -> Option<Result<(TaskOutcome, Vec<Change>)>> {
483        match self {
484            FunctionConfig::Map { input, .. } => {
485                Some(input.execute_in_arena(message, arena_ctx, engine, mapping_snapshots))
486            }
487            FunctionConfig::Validation { input, .. } => {
488                Some(input.execute_in_arena(message, arena_ctx, engine))
489            }
490            FunctionConfig::ParseJson { input, .. } => {
491                Some(execute_parse_json_in_arena(message, input, arena_ctx))
492            }
493            FunctionConfig::ParseXml { input, .. } => {
494                // parse_xml/publish_json/publish_xml all write through
495                // `set_nested_value` on the owned context rather than the
496                // arena, so the arena's "data" slot needs a manual refresh —
497                // but only on success; on error the context didn't change
498                // either, so the arena cache is still in sync.
499                let result = execute_parse_xml(message, input);
500                Some(refresh_data_on_success(message, arena_ctx, result))
501            }
502            FunctionConfig::PublishJson { input, .. } => {
503                let result = execute_publish_json(message, input);
504                Some(refresh_data_on_success(message, arena_ctx, result))
505            }
506            FunctionConfig::PublishXml { input, .. } => {
507                let result = execute_publish_xml(message, input);
508                Some(refresh_data_on_success(message, arena_ctx, result))
509            }
510            FunctionConfig::Filter { input, .. } => {
511                Some(input.execute_in_arena(message, arena_ctx, engine))
512            }
513            FunctionConfig::Log { input, .. } => {
514                Some(input.execute_in_arena(message, arena_ctx, engine))
515            }
516            FunctionConfig::HttpCall { .. }
517            | FunctionConfig::Enrich { .. }
518            | FunctionConfig::PublishKafka { .. }
519            | FunctionConfig::Custom { .. } => None,
520        }
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use serde_json::json;
528
529    fn parse(value: serde_json::Value) -> std::result::Result<FunctionConfig, serde_json::Error> {
530        serde_json::from_value(value)
531    }
532
533    #[test]
534    fn map_with_valid_config_deserializes_to_map_variant() {
535        let cfg = parse(json!({
536            "name": "map",
537            "input": {
538                "mappings": [
539                    { "path": "data.x", "logic": { "var": "data.y" } }
540                ]
541            }
542        }))
543        .expect("valid map config should deserialize");
544        assert!(matches!(cfg, FunctionConfig::Map { .. }));
545    }
546
547    #[test]
548    fn map_with_missing_mappings_gives_clear_error() {
549        let err = parse(json!({
550            "name": "map",
551            "input": {}
552        }))
553        .expect_err("map with empty input should fail");
554        let msg = err.to_string();
555        assert!(
556            msg.starts_with("config for function 'map':"),
557            "error should be prefixed with function envelope, got: {msg}"
558        );
559        assert!(
560            msg.contains("mappings"),
561            "error should mention the missing field, got: {msg}"
562        );
563    }
564
565    #[test]
566    fn map_with_wrong_input_shape_gives_clear_error() {
567        let err = parse(json!({
568            "name": "map",
569            "input": { "mappings": "not an array" }
570        }))
571        .expect_err("map with bad mappings type should fail");
572        let msg = err.to_string();
573        assert!(
574            msg.starts_with("config for function 'map':"),
575            "error should be prefixed with function envelope, got: {msg}"
576        );
577    }
578
579    #[test]
580    fn validation_accepts_both_spellings() {
581        for name in ["validate", "validation"] {
582            let cfg = parse(json!({
583                "name": name,
584                "input": { "rules": [] }
585            }))
586            .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
587            assert!(matches!(cfg, FunctionConfig::Validation { .. }));
588        }
589    }
590
591    #[test]
592    fn unknown_name_falls_through_to_custom() {
593        let cfg = parse(json!({
594            "name": "my_custom_handler",
595            "input": { "anything": "goes" }
596        }))
597        .expect("unknown name should produce Custom");
598        match cfg {
599            FunctionConfig::Custom {
600                name,
601                compiled_input,
602                ..
603            } => {
604                assert_eq!(name, "my_custom_handler");
605                assert!(compiled_input.is_none());
606            }
607            other => panic!("expected Custom, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn missing_name_field_errors() {
613        let err = parse(json!({ "input": {} })).expect_err("missing name should fail");
614        assert!(err.to_string().contains("name"));
615    }
616
617    #[test]
618    fn missing_input_field_errors() {
619        let err = parse(json!({ "name": "map" })).expect_err("missing input should fail");
620        assert!(err.to_string().contains("input"));
621    }
622
623    #[test]
624    fn http_call_with_missing_connector_gives_clear_error() {
625        let err = parse(json!({
626            "name": "http_call",
627            "input": { "method": "GET" }
628        }))
629        .expect_err("http_call needs connector");
630        let msg = err.to_string();
631        assert!(
632            msg.starts_with("config for function 'http_call':"),
633            "error should be prefixed with function envelope, got: {msg}"
634        );
635        assert!(msg.contains("connector"));
636    }
637
638    #[test]
639    fn builtin_names_never_fall_through_to_custom() {
640        // Every name in BUILTIN_FUNCTION_NAMES must be handled by the
641        // dispatch — either parsing successfully or failing with the
642        // envelope. None should silently land in Custom.
643        for name in BUILTIN_FUNCTION_NAMES {
644            let cfg = parse(json!({
645                "name": name,
646                "input": {}
647            }));
648            match cfg {
649                Ok(c) => assert!(
650                    !matches!(c, FunctionConfig::Custom { .. }),
651                    "name '{name}' silently fell through to Custom"
652                ),
653                Err(e) => assert!(
654                    e.to_string()
655                        .starts_with(&format!("config for function '{name}':")),
656                    "name '{name}' failed without envelope: {e}"
657                ),
658            }
659
660            // The const and the classifier cannot drift: anything listed as a
661            // built-in must classify as one.
662            assert!(
663                builtin_function_kind(name).is_some(),
664                "name '{name}' is in BUILTIN_FUNCTION_NAMES but classifies as None"
665            );
666        }
667    }
668
669    /// Parse an `http_call` task and hand back its typed config.
670    fn parse_http_call(
671        input: serde_json::Value,
672    ) -> std::result::Result<HttpCallConfig, serde_json::Error> {
673        match parse(json!({ "name": "http_call", "input": input }))? {
674            FunctionConfig::HttpCall { input, .. } => Ok(input),
675            other => panic!("expected HttpCall, got {other:?}"),
676        }
677    }
678
679    #[test]
680    fn http_call_response_path_is_read_under_its_own_name() {
681        let cfg = parse_http_call(json!({ "connector": "c", "response_path": "data.x" }))
682            .expect("response_path should parse");
683        assert_eq!(cfg.response_path.as_deref(), Some("data.x"));
684    }
685
686    #[test]
687    fn http_call_response_path_accepts_the_output_alias() {
688        // This is the case that previously yielded None, silently: the request
689        // was made and the response thrown away.
690        let cfg = parse_http_call(json!({ "connector": "c", "output": "data.x" }))
691            .expect("output should be accepted as an alias");
692        assert_eq!(cfg.response_path.as_deref(), Some("data.x"));
693    }
694
695    #[test]
696    fn http_call_response_path_is_optional() {
697        let cfg = parse_http_call(json!({ "connector": "c" })).expect("no destination is valid");
698        assert_eq!(cfg.response_path, None);
699    }
700
701    #[test]
702    fn http_call_rejects_both_destination_keys_in_either_order() {
703        // Asserting both orderings matters: a single-order test would also pass
704        // on an implementation that had an order-dependent precedence rule.
705        for input in [
706            json!({ "connector": "c", "response_path": "a", "output": "b" }),
707            json!({ "connector": "c", "output": "b", "response_path": "a" }),
708        ] {
709            let err = parse_http_call(input.clone())
710                .expect_err("supplying both destination keys must fail");
711            let msg = err.to_string();
712            assert!(
713                msg.starts_with("config for function 'http_call':"),
714                "error should carry the function envelope, got: {msg}"
715            );
716            assert!(
717                msg.contains("duplicate field"),
718                "error should name the conflict, got: {msg}"
719            );
720        }
721    }
722
723    #[test]
724    fn http_call_rejects_a_misspelled_destination_field() {
725        // The recorded decision: `HttpCallConfig` is `deny_unknown_fields`, so a
726        // near-miss spelling is a parse error naming the field rather than a
727        // silently discarded response. This is the defect this closes.
728        for bad in ["outputs", "Output", "respose_path", "response-path"] {
729            let mut input = serde_json::Map::new();
730            input.insert("connector".to_string(), json!("c"));
731            input.insert(bad.to_string(), json!("data.x"));
732
733            let err = parse_http_call(serde_json::Value::Object(input))
734                .expect_err("a misspelled field must be rejected, not silently discarded");
735            let msg = err.to_string();
736            assert!(
737                msg.starts_with("config for function 'http_call':"),
738                "error should carry the function envelope, got: {msg}"
739            );
740            assert!(
741                msg.contains("unknown field"),
742                "error should say the field is unknown, got: {msg}"
743            );
744            assert!(
745                msg.contains(bad),
746                "error should name the offending field '{bad}', got: {msg}"
747            );
748        }
749    }
750
751    #[test]
752    fn enrich_does_not_accept_the_output_alias() {
753        // The asymmetry is deliberate: only `HttpCallConfig::response_path`
754        // takes the alias. `EnrichConfig`'s destination is `merge_path`, and
755        // `deny_unknown_fields` makes the mistake loud instead of silent.
756        let err = parse(json!({
757            "name": "enrich",
758            "input": { "connector": "c", "output": "data.x" }
759        }))
760        .expect_err("enrich has no `output` field");
761        let msg = err.to_string();
762        assert!(
763            msg.starts_with("config for function 'enrich':"),
764            "error should carry the function envelope, got: {msg}"
765        );
766
767        // And the real spelling still works.
768        let ok = parse(json!({
769            "name": "enrich",
770            "input": { "connector": "c", "merge_path": "data.x" }
771        }))
772        .expect("merge_path is enrich's destination field");
773        assert!(matches!(ok, FunctionConfig::Enrich { .. }));
774    }
775
776    #[test]
777    fn publish_kafka_rejects_unknown_fields() {
778        let err = parse(json!({
779            "name": "publish_kafka",
780            "input": { "connector": "c", "topic": "t", "tpoic": "typo" }
781        }))
782        .expect_err("publish_kafka should reject an unknown field");
783        assert!(err.to_string().contains("unknown field"), "got: {err}");
784    }
785
786    #[test]
787    fn connector_is_returned_for_the_three_typed_integrations() {
788        let cases = [
789            (
790                json!({ "name": "http_call", "input": { "connector": "user_service" } }),
791                "user_service",
792            ),
793            (
794                json!({ "name": "enrich",
795                        "input": { "connector": "ref_data", "merge_path": "data.out" } }),
796                "ref_data",
797            ),
798            (
799                json!({ "name": "publish_kafka",
800                        "input": { "connector": "events", "topic": "t" } }),
801                "events",
802            ),
803        ];
804        for (input, expected) in cases {
805            let cfg = parse(input.clone()).expect("should parse");
806            assert_eq!(cfg.connector(), Some(expected), "for {input}");
807        }
808    }
809
810    #[test]
811    fn connector_is_none_for_every_non_connector_builtin() {
812        // Table-driven over BUILTIN_FUNCTION_NAMES minus the three integration
813        // names, so this cannot go stale when a built-in is added.
814        let minimal_input = |name: &str| -> serde_json::Value {
815            match name {
816                "map" => json!({ "mappings": [] }),
817                "validation" | "validate" => json!({ "rules": [] }),
818                "parse_json" | "parse_xml" | "publish_json" | "publish_xml" => {
819                    json!({ "source": "data.in", "target": "out" })
820                }
821                "filter" => json!({ "condition": true }),
822                "log" => json!({ "message": "hi" }),
823                _ => json!({}),
824            }
825        };
826
827        for name in BUILTIN_FUNCTION_NAMES {
828            if matches!(*name, "http_call" | "enrich" | "publish_kafka") {
829                continue;
830            }
831            let cfg = parse(json!({ "name": name, "input": minimal_input(name) }))
832                .unwrap_or_else(|e| panic!("'{name}' should parse: {e}"));
833            assert_eq!(cfg.connector(), None, "'{name}' names no connector");
834        }
835    }
836
837    #[test]
838    fn connector_reads_the_custom_convention() {
839        let cfg = parse(json!({
840            "name": "pg_query",
841            "input": { "connector": "pg_main", "database": "orders" }
842        }))
843        .unwrap();
844        assert_eq!(cfg.connector(), Some("pg_main"));
845    }
846
847    #[test]
848    fn connector_is_none_for_a_custom_input_without_a_string_connector() {
849        // `Custom` accepts arbitrary input, so every one of these is reachable.
850        for input in [
851            json!({}),                            // key absent
852            json!({ "connector": 7 }),            // number
853            json!({ "connector": true }),         // bool
854            json!({ "connector": null }),         // null
855            json!({ "connector": ["a"] }),        // array
856            json!({ "connector": { "n": "a" } }), // object
857            json!([]),                            // input is not an object
858            json!(7),                             // input is a scalar
859        ] {
860            let cfg = parse(json!({ "name": "my_handler", "input": input.clone() }))
861                .unwrap_or_else(|e| panic!("custom should parse {input}: {e}"));
862            assert_eq!(cfg.connector(), None, "for input {input}");
863        }
864    }
865
866    #[test]
867    fn connector_returns_an_empty_name_verbatim() {
868        // The recorded decision: the accessor reports what was authored and
869        // never disagrees with itself across the typed and Custom arms. Whether
870        // an empty connector is acceptable is the host's validation question.
871        let typed = parse(json!({ "name": "http_call", "input": { "connector": "" } })).unwrap();
872        assert_eq!(typed.connector(), Some(""));
873
874        let custom = parse(json!({ "name": "x", "input": { "connector": "" } })).unwrap();
875        assert_eq!(custom.connector(), Some(""));
876    }
877
878    #[test]
879    fn connector_returns_a_non_ascii_name_byte_for_byte() {
880        // A pin against a future "normalize or trim it here" change.
881        let cfg =
882            parse(json!({ "name": "http_call", "input": { "connector": "連携先" } })).unwrap();
883        assert_eq!(cfg.connector(), Some("連携先"));
884    }
885
886    #[test]
887    fn builtin_function_kind_is_none_for_non_builtins() {
888        // Classification is exact-match, same as the deserializer dispatch.
889        for name in [
890            "",
891            "__not_a_builtin__",
892            "HTTP_CALL",    // case differs
893            "htttp_call",   // typo
894            "map ",         // trailing space
895            "publish_kafk", // truncated
896        ] {
897            assert_eq!(
898                builtin_function_kind(name),
899                None,
900                "'{name}' must not classify as a built-in"
901            );
902            assert!(!is_builtin_function(name));
903        }
904    }
905
906    #[test]
907    fn builtin_kinds_partition_matches_real_dispatch_behaviour() {
908        // Tie the classifier to executed code rather than to a second
909        // hand-maintained list: `is_sync_builtin` decides whether the workflow
910        // executor runs a task itself in the arena, and `try_execute_in_arena`
911        // returns `None` for exactly the handler-backed variants. So a
912        // SelfContained name must be a sync built-in and a RequiresHandler name
913        // must not be.
914        let minimal_input = |name: &str| -> serde_json::Value {
915            match name {
916                "map" => json!({ "mappings": [] }),
917                "validation" | "validate" => json!({ "rules": [] }),
918                "parse_json" | "parse_xml" | "publish_json" | "publish_xml" => {
919                    json!({ "source": "data.in", "target": "out" })
920                }
921                "filter" => json!({ "condition": true }),
922                "log" => json!({ "message": "hi" }),
923                "http_call" => json!({ "connector": "c" }),
924                "enrich" => json!({ "connector": "c", "merge_path": "data.out" }),
925                "publish_kafka" => json!({ "connector": "c", "topic": "t" }),
926                // A new built-in with required fields will fail loudly below
927                // rather than silently skewing the partition.
928                _ => json!({}),
929            }
930        };
931
932        for name in BUILTIN_FUNCTION_NAMES {
933            let kind = builtin_function_kind(name)
934                .unwrap_or_else(|| panic!("'{name}' must classify as a built-in"));
935            let cfg = parse(json!({ "name": name, "input": minimal_input(name) }))
936                .unwrap_or_else(|e| panic!("'{name}' should parse with minimal input: {e}"));
937
938            assert_eq!(
939                cfg.is_sync_builtin(),
940                matches!(kind, BuiltinKind::SelfContained),
941                "'{name}' classifies as {kind:?} but is_sync_builtin() is {}",
942                cfg.is_sync_builtin()
943            );
944        }
945    }
946
947    #[test]
948    fn requires_handler_kind_covers_exactly_the_config_only_integrations() {
949        // The three names documented as shipping config-only.
950        for name in ["http_call", "enrich", "publish_kafka"] {
951            assert_eq!(
952                builtin_function_kind(name),
953                Some(BuiltinKind::RequiresHandler),
954                "'{name}' ships as config only and needs a registered handler"
955            );
956        }
957
958        // Both accepted spellings of validation are self-contained. The
959        // deserializer takes either; `function_name()` only ever returns
960        // "validate", so checking one spelling would miss a regression.
961        for name in [
962            "map",
963            "validation",
964            "validate",
965            "parse_json",
966            "parse_xml",
967            "publish_json",
968            "publish_xml",
969            "filter",
970            "log",
971        ] {
972            assert_eq!(
973                builtin_function_kind(name),
974                Some(BuiltinKind::SelfContained),
975                "'{name}' is executed by this crate"
976            );
977        }
978    }
979}