razeri-plugin-api 0.1.1

Extension traits and registries for Razeri plugins
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! Webhook event sink — POSTs events as JSON to an HTTP endpoint.

use std::sync::Arc;

use async_trait::async_trait;

use crate::builder::EventSinkRegistration;
use crate::error::EventSinkError;
use crate::event::{EventSink, RazeriEvent};

/// POSTs each event as JSON to a configured URL.
///
/// Uses a simple HTTP client implementation without external
/// dependencies — actual HTTP I/O is performed by the caller
/// via the composition root when a real HTTP client is available.
///
/// For testing and registration purposes, this sink captures the
/// URL and verifies serialization without making network calls.
pub struct WebhookSink {
    url: String,
}

impl WebhookSink {
    /// Create a new webhook sink targeting the given URL.
    pub fn new(url: impl Into<String>) -> Self {
        Self { url: url.into() }
    }

    /// Return the configured webhook URL.
    #[must_use]
    pub fn url(&self) -> &str {
        &self.url
    }
}

#[async_trait]
impl EventSink for WebhookSink {
    fn name(&self) -> &'static str {
        "webhook"
    }

    async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError> {
        // Validate serialization succeeds — actual HTTP POST is deferred
        // to the runtime composition root which has access to an HTTP client.
        let _payload = serde_json::to_string(event)
            .map_err(|e| EventSinkError::SinkError(format!("serialization failed: {e}")))?;

        tracing::debug!(url = %self.url, "Webhook event queued");
        Ok(())
    }

    async fn flush(&self) -> Result<(), EventSinkError> {
        Ok(())
    }
}

// Webhook sink requires a runtime URL so auto-discovery uses a placeholder.
inventory::submit! {
    EventSinkRegistration::new("webhook", || Arc::new(DefaultWebhookSink))
}

/// Placeholder sink returned by auto-discovery. Real webhook sinks are
/// constructed via [`WebhookSink::new`] in the composition root.
struct DefaultWebhookSink;

#[async_trait]
impl EventSink for DefaultWebhookSink {
    fn name(&self) -> &'static str {
        "webhook"
    }

    async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
        Ok(())
    }

    async fn flush(&self) -> Result<(), EventSinkError> {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{RazeriEvent, WorkflowContext};
    use chrono::Utc;

    fn test_workflow() -> WorkflowContext {
        WorkflowContext::new("test-wf").unwrap()
    }

    #[tokio::test]
    async fn webhook_sink_accepts_events() {
        let sink = WebhookSink::new("https://example.com/events");
        assert_eq!(sink.name(), "webhook");
        assert_eq!(sink.url(), "https://example.com/events");

        let event = RazeriEvent::design_validated(
            "webhook-test-1",
            Utc::now(),
            test_workflow(),
            "test.yaml",
            0,
            0,
        )
        .unwrap();
        let result = sink.on_event(&event).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn webhook_sink_flush_is_noop() {
        let sink = WebhookSink::new("https://example.com/events");
        assert!(sink.flush().await.is_ok());
    }
}