use std::sync::Arc;
use std::time::Duration;
use dataflow_rs::engine::error::DataflowError;
use dataflow_rs::engine::task_context::TaskContext;
use dataflow_rs::engine::task_outcome::TaskOutcome;
use serde_json::{Map, Value};
use super::templated_input::TemplatedInput;
use crate::connector::ConnectorTarget;
use crate::connector::{
ConnectorConfig, ConnectorRegistry, EsConnectorConfig, HttpOperationGates, OperationGates,
};
use crate::engine::{ErrorClass, HandlerError};
use crate::query::EntityRegistry;
pub fn build_entity_registry(
schema: Option<&Value>,
connector_config: &ConnectorConfig,
connector_name: &str,
) -> Result<EntityRegistry, DataflowError> {
let mut registry = match schema {
Some(s) => EntityRegistry::from_json(s)?,
None => EntityRegistry::default(),
};
if let Some(guards) = connector_config.dialect_guards() {
if !guards.schema_is_sufficient(!registry.is_empty(), registry.is_identity_mode()) {
return Err(crate::errors::connector_detail_error(format!(
"connector '{connector_name}' requires a declared schema \
(dialect.require_schema): supply \"schema\" with an \"entities\" map \
and without \"unmapped\": \"identity\""
)));
}
registry.restrict_to(&guards.allowed_entities);
}
Ok(registry)
}
pub fn require_op_allowed(
gates: &OperationGates,
op: &str,
connector_name: &str,
) -> Result<(), DataflowError> {
require_op(gates.allows(op), op, connector_name)
}
pub fn require_op(allowed: bool, op: &str, connector_name: &str) -> Result<(), DataflowError> {
if !allowed {
return Err(crate::errors::connector_detail_error(format!(
"operation '{op}' is disabled on connector '{connector_name}'"
)));
}
Ok(())
}
pub fn require_method_allowed(
gates: &HttpOperationGates,
method: &str,
connector_name: &str,
) -> Result<(), DataflowError> {
if !gates.allows_method(method) {
return Err(crate::errors::connector_detail_error(format!(
"HTTP method '{method}' is not allowed on connector \
'{connector_name}' (allowed: {})",
gates.methods.join(", ")
)));
}
Ok(())
}
pub async fn es_request(
client: &reqwest::Client,
es: &EsConnectorConfig,
method: reqwest::Method,
url: &str,
) -> Result<reqwest::RequestBuilder, DataflowError> {
if !es.allow_private_urls
&& let Err(msg) = crate::validation::validate_url_not_private(url).await
{
return Err(DataflowError::function_execution(
format!("SSRF protection: {msg}"),
None,
));
}
let mut req = client.request(method, url);
if let Some(auth) = &es.auth {
req = super::http_common::apply_auth(req, auth);
}
if let Some(ms) = es.request_timeout_ms {
req = req.timeout(Duration::from_millis(ms));
}
Ok(req)
}
pub async fn read_es_body(
resp: reqwest::Response,
max_size: usize,
) -> Result<Value, DataflowError> {
if let Some(len) = resp.content_length()
&& len as usize > max_size
{
return Err(DataflowError::function_execution(
format!(
"Elasticsearch response declared Content-Length {len} exceeds \
limit of {max_size} bytes"
),
None,
));
}
let bytes = resp.bytes().await.map_err(to_exec_error)?;
if bytes.len() > max_size {
return Err(DataflowError::function_execution(
format!(
"Elasticsearch response body is {} bytes, exceeding limit of {max_size} bytes",
bytes.len()
),
None,
));
}
serde_json::from_slice(&bytes).map_err(|e| to_exec_error(e).into())
}
pub async fn send_es(
req: reqwest::RequestBuilder,
max_response_size: usize,
) -> Result<(reqwest::StatusCode, Value), DataflowError> {
let resp = req
.send()
.await
.map_err(|e| to_exec_error(e.without_url()))?;
let status = resp.status();
let body: Value = read_es_body(resp, max_response_size).await?;
Ok((status, body))
}
pub fn es_write_error(status: reqwest::StatusCode, body: &Value) -> DataflowError {
DataflowError::function_execution(
format!("Elasticsearch write failed ({status}): {body}"),
None,
)
}
pub struct ConnectorCall<'a> {
pub name: &'static str,
pub connector: &'a str,
pub channel: String,
pub output: String,
}
impl<'a> ConnectorCall<'a> {
pub fn begin<I: super::connector_handler::ConnectorInput>(
name: &'static str,
input: &'a I,
ctx: &TaskContext<'_>,
) -> Result<Self, DataflowError> {
Ok(Self {
name,
connector: input.connector(name)?,
channel: super::extract_channel(ctx.message()).to_string(),
output: input.output(name, ctx)?,
})
}
pub fn require_str<'i>(
&self,
input: &'i TemplatedInput,
field: &str,
) -> Result<&'i str, DataflowError> {
require_str_field(input.raw(), field, self.name)
}
pub async fn resolve(
&self,
registry: &ConnectorRegistry,
op: Option<&str>,
) -> Result<Arc<ConnectorConfig>, DataflowError> {
let config = resolve_connector(registry, self.connector).await?;
if let Some(op) = op
&& let Some(gates) = config.operation_gates()
{
require_op_allowed(gates, op, self.connector)?;
}
Ok(config)
}
pub async fn run<F>(
&self,
registry: &ConnectorRegistry,
fut: F,
) -> dataflow_rs::Result<TaskOutcome>
where
F: std::future::Future<Output = dataflow_rs::Result<TaskOutcome>>,
{
guarded_handler(self.name, registry, self.connector, &self.channel, fut).await
}
}
pub async fn guarded_handler<F>(
fn_name: &'static str,
registry: &ConnectorRegistry,
connector: &str,
channel: &str,
fut: F,
) -> dataflow_rs::Result<TaskOutcome>
where
F: std::future::Future<Output = dataflow_rs::Result<TaskOutcome>>,
{
if !registry.circuit_breaker_enabled() {
return observed_handler_named(fn_name, connector, channel, fut).await;
}
let breaker = registry
.get_or_create_breaker(&format!("{channel}:{connector}"))
.await;
if !breaker.check() {
crate::metrics::record_circuit_breaker_rejection(connector, channel);
return Err(crate::errors::circuit_open_dataflow_error(
connector, channel,
));
}
let result = observed_handler_named(fn_name, connector, channel, fut).await;
match &result {
Ok(_) => breaker.record_success(),
Err(e) if e.retryable() => {
if breaker.record_failure() {
tracing::warn!(
connector = connector,
channel = channel,
"Circuit breaker tripped"
);
crate::metrics::record_circuit_breaker_trip(connector, channel);
}
}
Err(_) => {}
}
result
}
pub async fn observed_handler_named<F>(
fn_name: &'static str,
connector: &str,
channel: &str,
fut: F,
) -> dataflow_rs::Result<TaskOutcome>
where
F: std::future::Future<Output = dataflow_rs::Result<TaskOutcome>>,
{
let start = std::time::Instant::now();
let result = crate::engine::profile::record(fn_name, Some(connector), fut).await;
let status = if result.is_ok() { "ok" } else { "error" };
crate::metrics::record_connector_request(connector, channel, status);
crate::metrics::record_connector_duration(connector, channel, start.elapsed().as_secs_f64());
result
}
pub fn extract_output_path(input: &Value) -> &str {
input
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("data")
}
pub fn resolve_output_path(
input: &TemplatedInput,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<String, DataflowError> {
match input.value_of("output", handler_name, ctx) {
None => Ok("data".to_string()),
Some(value) => match value? {
Value::Null => Ok("data".to_string()),
Value::String(path) if !path.is_empty() => Ok(path),
other => Err(DataflowError::Validation(format!(
"'output' must resolve to a non-empty dotted path, got {}",
json_type_name(&other)
))),
},
}
}
pub fn to_exec_error(e: impl std::fmt::Display) -> HandlerError {
HandlerError::new(ErrorClass::Backend, e)
}
pub fn to_connect_error(e: impl std::fmt::Display) -> HandlerError {
HandlerError::new(ErrorClass::Connector, e)
}
pub fn to_limit_error(message: impl std::fmt::Display) -> HandlerError {
HandlerError::new(ErrorClass::Limit, message)
}
#[derive(Debug)]
pub enum QueryFailure {
Backend(String),
Limit(String),
Integrity(crate::errors::IntegrityKind, String),
}
impl From<String> for QueryFailure {
fn from(message: String) -> Self {
Self::Backend(message)
}
}
impl From<sqlx::Error> for QueryFailure {
fn from(e: sqlx::Error) -> Self {
if let Some(db) = e.as_database_error() {
use crate::errors::IntegrityKind as K;
let integrity = match db.kind() {
sqlx::error::ErrorKind::UniqueViolation => Some(K::Unique),
sqlx::error::ErrorKind::ForeignKeyViolation => Some(K::ForeignKey),
sqlx::error::ErrorKind::NotNullViolation => Some(K::NotNull),
sqlx::error::ErrorKind::CheckViolation => Some(K::Check),
_ => None,
};
if let Some(integrity) = integrity {
return Self::Integrity(integrity, e.to_string());
}
}
Self::Backend(e.to_string())
}
}
impl std::fmt::Display for QueryFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Backend(m) | Self::Limit(m) | Self::Integrity(_, m) => f.write_str(m),
}
}
}
pub fn require_str_field<'a>(
input: &'a Value,
field: &str,
handler_name: &str,
) -> Result<&'a str, DataflowError> {
input.get(field).and_then(|v| v.as_str()).ok_or_else(|| {
DataflowError::Validation(format!("{handler_name} requires '{field}' field"))
})
}
pub use crate::connector::is_mongo_url as is_mongo;
pub fn reject_mongo_connector(
function: &str,
connector_name: &str,
db_config: &crate::connector::DbConnectorConfig,
) -> Result<(), DataflowError> {
if is_mongo(&db_config.connection_string) {
return Err(DataflowError::Validation(format!(
"{function} requires a SQL connector, but '{connector_name}' is a MongoDB \
connector — use mongo_read or data_query for MongoDB"
)));
}
Ok(())
}
pub async fn resolve_connector(
registry: &ConnectorRegistry,
name: &str,
) -> Result<Arc<ConnectorConfig>, DataflowError> {
registry.get(name).await.ok_or_else(|| {
DataflowError::function_execution(format!("Connector '{name}' not found"), None)
})
}
pub fn require_connector<'a, K: ConnectorTarget>(
config: &'a ConnectorConfig,
name: &str,
) -> Result<&'a K::Config, DataflowError> {
K::extract(config).ok_or_else(|| {
crate::errors::connector_detail_error(format!("Connector '{name}' is not {}", K::noun()))
})
}
pub fn apply_output(ctx: &mut TaskContext<'_>, output_path: &str, new_value: Value) {
ctx.set_json(output_path, &new_value);
}
pub fn resolve_value(value: &Value, ctx: &TaskContext<'_>) -> Value {
match value {
Value::Object(o) => {
if o.len() == 1
&& let Some(spec) = o.get("var")
{
return resolve_var(spec, ctx);
}
Value::Object(
o.iter()
.map(|(k, v)| (k.clone(), resolve_value(v, ctx)))
.collect(),
)
}
Value::Array(a) => Value::Array(a.iter().map(|v| resolve_value(v, ctx)).collect()),
other => other.clone(),
}
}
fn resolve_var(spec: &Value, ctx: &TaskContext<'_>) -> Value {
let (path, default) = match spec {
Value::String(p) => (p.as_str(), Value::Null),
Value::Array(a) => match a.first().and_then(|v| v.as_str()) {
Some(p) => (p, a.get(1).cloned().unwrap_or(Value::Null)),
None => return Value::Null,
},
_ => return Value::Null,
};
ctx.get(path).map(Value::from).unwrap_or(default)
}
pub fn resolve_declared_field(
function: &str,
field: &str,
raw: &Value,
ctx: &TaskContext<'_>,
) -> Value {
if super::schema::is_resolvable_field(function, field) {
resolve_value(raw, ctx)
} else {
raw.clone()
}
}
pub fn resolve_params(
input: &TemplatedInput,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Map<String, Value> {
match input.value_of("params", handler_name, ctx) {
Some(Ok(Value::Object(map))) => map,
_ => Map::new(),
}
}
pub fn resolve_required_str(
input: &TemplatedInput,
field: &str,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<String, DataflowError> {
let Some(value) = input.value_of(field, handler_name, ctx) else {
return Err(DataflowError::Validation(format!(
"{handler_name} requires '{field}' field"
)));
};
match value? {
Value::String(s) => Ok(s),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
other => Err(DataflowError::Validation(format!(
"{handler_name} '{field}' must resolve to a string or number, got {}",
json_type_name(&other)
))),
}
}
pub fn parse_duration_secs(s: &str) -> Result<u64, String> {
let s = s.trim();
let (number, unit) = s.split_at(s.len().saturating_sub(1));
let multiplier = match unit {
"s" => 1,
"m" => 60,
"h" => 3_600,
"d" => 86_400,
_ => {
return Err(format!(
"'{s}' is not a duration — \"<n>s\", \"<n>m\", \"<n>h\" or \"<n>d\""
));
}
};
let n: u64 = number.parse().map_err(|_| {
format!("'{s}' is not a duration — the part before the unit must be a number")
})?;
n.checked_mul(multiplier)
.ok_or_else(|| format!("'{s}' overflows"))
}
pub fn resolve_duration_secs(
input: &TemplatedInput,
ctx: &TaskContext<'_>,
handler_name: &str,
field: &str,
) -> Result<Option<u64>, DataflowError> {
let Some(value) = input.value_of(field, handler_name, ctx) else {
return Ok(None);
};
match value? {
Value::Null => Ok(None),
Value::Number(n) => n
.as_u64()
.ok_or_else(|| {
DataflowError::Validation(format!(
"{handler_name}: '{field}' must be a positive integer"
))
})
.map(Some),
Value::String(s) => parse_duration_secs(&s)
.map_err(|e| DataflowError::Validation(format!("{handler_name}: '{field}': {e}")))
.map(Some),
_ => Err(DataflowError::Validation(format!(
"{handler_name}: '{field}' must be seconds (integer) or a duration like \"24h\""
))),
}
}
pub fn resolve_optional_str(
input: &TemplatedInput,
field: &str,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<Option<String>, DataflowError> {
match input.value_of(field, handler_name, ctx) {
None => Ok(None),
Some(value) => match value? {
Value::String(s) => Ok(Some(s)),
Value::Null => Ok(None),
_ => Err(DataflowError::Validation(format!(
"{handler_name}: '{field}' must resolve to a string"
))),
},
}
}
pub fn resolve_bool_or(
input: &TemplatedInput,
field: &str,
handler_name: &str,
ctx: &TaskContext<'_>,
default: bool,
) -> Result<bool, DataflowError> {
match input.value_of(field, handler_name, ctx) {
None => Ok(default),
Some(value) => match value? {
Value::Null => Ok(default),
Value::Bool(b) => Ok(b),
other => Err(DataflowError::Validation(format!(
"{handler_name} '{field}' must resolve to a boolean, got {}",
json_type_name(&other)
))),
},
}
}
pub fn resolve_bool(
input: &TemplatedInput,
field: &str,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<bool, DataflowError> {
resolve_bool_or(input, field, handler_name, ctx, false)
}
pub fn resolve_optional_u64(
input: &TemplatedInput,
field: &str,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<Option<u64>, DataflowError> {
match input.value_of(field, handler_name, ctx) {
None => Ok(None),
Some(value) => match value? {
Value::Null => Ok(None),
Value::Number(n) => n.as_u64().map(Some).ok_or_else(|| {
DataflowError::Validation(format!(
"{handler_name} '{field}' must resolve to a non-negative integer"
))
}),
other => Err(DataflowError::Validation(format!(
"{handler_name} '{field}' must resolve to a number, got {}",
json_type_name(&other)
))),
},
}
}
pub fn resolve_bind_params(
input: &TemplatedInput,
handler_name: &str,
ctx: &TaskContext<'_>,
) -> Result<Vec<Value>, DataflowError> {
match input.value_of("params", handler_name, ctx) {
None => Ok(Vec::new()),
Some(value) => match value? {
Value::Null => Ok(Vec::new()),
Value::Array(a) => Ok(a),
other => Err(DataflowError::Validation(format!(
"{handler_name} 'params' must resolve to an array of bind values, got {}",
json_type_name(&other)
))),
},
}
}
pub fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub fn bind_json_params<'q>(
mut query: sqlx::query::Query<'q, sqlx::Any, sqlx::any::AnyArguments<'q>>,
params: &'q [Value],
) -> sqlx::query::Query<'q, sqlx::Any, sqlx::any::AnyArguments<'q>> {
for param in params {
query = match param {
Value::String(s) => query.bind(s.as_str()),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
query.bind(i)
} else if let Some(f) = n.as_f64() {
query.bind(f)
} else {
query.bind(n.to_string())
}
}
Value::Bool(b) => query.bind(*b),
Value::Null => query.bind(None::<String>),
_ => query.bind(param.to_string()),
};
}
query
}
const DEFAULT_QUERY_TIMEOUT_MS: u64 = 30_000;
#[derive(Debug, Clone, Copy)]
pub struct QueryBudget {
deadline: tokio::time::Instant,
total_ms: u64,
}
impl QueryBudget {
pub fn start(timeout_ms: Option<u64>) -> Self {
let total_ms = timeout_ms.unwrap_or(DEFAULT_QUERY_TIMEOUT_MS);
Self {
deadline: tokio::time::Instant::now() + Duration::from_millis(total_ms),
total_ms,
}
}
pub async fn run<F, T, E>(&self, handler_name: &str, operation: F) -> Result<T, DataflowError>
where
F: std::future::Future<Output = Result<T, E>>,
E: Into<QueryFailure>,
{
let total_ms = self.total_ms;
tokio::time::timeout_at(self.deadline, operation)
.await
.map_err(|_| {
HandlerError::new(
ErrorClass::Timeout,
format!("{handler_name} query timed out after {total_ms}ms"),
)
})?
.map_err(|e| {
match e.into() {
QueryFailure::Limit(detail) => to_limit_error(detail),
QueryFailure::Integrity(integrity, text) => {
HandlerError::from(crate::errors::integrity_dataflow_error(
integrity,
format!("{handler_name} query failed: {text}"),
))
}
QueryFailure::Backend(text) => {
to_exec_error(format!("{handler_name} query failed: {text}"))
}
}
})
.map_err(DataflowError::from)
}
}
pub async fn timed_query<F, T, E>(
timeout_ms: Option<u64>,
handler_name: &str,
operation: F,
) -> Result<T, DataflowError>
where
F: std::future::Future<Output = Result<T, E>>,
E: Into<QueryFailure>,
{
QueryBudget::start(timeout_ms)
.run(handler_name, operation)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::connector::DialectGuards;
fn es_config(allow_private_urls: bool) -> EsConnectorConfig {
EsConnectorConfig {
max_response_size: 10 * 1024 * 1024,
url: "http://127.0.0.1:9200".to_string(),
auth: None,
request_timeout_ms: None,
allow_private_urls,
operations: OperationGates::default(),
dialect: DialectGuards::default(),
}
}
#[tokio::test]
async fn test_es_request_blocks_private_url() {
let client = reqwest::Client::new();
let result = es_request(
&client,
&es_config(false),
reqwest::Method::POST,
"http://127.0.0.1:9200/idx/_search",
)
.await;
let err = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("SSRF protection"), "unexpected error: {err}");
}
#[tokio::test]
async fn test_es_request_allows_private_url_when_opted_in() {
let client = reqwest::Client::new();
let result = es_request(
&client,
&es_config(true),
reqwest::Method::POST,
"http://127.0.0.1:9200/idx/_search",
)
.await;
assert!(result.is_ok());
}
}
#[cfg(test)]
mod error_taxonomy_tests {
use super::*;
#[test]
fn a_failure_to_connect_is_retryable_but_a_failed_query_is_not() {
assert!(
DataflowError::from(to_connect_error("connection refused")).retryable(),
"an unreachable backend must be retryable, like the HTTP path"
);
assert!(
!DataflowError::from(to_exec_error("syntax error at or near \"SELCT\"")).retryable(),
"a query the backend rejected is not worth retrying"
);
}
#[test]
fn a_limit_error_is_validation_not_execution() {
let err = DataflowError::from(to_limit_error(
"result exceeds query.max_limit — add a LIMIT",
));
assert!(
matches!(err, DataflowError::Validation(_)),
"expected Validation, got {err:?}"
);
assert!(!err.retryable(), "a limit does not fix itself on retry");
}
#[tokio::test]
async fn timed_query_reports_a_classified_limit_as_validation() {
let err = timed_query(Some(1_000), "db_read", async {
Err::<(), _>(QueryFailure::Limit(
"too many rows — add a LIMIT".to_string(),
))
})
.await
.expect_err("the operation failed");
assert!(
matches!(err, DataflowError::Validation(ref m) if m == "too many rows — add a LIMIT"),
"expected the message intact under Validation, got {err:?}"
);
}
#[tokio::test]
async fn a_backend_failure_is_never_reclassified_by_its_text() {
let err = timed_query(Some(1_000), "db_read", async {
Err::<(), String>("orion.limit: not a limit, just text".to_string())
})
.await
.expect_err("the operation failed");
assert!(
matches!(err, DataflowError::FunctionExecution { .. }),
"text cannot promote a backend failure to a limit: {err:?}"
);
}
#[tokio::test]
async fn timed_query_leaves_an_ordinary_failure_as_execution() {
let err = timed_query(Some(1_000), "db_read", async {
Err::<(), String>("connection reset".to_string())
})
.await
.expect_err("the operation failed");
assert!(
matches!(err, DataflowError::FunctionExecution { .. }),
"expected FunctionExecution, got {err:?}"
);
}
#[test]
fn a_driver_error_with_no_database_error_stays_backend() {
let failure = QueryFailure::from(sqlx::Error::RowNotFound);
assert!(
matches!(failure, QueryFailure::Backend(_)),
"expected Backend, got {failure:?}"
);
}
#[test]
fn an_integrity_failure_keeps_its_kind_through_the_conversion() {
use crate::errors::IntegrityKind;
let err: DataflowError = crate::errors::integrity_dataflow_error(
IntegrityKind::Unique,
"db_write query failed: UNIQUE constraint failed: models.id",
);
let back: DataflowError = HandlerError::from(err).into();
assert_eq!(
back.kind(),
Some(crate::errors::kind::INTEGRITY_UNIQUE),
"the service kind is what a workflow branches on: {back:?}"
);
assert!(
!back.retryable(),
"an integrity failure must not be retried, or the circuit breaker \
counts it: {back:?}"
);
assert_eq!(
back.to_string(),
"The request conflicts with an existing record",
"Display is the caller-safe half and must not carry the driver text"
);
}
}