Skip to main content

razeri_plugin_api/sinks/
webhook.rs

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