faucet_cli/serve/triggers/
webhook.rs1use super::context::TriggerEvent;
8use super::enqueue::{self, FireOutcome};
9use crate::serve::error::ServeError;
10use crate::serve::state::ServerState;
11use crate::serve::triggers::spec::TriggerKind;
12use axum::Json;
13use axum::extract::{Path, RawQuery, State};
14use axum::http::{HeaderMap, Method, StatusCode};
15use serde_json::json;
16use std::collections::BTreeMap;
17
18pub async fn handle(
19 State(state): State<ServerState>,
20 Path(name): Path<String>,
21 method: Method,
22 headers: HeaderMap,
23 RawQuery(raw_query): RawQuery,
24 body: String,
25) -> Result<(StatusCode, Json<serde_json::Value>), ServeError> {
26 let compiled = state
27 .triggers()
28 .webhook(&name)
29 .ok_or(ServeError::NotFound)?;
30
31 let (methods, dedupe_header, debounce_secs) = match &compiled.spec.kind {
32 TriggerKind::Webhook {
33 methods,
34 dedupe_header,
35 debounce_secs,
36 } => (methods, dedupe_header, *debounce_secs),
37 _ => return Err(ServeError::NotFound),
38 };
39 let m = method.as_str().to_ascii_uppercase();
40 if !methods
41 .iter()
42 .any(|allowed| allowed.to_ascii_uppercase() == m)
43 {
44 return Err(ServeError::BadConfig(format!(
45 "method {m} not allowed for webhook trigger '{name}'"
46 )));
47 }
48
49 if debounce_secs > 0 {
53 let now_ms = chrono::Utc::now().timestamp_millis();
54 if !state
55 .triggers()
56 .allow_fire(&name, (debounce_secs as i64) * 1000, now_ms)
57 {
58 crate::serve::triggers::metrics::coalesced(&name);
59 return Ok((StatusCode::OK, Json(json!({ "status": "coalesced" }))));
60 }
61 }
62
63 let idem = dedupe_header
65 .as_ref()
66 .and_then(|h| headers.get(h.as_str()))
67 .and_then(|v| v.to_str().ok())
68 .map(|s| s.to_string())
69 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
70
71 let header_map: BTreeMap<String, String> = headers
72 .iter()
73 .filter_map(|(k, v)| {
74 v.to_str()
75 .ok()
76 .map(|val| (k.as_str().to_ascii_lowercase(), val.to_string()))
77 })
78 .collect();
79 let query_map = parse_query(raw_query.as_deref());
80
81 let event = TriggerEvent::Webhook {
82 method: m,
83 body,
84 headers: header_map,
85 query: query_map,
86 idem,
87 };
88 let fired_at = chrono::Utc::now().to_rfc3339();
89 match enqueue::fire(&state, &compiled, event, &fired_at).await {
90 FireOutcome::Enqueued(run_id) => {
91 state.triggers().record_ok(&name, Some(fired_at));
92 Ok((
93 StatusCode::ACCEPTED,
94 Json(json!({ "run_id": run_id, "status": "queued" })),
95 ))
96 }
97 FireOutcome::Coalesced => Ok((StatusCode::OK, Json(json!({ "status": "coalesced" })))),
98 FireOutcome::Dropped(reason) => {
99 tracing::warn!(trigger = %name, %reason, "webhook fire dropped");
100 Err(ServeError::QueueFull {
101 retry_after_secs: 5,
102 })
103 }
104 FireOutcome::Error(msg) => {
105 state
106 .triggers()
107 .record_err(&name, msg.clone(), super::watcher::UNHEALTHY_THRESHOLD);
108 Err(ServeError::Internal(msg))
109 }
110 }
111}
112
113fn parse_query(raw: Option<&str>) -> BTreeMap<String, String> {
114 let mut m = BTreeMap::new();
115 if let Some(q) = raw {
116 for pair in q.split('&').filter(|s| !s.is_empty()) {
117 let mut it = pair.splitn(2, '=');
118 let k = it.next().unwrap_or_default().to_string();
119 let v = it.next().unwrap_or_default().to_string();
120 m.insert(k, v);
121 }
122 }
123 m
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn parses_query_pairs() {
132 let q = parse_query(Some("mode=full&tenant=acme"));
133 assert_eq!(q.get("mode").map(String::as_str), Some("full"));
134 assert_eq!(q.get("tenant").map(String::as_str), Some("acme"));
135 assert!(parse_query(None).is_empty());
136 }
137}