use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TriggersFile {
pub version: u32,
pub triggers: Vec<TriggerSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TriggerSpec {
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub config: PipelineRef,
#[serde(default)]
pub run: RunTemplate,
#[serde(flatten)]
pub kind: TriggerKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum PipelineRef {
Path(String),
Inline(serde_json::Value),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerKind {
ObjectArrival {
store: StoreSpec,
#[serde(default = "default_poll_secs")]
poll_interval_secs: u64,
#[serde(default)]
mode: ArrivalMode,
#[serde(default)]
start_at: StartAt,
},
Webhook {
#[serde(default = "default_webhook_methods")]
methods: Vec<String>,
#[serde(default)]
dedupe_header: Option<String>,
#[serde(default)]
debounce_secs: u64,
},
QueueDepth {
queue: QueueSpec,
#[serde(default = "default_threshold")]
threshold: u64,
#[serde(default = "default_poll_secs")]
poll_interval_secs: u64,
},
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ArrivalMode {
#[default]
PerObject,
Batch,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StartAt {
#[default]
Now,
Beginning,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StoreSpec {
S3 {
bucket: String,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
endpoint: Option<String>,
},
Gcs {
bucket: String,
#[serde(default)]
prefix: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum QueueSpec {
Redis {
url: String,
key: String,
#[serde(default)]
kind: RedisQueueKind,
},
Kafka {
brokers: String,
topic: String,
group: String,
},
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RedisQueueKind {
#[default]
List,
Stream,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RunTemplate {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub labels: BTreeMap<String, String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
pub fn unknown_trigger_fields(
raw: &serde_json::Value,
file: &TriggersFile,
) -> Vec<(String, String)> {
let mut out = Vec::new();
let Some(raw_triggers) = raw.get("triggers").and_then(|v| v.as_array()) else {
return out;
};
for (i, trigger) in file.triggers.iter().enumerate() {
let Some(raw_trigger) = raw_triggers.get(i) else {
continue;
};
let Ok(known) = serde_json::to_value(trigger) else {
continue;
};
let label = if trigger.name.trim().is_empty() {
format!("#{i}")
} else {
trigger.name.clone()
};
let mut fields = Vec::new();
collect_unknown_keys(raw_trigger, &known, "", true, &mut fields);
for f in fields {
out.push((label.clone(), f));
}
}
out
}
fn collect_unknown_keys(
raw: &serde_json::Value,
known: &serde_json::Value,
path: &str,
at_trigger_root: bool,
out: &mut Vec<String>,
) {
let (Some(raw_obj), Some(known_obj)) = (raw.as_object(), known.as_object()) else {
return;
};
for (key, raw_val) in raw_obj {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
match known_obj.get(key) {
None => out.push(child_path),
Some(known_val) => {
if at_trigger_root && key == "config" {
continue;
}
collect_unknown_keys(raw_val, known_val, &child_path, false, out);
}
}
}
}
fn default_true() -> bool {
true
}
fn default_poll_secs() -> u64 {
30
}
fn default_threshold() -> u64 {
1
}
fn default_webhook_methods() -> Vec<String> {
vec!["POST".to_string()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_object_arrival_with_defaults() {
let yaml = r#"
version: 1
triggers:
- name: drop
type: object_arrival
config: ./pipelines/load.yaml
store: { type: s3, bucket: b, prefix: incoming/ }
"#;
let f: TriggersFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(f.version, 1);
assert_eq!(f.triggers.len(), 1);
let t = &f.triggers[0];
assert_eq!(t.name, "drop");
assert!(t.enabled);
assert!(matches!(t.config, PipelineRef::Path(ref p) if p == "./pipelines/load.yaml"));
match &t.kind {
TriggerKind::ObjectArrival {
poll_interval_secs,
mode,
start_at,
..
} => {
assert_eq!(*poll_interval_secs, 30);
assert!(matches!(mode, ArrivalMode::PerObject));
assert!(matches!(start_at, StartAt::Now));
}
_ => panic!("wrong kind"),
}
}
#[test]
fn parses_inline_pipeline_and_webhook_and_queue() {
let yaml = r#"
version: 1
triggers:
- name: hook
type: webhook
config: { pipeline: { sources: {}, sinks: {} } }
dedupe_header: Idempotency-Key
debounce_secs: 30
- name: drain
type: queue_depth
config: ./drain.yaml
queue: { type: redis, url: "redis://x", key: jobs, kind: stream }
threshold: 5
"#;
let f: TriggersFile = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(f.triggers[0].config, PipelineRef::Inline(_)));
match &f.triggers[0].kind {
TriggerKind::Webhook {
methods,
dedupe_header,
debounce_secs,
} => {
assert_eq!(methods, &vec!["POST".to_string()]);
assert_eq!(dedupe_header.as_deref(), Some("Idempotency-Key"));
assert_eq!(*debounce_secs, 30);
}
_ => panic!("wrong kind"),
}
match &f.triggers[1].kind {
TriggerKind::QueueDepth {
threshold, queue, ..
} => {
assert_eq!(*threshold, 5);
assert!(matches!(
queue,
QueueSpec::Redis {
kind: RedisQueueKind::Stream,
..
}
));
}
_ => panic!("wrong kind"),
}
}
#[test]
fn rejects_unknown_top_level_field() {
let yaml = "version: 1\ntriggers: []\nbogus: 1\n";
assert!(serde_yaml::from_str::<TriggersFile>(yaml).is_err());
}
fn parse_both(yaml: &str) -> (TriggersFile, serde_json::Value) {
let file: TriggersFile = serde_yaml::from_str(yaml).expect("typed parse");
let raw: serde_json::Value = serde_yaml::from_str(yaml).expect("raw parse");
(file, raw)
}
#[test]
fn detects_unknown_top_level_trigger_field() {
let yaml = "\
version: 1
triggers:
- name: hook
type: webhook
config: ./x.yaml
debounce_sec: 5
";
let (file, raw) = parse_both(yaml);
assert_eq!(file.triggers.len(), 1);
let unknown = unknown_trigger_fields(&raw, &file);
assert_eq!(
unknown,
vec![("hook".to_string(), "debounce_sec".to_string())]
);
}
#[test]
fn accepts_all_known_fields_for_every_type() {
let yaml = "\
version: 1
triggers:
- name: obj
type: object_arrival
enabled: true
config: ./load.yaml
run: { name: r, timeout_secs: 10 }
store: { type: s3, bucket: b, prefix: in/, region: us-east-1, endpoint: http://x }
poll_interval_secs: 15
mode: batch
start_at: beginning
- name: hook
type: webhook
config: { pipeline: { sources: {}, sinks: {} } }
methods: [POST, PUT]
dedupe_header: Idempotency-Key
debounce_secs: 5
- name: drain
type: queue_depth
config: ./drain.yaml
queue: { type: kafka, brokers: b, topic: t, group: g }
threshold: 3
poll_interval_secs: 20
";
let (file, raw) = parse_both(yaml);
let unknown = unknown_trigger_fields(&raw, &file);
assert!(
unknown.is_empty(),
"expected no unknown fields, got {unknown:?}"
);
}
#[test]
fn detects_unknown_nested_store_field() {
let yaml = "\
version: 1
triggers:
- name: obj
type: object_arrival
config: ./load.yaml
store: { type: s3, bucket: b, prefx: in/ }
";
let (file, raw) = parse_both(yaml);
let unknown = unknown_trigger_fields(&raw, &file);
assert_eq!(
unknown,
vec![("obj".to_string(), "store.prefx".to_string())]
);
}
#[test]
fn ignores_keys_inside_inline_pipeline_config() {
let yaml = "\
version: 1
triggers:
- name: hook
type: webhook
config:
version: 1
pipeline: { sources: {}, sinks: {} }
anything_goes_here: true
";
let (file, raw) = parse_both(yaml);
let unknown = unknown_trigger_fields(&raw, &file);
assert!(
unknown.is_empty(),
"inline config keys must be ignored, got {unknown:?}"
);
}
}