Skip to main content

dataflow_rs/engine/
authoring.rs

1//! Authoring-time validation: checking a workflow definition *before* it
2//! reaches [`Engine::build`](crate::Engine::build).
3//!
4//! The engine's own enforcement — parse, [`Workflow::validate`], and
5//! `LoopConfig::validate` — all fires when the *engine* is built. For a host
6//! that stores definitions and builds one engine over many of them, that is the
7//! wrong time (one bad row aborts the whole build, at reload, for every
8//! workflow in the process), the wrong shape (one stringly error, so an
9//! authoring API cannot point a 400 at `tasks[1].tasks[0].id`), and the wrong
10//! cardinality (fail-fast, so the author fixes one violation per round trip).
11//!
12//! [`Workflow::validate_authored`] answers all three, and carries one
13//! guarantee:
14//!
15//! > It returns empty **if and only if** the JSON parses into a [`Workflow`]
16//! > and that workflow validates.
17//!
18//! That biconditional is true *by construction*, not by keeping a rule list in
19//! sync — see the stages below.
20
21use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
22use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
23use crate::engine::steps::{StepKind, walk_authored_steps};
24use crate::engine::workflow::Workflow;
25use serde_json::Value;
26use std::collections::HashMap;
27use std::fmt;
28
29/// One problem with a workflow definition.
30///
31/// Shared by [`Workflow::validate_authored`] and, from the registry side,
32/// `EngineBuilder::check_workflow`. Both fill what they genuinely know: a
33/// definition check always has an authored coordinate and knows the step id
34/// when the problem concerns a step; a registry check always has a task id and
35/// reports a path relative to that task.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct WorkflowIssue {
38    /// Stable machine-readable classification.
39    pub code: IssueCode,
40    /// Human-readable explanation. Not stable — branch on [`Self::code`].
41    pub message: String,
42    /// Where the problem is. From [`Workflow::validate_authored`] this is the
43    /// coordinate the author typed, rooted at the workflow document:
44    /// `tasks[1].tasks[0].id`.
45    pub path: Option<String>,
46    /// The step this concerns, when it concerns one. Step ids are unique across
47    /// tasks *and* groups, so this identifies a step on its own.
48    pub task_id: Option<String>,
49}
50
51impl WorkflowIssue {
52    fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
53        Self {
54            code,
55            message: message.into(),
56            path: Some(path.into()),
57            task_id: None,
58        }
59    }
60
61    fn with_step(mut self, id: Option<&str>) -> Self {
62        self.task_id = id.map(str::to_string);
63        self
64    }
65}
66
67impl fmt::Display for WorkflowIssue {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match &self.path {
70            Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
71            None => write!(f, "{} [{}]", self.message, self.code.as_str()),
72        }
73    }
74}
75
76/// Why a workflow definition is not loadable.
77///
78/// `#[non_exhaustive]`: a later minor may add a rule, and a host matching on
79/// the codes it cares about should keep compiling. Use [`Self::as_str`] to
80/// serialize.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82#[non_exhaustive]
83pub enum IssueCode {
84    /// `id` is missing or empty.
85    EmptyWorkflowId,
86    /// `name` is missing or empty.
87    EmptyWorkflowName,
88    /// `tasks` is missing, not an array, or empty.
89    NoTasks,
90    /// A step carries no `id`.
91    MissingStepId,
92    /// Two steps share an id. Groups share the task id namespace.
93    DuplicateStepId,
94    /// A group's `tasks` is not a non-empty array.
95    EmptyGroup,
96    /// A group is nested at or beyond
97    /// [`MAX_GROUP_DEPTH`](crate::engine::steps::MAX_GROUP_DEPTH).
98    GroupTooDeep,
99    /// A task carries no `function`.
100    MissingFunction,
101    /// `function` is not an object, or its `name` is missing or empty.
102    InvalidFunctionName,
103    /// `terminal` is present but not a boolean.
104    InvalidTerminal,
105    /// `loop.increment` is below 1 — the counter would never reach `max`.
106    LoopIncrementTooSmall,
107    /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
108    LoopBoundEmpty,
109    /// `loop.counter` is not a non-empty dotted path.
110    LoopCounterInvalid,
111    /// No handler will dispatch this function name, and it is not a built-in.
112    /// Usually a typo or a handler the host forgot to register.
113    UnknownFunction,
114    /// The name *is* a built-in, but one that ships as a config schema only
115    /// (`http_call`, `enrich`, `publish_kafka`) and no handler is registered
116    /// under it. The workflow builds cleanly and then fails every message.
117    MissingHandler,
118    /// A custom task's `input` does not deserialize into its handler's declared
119    /// `Input` type.
120    InputParse,
121    /// A `Template` field of a custom task's input does not compile.
122    TemplateCompile,
123    /// The document does not deserialize into a [`Workflow`]. Carries the
124    /// parser's own message, which names the offending field and type.
125    ParseFailed,
126    /// The document parses but [`Workflow::validate`] rejects it. A backstop:
127    /// reaching this means a rule exists that the checks above do not model.
128    ValidateFailed,
129}
130
131impl IssueCode {
132    /// The stable string form, for serializing into an API response.
133    pub fn as_str(&self) -> &'static str {
134        match self {
135            Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
136            Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
137            Self::NoTasks => "NO_TASKS",
138            Self::MissingStepId => "MISSING_STEP_ID",
139            Self::DuplicateStepId => "DUPLICATE_STEP_ID",
140            Self::EmptyGroup => "EMPTY_GROUP",
141            Self::GroupTooDeep => "GROUP_TOO_DEEP",
142            Self::MissingFunction => "MISSING_FUNCTION",
143            Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
144            Self::InvalidTerminal => "INVALID_TERMINAL",
145            Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
146            Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
147            Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
148            Self::UnknownFunction => "UNKNOWN_FUNCTION",
149            Self::MissingHandler => "MISSING_HANDLER",
150            Self::InputParse => "INPUT_PARSE",
151            Self::TemplateCompile => "TEMPLATE_COMPILE",
152            Self::ParseFailed => "PARSE_FAILED",
153            Self::ValidateFailed => "VALIDATE_FAILED",
154        }
155    }
156}
157
158impl fmt::Display for IssueCode {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str(self.as_str())
161    }
162}
163
164impl Workflow {
165    /// Check authored workflow JSON without building an engine.
166    ///
167    /// Returns empty **if and only if** the JSON parses into a [`Workflow`] and
168    /// that workflow validates.
169    ///
170    /// That is the *shape* question, and it is the whole of it. It is not the
171    /// same as "this engine can run it": [`Engine::build`](crate::Engine::build)
172    /// also resolves every task to a handler and parses custom inputs, so a
173    /// structurally perfect definition naming an unregistered function still
174    /// aborts a build. [`Engine::check_workflow`](crate::Engine::check_workflow)
175    /// answers that half; run both.
176    ///
177    /// # How the guarantee holds
178    ///
179    /// Three stages. A structural walk collects *every* semantic violation with
180    /// the coordinate the author typed; if it finds none, the document is then
181    /// actually parsed and validated, and either failure is reported as one
182    /// further issue. The second and third stages are what make the promise
183    /// true by construction: the crate's serde schema is far larger than any
184    /// rule list — a `"priority": "high"` or a `map` task missing `mappings`
185    /// breaks no *semantic* rule and still cannot load — and mirroring it here
186    /// would recreate the very drift this API exists to remove.
187    ///
188    /// # Example
189    ///
190    /// ```
191    /// use dataflow_rs::{IssueCode, Workflow};
192    /// use serde_json::json;
193    ///
194    /// let broken = json!({
195    ///     "id": "w", "name": "w", "priority": 0,
196    ///     "tasks": [
197    ///         {"id": "dup", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
198    ///         {"id": "dup", "name": "b", "function": {"name": "map", "input": {"mappings": []}}}
199    ///     ]
200    /// });
201    ///
202    /// let issues = Workflow::validate_authored(&broken);
203    /// assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
204    /// assert_eq!(issues[0].path.as_deref(), Some("tasks[1].id"));
205    /// assert_eq!(issues[0].task_id.as_deref(), Some("dup"));
206    /// ```
207    ///
208    /// Every problem is reported, not just the first:
209    ///
210    /// ```
211    /// # use dataflow_rs::{IssueCode, Workflow};
212    /// # use serde_json::json;
213    /// let issues = Workflow::validate_authored(&json!({
214    ///     "id": "", "name": "w", "tasks": [{"id": "t", "name": "t"}]
215    /// }));
216    ///
217    /// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
218    /// assert!(codes.contains(&IssueCode::EmptyWorkflowId));
219    /// assert!(codes.contains(&IssueCode::MissingFunction));
220    /// ```
221    pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
222        let mut issues = check_shape(json);
223        if !issues.is_empty() {
224            return issues;
225        }
226
227        // Stage 2 — the schema is much wider than the rules above, and
228        // enumerating it here would be the mirror this API exists to delete.
229        let workflow: Workflow = match serde_json::from_value(json.clone()) {
230            Ok(w) => w,
231            Err(err) => {
232                issues.push(WorkflowIssue {
233                    code: IssueCode::ParseFailed,
234                    message: err.to_string(),
235                    path: None,
236                    task_id: None,
237                });
238                return issues;
239            }
240        };
241
242        // Stage 3 — a backstop. Reaching this means `check_shape` does not
243        // model some rule `validate` enforces; the caller still gets a correct
244        // answer, and the test suite is what keeps this unreachable.
245        if let Err(err) = workflow.validate() {
246            issues.push(WorkflowIssue {
247                code: IssueCode::ValidateFailed,
248                message: err.to_string(),
249                path: None,
250                task_id: None,
251            });
252        }
253        issues
254    }
255}
256
257/// Check a parsed workflow against a handler registry.
258///
259/// Shared by [`crate::EngineBuilder::check_workflow`] and
260/// [`crate::Engine::check_workflow`] so the two cannot answer differently, and
261/// run against the crate's *real* `TemplateCompiler` rather than a host's
262/// reconstruction of one.
263///
264/// `workflow.tasks` is already flattened, so iterating it covers members of
265/// task groups without any extra traversal.
266pub(crate) fn check_against_registry(
267    workflow: &Workflow,
268    registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
269    template_compiler: &TemplateCompiler,
270) -> Vec<WorkflowIssue> {
271    let mut issues = Vec::new();
272
273    for task in &workflow.tasks {
274        let name = task.function.function_name();
275
276        if !can_dispatch_in(registry, name) {
277            // Distinguish the two reasons, because the fixes differ: a
278            // `RequiresHandler` built-in is a real name awaiting a
279            // registration, while anything else is likely a typo.
280            let (code, message) = match builtin_function_kind(name) {
281                Some(BuiltinKind::RequiresHandler) => (
282                    IssueCode::MissingHandler,
283                    format!(
284                        "'{name}' ships as a config schema only — register a handler under \
285                         that name, or this workflow will build cleanly and fail every message"
286                    ),
287                ),
288                _ => (
289                    IssueCode::UnknownFunction,
290                    format!("no handler is registered for '{name}', and it is not a built-in"),
291                ),
292            };
293            issues.push(WorkflowIssue {
294                code,
295                message,
296                path: Some("function.name".to_string()),
297                task_id: Some(task.id.clone()),
298            });
299            continue;
300        }
301
302        // Only `Custom` inputs are still raw at this point: the built-in
303        // variants were typed by serde when the workflow parsed.
304        let FunctionConfig::Custom { name, input, .. } = &task.function else {
305            continue;
306        };
307        let Some(handler) = registry.get(name) else {
308            continue;
309        };
310
311        let mut parsed = match handler.parse_input_box(input) {
312            Ok(parsed) => parsed,
313            Err(err) => {
314                issues.push(WorkflowIssue {
315                    code: IssueCode::InputParse,
316                    message: format!("input does not match the handler's Input type: {err}"),
317                    path: Some("function.input".to_string()),
318                    task_id: Some(task.id.clone()),
319                });
320                continue;
321            }
322        };
323
324        if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
325            issues.push(WorkflowIssue {
326                code: IssueCode::TemplateCompile,
327                message: format!("a template field does not compile: {err}"),
328                path: Some("function.input".to_string()),
329                task_id: Some(task.id.clone()),
330            });
331        }
332    }
333
334    issues
335}
336
337/// Stage 1: every semantic violation, with authored coordinates.
338fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
339    let mut issues = Vec::new();
340
341    if non_empty_str(json.get("id")).is_none() {
342        issues.push(WorkflowIssue::at(
343            IssueCode::EmptyWorkflowId,
344            "id",
345            "workflow id must be a non-empty string",
346        ));
347    }
348    if non_empty_str(json.get("name")).is_none() {
349        issues.push(WorkflowIssue::at(
350            IssueCode::EmptyWorkflowName,
351            "name",
352            "workflow name must be a non-empty string",
353        ));
354    }
355
356    match json.get("tasks").and_then(Value::as_array) {
357        Some(tasks) if !tasks.is_empty() => {}
358        _ => issues.push(WorkflowIssue::at(
359            IssueCode::NoTasks,
360            "tasks",
361            "workflow must have at least one task",
362        )),
363    }
364
365    check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
366
367    if let Some(loop_config) = json.get("loop") {
368        check_loop(loop_config, &mut issues);
369    }
370
371    issues
372}
373
374/// Walk the authored step tree, checking each node and the id namespace.
375///
376/// Built on [`walk_authored_steps`], so the group test, the traversal order and
377/// the depth cap have exactly one definition shared with the parser.
378fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
379    // Step id -> the path that first claimed it.
380    let mut seen: HashMap<&str, String> = HashMap::new();
381
382    for step in walk_authored_steps(tasks) {
383        let id = non_empty_str(step.node.get("id"));
384
385        match id {
386            None => issues.push(
387                WorkflowIssue::at(
388                    IssueCode::MissingStepId,
389                    format!("{}.id", step.path),
390                    "every step needs a non-empty id",
391                )
392                .with_step(None),
393            ),
394            Some(id) => {
395                if let Some(first) = seen.get(id) {
396                    issues.push(
397                        WorkflowIssue::at(
398                            IssueCode::DuplicateStepId,
399                            format!("{}.id", step.path),
400                            format!(
401                                "step id '{id}' is already used at {first} — task groups \
402                                 share the task id namespace"
403                            ),
404                        )
405                        .with_step(Some(id)),
406                    );
407                } else {
408                    seen.insert(id, step.path.clone());
409                }
410            }
411        }
412
413        if let Some(terminal) = step.node.get("terminal") {
414            if !terminal.is_boolean() {
415                issues.push(
416                    WorkflowIssue::at(
417                        IssueCode::InvalidTerminal,
418                        format!("{}.terminal", step.path),
419                        "terminal must be a boolean",
420                    )
421                    .with_step(id),
422                );
423            }
424        }
425
426        match step.kind {
427            StepKind::Leaf => check_function(&step.path, step.node, id, issues),
428            StepKind::Group => {
429                // The parser rejects a group whose `tasks` is not a non-empty
430                // array; the walker reports the node so we can say which.
431                let has_members = step
432                    .node
433                    .get("tasks")
434                    .and_then(Value::as_array)
435                    .is_some_and(|members| !members.is_empty());
436                if !has_members {
437                    issues.push(
438                        WorkflowIssue::at(
439                            IssueCode::EmptyGroup,
440                            format!("{}.tasks", step.path),
441                            "a task group's tasks must be a non-empty array — \
442                             an empty group can only be a mistake",
443                        )
444                        .with_step(id),
445                    );
446                }
447            }
448            StepKind::TooDeep => issues.push(
449                WorkflowIssue::at(
450                    IssueCode::GroupTooDeep,
451                    step.path.clone(),
452                    format!(
453                        "task groups nested deeper than {} levels",
454                        crate::engine::steps::MAX_GROUP_DEPTH
455                    ),
456                )
457                .with_step(id),
458            ),
459        }
460    }
461}
462
463/// A leaf must carry a `function` object with a non-empty `name`.
464fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
465    let Some(function) = node.get("function") else {
466        issues.push(
467            WorkflowIssue::at(
468                IssueCode::MissingFunction,
469                format!("{path}.function"),
470                "a task needs a function — an element with neither `function` nor \
471                 `tasks` is neither a task nor a group",
472            )
473            .with_step(id),
474        );
475        return;
476    };
477
478    if !function.is_object() || non_empty_str(function.get("name")).is_none() {
479        issues.push(
480            WorkflowIssue::at(
481                IssueCode::InvalidFunctionName,
482                format!("{path}.function.name"),
483                "function must be an object with a non-empty name",
484            )
485            .with_step(id),
486        );
487    }
488}
489
490/// The three `LoopConfig::validate` rules, against the authored JSON.
491fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
492    // Absent fields take their serde defaults, which are valid; only a present
493    // field can be wrong here. A non-integer is a *type* error and belongs to
494    // stage 2, so it is deliberately not reported twice.
495    if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
496        if increment < 1 {
497            issues.push(WorkflowIssue::at(
498                IssueCode::LoopIncrementTooSmall,
499                "loop.increment",
500                format!(
501                    "loop increment must be >= 1, got {increment} \
502                     (a non-advancing counter would never reach max)"
503                ),
504            ));
505        }
506    }
507
508    let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
509    if let Some(max) = config.get("max").and_then(Value::as_i64) {
510        if max <= init {
511            issues.push(WorkflowIssue::at(
512                IssueCode::LoopBoundEmpty,
513                "loop.max",
514                format!(
515                    "loop max ({max}) must be greater than init ({init}) — \
516                     the bound is half-open, so this could never run a sweep"
517                ),
518            ));
519        }
520    }
521
522    if let Some(counter) = config.get("counter") {
523        if let Some(counter) = counter.as_str() {
524            if counter.is_empty() || counter.split('.').any(str::is_empty) {
525                issues.push(WorkflowIssue::at(
526                    IssueCode::LoopCounterInvalid,
527                    "loop.counter",
528                    format!(
529                        "loop counter must be a non-empty temp_data field path, got {counter:?}"
530                    ),
531                ));
532            }
533        }
534    }
535}
536
537/// The value at `field` as a non-empty string, if it is one.
538fn non_empty_str(field: Option<&Value>) -> Option<&str> {
539    field.and_then(Value::as_str).filter(|s| !s.is_empty())
540}