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}