use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, OnceLock, RwLock};
use dataflow_rs::engine::error::DataflowError;
use serde::Serialize;
use serde_json::Value;
use super::schema::{
self, ConnectorRule, FieldKind, FieldSchema, FunctionSchema, RetrySafety, Source,
StaticValidator, WriteShape,
};
use crate::errors::FieldError;
#[derive(Debug, Clone, Serialize)]
pub struct FieldSpec {
pub name: String,
pub description: String,
pub kind: FieldKind,
pub required: bool,
pub resolvable: bool,
pub secret_at: &'static [&'static str],
pub template_at: &'static [&'static str],
pub alias: Option<String>,
}
impl FieldSpec {
pub fn answers_to(&self, key: &str) -> bool {
self.name == key || self.alias.as_deref() == Some(key)
}
fn is_template(&self) -> bool {
self.template_at.contains(&"")
}
}
impl From<&FieldSchema> for FieldSpec {
fn from(f: &FieldSchema) -> Self {
Self {
name: f.name.to_string(),
description: f.description.to_string(),
kind: f.kind,
required: f.required,
resolvable: f.resolvable,
secret_at: f.secret_at,
template_at: f.template_at,
alias: f.alias.map(str::to_string),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PluginBinding {
pub id: String,
pub version: i64,
pub digest: String,
pub abi: String,
}
#[derive(Debug, Clone)]
pub struct FunctionEntry {
pub name: String,
pub description: String,
pub category: String,
pub source: Source,
pub aliases: Vec<String>,
pub input_fields: Option<Vec<FieldSpec>>,
pub writes: WriteShape,
pub retry_safety: RetrySafety,
pub deny_unknown: bool,
pub validate_static: Option<StaticValidator>,
pub connector: Option<ConnectorRule>,
pub plugin: Option<PluginBinding>,
}
impl FunctionEntry {
pub fn orion(schema: &FunctionSchema) -> Self {
Self {
name: schema.name.to_string(),
description: schema.description.to_string(),
category: schema.category.to_string(),
source: Source::Orion,
aliases: Vec::new(),
input_fields: Some(schema.input_fields.iter().map(FieldSpec::from).collect()),
writes: schema.writes,
retry_safety: schema.retry_safety,
deny_unknown: schema.deny_unknown,
validate_static: schema.validate_static,
connector: schema.connector,
plugin: None,
}
}
fn engine(
name: &str,
description: &str,
aliases: &[&str],
writes: WriteShape,
retry_safety: RetrySafety,
) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
category: "data".to_string(),
source: Source::Engine,
aliases: aliases.iter().map(|a| a.to_string()).collect(),
input_fields: None,
writes,
retry_safety,
deny_unknown: false,
validate_static: None,
connector: None,
plugin: None,
}
}
pub fn takes_connector(&self) -> bool {
self.connector.is_some()
}
fn field(&self, key: &str) -> Option<&FieldSpec> {
self.input_fields
.as_deref()?
.iter()
.find(|f| f.answers_to(key))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogueEntry {
pub name: String,
pub description: String,
pub category: String,
pub source: Source,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_fields: Option<Vec<FieldSpec>>,
pub retry_safety: RetrySafety,
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin: Option<PluginBinding>,
}
#[derive(Debug)]
pub struct FunctionRegistry {
entries: Vec<Arc<FunctionEntry>>,
index: HashMap<String, usize>,
}
impl FunctionRegistry {
pub fn builtin() -> &'static Arc<FunctionRegistry> {
static BUILTIN: OnceLock<Arc<FunctionRegistry>> = OnceLock::new();
BUILTIN.get_or_init(|| {
let entries = schema::registry()
.iter()
.map(FunctionEntry::orion)
.chain(schema::ENGINE_BUILTINS.iter().map(
|&(name, description, aliases, writes, retry_safety)| {
FunctionEntry::engine(name, description, aliases, writes, retry_safety)
},
))
.collect();
Arc::new(
FunctionRegistry::from_entries(entries)
.expect("the static tables declare each function once"),
)
})
}
pub fn from_entries(entries: Vec<FunctionEntry>) -> Result<Self, String> {
let mut entries: Vec<Arc<FunctionEntry>> = entries.into_iter().map(Arc::new).collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
let mut index = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
for name in std::iter::once(&entry.name).chain(&entry.aliases) {
if let Some(prior) = index.insert(name.clone(), i) {
let prior: &FunctionEntry = &entries[prior];
return Err(format!(
"function name '{name}' is claimed twice: by '{}' ({}) and '{}' ({})",
prior.name,
prior.source.as_str(),
entry.name,
entry.source.as_str()
));
}
intern(name);
}
}
Ok(Self { entries, index })
}
pub fn with_entries(&self, extra: Vec<FunctionEntry>) -> Result<Self, String> {
let entries = self
.entries
.iter()
.map(|e| (**e).clone())
.chain(extra)
.collect();
Self::from_entries(entries)
}
pub fn get(&self, name: &str) -> Option<&FunctionEntry> {
self.index.get(name).map(|&i| &*self.entries[i])
}
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|e| e.name.as_str())
}
pub fn accepted_names(&self) -> impl Iterator<Item = &str> {
self.index.keys().map(String::as_str)
}
pub fn entries(&self) -> impl Iterator<Item = &FunctionEntry> {
self.entries.iter().map(|e| &**e)
}
pub fn suggest(&self, name: &str) -> Option<&str> {
let needle: Vec<char> = name.chars().collect();
self.names()
.map(|candidate| {
let candidate_chars: Vec<char> = candidate.chars().collect();
(
crate::text::edit_distance_chars(&needle, &candidate_chars),
candidate,
)
})
.filter(|(distance, candidate)| {
let window = (name.len().min(candidate.len()) / 3).clamp(1, 3);
*distance <= window
})
.min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)))
.map(|(_, candidate)| candidate)
}
pub fn catalogue(&self) -> Vec<CatalogueEntry> {
self.entries
.iter()
.map(|e| CatalogueEntry {
name: e.name.clone(),
description: e.description.clone(),
category: e.category.clone(),
source: e.source,
aliases: e.aliases.clone(),
input_fields: e.input_fields.clone(),
retry_safety: e.retry_safety,
plugin: e.plugin.clone(),
})
.collect()
}
pub fn write_shape(&self, function: &str) -> Option<WriteShape> {
self.get(function).map(|e| e.writes)
}
pub fn takes_connector(&self, function: &str) -> bool {
self.get(function)
.is_some_and(FunctionEntry::takes_connector)
}
pub fn is_resolvable_field(&self, function: &str, field: &str) -> bool {
self.get(function)
.and_then(|e| e.field(field))
.is_some_and(|f| f.resolvable)
}
pub fn secret_paths(&self, function: &str, field: &str) -> &'static [&'static str] {
self.get(function)
.and_then(|e| e.field(field))
.map(|f| f.secret_at)
.unwrap_or(&[])
}
pub fn template_paths(&self, function: &str, field: &str) -> &'static [&'static str] {
self.get(function)
.and_then(|e| e.field(field))
.map(|f| f.template_at)
.unwrap_or(&[])
}
pub fn validate_input(
&self,
function_name: &str,
input: &Value,
task_path: &str,
) -> Vec<FieldError> {
self.get(function_name)
.map(|entry| entry.validate_input(input, task_path))
.unwrap_or_default()
}
}
impl FunctionEntry {
pub fn validate_input(&self, input: &Value, task_path: &str) -> Vec<FieldError> {
let Some(fields) = self.input_fields.as_deref() else {
return Vec::new();
};
let entry = self;
let function_name = self.name.as_str();
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(fields, input, &input_path, function_name));
if entry.deny_unknown {
errors.extend(check_unknown_fields(
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,
w,
&format!("{input_path}.write"),
function_name,
)),
Some(_) => {}
None if obj.contains_key("op") => errors.extend(check_fields(
&DATA_WRITE_ENVELOPE,
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 let Some(field) = fields.iter().find(|f| f.name == "connector")
&& field.is_template()
&& let Some(value) = obj.get(field.name.as_str())
&& (value.is_object() || value.is_array())
{
errors.push(
FieldError::new(
format!("{input_path}.connector"),
"TYPE_MISMATCH",
format!(
"function '{function_name}' needs a literal connector name — the \
connector is resolved before the message is read, and the same name \
is what the dependency list, the activation gate and the connector \
rename guard are built from"
),
)
.with_expected(Value::String("string".to_string()))
.with_got(value.clone()),
);
}
if let Some(validate) = entry.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
}
}
static DATA_WRITE_ENVELOPE: LazyLock<Vec<FieldSpec>> = LazyLock::new(|| {
super::data_write::DATA_WRITE_ENVELOPE_FIELDS
.iter()
.map(FieldSpec::from)
.collect()
});
fn is_var_node(v: &Value) -> bool {
v.as_object()
.is_some_and(|o| o.len() == 1 && o.contains_key("var"))
}
fn takes_secret_node(field: &FieldSpec, v: &Value) -> bool {
field.secret_at.contains(&"") && super::secret_ref::secret_name(v).is_some()
}
fn check_fields(
fields: &[FieldSpec],
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.as_deref().and_then(|alias| obj.get(alias));
if let Some(alias) = field.alias.as_deref()
&& obj.contains_key(field.name.as_str())
&& 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.as_str()).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.is_template() && (v.is_object() || v.is_array()))
&& !(field.resolvable && is_var_node(v))
&& !takes_secret_node(field, 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: &[FieldSpec],
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.answers_to(key)))
.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()
}
fn labels() -> &'static RwLock<HashSet<&'static str>> {
static LABELS: OnceLock<RwLock<HashSet<&'static str>>> = OnceLock::new();
LABELS.get_or_init(|| RwLock::new(HashSet::new()))
}
fn intern(name: &str) {
let labels = labels();
if labels
.read()
.unwrap_or_else(|e| e.into_inner())
.contains(name)
{
return;
}
let mut write = labels.write().unwrap_or_else(|e| e.into_inner());
if !write.contains(name) {
write.insert(Box::leak(name.to_string().into_boxed_str()));
}
}
pub fn interned(name: &str) -> Option<&'static str> {
labels()
.read()
.unwrap_or_else(|e| e.into_inner())
.get(name)
.copied()
}
impl FunctionEntry {
pub fn label(&self) -> &'static str {
if let Some(label) = interned(&self.name) {
return label;
}
intern(&self.name);
interned(&self.name).expect("interned just above")
}
}
#[cfg(test)]
fn validate_input(function: &str, input: &Value, task_path: &str) -> Vec<FieldError> {
FunctionRegistry::builtin().validate_input(function, input, task_path)
}
#[cfg(test)]
fn is_resolvable_field(function: &str, field: &str) -> bool {
FunctionRegistry::builtin().is_resolvable_field(function, field)
}
#[cfg(test)]
fn write_shape(function: &str) -> Option<WriteShape> {
FunctionRegistry::builtin().write_shape(function)
}
#[cfg(test)]
mod registry_tests {
use super::*;
use dataflow_rs::BuiltinKind;
fn plugin_entry(name: &str) -> FunctionEntry {
FunctionEntry {
name: name.to_string(),
description: "a plugin function".to_string(),
category: "transform".to_string(),
source: Source::Plugin,
aliases: Vec::new(),
input_fields: Some(vec![FieldSpec {
name: "message".to_string(),
description: "the message".to_string(),
kind: FieldKind::String,
required: true,
resolvable: true,
secret_at: &[],
template_at: &[],
alias: None,
}]),
writes: WriteShape::OutputPath {
default_root: Some("data"),
},
retry_safety: RetrySafety::Pure,
deny_unknown: true,
validate_static: None,
connector: None,
plugin: Some(PluginBinding {
id: "acme.codec".to_string(),
version: 1,
digest: "sha256:00".to_string(),
abi: "orion:plugin@1.0.0".to_string(),
}),
}
}
#[test]
fn every_self_contained_builtin_is_accepted() {
let registry = FunctionRegistry::builtin();
let mut checked = 0;
for name in dataflow_rs::BUILTIN_FUNCTION_NAMES {
if dataflow_rs::builtin_function_kind(name) == Some(BuiltinKind::SelfContained) {
assert!(
registry.contains(name),
"'{name}' runs with no registration, so rejecting it at create \
refuses a workflow the engine would happily execute"
);
checked += 1;
}
}
assert!(checked >= 8, "implausibly few self-contained built-ins");
}
#[test]
fn a_builtin_needing_a_handler_is_accepted_only_when_one_is_registered() {
let registry = FunctionRegistry::builtin();
for name in dataflow_rs::BUILTIN_FUNCTION_NAMES {
if dataflow_rs::builtin_function_kind(name) != Some(BuiltinKind::RequiresHandler) {
continue;
}
assert_eq!(
registry.contains(name),
schema::registry().iter().any(|s| s.name == *name),
"'{name}' needs a registered handler; accepting it without one \
green-lights a workflow that 500s on every request"
);
}
assert!(registry.contains("http_call"));
assert!(registry.contains("publish_kafka"));
assert!(
!registry.contains("enrich"),
"Orion registers no `enrich` handler, so the name must be refused \
at create rather than at every request"
);
}
#[test]
fn every_orion_handler_is_an_entry_with_its_schema() {
let registry = FunctionRegistry::builtin();
for schema in schema::registry() {
let entry = registry.get(schema.name);
assert!(entry.is_some(), "'{}' has no entry", schema.name);
let entry = entry.expect("asserted above");
assert_eq!(entry.source, Source::Orion);
assert_eq!(
entry.input_fields.as_ref().map(Vec::len),
Some(schema.input_fields.len()),
"'{}' lost fields in conversion",
schema.name
);
assert_eq!(entry.writes, schema.writes);
assert_eq!(entry.retry_safety, schema.retry_safety);
assert_eq!(entry.connector, schema.connector);
}
assert!(!registry.contains("__not_a_function__"));
assert!(registry.get("__not_a_function__").is_none());
}
#[test]
fn an_alias_resolves_to_its_function() {
let registry = FunctionRegistry::builtin();
assert_eq!(
registry.get("validate").map(|e| e.name.as_str()),
Some("validation")
);
assert!(registry.names().all(|n| n != "validate"));
assert!(registry.catalogue().iter().all(|e| e.name != "validate"));
}
#[test]
fn the_catalogue_is_sorted_by_name() {
let names: Vec<String> = FunctionRegistry::builtin()
.catalogue()
.into_iter()
.map(|e| e.name)
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[test]
fn suggestion_recovers_common_typos() {
let registry = FunctionRegistry::builtin();
for (typo, expected) in [
("mongo_writes", "mongo_write"),
("jwt_verifiy", "jwt_verify"),
("cache_readd", "cache_read"),
] {
assert_eq!(
registry.suggest(typo),
Some(expected),
"'{typo}' should point at '{expected}'"
);
}
}
#[test]
fn suggestion_is_silent_when_nothing_is_close() {
let registry = FunctionRegistry::builtin();
assert_eq!(registry.suggest("http_request"), None);
assert_eq!(registry.suggest("no_such_function_xyz"), None);
assert_eq!(registry.suggest("totally_unrelated"), None);
assert_eq!(registry.suggest("x"), None);
}
#[test]
fn suggestion_never_names_an_unknown_function() {
let registry = FunctionRegistry::builtin();
for typo in ["mongo_writes", "jwt_verifiy", "cache_readd"] {
if let Some(candidate) = registry.suggest(typo) {
assert!(
registry.contains(candidate),
"'{candidate}' is suggested for '{typo}' but is not itself registered"
);
}
}
}
#[test]
fn the_connector_bearing_functions_are_exactly_these() {
let registry = FunctionRegistry::builtin();
let mut takes: Vec<&str> = registry
.entries()
.filter(|e| e.takes_connector())
.map(|e| e.name.as_str())
.collect();
takes.sort_unstable();
assert_eq!(
takes,
[
"cache_read",
"cache_write",
"data_query",
"data_write",
"db_read",
"db_write",
"http_call",
"mongo_aggregate",
"mongo_read",
"mongo_write",
"publish_kafka",
"send_email",
"storage_head",
"storage_presign",
]
);
for entry in registry.entries() {
if let Some(rule) = entry.connector {
assert!(
!rule.types.is_empty(),
"'{}' takes a connector but names no connector type, so activation \
cannot check it",
entry.name
);
assert!(
entry
.input_fields
.as_deref()
.is_some_and(|fields| { fields.iter().any(|f| f.name == "connector") }),
"'{}' declares a connector rule but no `connector` field",
entry.name
);
}
}
let mongo: Vec<&str> = registry
.entries()
.filter(|e| e.connector.is_some_and(|r| r.requires_mongo_database))
.map(|e| e.name.as_str())
.collect();
assert_eq!(
mongo,
[
"data_query",
"data_write",
"mongo_aggregate",
"mongo_read",
"mongo_write"
]
);
}
#[test]
fn an_engine_builtin_declares_no_field_facts() {
let registry = FunctionRegistry::builtin();
assert!(!registry.is_resolvable_field("map", "mappings"));
assert!(registry.secret_paths("map", "mappings").is_empty());
assert!(registry.template_paths("map", "mappings").is_empty());
assert!(
registry
.validate_input("map", &serde_json::json!(7), "tasks[0]")
.is_empty()
);
}
#[test]
fn a_name_claimed_twice_is_refused() {
let registry = FunctionRegistry::builtin();
let err = registry
.with_entries(vec![plugin_entry("crypto")])
.expect_err("a plugin may not shadow an Orion handler");
assert!(err.contains("'crypto'"), "{err}");
assert!(err.contains("orion") && err.contains("plugin"), "{err}");
let err = registry
.with_entries(vec![plugin_entry("validate")])
.expect_err("a plugin may not shadow an alias either");
assert!(err.contains("'validate'"), "{err}");
let err = registry
.with_entries(vec![plugin_entry("acme.a"), plugin_entry("acme.a")])
.expect_err("two plugin entries of one name");
assert!(err.contains("'acme.a'"), "{err}");
}
#[test]
fn a_plugin_entry_is_an_ordinary_entry() {
let registry = FunctionRegistry::builtin()
.with_entries(vec![plugin_entry("acme.codec.parse")])
.expect("extends");
assert!(registry.contains("acme.codec.parse"));
assert!(registry.contains("map") && registry.contains("crypto"));
assert_eq!(
registry.write_shape("acme.codec.parse"),
Some(WriteShape::OutputPath {
default_root: Some("data")
})
);
assert!(registry.is_resolvable_field("acme.codec.parse", "message"));
assert!(!registry.takes_connector("acme.codec.parse"));
let errors = registry.validate_input(
"acme.codec.parse",
&serde_json::json!({"messag": "x"}),
"tasks[0]",
);
let codes: Vec<&str> = errors.iter().map(|e| e.code.as_str()).collect();
assert!(codes.contains(&"REQUIRED"), "{errors:?}");
assert!(codes.contains(&"UNKNOWN_FIELD"), "{errors:?}");
let catalogue = registry.catalogue();
let entry = catalogue
.iter()
.find(|e| e.name == "acme.codec.parse")
.expect("catalogued");
assert_eq!(entry.source, Source::Plugin);
let json = serde_json::to_value(entry).expect("serialises");
assert_eq!(json["source"], "plugin");
assert_eq!(json["plugin"]["id"], "acme.codec");
assert_eq!(json["plugin"]["digest"], "sha256:00");
assert_eq!(json["retry_safety"]["kind"], "pure");
let map = catalogue.iter().find(|e| e.name == "map").expect("map");
let json = serde_json::to_value(map).expect("serialises");
assert!(json.get("plugin").is_none(), "{json}");
assert!(json.get("input_fields").is_none(), "{json}");
}
#[test]
fn an_owned_field_serialises_like_the_static_row() {
for schema in schema::registry() {
for field in schema.input_fields {
let from_row = serde_json::to_value(field).expect("row");
let from_spec = serde_json::to_value(FieldSpec::from(field)).expect("spec");
assert_eq!(from_row, from_spec, "{}.{}", schema.name, field.name);
}
}
}
#[test]
fn registered_names_intern_and_unregistered_ones_do_not() {
let registry = FunctionRegistry::builtin();
for name in registry.names() {
assert_eq!(interned(name), Some(name), "'{name}' not interned");
}
assert_eq!(interned("validate"), Some("validate"));
assert_eq!(interned("__never_registered__"), None);
assert_eq!(registry.get("map").expect("map").label(), "map");
}
}
#[cfg(test)]
mod write_shape_tests {
use super::*;
#[test]
fn every_function_declares_where_it_writes() {
for schema in schema::registry() {
assert!(
write_shape(schema.name).is_some(),
"function '{}' has no WriteShape",
schema.name
);
}
for (name, _, aliases, _, _) in schema::ENGINE_BUILTINS {
assert!(
write_shape(name).is_some(),
"built-in '{name}' has no WriteShape"
);
for alias in *aliases {
assert!(
write_shape(alias).is_some(),
"built-in alias '{alias}' has no WriteShape"
);
}
}
}
#[test]
fn an_unknown_function_has_no_write_shape() {
assert!(write_shape("no_such_function").is_none());
}
#[test]
fn the_declared_shapes_match_the_handlers_they_describe() {
assert_eq!(write_shape("map"), Some(WriteShape::Mappings));
assert_eq!(write_shape("parse_json"), Some(WriteShape::Target));
assert_eq!(write_shape("filter"), Some(WriteShape::Nothing));
assert_eq!(
write_shape("data_query"),
Some(WriteShape::OutputPath {
default_root: Some("data")
}),
"data_query defaults its output to the data root"
);
assert_eq!(
write_shape("db_read"),
Some(WriteShape::OutputPath { default_root: None })
);
}
}
#[cfg(test)]
mod resolvable_contract_tests {
use super::*;
#[test]
fn the_table_is_what_decides_whether_a_field_folds() {
assert!(
is_resolvable_field("db_read", "params"),
"bind parameters are the request-controlled half of a statement"
);
assert!(
!is_resolvable_field("db_read", "query"),
"the SQL text is literal by design — it is what makes `params` the \
*only* request-controlled part of the statement"
);
assert!(
!is_resolvable_field("db_read", "connector"),
"a connector name must not be chosen by the message"
);
assert!(!is_resolvable_field("no_such_function", "params"));
}
#[test]
fn an_expression_in_a_literal_field_is_refused_at_create_time() {
let errors = validate_input(
"db_read",
&serde_json::json!({
"connector": "orders",
"query": {"var": "data.req.sql"},
}),
"tasks[0]",
);
assert!(
errors.iter().any(|e| e.path.contains("query")),
"a message-derived `query` must be refused at authoring time: {errors:?}"
);
}
#[test]
fn an_expression_in_a_resolvable_field_is_accepted_at_create_time() {
let errors = validate_input(
"db_read",
&serde_json::json!({
"connector": "orders",
"query": "SELECT 1 WHERE id = $1",
"params": [{"var": "data.req.id"}],
}),
"tasks[0]",
);
assert!(errors.is_empty(), "{errors:?}");
}
}
#[cfg(test)]
mod validate_input_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 a_computed_connector_is_refused_with_the_reason() {
let errs = validate_input(
"http_call",
&json!({"connector": {"var": "data.which"}}),
"tasks[0]",
);
let connector: Vec<_> = errs
.iter()
.filter(|e| e.path == "tasks[0].function.input.connector")
.collect();
assert_eq!(connector.len(), 1, "{errs:?}");
assert_eq!(connector[0].code, "TYPE_MISMATCH");
assert!(connector[0].message.contains("literal connector name"));
}
#[test]
fn the_other_http_call_parameters_stay_computable() {
let errs = validate_input(
"http_call",
&json!({
"connector": "api",
"path": {"cat": ["/o/", {"var": "data.id"}]},
"timeout_ms": {"var": "data.t"},
"headers": {"X": {"var": "data.h"}}
}),
"tasks[0]",
);
assert!(errs.is_empty(), "{errs:?}");
}
#[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_a_channel() {
let errs = validate_input("channel_call", &json!({}), "tasks[0]");
assert!(errs.iter().any(|e| e.code == "REQUIRED"
&& e.path == "tasks[0].function.input.channel"
&& e.message.contains("channel_call")));
}
#[test]
fn the_pre_1_0_channel_logic_spelling_still_names_a_target() {
let errs = validate_input(
"channel_call",
&json!({"channel_logic": {"var": "data.target"}}),
"tasks[0]",
);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn a_computed_channel_is_not_type_checked_against_string() {
let errs = validate_input(
"channel_call",
&json!({"channel": {"cat": ["orders-", {"var": "data.region"}]}}),
"tasks[0]",
);
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn a_scalar_channel_of_the_wrong_type_is_still_caught() {
let errs = validate_input("channel_call", &json!({"channel": 7}), "tasks[0]");
assert!(
errs.iter()
.any(|e| e.code == "TYPE_MISMATCH" && e.path.ends_with(".channel")),
"{errs:?}"
);
}
#[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> = schema::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"));
}
}