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>>;
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>,
}
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);
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>,
}
#[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> {
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>,
}
#[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> {
resolve(&self.stubs, "publish_kafka", Some(&input.connector))?;
Ok(TaskOutcome::Success)
}
}
pub struct ChannelCallStub {
pub stubs: Arc<StubTable>,
}
#[async_trait]
impl AsyncFunctionHandler for ChannelCallStub {
type Input = super::channel_call::ChannelCallInput;
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());
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(
stubs: StubTable,
) -> 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 handler: dataflow_rs::BoxedFunctionHandler = match function {
"http_call" => Box::new(HttpCallStub {
stubs: stubs.clone(),
}),
"publish_kafka" => Box::new(PublishKafkaStub {
stubs: stubs.clone(),
}),
"channel_call" => Box::new(ChannelCallStub {
stubs: stubs.clone(),
}),
_ => Box::new(StubHandler {
function,
stubs: stubs.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(StubTable::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()),
};
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);
}
#[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(stubs))
.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"
);
}
}