Skip to main content

oximedia_workflow/
webhook.rs

1//! Outbound webhook notifier for workflow lifecycle events.
2//!
3//! This module provides [`WebhookNotifier`] which builds JSON payloads and
4//! HMAC-SHA256 signatures for outbound HTTP POST webhooks fired when workflow
5//! events occur (started, completed, failed, step completed/failed).
6//!
7//! This is distinct from [`crate::triggers::WebhookTrigger`] which handles
8//! *inbound* webhooks that start a workflow.  The notifier sends *outbound*
9//! notifications to external systems.
10//!
11//! # Usage
12//!
13//! ```rust
14//! use oximedia_workflow::webhook::{WebhookConfig, WebhookEvent, WebhookNotifier, WorkflowContext};
15//!
16//! let config = WebhookConfig {
17//!     url: "https://example.com/hooks/workflow".to_string(),
18//!     secret: Some("my-secret".to_string()),
19//!     events: vec![WebhookEvent::WorkflowCompleted, WebhookEvent::WorkflowFailed],
20//!     max_retries: 3,
21//!     timeout_ms: 5_000,
22//! };
23//!
24//! let notifier = WebhookNotifier::new(config);
25//!
26//! let ctx = WorkflowContext {
27//!     workflow_id: "wf-001".to_string(),
28//!     workflow_name: "transcode-pipeline".to_string(),
29//!     state: "completed".to_string(),
30//!     variables: std::collections::HashMap::new(),
31//! };
32//!
33//! let payload = notifier.build_payload(&WebhookEvent::WorkflowCompleted, &ctx);
34//! let signature = notifier.compute_signature(&payload);
35//! ```
36
37use std::collections::HashMap;
38
39// ---------------------------------------------------------------------------
40// Public types
41// ---------------------------------------------------------------------------
42
43/// Events that can trigger an outbound webhook notification.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum WebhookEvent {
46    /// Fired when a workflow transitions to the *running* state.
47    WorkflowStarted,
48    /// Fired when a workflow reaches a terminal *completed* state.
49    WorkflowCompleted,
50    /// Fired when a workflow reaches a terminal *failed* state.
51    WorkflowFailed,
52    /// Fired when a named workflow step (task) succeeds.
53    StepCompleted {
54        /// Name of the step that completed.
55        step_name: String,
56    },
57    /// Fired when a named workflow step (task) fails.
58    StepFailed {
59        /// Name of the step that failed.
60        step_name: String,
61    },
62}
63
64impl WebhookEvent {
65    /// A stable dot-separated event type string suitable for JSON payloads.
66    #[must_use]
67    pub fn event_type(&self) -> &str {
68        match self {
69            Self::WorkflowStarted => "workflow.started",
70            Self::WorkflowCompleted => "workflow.completed",
71            Self::WorkflowFailed => "workflow.failed",
72            Self::StepCompleted { .. } => "step.completed",
73            Self::StepFailed { .. } => "step.failed",
74        }
75    }
76
77    /// Optional step name for step-level events; `None` for workflow-level events.
78    #[must_use]
79    pub fn step_name(&self) -> Option<&str> {
80        match self {
81            Self::StepCompleted { step_name } | Self::StepFailed { step_name } => {
82                Some(step_name.as_str())
83            }
84            _ => None,
85        }
86    }
87}
88
89/// Configuration for an outbound webhook endpoint.
90#[derive(Debug, Clone)]
91pub struct WebhookConfig {
92    /// Destination URL for HTTP POST notifications.
93    pub url: String,
94    /// Optional HMAC-SHA256 signing secret.  When set, the notifier will
95    /// add an `X-Hub-Signature-256` header to each notification.
96    pub secret: Option<String>,
97    /// Set of events that should trigger a notification.
98    /// An empty `events` list means *no* events are sent.
99    pub events: Vec<WebhookEvent>,
100    /// Maximum number of delivery retries on failure (caller-managed).
101    pub max_retries: u32,
102    /// Per-attempt timeout in milliseconds (caller-managed).
103    pub timeout_ms: u64,
104}
105
106impl Default for WebhookConfig {
107    fn default() -> Self {
108        Self {
109            url: String::new(),
110            secret: None,
111            events: Vec::new(),
112            max_retries: 3,
113            timeout_ms: 5_000,
114        }
115    }
116}
117
118/// Contextual information about a workflow included in every notification payload.
119#[derive(Debug, Clone, Default)]
120pub struct WorkflowContext {
121    /// Unique workflow instance identifier.
122    pub workflow_id: String,
123    /// Human-readable workflow name.
124    pub workflow_name: String,
125    /// Current workflow state string (e.g. `"running"`, `"completed"`, `"failed"`).
126    pub state: String,
127    /// Arbitrary key-value variables from the workflow execution context.
128    pub variables: HashMap<String, serde_json::Value>,
129}
130
131// ---------------------------------------------------------------------------
132// WebhookNotifier
133// ---------------------------------------------------------------------------
134
135/// Builds outbound webhook payloads and signatures.
136///
137/// The notifier is stateless with respect to HTTP transport — it only builds
138/// the JSON body and HMAC-SHA256 signature.  Callers are responsible for
139/// actually sending the HTTP POST (e.g. using `reqwest` or `hyper`).
140#[derive(Debug, Clone)]
141pub struct WebhookNotifier {
142    config: WebhookConfig,
143}
144
145impl WebhookNotifier {
146    /// Create a new notifier with the given configuration.
147    #[must_use]
148    pub fn new(config: WebhookConfig) -> Self {
149        Self { config }
150    }
151
152    /// Returns a reference to the underlying configuration.
153    #[must_use]
154    pub fn config(&self) -> &WebhookConfig {
155        &self.config
156    }
157
158    /// Build the JSON payload string for the given `event` and `context`.
159    ///
160    /// The payload is a JSON object with the following fields:
161    /// - `event_type`: dot-separated event name (see [`WebhookEvent::event_type`])
162    /// - `workflow_id`: from `context.workflow_id`
163    /// - `workflow_name`: from `context.workflow_name`
164    /// - `state`: from `context.state`
165    /// - `timestamp_ms`: Unix epoch in milliseconds
166    /// - `variables`: from `context.variables`
167    /// - `step_name` *(optional)*: only present for step-level events
168    #[must_use]
169    pub fn build_payload(&self, event: &WebhookEvent, context: &WorkflowContext) -> String {
170        let timestamp_ms = std::time::SystemTime::now()
171            .duration_since(std::time::UNIX_EPOCH)
172            .unwrap_or_default()
173            .as_millis();
174
175        let mut payload = serde_json::json!({
176            "event_type": event.event_type(),
177            "workflow_id": context.workflow_id,
178            "workflow_name": context.workflow_name,
179            "state": context.state,
180            "timestamp_ms": timestamp_ms,
181            "variables": context.variables,
182        });
183
184        if let Some(step) = event.step_name() {
185            if let Some(obj) = payload.as_object_mut() {
186                obj.insert(
187                    "step_name".to_string(),
188                    serde_json::Value::String(step.to_string()),
189                );
190            }
191        }
192
193        payload.to_string()
194    }
195
196    /// Compute HMAC-SHA256 of `payload` using the configured secret.
197    ///
198    /// Returns `None` when no secret is configured, or `Some(hex_string)` (64
199    /// lowercase hex characters) when a secret is set.
200    #[must_use]
201    pub fn compute_signature(&self, payload: &str) -> Option<String> {
202        self.config
203            .secret
204            .as_ref()
205            .map(|secret| hmac_sha256(secret.as_bytes(), payload.as_bytes()))
206    }
207
208    /// Returns `true` when the notifier is configured to send a notification for
209    /// the given event.
210    ///
211    /// Matching is done by event type string so that, for example, any
212    /// `StepCompleted { .. }` event matches a `StepCompleted { step_name: _ }` entry.
213    #[must_use]
214    pub fn should_notify(&self, event: &WebhookEvent) -> bool {
215        self.config
216            .events
217            .iter()
218            .any(|e| e.event_type() == event.event_type())
219    }
220
221    /// Build an HTTP headers map for a notification.
222    ///
223    /// Always includes:
224    /// - `Content-Type: application/json`
225    ///
226    /// When a secret is configured, also includes:
227    /// - `X-Hub-Signature-256: <hex-digest>`
228    #[must_use]
229    pub fn build_headers(&self, payload: &str) -> HashMap<String, String> {
230        let mut headers = HashMap::new();
231        headers.insert("Content-Type".to_string(), "application/json".to_string());
232
233        if let Some(sig) = self.compute_signature(payload) {
234            headers.insert("X-Hub-Signature-256".to_string(), sig);
235        }
236
237        headers
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Pure-Rust HMAC-SHA256 (private — mirrors triggers.rs implementation)
243// ---------------------------------------------------------------------------
244
245/// Compute HMAC-SHA256 of `message` keyed with `key`, returned as lowercase hex.
246fn hmac_sha256(key: &[u8], message: &[u8]) -> String {
247    const BLOCK: usize = 64;
248
249    let mut k = [0u8; BLOCK];
250    if key.len() > BLOCK {
251        let h = sha256(key);
252        k[..32].copy_from_slice(&h);
253    } else {
254        k[..key.len()].copy_from_slice(key);
255    }
256
257    let mut i_key_pad = [0u8; BLOCK];
258    let mut o_key_pad = [0u8; BLOCK];
259    for i in 0..BLOCK {
260        i_key_pad[i] = k[i] ^ 0x36;
261        o_key_pad[i] = k[i] ^ 0x5c;
262    }
263
264    let mut inner_input = Vec::with_capacity(BLOCK + message.len());
265    inner_input.extend_from_slice(&i_key_pad);
266    inner_input.extend_from_slice(message);
267    let inner_hash = sha256(&inner_input);
268
269    let mut outer_input = Vec::with_capacity(BLOCK + 32);
270    outer_input.extend_from_slice(&o_key_pad);
271    outer_input.extend_from_slice(&inner_hash);
272    let outer_hash = sha256(&outer_input);
273
274    outer_hash
275        .iter()
276        .fold(String::with_capacity(64), |mut s, b| {
277            s.push_str(&format!("{b:02x}"));
278            s
279        })
280}
281
282/// Minimal pure-Rust SHA-256 (NIST FIPS 180-4).
283#[allow(clippy::many_single_char_names)]
284fn sha256(data: &[u8]) -> [u8; 32] {
285    let mut h: [u32; 8] = [
286        0x6a09_e667,
287        0xbb67_ae85,
288        0x3c6e_f372,
289        0xa54f_f53a,
290        0x510e_527f,
291        0x9b05_688c,
292        0x1f83_d9ab,
293        0x5be0_cd19,
294    ];
295
296    const K: [u32; 64] = [
297        0x428a_2f98,
298        0x7137_4491,
299        0xb5c0_fbcf,
300        0xe9b5_dba5,
301        0x3956_c25b,
302        0x59f1_11f1,
303        0x923f_82a4,
304        0xab1c_5ed5,
305        0xd807_aa98,
306        0x1283_5b01,
307        0x2431_85be,
308        0x550c_7dc3,
309        0x72be_5d74,
310        0x80de_b1fe,
311        0x9bdc_06a7,
312        0xc19b_f174,
313        0xe49b_69c1,
314        0xefbe_4786,
315        0x0fc1_9dc6,
316        0x240c_a1cc,
317        0x2de9_2c6f,
318        0x4a74_84aa,
319        0x5cb0_a9dc,
320        0x76f9_88da,
321        0x983e_5152,
322        0xa831_c66d,
323        0xb003_27c8,
324        0xbf59_7fc7,
325        0xc6e0_0bf3,
326        0xd5a7_9147,
327        0x06ca_6351,
328        0x1429_2967,
329        0x27b7_0a85,
330        0x2e1b_2138,
331        0x4d2c_6dfc,
332        0x5338_0d13,
333        0x650a_7354,
334        0x766a_0abb,
335        0x81c2_c92e,
336        0x9272_2c85,
337        0xa2bf_e8a1,
338        0xa81a_664b,
339        0xc24b_8b70,
340        0xc76c_51a3,
341        0xd192_e819,
342        0xd699_0624,
343        0xf40e_3585,
344        0x106a_a070,
345        0x19a4_c116,
346        0x1e37_6c08,
347        0x2748_774c,
348        0x34b0_bcb5,
349        0x391c_0cb3,
350        0x4ed8_aa4a,
351        0x5b9c_ca4f,
352        0x682e_6ff3,
353        0x748f_82ee,
354        0x78a5_636f,
355        0x84c8_7814,
356        0x8cc7_0208,
357        0x90be_fffa,
358        0xa450_6ceb,
359        0xbef9_a3f7,
360        0xc671_78f2,
361    ];
362
363    let mut msg = data.to_vec();
364    let bit_len = (data.len() as u64).wrapping_mul(8);
365    msg.push(0x80);
366    while (msg.len() % 64) != 56 {
367        msg.push(0x00);
368    }
369    msg.extend_from_slice(&bit_len.to_be_bytes());
370
371    for chunk in msg.chunks(64) {
372        let mut w = [0u32; 64];
373        for (i, word_bytes) in chunk.chunks(4).enumerate().take(16) {
374            w[i] = u32::from_be_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
375        }
376        for i in 16..64 {
377            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
378            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
379            w[i] = w[i - 16]
380                .wrapping_add(s0)
381                .wrapping_add(w[i - 7])
382                .wrapping_add(s1);
383        }
384
385        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
386
387        for i in 0..64 {
388            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
389            let ch = (e & f) ^ ((!e) & g);
390            let temp1 = hh
391                .wrapping_add(s1)
392                .wrapping_add(ch)
393                .wrapping_add(K[i])
394                .wrapping_add(w[i]);
395            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
396            let maj = (a & b) ^ (a & c) ^ (b & c);
397            let temp2 = s0.wrapping_add(maj);
398
399            hh = g;
400            g = f;
401            f = e;
402            e = d.wrapping_add(temp1);
403            d = c;
404            c = b;
405            b = a;
406            a = temp1.wrapping_add(temp2);
407        }
408
409        h[0] = h[0].wrapping_add(a);
410        h[1] = h[1].wrapping_add(b);
411        h[2] = h[2].wrapping_add(c);
412        h[3] = h[3].wrapping_add(d);
413        h[4] = h[4].wrapping_add(e);
414        h[5] = h[5].wrapping_add(f);
415        h[6] = h[6].wrapping_add(g);
416        h[7] = h[7].wrapping_add(hh);
417    }
418
419    let mut digest = [0u8; 32];
420    for (i, &word) in h.iter().enumerate() {
421        digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
422    }
423    digest
424}
425
426// ---------------------------------------------------------------------------
427// Tests
428// ---------------------------------------------------------------------------
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn make_notifier(events: Vec<WebhookEvent>, secret: Option<&str>) -> WebhookNotifier {
435        WebhookNotifier::new(WebhookConfig {
436            url: "https://example.com/hooks/workflow".to_string(),
437            secret: secret.map(str::to_string),
438            events,
439            max_retries: 3,
440            timeout_ms: 5_000,
441        })
442    }
443
444    fn make_context() -> WorkflowContext {
445        WorkflowContext {
446            workflow_id: "wf-001".to_string(),
447            workflow_name: "transcode-pipeline".to_string(),
448            state: "completed".to_string(),
449            variables: HashMap::new(),
450        }
451    }
452
453    #[test]
454    fn test_webhook_event_types() {
455        assert_eq!(
456            WebhookEvent::WorkflowStarted.event_type(),
457            "workflow.started"
458        );
459        assert_eq!(
460            WebhookEvent::WorkflowCompleted.event_type(),
461            "workflow.completed"
462        );
463        assert_eq!(WebhookEvent::WorkflowFailed.event_type(), "workflow.failed");
464        assert_eq!(
465            WebhookEvent::StepCompleted {
466                step_name: "encode".to_string()
467            }
468            .event_type(),
469            "step.completed"
470        );
471        assert_eq!(
472            WebhookEvent::StepFailed {
473                step_name: "qc-check".to_string()
474            }
475            .event_type(),
476            "step.failed"
477        );
478    }
479
480    #[test]
481    fn test_webhook_event_step_name() {
482        let ev = WebhookEvent::StepCompleted {
483            step_name: "encode".to_string(),
484        };
485        assert_eq!(ev.step_name(), Some("encode"));
486        assert!(WebhookEvent::WorkflowStarted.step_name().is_none());
487    }
488
489    #[test]
490    fn test_build_payload_workflow_started() {
491        let notifier = make_notifier(vec![WebhookEvent::WorkflowStarted], None);
492        let ctx = make_context();
493        let payload = notifier.build_payload(&WebhookEvent::WorkflowStarted, &ctx);
494
495        let parsed: serde_json::Value =
496            serde_json::from_str(&payload).expect("payload must be valid JSON");
497
498        assert_eq!(parsed["event_type"], "workflow.started");
499        assert_eq!(parsed["workflow_id"], "wf-001");
500        assert_eq!(parsed["workflow_name"], "transcode-pipeline");
501        assert_eq!(parsed["state"], "completed");
502        assert!(parsed["timestamp_ms"].is_number());
503        assert!(parsed["variables"].is_object());
504        // step_name should NOT be present for workflow-level events
505        assert!(parsed.get("step_name").is_none());
506    }
507
508    #[test]
509    fn test_build_payload_step_completed_includes_step_name() {
510        let notifier = make_notifier(vec![], None);
511        let ctx = make_context();
512        let ev = WebhookEvent::StepCompleted {
513            step_name: "encode".to_string(),
514        };
515        let payload = notifier.build_payload(&ev, &ctx);
516
517        let parsed: serde_json::Value =
518            serde_json::from_str(&payload).expect("payload must be valid JSON");
519        assert_eq!(parsed["step_name"], "encode");
520        assert_eq!(parsed["event_type"], "step.completed");
521    }
522
523    #[test]
524    fn test_compute_signature_no_secret_returns_none() {
525        let notifier = make_notifier(vec![], None);
526        let sig = notifier.compute_signature("payload");
527        assert!(sig.is_none(), "no secret should produce None signature");
528    }
529
530    #[test]
531    fn test_compute_signature_with_secret_returns_64_hex_chars() {
532        let notifier = make_notifier(vec![], Some("s3cr3t"));
533        let sig = notifier.compute_signature("some payload");
534        let sig_str = sig.expect("should have signature");
535        assert_eq!(sig_str.len(), 64, "HMAC-SHA256 hex should be 64 chars");
536        assert!(
537            sig_str.chars().all(|c| c.is_ascii_hexdigit()),
538            "signature should be hex digits"
539        );
540    }
541
542    #[test]
543    fn test_compute_signature_deterministic() {
544        let notifier = make_notifier(vec![], Some("key"));
545        let sig1 = notifier.compute_signature("hello");
546        let sig2 = notifier.compute_signature("hello");
547        assert_eq!(sig1, sig2, "same input should produce same signature");
548    }
549
550    #[test]
551    fn test_compute_signature_different_payloads_differ() {
552        let notifier = make_notifier(vec![], Some("key"));
553        let sig1 = notifier.compute_signature("hello");
554        let sig2 = notifier.compute_signature("world");
555        assert_ne!(
556            sig1, sig2,
557            "different payloads should produce different signatures"
558        );
559    }
560
561    #[test]
562    fn test_should_notify_matching_event() {
563        let notifier = make_notifier(
564            vec![
565                WebhookEvent::WorkflowCompleted,
566                WebhookEvent::WorkflowFailed,
567            ],
568            None,
569        );
570        assert!(notifier.should_notify(&WebhookEvent::WorkflowCompleted));
571        assert!(notifier.should_notify(&WebhookEvent::WorkflowFailed));
572    }
573
574    #[test]
575    fn test_should_notify_non_matching_event() {
576        let notifier = make_notifier(vec![WebhookEvent::WorkflowCompleted], None);
577        assert!(!notifier.should_notify(&WebhookEvent::WorkflowStarted));
578        assert!(!notifier.should_notify(&WebhookEvent::WorkflowFailed));
579    }
580
581    #[test]
582    fn test_should_notify_step_event_matches_by_type() {
583        // Any StepCompleted event should match if StepCompleted is in the list
584        let notifier = make_notifier(
585            vec![WebhookEvent::StepCompleted {
586                step_name: "*".to_string(),
587            }],
588            None,
589        );
590        assert!(notifier.should_notify(&WebhookEvent::StepCompleted {
591            step_name: "encode".to_string()
592        }));
593        assert!(notifier.should_notify(&WebhookEvent::StepCompleted {
594            step_name: "qc".to_string()
595        }));
596    }
597
598    #[test]
599    fn test_should_notify_empty_events_returns_false() {
600        let notifier = make_notifier(vec![], None);
601        assert!(!notifier.should_notify(&WebhookEvent::WorkflowStarted));
602    }
603
604    #[test]
605    fn test_build_headers_without_secret() {
606        let notifier = make_notifier(vec![], None);
607        let headers = notifier.build_headers("payload");
608        assert_eq!(
609            headers.get("Content-Type").map(String::as_str),
610            Some("application/json")
611        );
612        assert!(!headers.contains_key("X-Hub-Signature-256"));
613    }
614
615    #[test]
616    fn test_build_headers_with_secret() {
617        let notifier = make_notifier(vec![], Some("secret"));
618        let headers = notifier.build_headers("test payload");
619        assert_eq!(
620            headers.get("Content-Type").map(String::as_str),
621            Some("application/json")
622        );
623        let sig = headers
624            .get("X-Hub-Signature-256")
625            .expect("signature header should be present");
626        assert_eq!(sig.len(), 64);
627    }
628
629    #[test]
630    fn test_build_headers_signature_matches_compute_signature() {
631        let notifier = make_notifier(vec![], Some("my-key"));
632        let payload = "test-payload";
633        let headers = notifier.build_headers(payload);
634        let expected = notifier
635            .compute_signature(payload)
636            .expect("should have sig");
637        let actual = headers
638            .get("X-Hub-Signature-256")
639            .expect("should have header");
640        assert_eq!(*actual, expected);
641    }
642
643    #[test]
644    fn test_build_payload_with_variables() {
645        let notifier = make_notifier(vec![], None);
646        let mut vars = HashMap::new();
647        vars.insert(
648            "output_path".to_string(),
649            serde_json::json!("/out/clip.mp4"),
650        );
651        vars.insert("duration_secs".to_string(), serde_json::json!(120));
652        let ctx = WorkflowContext {
653            workflow_id: "wf-42".to_string(),
654            workflow_name: "ingest".to_string(),
655            state: "running".to_string(),
656            variables: vars,
657        };
658        let payload = notifier.build_payload(&WebhookEvent::WorkflowStarted, &ctx);
659        let parsed: serde_json::Value = serde_json::from_str(&payload).expect("valid JSON");
660        assert_eq!(parsed["variables"]["output_path"], "/out/clip.mp4");
661        assert_eq!(parsed["variables"]["duration_secs"], 120);
662    }
663
664    #[test]
665    fn test_sha256_known_empty_value() {
666        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
667        let hash = sha256(b"");
668        let hex: String = hash.iter().fold(String::new(), |mut s, b| {
669            s.push_str(&format!("{b:02x}"));
670            s
671        });
672        assert_eq!(
673            hex,
674            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
675        );
676    }
677
678    #[test]
679    fn test_hmac_sha256_is_64_hex_chars() {
680        let mac = hmac_sha256(b"key", b"message");
681        assert_eq!(mac.len(), 64);
682    }
683}