use crate::engine::error::Result;
use crate::engine::functions::template::Template;
use crate::engine::task_context::TaskContext;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpCallConfig {
pub connector: String,
#[serde(default = "default_method")]
pub method: HttpMethod,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub path_logic: Option<Template>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub body: Option<Value>,
#[serde(default)]
pub body_logic: Option<Template>,
#[serde(default, alias = "output")]
pub response_path: Option<String>,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
#[default]
Get,
Post,
Put,
Patch,
Delete,
}
impl HttpMethod {
pub const ALL: &'static [HttpMethod] = &[
HttpMethod::Get,
HttpMethod::Post,
HttpMethod::Put,
HttpMethod::Patch,
HttpMethod::Delete,
];
pub const fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
}
pub const fn is_idempotent(&self) -> bool {
match self {
HttpMethod::Get | HttpMethod::Put | HttpMethod::Delete => true,
HttpMethod::Post | HttpMethod::Patch => false,
}
}
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn default_method() -> HttpMethod {
HttpMethod::Get
}
fn default_timeout() -> u64 {
30000
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnrichConfig {
pub connector: String,
#[serde(default = "default_method")]
pub method: HttpMethod,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub path_logic: Option<Template>,
pub merge_path: String,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default)]
pub on_error: EnrichErrorAction,
}
fn resolve_string_field(
logic: &Option<Template>,
static_value: Option<String>,
ctx: &TaskContext<'_>,
) -> Result<Option<String>> {
match logic {
Some(t) => Ok(Some(t.eval_to_plain_string(ctx)?)),
None => Ok(static_value),
}
}
fn resolve_value_field(
logic: &Option<Template>,
static_value: Option<Value>,
ctx: &TaskContext<'_>,
) -> Result<Option<Value>> {
match logic {
Some(t) => Ok(Some(t.eval_into(ctx)?)),
None => Ok(static_value),
}
}
impl HttpCallConfig {
pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_string_field(&self.path_logic, self.path.clone(), ctx)
}
pub fn resolve_body(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
resolve_value_field(&self.body_logic, self.body.clone(), ctx)
}
}
impl EnrichConfig {
pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_string_field(&self.path_logic, self.path.clone(), ctx)
}
}
impl PublishKafkaConfig {
pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_string_field(&self.key_logic, None, ctx)
}
pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
resolve_value_field(&self.value_logic, None, ctx)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnrichErrorAction {
#[default]
Fail,
Skip,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishKafkaConfig {
pub connector: String,
pub topic: String,
#[serde(default)]
pub key_logic: Option<Template>,
#[serde(default)]
pub value_logic: Option<Template>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn as_str_round_trips_through_deserialize() {
for m in HttpMethod::ALL {
let parsed: HttpMethod = serde_json::from_value(json!(m.as_str()))
.unwrap_or_else(|e| panic!("'{}' should deserialize: {e}", m.as_str()));
assert_eq!(parsed, *m);
}
}
#[test]
fn lowercase_method_is_rejected() {
assert!(serde_json::from_value::<HttpMethod>(json!("get")).is_err());
assert!(serde_json::from_value::<HttpMethod>(json!("Post")).is_err());
assert!(serde_json::from_value::<HttpMethod>(json!("HEAD")).is_err());
}
#[test]
fn all_covers_every_variant() {
for m in HttpMethod::ALL {
match m {
HttpMethod::Get
| HttpMethod::Post
| HttpMethod::Put
| HttpMethod::Patch
| HttpMethod::Delete => {}
}
}
assert_eq!(HttpMethod::ALL.len(), 5);
}
#[test]
fn is_idempotent_follows_rfc_9110() {
assert!(HttpMethod::Get.is_idempotent());
assert!(HttpMethod::Put.is_idempotent());
assert!(HttpMethod::Delete.is_idempotent());
assert!(!HttpMethod::Post.is_idempotent());
assert!(!HttpMethod::Patch.is_idempotent());
}
#[test]
fn display_matches_as_str() {
for m in HttpMethod::ALL {
assert_eq!(m.to_string(), m.as_str());
}
}
#[test]
fn default_method_is_get() {
assert_eq!(HttpMethod::default(), HttpMethod::Get);
assert_eq!(default_method(), HttpMethod::Get);
}
use crate::engine::functions::template::TemplateCompiler;
use crate::engine::message::Message;
use crate::engine::utils::set_nested_value;
use datavalue::OwnedDataValue;
use std::sync::Arc;
fn dv(v: serde_json::Value) -> OwnedDataValue {
OwnedDataValue::from(&v)
}
fn engine() -> Arc<datalogic_rs::Engine> {
Arc::new(
datalogic_rs::Engine::builder()
.with_templating(true)
.build(),
)
}
fn fresh_message() -> Message {
let mut m = Message::from_value(&json!({}));
set_nested_value(&mut m.context, "data.id", dv(json!("abc")));
set_nested_value(&mut m.context, "data.n", dv(json!(7)));
set_nested_value(&mut m.context, "data.obj", dv(json!({"a": 1})));
m
}
fn compile(dl: &Arc<datalogic_rs::Engine>, logic: serde_json::Value) -> Option<Template> {
let c = TemplateCompiler::new(Arc::clone(dl));
let mut t: Template = serde_json::from_value(logic).expect("Template::deserialize");
t.compile(&c, "test").expect("logic should compile");
Some(t)
}
fn http_config() -> HttpCallConfig {
serde_json::from_value(json!({ "connector": "c" })).unwrap()
}
fn enrich_config() -> EnrichConfig {
serde_json::from_value(json!({ "connector": "c", "merge_path": "data.out" })).unwrap()
}
fn kafka_config() -> PublishKafkaConfig {
serde_json::from_value(json!({ "connector": "c", "topic": "t" })).unwrap()
}
#[test]
fn http_resolve_path_covers_all_four_slot_combinations() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut cfg = http_config();
cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
let mut cfg = http_config();
cfg.path = Some("/static".to_string());
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/static".to_string()));
assert_eq!(http_config().resolve_path(&ctx).unwrap(), None);
let mut cfg = http_config();
cfg.path = Some("/static".to_string());
cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
}
#[test]
fn http_resolve_body_covers_all_four_slot_combinations() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut cfg = http_config();
cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
let mut cfg = http_config();
cfg.body = Some(json!({"static": true}));
assert_eq!(
cfg.resolve_body(&ctx).unwrap(),
Some(json!({"static": true}))
);
assert_eq!(http_config().resolve_body(&ctx).unwrap(), None);
let mut cfg = http_config();
cfg.body = Some(json!({"static": true}));
cfg.body_logic = compile(&dl, json!({"var": "data.obj"}));
assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
}
#[test]
fn enrich_resolve_path_covers_all_four_slot_combinations() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut cfg = enrich_config();
cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
let mut cfg = enrich_config();
cfg.path = Some("/lookup".to_string());
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("/lookup".to_string()));
assert_eq!(enrich_config().resolve_path(&ctx).unwrap(), None);
let mut cfg = enrich_config();
cfg.path = Some("/lookup".to_string());
cfg.path_logic = compile(&dl, json!({"var": "data.id"}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("abc".to_string()));
}
#[test]
fn kafka_resolve_key_and_value() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = kafka_config();
assert_eq!(cfg.resolve_key(&ctx).unwrap(), None);
assert_eq!(cfg.resolve_value(&ctx).unwrap(), None);
let mut cfg = kafka_config();
cfg.key_logic = compile(&dl, json!({"var": "data.id"}));
cfg.value_logic = compile(&dl, json!({"var": "data.obj"}));
assert_eq!(cfg.resolve_key(&ctx).unwrap(), Some("abc".to_string()));
assert_eq!(cfg.resolve_value(&ctx).unwrap(), Some(json!({"a": 1})));
}
#[test]
fn path_resolution_coerces_non_strings_for_the_url() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut cfg = http_config();
cfg.path_logic = compile(&dl, json!({"var": "data.n"}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("7".to_string()));
let mut cfg = http_config();
cfg.path_logic = compile(&dl, json!({"var": "data.obj"}));
assert_eq!(
cfg.resolve_path(&ctx).unwrap(),
Some("{\"a\":1}".to_string())
);
}
#[test]
fn a_failing_expression_propagates_instead_of_falling_back() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut cfg = http_config();
cfg.path = Some("/static".to_string());
cfg.path_logic = compile(&dl, json!({"+": ["abc", 1]}));
match cfg.resolve_path(&ctx) {
Err(crate::engine::error::DataflowError::LogicEvaluation(msg)) => {
assert!(!msg.is_empty());
}
other => panic!("expected LogicEvaluation, got {other:?}"),
}
let mut cfg = http_config();
cfg.body = Some(json!({"static": true}));
cfg.body_logic = compile(&dl, json!({"+": ["abc", 1]}));
assert!(cfg.resolve_body(&ctx).is_err());
}
}