Skip to main content

razeri_plugin_api/sinks/
webhook.rs

1//! Webhook event sink — POSTs events as JSON to an HTTP endpoint.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6
7use crate::builder::EventSinkRegistration;
8use crate::error::EventSinkError;
9use crate::event::{EventSink, RazeriEvent};
10
11/// POSTs each event as JSON to a configured URL.
12///
13/// Uses a simple HTTP client implementation without external
14/// dependencies — actual HTTP I/O is performed by the caller
15/// via the composition root when a real HTTP client is available.
16///
17/// For testing and registration purposes, this sink captures the
18/// URL and verifies serialization without making network calls.
19pub struct WebhookSink {
20    url: String,
21}
22
23impl WebhookSink {
24    /// Create a new webhook sink targeting the given URL.
25    pub fn new(url: impl Into<String>) -> Self {
26        Self { url: url.into() }
27    }
28
29    /// Return the configured webhook URL.
30    #[must_use]
31    pub fn url(&self) -> &str {
32        &self.url
33    }
34}
35
36#[async_trait]
37impl EventSink for WebhookSink {
38    fn name(&self) -> &'static str {
39        "webhook"
40    }
41
42    async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError> {
43        // Validate serialization succeeds — actual HTTP POST is deferred
44        // to the runtime composition root which has access to an HTTP client.
45        let _payload = serde_json::to_string(event)
46            .map_err(|e| EventSinkError::SinkError(format!("serialization failed: {e}")))?;
47
48        tracing::debug!(url = %self.url, "Webhook event queued");
49        Ok(())
50    }
51
52    async fn flush(&self) -> Result<(), EventSinkError> {
53        Ok(())
54    }
55}
56
57// Webhook sink requires a runtime URL so auto-discovery uses a placeholder.
58inventory::submit! {
59    EventSinkRegistration::new("webhook", || Arc::new(DefaultWebhookSink))
60}
61
62/// Placeholder sink returned by auto-discovery. Real webhook sinks are
63/// constructed via [`WebhookSink::new`] in the composition root.
64struct DefaultWebhookSink;
65
66#[async_trait]
67impl EventSink for DefaultWebhookSink {
68    fn name(&self) -> &'static str {
69        "webhook"
70    }
71
72    async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
73        Ok(())
74    }
75
76    async fn flush(&self) -> Result<(), EventSinkError> {
77        Ok(())
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::event::{RazeriEvent, WorkflowContext};
85    use chrono::Utc;
86
87    fn test_workflow() -> WorkflowContext {
88        WorkflowContext::new("test-wf").unwrap()
89    }
90
91    #[tokio::test]
92    async fn webhook_sink_accepts_events() {
93        let sink = WebhookSink::new("https://example.com/events");
94        assert_eq!(sink.name(), "webhook");
95        assert_eq!(sink.url(), "https://example.com/events");
96
97        let event = RazeriEvent::design_validated(
98            "webhook-test-1",
99            Utc::now(),
100            test_workflow(),
101            "test.yaml",
102            0,
103            0,
104        )
105        .unwrap();
106        let result = sink.on_event(&event).await;
107        assert!(result.is_ok());
108    }
109
110    #[tokio::test]
111    async fn webhook_sink_flush_is_noop() {
112        let sink = WebhookSink::new("https://example.com/events");
113        assert!(sink.flush().await.is_ok());
114    }
115}