1use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(deny_unknown_fields)]
11pub struct TriggersFile {
12 pub version: u32,
14 pub triggers: Vec<TriggerSpec>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23pub struct TriggerSpec {
24 pub name: String,
26 #[serde(default = "default_true")]
28 pub enabled: bool,
29 pub config: PipelineRef,
31 #[serde(default)]
33 pub run: RunTemplate,
34 #[serde(flatten)]
36 pub kind: TriggerKind,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
42#[serde(untagged)]
43pub enum PipelineRef {
44 Path(String),
45 Inline(serde_json::Value),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum TriggerKind {
53 ObjectArrival {
54 store: StoreSpec,
55 #[serde(default = "default_poll_secs")]
56 poll_interval_secs: u64,
57 #[serde(default)]
58 mode: ArrivalMode,
59 #[serde(default)]
60 start_at: StartAt,
61 },
62 Webhook {
63 #[serde(default = "default_webhook_methods")]
64 methods: Vec<String>,
65 #[serde(default)]
67 dedupe_header: Option<String>,
68 #[serde(default)]
71 debounce_secs: u64,
72 },
73 QueueDepth {
74 queue: QueueSpec,
75 #[serde(default = "default_threshold")]
76 threshold: u64,
77 #[serde(default = "default_poll_secs")]
78 poll_interval_secs: u64,
79 },
80}
81
82#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "snake_case")]
84pub enum ArrivalMode {
85 #[default]
86 PerObject,
87 Batch,
88}
89
90#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "snake_case")]
92pub enum StartAt {
93 #[default]
94 Now,
95 Beginning,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum StoreSpec {
103 S3 {
104 bucket: String,
105 #[serde(default)]
106 prefix: Option<String>,
107 #[serde(default)]
108 region: Option<String>,
109 #[serde(default)]
110 endpoint: Option<String>,
111 },
112 Gcs {
113 bucket: String,
114 #[serde(default)]
115 prefix: Option<String>,
116 },
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122#[serde(tag = "type", rename_all = "snake_case")]
123pub enum QueueSpec {
124 Redis {
125 url: String,
126 key: String,
127 #[serde(default)]
128 kind: RedisQueueKind,
129 },
130 Kafka {
131 brokers: String,
132 topic: String,
133 group: String,
134 },
135}
136
137#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
138#[serde(rename_all = "snake_case")]
139pub enum RedisQueueKind {
140 #[default]
141 List,
142 Stream,
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148pub struct RunTemplate {
149 #[serde(default)]
152 pub name: Option<String>,
153 #[serde(default)]
155 pub labels: BTreeMap<String, String>,
156 #[serde(default)]
158 pub timeout_secs: Option<u64>,
159}
160
161pub fn unknown_trigger_fields(
175 raw: &serde_json::Value,
176 file: &TriggersFile,
177) -> Vec<(String, String)> {
178 let mut out = Vec::new();
179 let Some(raw_triggers) = raw.get("triggers").and_then(|v| v.as_array()) else {
180 return out;
181 };
182 for (i, trigger) in file.triggers.iter().enumerate() {
183 let Some(raw_trigger) = raw_triggers.get(i) else {
184 continue;
185 };
186 let Ok(known) = serde_json::to_value(trigger) else {
187 continue;
188 };
189 let label = if trigger.name.trim().is_empty() {
190 format!("#{i}")
191 } else {
192 trigger.name.clone()
193 };
194 let mut fields = Vec::new();
195 collect_unknown_keys(raw_trigger, &known, "", true, &mut fields);
196 for f in fields {
197 out.push((label.clone(), f));
198 }
199 }
200 out
201}
202
203fn collect_unknown_keys(
207 raw: &serde_json::Value,
208 known: &serde_json::Value,
209 path: &str,
210 at_trigger_root: bool,
211 out: &mut Vec<String>,
212) {
213 let (Some(raw_obj), Some(known_obj)) = (raw.as_object(), known.as_object()) else {
214 return;
215 };
216 for (key, raw_val) in raw_obj {
217 let child_path = if path.is_empty() {
218 key.clone()
219 } else {
220 format!("{path}.{key}")
221 };
222 match known_obj.get(key) {
223 None => out.push(child_path),
224 Some(known_val) => {
225 if at_trigger_root && key == "config" {
228 continue;
229 }
230 collect_unknown_keys(raw_val, known_val, &child_path, false, out);
231 }
232 }
233 }
234}
235
236fn default_true() -> bool {
237 true
238}
239fn default_poll_secs() -> u64 {
240 30
241}
242fn default_threshold() -> u64 {
243 1
244}
245fn default_webhook_methods() -> Vec<String> {
246 vec!["POST".to_string()]
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn parses_object_arrival_with_defaults() {
255 let yaml = r#"
256version: 1
257triggers:
258 - name: drop
259 type: object_arrival
260 config: ./pipelines/load.yaml
261 store: { type: s3, bucket: b, prefix: incoming/ }
262"#;
263 let f: TriggersFile = serde_yaml::from_str(yaml).unwrap();
264 assert_eq!(f.version, 1);
265 assert_eq!(f.triggers.len(), 1);
266 let t = &f.triggers[0];
267 assert_eq!(t.name, "drop");
268 assert!(t.enabled);
269 assert!(matches!(t.config, PipelineRef::Path(ref p) if p == "./pipelines/load.yaml"));
270 match &t.kind {
271 TriggerKind::ObjectArrival {
272 poll_interval_secs,
273 mode,
274 start_at,
275 ..
276 } => {
277 assert_eq!(*poll_interval_secs, 30);
278 assert!(matches!(mode, ArrivalMode::PerObject));
279 assert!(matches!(start_at, StartAt::Now));
280 }
281 _ => panic!("wrong kind"),
282 }
283 }
284
285 #[test]
286 fn parses_inline_pipeline_and_webhook_and_queue() {
287 let yaml = r#"
288version: 1
289triggers:
290 - name: hook
291 type: webhook
292 config: { pipeline: { sources: {}, sinks: {} } }
293 dedupe_header: Idempotency-Key
294 debounce_secs: 30
295 - name: drain
296 type: queue_depth
297 config: ./drain.yaml
298 queue: { type: redis, url: "redis://x", key: jobs, kind: stream }
299 threshold: 5
300"#;
301 let f: TriggersFile = serde_yaml::from_str(yaml).unwrap();
302 assert!(matches!(f.triggers[0].config, PipelineRef::Inline(_)));
303 match &f.triggers[0].kind {
304 TriggerKind::Webhook {
305 methods,
306 dedupe_header,
307 debounce_secs,
308 } => {
309 assert_eq!(methods, &vec!["POST".to_string()]);
310 assert_eq!(dedupe_header.as_deref(), Some("Idempotency-Key"));
311 assert_eq!(*debounce_secs, 30);
312 }
313 _ => panic!("wrong kind"),
314 }
315 match &f.triggers[1].kind {
316 TriggerKind::QueueDepth {
317 threshold, queue, ..
318 } => {
319 assert_eq!(*threshold, 5);
320 assert!(matches!(
321 queue,
322 QueueSpec::Redis {
323 kind: RedisQueueKind::Stream,
324 ..
325 }
326 ));
327 }
328 _ => panic!("wrong kind"),
329 }
330 }
331
332 #[test]
333 fn rejects_unknown_top_level_field() {
334 let yaml = "version: 1\ntriggers: []\nbogus: 1\n";
335 assert!(serde_yaml::from_str::<TriggersFile>(yaml).is_err());
336 }
337
338 fn parse_both(yaml: &str) -> (TriggersFile, serde_json::Value) {
340 let file: TriggersFile = serde_yaml::from_str(yaml).expect("typed parse");
341 let raw: serde_json::Value = serde_yaml::from_str(yaml).expect("raw parse");
342 (file, raw)
343 }
344
345 #[test]
346 fn detects_unknown_top_level_trigger_field() {
347 let yaml = "\
350version: 1
351triggers:
352 - name: hook
353 type: webhook
354 config: ./x.yaml
355 debounce_sec: 5
356";
357 let (file, raw) = parse_both(yaml);
358 assert_eq!(file.triggers.len(), 1);
360 let unknown = unknown_trigger_fields(&raw, &file);
362 assert_eq!(
363 unknown,
364 vec![("hook".to_string(), "debounce_sec".to_string())]
365 );
366 }
367
368 #[test]
369 fn accepts_all_known_fields_for_every_type() {
370 let yaml = "\
371version: 1
372triggers:
373 - name: obj
374 type: object_arrival
375 enabled: true
376 config: ./load.yaml
377 run: { name: r, timeout_secs: 10 }
378 store: { type: s3, bucket: b, prefix: in/, region: us-east-1, endpoint: http://x }
379 poll_interval_secs: 15
380 mode: batch
381 start_at: beginning
382 - name: hook
383 type: webhook
384 config: { pipeline: { sources: {}, sinks: {} } }
385 methods: [POST, PUT]
386 dedupe_header: Idempotency-Key
387 debounce_secs: 5
388 - name: drain
389 type: queue_depth
390 config: ./drain.yaml
391 queue: { type: kafka, brokers: b, topic: t, group: g }
392 threshold: 3
393 poll_interval_secs: 20
394";
395 let (file, raw) = parse_both(yaml);
396 let unknown = unknown_trigger_fields(&raw, &file);
397 assert!(
398 unknown.is_empty(),
399 "expected no unknown fields, got {unknown:?}"
400 );
401 }
402
403 #[test]
404 fn detects_unknown_nested_store_field() {
405 let yaml = "\
408version: 1
409triggers:
410 - name: obj
411 type: object_arrival
412 config: ./load.yaml
413 store: { type: s3, bucket: b, prefx: in/ }
414";
415 let (file, raw) = parse_both(yaml);
416 let unknown = unknown_trigger_fields(&raw, &file);
417 assert_eq!(
418 unknown,
419 vec![("obj".to_string(), "store.prefx".to_string())]
420 );
421 }
422
423 #[test]
424 fn ignores_keys_inside_inline_pipeline_config() {
425 let yaml = "\
428version: 1
429triggers:
430 - name: hook
431 type: webhook
432 config:
433 version: 1
434 pipeline: { sources: {}, sinks: {} }
435 anything_goes_here: true
436";
437 let (file, raw) = parse_both(yaml);
438 let unknown = unknown_trigger_fields(&raw, &file);
439 assert!(
440 unknown.is_empty(),
441 "inline config keys must be ignored, got {unknown:?}"
442 );
443 }
444}