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>,
}
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());
}
}