use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use dataflow_rs::engine::error::DataflowError;
use dataflow_rs::engine::functions::AsyncFunctionHandler;
use dataflow_rs::engine::task_context::TaskContext;
use dataflow_rs::engine::task_outcome::TaskOutcome;
use serde_json::Value;
use super::connector_helpers::apply_output;
const ANY_TARGET: &str = "*";
pub type StubTable = HashMap<String, HashMap<String, Value>>;
type BuildRealHandler = fn() -> dataflow_rs::BoxedFunctionHandler;
const SELF_CONTAINED: [(&str, BuildRealHandler); 3] = [
("crypto", || Box::new(super::crypto::CryptoHandler)),
("jwt_sign", || Box::new(super::jwt_sign::JwtSignHandler)),
("jwt_verify", || {
Box::new(super::jwt_verify::JwtVerifyHandler)
}),
];
#[derive(Debug, Clone, serde::Serialize)]
pub struct RecordedCall {
pub seq: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
pub function: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub stub_target: Option<String>,
pub input: Value,
}
#[derive(Debug, Default)]
pub struct CallLog(std::sync::Mutex<Vec<RecordedCall>>);
impl CallLog {
pub fn new() -> Self {
Self::default()
}
fn record(
&self,
ctx: &TaskContext<'_>,
function: &'static str,
stub_target: Option<String>,
input: Value,
) {
let mut calls = self.0.lock().expect("call log mutex poisoned");
let seq = calls.len();
calls.push(RecordedCall {
seq,
task_id: ctx.task_id().map(str::to_string),
function,
stub_target,
input,
});
}
pub fn calls(&self) -> Vec<RecordedCall> {
self.0.lock().expect("call log mutex poisoned").clone()
}
pub fn grouped(&self) -> serde_json::Map<String, Value> {
let mut grouped: serde_json::Map<String, Value> = serde_json::Map::new();
for call in self.0.lock().expect("call log mutex poisoned").iter() {
let entry = grouped
.entry(call.function.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(list) = entry.as_array_mut()
&& let Ok(value) = serde_json::to_value(call)
{
list.push(value);
}
}
grouped
}
}
pub const RUN_DOCUMENTS: [&str; 5] = ["data", "metadata", "temp_data", "calls", "audit_trail"];
pub fn is_rooted(path: &str) -> bool {
let head = path.split(['.', '[']).next().unwrap_or(path);
RUN_DOCUMENTS.contains(&head)
}
pub fn run_documents(
message: &dataflow_rs::Message,
log: &CallLog,
) -> serde_json::Map<String, Value> {
let mut docs = serde_json::Map::new();
docs.insert("data".to_string(), message.data().into());
docs.insert("metadata".to_string(), message.metadata().into());
docs.insert("temp_data".to_string(), message.temp_data().into());
docs.insert("calls".to_string(), Value::Object(log.grouped()));
docs.insert(
"audit_trail".to_string(),
serde_json::to_value(message.audit_trail()).unwrap_or(Value::Null),
);
docs
}
fn resolved_input(function: &str, input: &Value, ctx: &TaskContext<'_>) -> Value {
let Some(obj) = input.as_object() else {
return input.clone();
};
Value::Object(
obj.iter()
.map(|(key, value)| {
let value = if crate::engine::functions::schema::is_resolvable_field(function, key)
{
super::connector_helpers::resolve_value(value, ctx)
} else {
value.clone()
};
(key.clone(), value)
})
.collect(),
)
}
pub fn parse_stubs(raw: &str, path: &str) -> Result<StubTable, String> {
let root: Value =
serde_json::from_str(raw).map_err(|e| format!("'{path}' is not valid JSON: {e}"))?;
parse_stub_value(&root, path)
}
pub fn parse_stub_value(root: &Value, path: &str) -> Result<StubTable, String> {
let Some(object) = root.as_object() else {
return Err(format!(
"'{path}' must be a JSON object mapping function names to \
{{target: response}} maps"
));
};
let mut table = StubTable::new();
for (function, targets) in object {
if !crate::engine::CUSTOM_HANDLER_FUNCTIONS.contains(&function.as_str()) {
return Err(format!(
"'{path}' stubs '{function}', which is not a connector-backed function. \
Stubbable functions: {}",
crate::engine::CUSTOM_HANDLER_FUNCTIONS.join(", ")
));
}
let Some(map) = targets.as_object() else {
return Err(format!(
"'{path}': the value of '{function}' must be a map of \
connector (or channel) name to response, e.g. \
{{\"{function}\": {{\"my-connector\": <response>}}}} — or use \
\"{ANY_TARGET}\" to match any target"
));
};
table.insert(
function.clone(),
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
);
}
Ok(table)
}
fn resolve<'a>(
stubs: &'a StubTable,
function: &str,
target: Option<&str>,
) -> dataflow_rs::Result<&'a Value> {
stubs
.get(function)
.and_then(|targets| {
target
.and_then(|t| targets.get(t))
.or_else(|| targets.get(ANY_TARGET))
})
.ok_or_else(|| {
let named = target.unwrap_or("<none>");
DataflowError::function_execution(
format!(
"dry-run: no stub for '{function}' on target '{named}'. Add it to the \
stubs file: {{\"{function}\": {{\"{named}\": <response>}}}}"
),
None,
)
})
}
pub struct StubHandler {
pub function: &'static str,
pub stubs: Arc<StubTable>,
pub log: Arc<CallLog>,
}
impl StubHandler {
fn output_path(&self, input: &Value) -> Option<String> {
if self.function == "cache_write" {
return None;
}
Some(
input
.get("output")
.and_then(Value::as_str)
.unwrap_or("data")
.to_string(),
)
}
}
#[async_trait]
impl AsyncFunctionHandler for StubHandler {
type Input = Value;
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &Value,
) -> dataflow_rs::Result<TaskOutcome> {
let target = input.get("connector").and_then(Value::as_str);
self.log.record(
ctx,
self.function,
target.map(str::to_string),
resolved_input(self.function, input, ctx),
);
let response = resolve(&self.stubs, self.function, target)?.clone();
if let Some(path) = self.output_path(input) {
apply_output(ctx, &path, response);
}
Ok(TaskOutcome::Success)
}
}
pub struct HttpCallStub {
pub stubs: Arc<StubTable>,
pub log: Arc<CallLog>,
}
#[async_trait]
impl AsyncFunctionHandler for HttpCallStub {
type Input = dataflow_rs::engine::functions::HttpCallConfig;
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &Self::Input,
) -> dataflow_rs::Result<TaskOutcome> {
self.log.record(
ctx,
"http_call",
Some(input.connector.clone()),
serde_json::json!({
"connector": input.connector,
"method": input.method.as_str(),
"path": input.resolve_path(ctx)?,
"body": input.resolve_body(ctx)?,
}),
);
let response = resolve(&self.stubs, "http_call", Some(&input.connector))?.clone();
if let Some(ref path) = input.response_path {
apply_output(ctx, path, response);
}
Ok(TaskOutcome::Success)
}
}
pub struct PublishKafkaStub {
pub stubs: Arc<StubTable>,
pub log: Arc<CallLog>,
}
#[async_trait]
impl AsyncFunctionHandler for PublishKafkaStub {
type Input = dataflow_rs::engine::functions::PublishKafkaConfig;
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &Self::Input,
) -> dataflow_rs::Result<TaskOutcome> {
self.log.record(
ctx,
"publish_kafka",
Some(input.connector.clone()),
serde_json::json!({
"connector": input.connector,
"topic": input.topic,
"key": input.resolve_key(ctx)?,
"value": input.resolve_value(ctx)?,
}),
);
resolve(&self.stubs, "publish_kafka", Some(&input.connector))?;
Ok(TaskOutcome::Success)
}
}
pub struct ChannelCallStub {
pub stubs: Arc<StubTable>,
pub log: Arc<CallLog>,
}
#[async_trait]
impl AsyncFunctionHandler for ChannelCallStub {
type Input = super::channel_call::ChannelCallInput;
fn compile_input(
input: &mut Self::Input,
c: &dataflow_rs::engine::functions::TemplateCompiler,
) -> dataflow_rs::Result<()> {
if let Some(t) = input.channel_logic.as_mut() {
t.compile(c, "channel_call.channel_logic")?;
}
if let Some(t) = input.data_logic.as_mut() {
t.compile(c, "channel_call.data_logic")?;
}
Ok(())
}
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
input: &Self::Input,
) -> dataflow_rs::Result<TaskOutcome> {
let target = (!input.channel.is_empty()).then_some(input.channel.as_str());
self.log.record(
ctx,
"channel_call",
target.map(str::to_string),
serde_json::json!({
"channel": target,
"data": match input.data_logic {
Some(ref logic) => Some(logic.eval_into::<Value>(ctx)?),
None => input.data.clone(),
},
}),
);
let response = resolve(&self.stubs, "channel_call", target)?.clone();
if let Some(ref path) = input.output {
apply_output(ctx, path, response);
}
Ok(TaskOutcome::Success)
}
}
pub fn build_stub_functions_with_log(
stubs: StubTable,
log: Arc<CallLog>,
) -> HashMap<String, dataflow_rs::BoxedFunctionHandler> {
let stubs = Arc::new(stubs);
let mut out: HashMap<String, dataflow_rs::BoxedFunctionHandler> = HashMap::new();
for &function in crate::engine::CUSTOM_HANDLER_FUNCTIONS {
let self_contained = SELF_CONTAINED
.iter()
.find(|(name, _)| *name == function)
.map(|(_, build)| build());
let handler: dataflow_rs::BoxedFunctionHandler = match self_contained {
Some(handler) => handler,
None => match function {
"http_call" => Box::new(HttpCallStub {
stubs: stubs.clone(),
log: log.clone(),
}),
"publish_kafka" => Box::new(PublishKafkaStub {
stubs: stubs.clone(),
log: log.clone(),
}),
"channel_call" => Box::new(ChannelCallStub {
stubs: stubs.clone(),
log: log.clone(),
}),
_ => Box::new(StubHandler {
function,
stubs: stubs.clone(),
log: log.clone(),
}),
},
};
out.insert(function.to_string(), handler);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_well_formed_stub_file_parses() {
let table = parse_stubs(
r#"{ "http_call": { "crm": {"name": "Ada"} }, "db_read": { "*": [] } }"#,
"stubs.json",
)
.expect("parses");
assert_eq!(table["http_call"]["crm"], json!({"name": "Ada"}));
assert_eq!(table["db_read"]["*"], json!([]));
}
#[test]
fn an_unknown_function_is_refused() {
let err = parse_stubs(r#"{ "htp_call": { "crm": {} } }"#, "stubs.json")
.expect_err("unknown function must be refused");
assert!(err.contains("htp_call"), "{err}");
assert!(
err.contains("http_call"),
"the error lists the real names: {err}"
);
}
#[test]
fn a_response_in_place_of_a_target_map_is_refused() {
let err = parse_stubs(r#"{ "http_call": [1, 2] }"#, "stubs.json")
.expect_err("a non-object target map must be refused");
assert!(err.contains("http_call"), "{err}");
}
#[test]
fn every_stubbable_function_gets_a_handler() {
let fns = build_stub_functions_with_log(StubTable::new(), Arc::new(CallLog::new()));
for name in crate::engine::CUSTOM_HANDLER_FUNCTIONS {
assert!(fns.contains_key(*name), "no stub handler for {name}");
}
}
#[test]
fn the_output_path_follows_each_functions_convention() {
let stub = |function| StubHandler {
function,
stubs: Arc::new(StubTable::new()),
log: Arc::new(CallLog::new()),
};
let read = stub("db_read");
assert_eq!(
read.output_path(&json!({"output": "data.x"})),
Some("data.x".to_string())
);
assert_eq!(read.output_path(&json!({})), Some("data".to_string()));
assert_eq!(
read.output_path(&json!({"response_path": "data.y"})),
Some("data".to_string())
);
for function in [
"cache_read",
"mongo_read",
"db_write",
"data_query",
"data_write",
] {
assert_eq!(
stub(function).output_path(&json!({})),
Some("data".to_string()),
"{function} defaults to the data root"
);
}
assert_eq!(stub("cache_write").output_path(&json!({})), None);
}
#[test]
fn every_root_is_published() {
let message = dataflow_rs::Message::from_value(&json!({}));
let docs = run_documents(&message, &CallLog::new());
for root in RUN_DOCUMENTS {
assert!(
docs.contains_key(root),
"'{root}' is accepted by is_rooted but never published — a case \
path under it would resolve to <absent> and pass on null"
);
assert!(is_rooted(root), "'{root}' must validate as a root");
}
assert_eq!(
docs.len(),
RUN_DOCUMENTS.len(),
"run_documents publishes {:?}, RUN_DOCUMENTS declares {:?}",
docs.keys().collect::<Vec<_>>(),
RUN_DOCUMENTS
);
assert!(!is_rooted("order.flagged"), "a bare path is not rooted");
assert!(!is_rooted("dat.order"), "a typo'd root is not rooted");
assert!(
is_rooted("calls[0].input"),
"a bracket ends the root segment"
);
}
#[tokio::test]
async fn a_recorded_call_carries_the_resolved_payload() {
let workflow: dataflow_rs::Workflow = serde_json::from_value(json!({
"id": "w", "name": "w", "condition": true,
"tasks": [{
"id": "persist", "name": "Persist",
"function": {"name": "mongo_write", "input": {
"connector": "sessions-db", "database": "app",
"collection": "sessions", "op": "update_one",
"filter": {"_id": {"var": "data.sid"}},
"update": {"$set": {"generation": {"if": [true, 2, 1]}}}
}}
}]
}))
.expect("workflow parses");
let mut stubs = StubTable::new();
stubs.insert(
"mongo_write".to_string(),
[("sessions-db".to_string(), json!({"modified": 1}))]
.into_iter()
.collect(),
);
let log = Arc::new(CallLog::new());
let engine = dataflow_rs::Engine::new(
vec![workflow],
build_stub_functions_with_log(stubs, log.clone()),
)
.expect("engine builds");
let mut message = dataflow_rs::Message::builder()
.payload_json(&json!({"sid": "sess-1"}))
.data_json(&json!({"sid": "sess-1"}))
.build();
engine.process_message(&mut message).await.expect("runs");
let calls = log.calls();
assert_eq!(calls.len(), 1, "one write, one record");
assert_eq!(calls[0].function, "mongo_write");
assert_eq!(calls[0].stub_target.as_deref(), Some("sessions-db"));
assert_eq!(
calls[0].input["filter"]["_id"], "sess-1",
"a resolvable field is folded against the message"
);
assert_eq!(
calls[0].input["collection"], "sessions",
"a literal field is left as authored"
);
assert_eq!(
calls[0].input["update"]["$set"]["generation"],
json!({"if": [true, 2, 1]}),
"an unresolvable JSONLogic node is recorded verbatim — which is how \
a case sees that Mongo would have stored the object, not the number"
);
}
#[tokio::test]
async fn an_unstubbed_call_is_still_recorded() {
let workflow: dataflow_rs::Workflow = serde_json::from_value(json!({
"id": "w", "name": "w", "condition": true,
"tasks": [{
"id": "read", "name": "Read",
"function": {"name": "db_read", "input": {
"connector": "orders", "sql": "SELECT 1"}}
}]
}))
.expect("workflow parses");
let log = Arc::new(CallLog::new());
let engine = dataflow_rs::Engine::new(
vec![workflow],
build_stub_functions_with_log(StubTable::new(), log.clone()),
)
.expect("engine builds");
let mut message = dataflow_rs::Message::from_value(&json!({}));
let _ = engine.process_message(&mut message).await;
let calls = log.calls();
assert_eq!(calls.len(), 1, "the call is recorded even with no stub");
assert_eq!(calls[0].input["sql"], "SELECT 1");
assert_eq!(
calls[0].task_id.as_deref(),
Some("read"),
"the task id is read off the context as the call is made"
);
}
#[tokio::test]
async fn a_self_contained_function_runs_for_real_and_is_not_recorded() {
let workflow: dataflow_rs::Workflow = serde_json::from_value(json!({
"id": "w", "name": "w", "condition": true,
"tasks": [{
"id": "digest", "name": "Digest",
"function": {"name": "crypto", "input": {
"op": "hash", "data": "abc", "output": "data.digest"}}
}]
}))
.expect("workflow parses");
let log = Arc::new(CallLog::new());
let engine = dataflow_rs::Engine::new(
vec![workflow],
build_stub_functions_with_log(StubTable::new(), log.clone()),
)
.expect("engine builds");
let mut message = dataflow_rs::Message::from_value(&json!({}));
engine
.process_message(&mut message)
.await
.expect("crypto runs for real, with no stub to look up");
assert!(
log.calls().is_empty(),
"a self-contained function must stay out of the call log"
);
}
#[tokio::test]
async fn a_typed_function_dispatches_through_its_stub() {
let mut stubs = StubTable::new();
stubs.insert(
"http_call".to_string(),
[("crm".to_string(), json!({"name": "Ada"}))]
.into_iter()
.collect(),
);
let workflow: dataflow_rs::Workflow = serde_json::from_value(json!({
"id": "typed", "name": "typed", "condition": true,
"tasks": [{
"id": "call", "name": "Call",
"function": {"name": "http_call", "input": {
"connector": "crm", "method": "GET", "path": "/x",
"output": "data.customer"}}
}]
}))
.expect("workflow parses");
let engine = dataflow_rs::Engine::new(
vec![workflow],
build_stub_functions_with_log(stubs, Arc::new(CallLog::new())),
)
.expect("engine builds");
let mut message = dataflow_rs::Message::from_value(&json!({}));
engine
.process_message(&mut message)
.await
.expect("a typed stub must dispatch, not mismatch");
let out: Value = message.data().into();
assert_eq!(
out["customer"],
json!({"name": "Ada"}),
"the stubbed response must reach the task's output path"
);
}
}