Skip to main content

faucet_cli/serve/triggers/
spec.rs

1//! Serde config types for the `--triggers` file. Pure data + `JsonSchema`; no IO.
2//! Validation lives in `compiled.rs`.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Top-level `--triggers` document.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(deny_unknown_fields)]
11pub struct TriggersFile {
12    /// Schema version; must be `1`.
13    pub version: u32,
14    pub triggers: Vec<TriggerSpec>,
15}
16
17/// One configured trigger.
18// Note: `deny_unknown_fields` is intentionally absent here — serde does not
19// support it on structs that contain a `#[serde(flatten)]` field. Unknown fields
20// are instead rejected at load time by [`unknown_trigger_fields`] (see #232),
21// which diffs the raw document against this type's re-serialization.
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23pub struct TriggerSpec {
24    /// Unique name; used in metrics, idempotency keys, and the webhook path.
25    pub name: String,
26    /// Spawn this trigger? Default `true`.
27    #[serde(default = "default_true")]
28    pub enabled: bool,
29    /// The pipeline to enqueue when the trigger fires (path string or inline doc).
30    pub config: PipelineRef,
31    /// Optional run-shaping.
32    #[serde(default)]
33    pub run: RunTemplate,
34    /// Type-specific settings.
35    #[serde(flatten)]
36    pub kind: TriggerKind,
37}
38
39/// A pipeline reference: a path to a config file, OR an inline config document.
40/// Untagged so YAML `config: ./x.yaml` and `config: { pipeline: … }` both parse.
41#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
42#[serde(untagged)]
43pub enum PipelineRef {
44    Path(String),
45    Inline(serde_json::Value),
46}
47
48/// Trigger type + its settings. Internally-tagged on `type` — variant fields
49/// sit flat alongside `type` in the serialized form.
50#[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        /// Header whose value is used as the idempotency key (else per-request UUID).
66        #[serde(default)]
67        dedupe_header: Option<String>,
68        /// Leading-edge debounce window in seconds: coalesce fires that arrive
69        /// within this many seconds of the last accepted fire. Default 0 (off).
70        #[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/// Object-store connection for `object_arrival`. Internally-tagged on `type`;
99/// variant fields sit flat alongside `type`.
100#[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/// Queue connection for `queue_depth`. Internally-tagged on `type`;
120/// variant fields sit flat alongside `type`.
121#[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/// Optional run-shaping applied to the enqueued run.
146#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148pub struct RunTemplate {
149    /// Run name template; `{field}` tokens resolve from trigger fields
150    /// (`name`, `type`, `object_key`, `bucket`, `queue`, `depth`).
151    #[serde(default)]
152    pub name: Option<String>,
153    /// Static labels merged with the auto-derived trigger labels.
154    #[serde(default)]
155    pub labels: BTreeMap<String, String>,
156    /// Per-run timeout in seconds.
157    #[serde(default)]
158    pub timeout_secs: Option<u64>,
159}
160
161/// Detect fields in the raw triggers document that the typed parse silently
162/// dropped. Returns `(trigger_label, dotted_field_path)` pairs — empty when the
163/// document has no unknown fields.
164///
165/// This works around serde's inability to combine `#[serde(flatten)]` (carried
166/// by `TriggerSpec.kind`) with `#[serde(deny_unknown_fields)]`: a misspelled
167/// top-level trigger field such as `debounce_sec` (for `debounce_secs`) would
168/// otherwise deserialize to its default with no error. We round-trip each parsed
169/// `TriggerSpec` back to JSON and report any raw key absent from that
170/// serialization, so the allow-list is derived from the types themselves and can
171/// never drift. Nested objects (`store`, `queue`, `run`) are checked too; the
172/// opaque inline pipeline `config:` sub-document is deliberately not descended
173/// into (its keys belong to a full pipeline config validated elsewhere).
174pub 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
203/// Recursively collect keys present in `raw` but absent from `known` (the typed
204/// re-serialization). `at_trigger_root` suppresses descent into the opaque
205/// `config:` sub-document at the trigger top level.
206fn 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                // The inline pipeline document is opaque here — its keys are a
226                // full pipeline config, validated by the pipeline loader.
227                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    /// Parse the same text both ways: the typed form and a raw JSON `Value`.
339    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        // `debounce_sec` is a typo for `debounce_secs` — silently dropped by the
348        // flatten-bearing TriggerSpec, so the typed parse alone would accept it.
349        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        // The bug: the typed parse accepts the typo silently.
359        assert_eq!(file.triggers.len(), 1);
360        // The fix: the diff surfaces it.
361        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        // A typo in an optional nested field (`prefx` for `prefix`) is also
406        // silently dropped by the internally-tagged StoreSpec enum.
407        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        // The inline `config:` document is a full pipeline config validated
426        // elsewhere; its arbitrary keys must not be flagged as unknown.
427        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}