use dataflow_rs::engine::error::DataflowError;
use serde::Serialize;
use serde_json::Value;
use crate::errors::FieldError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldKind {
String,
Number,
Bool,
Object,
Array,
Any,
}
impl FieldKind {
pub fn as_str(self) -> &'static str {
match self {
FieldKind::String => "string",
FieldKind::Number => "number",
FieldKind::Bool => "bool",
FieldKind::Object => "object",
FieldKind::Array => "array",
FieldKind::Any => "any",
}
}
fn matches(self, v: &Value) -> bool {
match self {
FieldKind::String => v.is_string(),
FieldKind::Number => v.is_number(),
FieldKind::Bool => v.is_boolean(),
FieldKind::Object => v.is_object(),
FieldKind::Array => v.is_array(),
FieldKind::Any => true,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldSchema {
pub name: &'static str,
pub description: &'static str,
pub kind: FieldKind,
pub required: bool,
pub resolvable: bool,
pub alias: Option<&'static str>,
}
pub type StaticValidator =
fn(&serde_json::Map<String, Value>) -> Vec<(&'static str, &'static str, String)>;
#[derive(Debug, Clone, Serialize)]
pub struct FunctionSchema {
pub name: &'static str,
pub description: &'static str,
pub category: &'static str,
pub input_fields: &'static [FieldSchema],
pub deny_unknown: bool,
#[serde(skip)]
pub validate_static: Option<StaticValidator>,
}
use super::cache_read::CACHE_READ_FIELDS;
use super::cache_write::CACHE_WRITE_FIELDS;
use super::channel_call::CHANNEL_CALL_FIELDS;
use super::crypto::CRYPTO_FIELDS;
use super::data_query::DATA_QUERY_FIELDS;
use super::data_write::{DATA_WRITE_ENVELOPE_FIELDS, DATA_WRITE_FIELDS};
use super::db_read::DB_READ_FIELDS;
use super::db_write::DB_WRITE_FIELDS;
use super::http_call::HTTP_CALL_FIELDS;
use super::jwt_sign::JWT_SIGN_FIELDS;
use super::jwt_verify::JWT_VERIFY_FIELDS;
use super::mongo_aggregate::MONGO_AGGREGATE_FIELDS;
use super::mongo_read::MONGO_READ_FIELDS;
use super::mongo_write::MONGO_WRITE_FIELDS;
use super::publish_kafka::PUBLISH_KAFKA_FIELDS;
use super::send_email::SEND_EMAIL_FIELDS;
use super::storage_head::STORAGE_HEAD_FIELDS;
use super::storage_presign::STORAGE_PRESIGN_FIELDS;
const REGISTRY: &[FunctionSchema] = &[
FunctionSchema {
name: "cache_read",
description: "Read a value from a cache connector (Redis or in-memory).",
category: "connector",
input_fields: CACHE_READ_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "cache_write",
description: "Write a value to a cache connector.",
category: "connector",
input_fields: CACHE_WRITE_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "db_read",
description: "Execute a SELECT against a SQL connector.",
category: "connector",
input_fields: DB_READ_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "db_write",
description: "Execute INSERT/UPDATE/DELETE against a SQL connector.",
category: "connector",
input_fields: DB_WRITE_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "data_query",
description: "Run a backend-neutral query (filter + envelope) against a SQL, MongoDB, or Elasticsearch connector.",
category: "connector",
input_fields: DATA_QUERY_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "data_write",
description: "Run a backend-neutral mutation (insert/update/delete/upsert) against a SQL, MongoDB, or Elasticsearch connector.",
category: "connector",
input_fields: DATA_WRITE_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "mongo_read",
description: "Run find() against a MongoDB connector, with optional projection/sort/limit/skip.",
category: "connector",
input_fields: MONGO_READ_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "mongo_write",
description: "Write documents to a MongoDB connector: insert/update/replace/delete, nested documents as extended JSON.",
category: "connector",
input_fields: MONGO_WRITE_FIELDS,
deny_unknown: true,
validate_static: Some(super::mongo_write::validate_static_input),
},
FunctionSchema {
name: "mongo_aggregate",
description: "Run an aggregation pipeline against a MongoDB connector (stage-allowlisted; $out/$merge behind a connector opt-in).",
category: "connector",
input_fields: MONGO_AGGREGATE_FIELDS,
deny_unknown: true,
validate_static: Some(super::mongo_aggregate::validate_static_input),
},
FunctionSchema {
name: "channel_call",
description: "Invoke another channel's workflow in-process (no HTTP hop).",
category: "control",
input_fields: CHANNEL_CALL_FIELDS,
deny_unknown: false,
validate_static: None,
},
FunctionSchema {
name: "crypto",
description: "Digests, HMAC compute/verify, and password hashing — a self-contained operation envelope.",
category: "utility",
input_fields: CRYPTO_FIELDS,
deny_unknown: true,
validate_static: Some(super::crypto::validate_static_input),
},
FunctionSchema {
name: "jwt_sign",
description: "Mint a signed JWT (login, refresh, client assertions).",
category: "utility",
input_fields: JWT_SIGN_FIELDS,
deny_unknown: true,
validate_static: Some(super::jwt_sign::validate_static_input),
},
FunctionSchema {
name: "jwt_verify",
description: "Verify a JWT mid-workflow (provider id_tokens, refresh tokens) against static keys or a JWKS.",
category: "utility",
input_fields: JWT_VERIFY_FIELDS,
deny_unknown: true,
validate_static: Some(super::jwt_verify::validate_static_input),
},
FunctionSchema {
name: "http_call",
description: "HTTP request to an HTTP connector with retry + circuit breaker.",
category: "connector",
input_fields: HTTP_CALL_FIELDS,
deny_unknown: true,
validate_static: None,
},
FunctionSchema {
name: "send_email",
description: "Send an email through an SMTP connector.",
category: "connector",
input_fields: SEND_EMAIL_FIELDS,
deny_unknown: true,
validate_static: Some(super::send_email::validate_static_input),
},
FunctionSchema {
name: "storage_presign",
description: "Compute a time-limited presigned URL for one object — no data path.",
category: "connector",
input_fields: STORAGE_PRESIGN_FIELDS,
deny_unknown: true,
validate_static: Some(super::storage_presign::validate_static_input),
},
FunctionSchema {
name: "storage_head",
description: "Object metadata (exists/size/etag) from a storage connector.",
category: "connector",
input_fields: STORAGE_HEAD_FIELDS,
deny_unknown: true,
validate_static: None,
},
FunctionSchema {
name: "publish_kafka",
description: "Publish a message to a Kafka topic via a Kafka connector.",
category: "connector",
input_fields: PUBLISH_KAFKA_FIELDS,
deny_unknown: true,
validate_static: None,
},
];
pub fn registry() -> &'static [FunctionSchema] {
REGISTRY
}
fn find(name: &str) -> Option<&'static FunctionSchema> {
REGISTRY.iter().find(|s| s.name == name)
}
fn is_var_node(v: &Value) -> bool {
v.as_object()
.is_some_and(|o| o.len() == 1 && o.contains_key("var"))
}
fn check_fields(
fields: &[FieldSchema],
input: &Value,
path_prefix: &str,
function_name: &str,
) -> Vec<FieldError> {
let mut errors = Vec::new();
let Some(obj) = input.as_object() else {
return errors;
};
for field in fields {
let alias_value = field.alias.and_then(|alias| obj.get(alias));
if let Some(alias) = field.alias
&& obj.contains_key(field.name)
&& alias_value.is_some()
{
errors.push(FieldError::new(
format!("{path_prefix}.{}", field.name),
"DUPLICATE_FIELD",
format!(
"'{}' and its alias '{alias}' are both set; supply exactly one",
field.name
),
));
continue;
}
match (obj.get(field.name).or(alias_value), field.required) {
(None, true) => errors.push(FieldError::new(
format!("{path_prefix}.{}", field.name),
"REQUIRED",
format!(
"function '{function_name}' requires '{}' ({})",
field.name,
field.kind.as_str()
),
)),
(Some(v), _) if !field.kind.matches(v) && !(field.resolvable && is_var_node(v)) => {
errors.push(
FieldError::new(
format!("{path_prefix}.{}", field.name),
"TYPE_MISMATCH",
format!("expected {} for '{}'", field.kind.as_str(), field.name),
)
.with_expected(Value::String(field.kind.as_str().to_string()))
.with_got(v.clone()),
);
}
_ => {}
}
}
errors
}
fn check_unknown_fields(
fields: &[FieldSchema],
input: &Value,
path_prefix: &str,
function_name: &str,
) -> Vec<FieldError> {
let Some(obj) = input.as_object() else {
return Vec::new();
};
obj.keys()
.filter(|key| {
!fields
.iter()
.any(|f| f.name == key.as_str() || f.alias == Some(key.as_str()))
})
.map(|key| {
FieldError::new(
format!("{path_prefix}.{key}"),
"UNKNOWN_FIELD",
format!(
"function '{function_name}' has no input field '{key}' — \
it would be rejected when the workflow is loaded"
),
)
})
.collect()
}
pub fn validate_input(function_name: &str, input: &Value, task_path: &str) -> Vec<FieldError> {
let Some(schema) = find(function_name) else {
return Vec::new();
};
let mut errors = Vec::new();
let obj = match input.as_object() {
Some(o) => o,
None => {
errors.push(FieldError::new(
format!("{task_path}.function.input"),
"TYPE_MISMATCH",
format!("function '{function_name}' input must be a JSON object"),
));
return errors;
}
};
let input_path = format!("{task_path}.function.input");
errors.extend(check_fields(
schema.input_fields,
input,
&input_path,
function_name,
));
if schema.deny_unknown {
errors.extend(check_unknown_fields(
schema.input_fields,
input,
&input_path,
function_name,
));
}
if function_name == "data_write" {
match obj.get("write") {
Some(w) if w.is_object() => errors.extend(check_fields(
DATA_WRITE_ENVELOPE_FIELDS,
w,
&format!("{input_path}.write"),
function_name,
)),
Some(_) => {}
None if obj.contains_key("op") => errors.extend(check_fields(
DATA_WRITE_ENVELOPE_FIELDS,
input,
&input_path,
function_name,
)),
None => errors.push(FieldError::new(
format!("{input_path}.write"),
"REQUIRED",
"function 'data_write' requires 'write' (object): the mutation \
envelope { op, target, … }",
)),
}
}
if function_name == "channel_call"
&& obj.get("channel").is_none()
&& obj.get("channel_logic").is_none()
{
errors.push(FieldError::new(
format!("{task_path}.function.input"),
"REQUIRED",
"channel_call requires either 'channel' (static) or 'channel_logic' (dynamic)",
));
}
if let Some(validate) = schema.validate_static {
for (suffix, code, message) in validate(obj) {
let path = if suffix.is_empty() {
input_path.clone()
} else {
format!("{input_path}.{suffix}")
};
errors.push(FieldError::new(path, code, message));
}
}
if function_name == "http_call" {
use super::http_common::{BodyFormat, ResponseFormat, encode_body};
let body_format = match BodyFormat::parse(obj.get("body_format").and_then(Value::as_str)) {
Ok(f) => Some(f),
Err(msg) => {
errors.push(FieldError::new(
format!("{input_path}.body_format"),
"INVALID",
msg,
));
None
}
};
if let Err(msg) = ResponseFormat::parse(obj.get("response_format").and_then(Value::as_str))
{
errors.push(FieldError::new(
format!("{input_path}.response_format"),
"INVALID",
msg,
));
}
if let (Some(format), Some(body)) = (body_format, obj.get("body"))
&& format != BodyFormat::Json
&& let Err(e) = encode_body(body, format)
{
let msg = match e {
DataflowError::Validation(m) => m,
other => other.to_string(),
};
errors.push(FieldError::new(
format!("{input_path}.body"),
"INVALID",
msg,
));
}
}
errors
}
pub(super) fn strip_handler_prefix(handler: &str, e: &DataflowError) -> String {
let s = e.to_string();
match s.split_once(&format!("{handler}: ")) {
Some((_, msg)) => msg.to_string(),
None => s,
}
}
pub(super) fn static_field_name(
fields: &[FieldSchema],
key: &str,
fallback: &'static str,
) -> &'static str {
fields
.iter()
.map(|f| f.name)
.find(|n| *n == key)
.unwrap_or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unknown_function_returns_no_errors() {
let errs = validate_input("nope", &json!({}), "tasks[0]");
assert!(errs.is_empty());
}
#[test]
fn cache_read_missing_connector_is_required_error() {
let errs = validate_input("cache_read", &json!({"key": "k"}), "tasks[0]");
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].path, "tasks[0].function.input.connector");
assert_eq!(errs[0].code, "REQUIRED");
}
#[test]
fn cache_read_full_input_validates() {
let errs = validate_input(
"cache_read",
&json!({"connector": "c", "key": "k", "output": "data.out"}),
"tasks[0]",
);
assert!(errs.is_empty(), "{:?}", errs);
}
#[test]
fn type_mismatch_reports_expected_and_got() {
let errs = validate_input(
"cache_read",
&json!({"connector": 42, "key": "k"}),
"tasks[1]",
);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].code, "TYPE_MISMATCH");
assert_eq!(errs[0].path, "tasks[1].function.input.connector");
assert_eq!(errs[0].expected.as_ref().expect("test"), &json!("string"));
assert_eq!(errs[0].got.as_ref().expect("test"), &json!(42));
}
#[test]
fn non_object_input_emits_single_type_error() {
let errs = validate_input("cache_read", &json!("not an object"), "tasks[0]");
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].path, "tasks[0].function.input");
assert_eq!(errs[0].code, "TYPE_MISMATCH");
}
#[test]
fn mongo_read_collects_all_missing_required_at_once() {
let errs = validate_input("mongo_read", &json!({"connector": "c"}), "tasks[0]");
let paths: Vec<&str> = errs.iter().map(|e| e.path.as_str()).collect();
assert!(paths.contains(&"tasks[0].function.input.database"));
assert!(paths.contains(&"tasks[0].function.input.collection"));
}
#[test]
fn channel_call_needs_channel_or_logic() {
let errs = validate_input("channel_call", &json!({}), "tasks[0]");
assert!(errs.iter().any(|e| e.code == "REQUIRED"
&& e.path == "tasks[0].function.input"
&& e.message.contains("channel_call")));
}
#[test]
fn channel_call_with_static_channel_is_ok() {
let errs = validate_input(
"channel_call",
&json!({"channel": "downstream"}),
"tasks[0]",
);
assert!(errs.is_empty(), "{:?}", errs);
}
#[test]
fn channel_call_with_dynamic_logic_is_ok() {
let errs = validate_input(
"channel_call",
&json!({"channel_logic": {"var": "data.target"}}),
"tasks[0]",
);
assert!(errs.is_empty(), "{:?}", errs);
}
#[test]
fn http_call_unknown_format_values_are_authoring_time_errors() {
let errs = validate_input(
"http_call",
&json!({"connector": "c", "body_format": "multipart", "response_format": "base64"}),
"tasks[0]",
);
assert_eq!(errs.len(), 2, "{errs:?}");
assert_eq!(errs[0].path, "tasks[0].function.input.body_format");
assert_eq!(errs[0].code, "INVALID");
assert_eq!(errs[1].path, "tasks[0].function.input.response_format");
assert_eq!(errs[1].code, "INVALID");
}
#[test]
fn http_call_known_format_values_validate() {
let errs = validate_input(
"http_call",
&json!({
"connector": "c",
"method": "POST",
"body_format": "form",
"body": {
"grant_type": "refresh_token",
"retries": 3,
"to": ["+15551111111", "+15552222222"],
"optional": null,
"metadata[order_id]": "6735",
},
"response_format": "text",
"output": "temp_data.token",
}),
"tasks[0]",
);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn http_call_static_body_is_shape_checked_against_the_format() {
let errs = validate_input(
"http_call",
&json!({"connector": "c", "body_format": "form", "body": {"bad": {"nested": 1}}}),
"tasks[0]",
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert_eq!(errs[0].path, "tasks[0].function.input.body");
assert_eq!(errs[0].code, "INVALID");
assert!(errs[0].message.contains("'bad'"), "{}", errs[0].message);
let errs = validate_input(
"http_call",
&json!({"connector": "c", "body_format": "text", "body": {"a": 1}}),
"tasks[0]",
);
assert_eq!(errs.len(), 1, "{errs:?}");
assert_eq!(errs[0].code, "INVALID");
let errs = validate_input(
"http_call",
&json!({"connector": "c", "body_format": "form", "body_logic": {"var": "data.form"}}),
"tasks[0]",
);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn registry_is_non_empty_and_contains_all_known_connector_functions() {
let names: Vec<&str> = registry().iter().map(|s| s.name).collect();
assert!(names.contains(&"cache_read"));
assert!(names.contains(&"cache_write"));
assert!(names.contains(&"db_read"));
assert!(names.contains(&"db_write"));
assert!(names.contains(&"mongo_read"));
assert!(names.contains(&"channel_call"));
assert!(names.contains(&"http_call"));
assert!(names.contains(&"publish_kafka"));
}
}