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: Template,
#[serde(default = "default_method")]
pub method: HttpMethod,
#[serde(default, alias = "path_logic")]
pub path: Option<Template>,
#[serde(default)]
pub headers: HashMap<String, Template>,
#[serde(default, alias = "body_logic")]
pub body: Option<Template>,
#[serde(default)]
pub body_format: Option<Template>,
#[serde(default, alias = "output")]
pub response_path: Option<Template>,
#[serde(default)]
pub response_format: Option<Template>,
#[serde(default = "default_timeout")]
pub timeout_ms: Template,
}
#[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 [Self] = &[Self::Get, Self::Post, Self::Put, Self::Patch, Self::Delete];
pub const fn as_str(&self) -> &'static str {
match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Put => "PUT",
Self::Patch => "PATCH",
Self::Delete => "DELETE",
}
}
pub const fn is_idempotent(&self) -> bool {
match self {
Self::Get | Self::Put | Self::Delete => true,
Self::Post | Self::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() -> Template {
Template::from(Value::from(DEFAULT_TIMEOUT_MS))
}
pub const DEFAULT_TIMEOUT_MS: u64 = 30000;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnrichConfig {
pub connector: Template,
#[serde(default = "default_method")]
pub method: HttpMethod,
#[serde(default, alias = "path_logic")]
pub path: Option<Template>,
pub merge_path: Template,
#[serde(default = "default_timeout")]
pub timeout_ms: Template,
#[serde(default)]
pub on_error: EnrichErrorAction,
}
fn resolve_opt_string(field: &Option<Template>, ctx: &TaskContext<'_>) -> Result<Option<String>> {
field.as_ref().map(|t| t.resolve_string(ctx)).transpose()
}
fn resolve_opt_value(field: &Option<Template>, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
field.as_ref().map(|t| t.eval_into(ctx)).transpose()
}
impl HttpCallConfig {
pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
self.connector.resolve_string(ctx)
}
pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.path, ctx)
}
pub fn resolve_headers(&self, ctx: &TaskContext<'_>) -> Result<HashMap<String, String>> {
self.headers
.iter()
.map(|(name, value)| Ok((name.clone(), value.resolve_string(ctx)?)))
.collect()
}
pub fn resolve_body(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
resolve_opt_value(&self.body, ctx)
}
pub fn resolve_body_format(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.body_format, ctx)
}
pub fn resolve_response_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.response_path, ctx)
}
pub fn resolve_response_format(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.response_format, ctx)
}
pub fn resolve_timeout_ms(&self, ctx: &TaskContext<'_>) -> Result<u64> {
self.timeout_ms.resolve_u64(ctx, "http_call timeout_ms")
}
}
impl EnrichConfig {
pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
self.connector.resolve_string(ctx)
}
pub fn resolve_path(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.path, ctx)
}
pub fn resolve_merge_path(&self, ctx: &TaskContext<'_>) -> Result<String> {
self.merge_path.resolve_string(ctx)
}
pub fn resolve_timeout_ms(&self, ctx: &TaskContext<'_>) -> Result<u64> {
self.timeout_ms.resolve_u64(ctx, "enrich timeout_ms")
}
}
impl PublishKafkaConfig {
pub fn resolve_connector(&self, ctx: &TaskContext<'_>) -> Result<String> {
self.connector.resolve_string(ctx)
}
pub fn resolve_topic(&self, ctx: &TaskContext<'_>) -> Result<String> {
self.topic.resolve_string(ctx)
}
pub fn resolve_key(&self, ctx: &TaskContext<'_>) -> Result<Option<String>> {
resolve_opt_string(&self.key, ctx)
}
pub fn resolve_value(&self, ctx: &TaskContext<'_>) -> Result<Option<Value>> {
resolve_opt_value(&self.value, 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: Template,
pub topic: Template,
#[serde(default, alias = "key_logic")]
pub key: Option<Template>,
#[serde(default, alias = "value_logic")]
pub value: Option<Template>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::compiler::datalogic_engine_builder;
use crate::engine::functions::template::TemplateCompiler;
use crate::engine::message::Message;
use crate::engine::utils::set_nested_value;
use datavalue::OwnedDataValue;
use serde_json::json;
use std::sync::Arc;
#[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);
}
fn engine() -> Arc<datalogic_rs::Engine> {
Arc::new(datalogic_engine_builder().build())
}
fn dv(v: serde_json::Value) -> OwnedDataValue {
OwnedDataValue::from(&v)
}
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 http_config(extra: serde_json::Value) -> HttpCallConfig {
let mut base = json!({ "connector": "c" });
let obj = base.as_object_mut().unwrap();
for (k, v) in extra.as_object().unwrap() {
obj.insert(k.clone(), v.clone());
}
let mut cfg: HttpCallConfig = serde_json::from_value(base).expect("config should parse");
let c = TemplateCompiler::new(engine());
cfg.connector.compile(&c, "connector").unwrap();
cfg.timeout_ms.compile(&c, "timeout_ms").unwrap();
for v in cfg.headers.values_mut() {
v.compile(&c, "header").unwrap();
}
for t in [
&mut cfg.path,
&mut cfg.body,
&mut cfg.body_format,
&mut cfg.response_path,
&mut cfg.response_format,
]
.into_iter()
.flatten()
{
t.compile(&c, "field").unwrap();
}
cfg
}
#[test]
fn format_fields_default_to_none() {
let cfg = http_config(json!({}));
assert!(cfg.body_format.is_none());
assert!(cfg.response_format.is_none());
}
#[test]
fn misspelled_format_field_is_rejected() {
let err = serde_json::from_value::<HttpCallConfig>(json!({
"connector": "c",
"body_fromat": "form",
}))
.expect_err("unknown field must be rejected");
let msg = err.to_string();
assert!(msg.contains("body_fromat"), "{msg}");
assert!(msg.contains("`body_format`"), "{msg}");
assert!(msg.contains("`response_format`"), "{msg}");
}
#[test]
fn the_pre_39_logic_spellings_still_deserialize() {
let http: HttpCallConfig = serde_json::from_value(json!({
"connector": "c",
"path_logic": {"var": "data.id"},
"body_logic": {"var": "data.obj"},
}))
.expect("pre-3.9 http_call spelling must still load");
assert_eq!(http.path.unwrap().as_json(), &json!({"var": "data.id"}));
assert_eq!(http.body.unwrap().as_json(), &json!({"var": "data.obj"}));
let enrich: EnrichConfig = serde_json::from_value(json!({
"connector": "c",
"merge_path": "data.out",
"path_logic": {"var": "data.id"},
}))
.expect("pre-3.9 enrich spelling must still load");
assert_eq!(enrich.path.unwrap().as_json(), &json!({"var": "data.id"}));
let kafka: PublishKafkaConfig = serde_json::from_value(json!({
"connector": "c",
"topic": "t",
"key_logic": {"var": "data.id"},
"value_logic": {"var": "data.obj"},
}))
.expect("pre-3.9 publish_kafka spelling must still load");
assert_eq!(kafka.key.unwrap().as_json(), &json!({"var": "data.id"}));
assert_eq!(kafka.value.unwrap().as_json(), &json!({"var": "data.obj"}));
}
#[test]
fn supplying_both_spellings_is_a_duplicate_field_error() {
let err = serde_json::from_value::<HttpCallConfig>(json!({
"connector": "c",
"path": "/a",
"path_logic": {"var": "data.id"},
}))
.expect_err("both spellings must be rejected");
assert!(err.to_string().contains("duplicate field"), "{err}");
}
#[test]
fn a_static_config_resolves_to_exactly_what_was_authored() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({
"path": "/static",
"headers": {"X-Env": "prod"},
"body_format": "json",
"response_path": "data.out",
}));
assert_eq!(cfg.resolve_connector(&ctx).unwrap(), "c");
assert_eq!(cfg.resolve_path(&ctx).unwrap().as_deref(), Some("/static"));
assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Env"], "prod");
assert_eq!(
cfg.resolve_body_format(&ctx).unwrap().as_deref(),
Some("json")
);
assert_eq!(
cfg.resolve_response_path(&ctx).unwrap().as_deref(),
Some("data.out")
);
assert_eq!(cfg.resolve_timeout_ms(&ctx).unwrap(), DEFAULT_TIMEOUT_MS);
assert!(cfg.connector.is_constant(), "a literal connector must fold");
assert!(
cfg.timeout_ms.is_constant(),
"the default timeout must fold"
);
}
#[test]
fn every_parameter_can_be_computed_from_the_message() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({
"connector": {"cat": ["gw_", {"var": "data.id"}]},
"path": {"cat": ["/orders/", {"var": "data.id"}]},
"headers": {"X-Request-Id": {"var": "data.id"}},
"body": {"var": "data.obj"},
"timeout_ms": {"var": "data.n"},
}));
assert_eq!(cfg.resolve_connector(&ctx).unwrap(), "gw_abc");
assert_eq!(
cfg.resolve_path(&ctx).unwrap().as_deref(),
Some("/orders/abc")
);
assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Request-Id"], "abc");
assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!({"a": 1})));
assert_eq!(cfg.resolve_timeout_ms(&ctx).unwrap(), 7);
}
#[test]
fn an_escaped_body_key_is_sent_as_data_not_evaluated() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({ "body": {"$cat": ["a", "b"]} }));
assert_eq!(
cfg.resolve_body(&ctx).unwrap(),
Some(json!({"cat": ["a", "b"]}))
);
let cfg = http_config(json!({ "body": {"cat": ["a", "b"]} }));
assert_eq!(cfg.resolve_body(&ctx).unwrap(), Some(json!("ab")));
}
#[test]
fn header_values_are_coerced_to_plain_strings() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({ "headers": {"X-Count": {"var": "data.n"}} }));
assert_eq!(cfg.resolve_headers(&ctx).unwrap()["X-Count"], "7");
}
#[test]
fn a_failing_header_fails_the_call_rather_than_being_dropped() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({ "headers": {"X-Bad": {"+": ["abc", 1]}} }));
assert!(cfg.resolve_headers(&ctx).is_err());
}
#[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 cfg = http_config(json!({ "path": {"var": "data.n"} }));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), Some("7".to_string()));
let cfg = http_config(json!({ "path": {"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 cfg = http_config(json!({ "path": {"+": ["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 cfg = http_config(json!({ "body": {"+": ["abc", 1]} }));
assert!(cfg.resolve_body(&ctx).is_err());
}
#[test]
fn a_non_numeric_timeout_is_a_configuration_error() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({ "timeout_ms": {"var": "data.nope"} }));
let err = cfg
.resolve_timeout_ms(&ctx)
.expect_err("a null timeout must be rejected");
assert!(err.to_string().contains("timeout_ms"), "{err}");
}
#[test]
fn enrich_and_kafka_resolve_their_own_parameters() {
let dl = engine();
let c = TemplateCompiler::new(engine());
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let mut enrich: EnrichConfig = serde_json::from_value(json!({
"connector": "lookup",
"path": {"cat": ["/users/", {"var": "data.id"}]},
"merge_path": {"cat": ["data.users.", {"var": "data.id"}]},
}))
.unwrap();
enrich.connector.compile(&c, "connector").unwrap();
enrich.merge_path.compile(&c, "merge_path").unwrap();
enrich.timeout_ms.compile(&c, "timeout_ms").unwrap();
enrich.path.as_mut().unwrap().compile(&c, "path").unwrap();
assert_eq!(enrich.resolve_connector(&ctx).unwrap(), "lookup");
assert_eq!(
enrich.resolve_path(&ctx).unwrap().as_deref(),
Some("/users/abc")
);
assert_eq!(enrich.resolve_merge_path(&ctx).unwrap(), "data.users.abc");
assert_eq!(enrich.resolve_timeout_ms(&ctx).unwrap(), DEFAULT_TIMEOUT_MS);
let mut kafka: PublishKafkaConfig = serde_json::from_value(json!({
"connector": "bus",
"topic": {"cat": ["orders.", {"var": "data.id"}]},
"key": {"var": "data.id"},
"value": {"var": "data.obj"},
}))
.unwrap();
kafka.connector.compile(&c, "connector").unwrap();
kafka.topic.compile(&c, "topic").unwrap();
kafka.key.as_mut().unwrap().compile(&c, "key").unwrap();
kafka.value.as_mut().unwrap().compile(&c, "value").unwrap();
assert_eq!(kafka.resolve_connector(&ctx).unwrap(), "bus");
assert_eq!(kafka.resolve_topic(&ctx).unwrap(), "orders.abc");
assert_eq!(kafka.resolve_key(&ctx).unwrap().as_deref(), Some("abc"));
assert_eq!(kafka.resolve_value(&ctx).unwrap(), Some(json!({"a": 1})));
}
#[test]
fn absent_optional_fields_resolve_to_none() {
let dl = engine();
let mut m = fresh_message();
let ctx = TaskContext::new(&mut m, &dl);
let cfg = http_config(json!({}));
assert_eq!(cfg.resolve_path(&ctx).unwrap(), None);
assert_eq!(cfg.resolve_body(&ctx).unwrap(), None);
assert_eq!(cfg.resolve_body_format(&ctx).unwrap(), None);
assert_eq!(cfg.resolve_response_path(&ctx).unwrap(), None);
assert_eq!(cfg.resolve_response_format(&ctx).unwrap(), None);
assert!(cfg.resolve_headers(&ctx).unwrap().is_empty());
}
}