Skip to main content

stygian_graph/ports/
webhook.rs

1//! Webhook trigger port — accept inbound HTTP requests that start pipelines.
2//!
3//! Defines the [`WebhookTrigger`] trait and associated types.  The port contains
4//! **zero** infrastructure dependencies: adapters (e.g. axum, actix) implement
5//! the trait with real HTTP servers.
6//!
7//! # Architecture
8//!
9//! ```text
10//! External service ──POST──▶ WebhookTrigger adapter
11//!                                │
12//!                                ▼
13//!                          WebhookEvent
14//!                                │
15//!                      Application layer decides
16//!                      which pipeline to execute
17//! ```
18
19use crate::domain::error::Result;
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24// ─────────────────────────────────────────────────────────────────────────────
25// Domain types
26// ─────────────────────────────────────────────────────────────────────────────
27
28/// An inbound webhook event received by the trigger listener.
29///
30/// Contains enough context for the application layer to decide which pipeline
31/// to execute and with what input.
32///
33/// # Example
34///
35/// ```
36/// use stygian_graph::ports::webhook::WebhookEvent;
37///
38/// let event = WebhookEvent {
39///     method: "POST".into(),
40///     path: "/hooks/github".into(),
41///     headers: [("content-type".into(), "application/json".into())].into(),
42///     body: r#"{"action":"push"}"#.into(),
43///     received_at_ms: 1_700_000_000_000,
44///     signature: Some("sha256=abc123".into()),
45///     source_ip: Some("203.0.113.1".into()),
46/// };
47/// assert_eq!(event.method, "POST");
48/// ```
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct WebhookEvent {
51    /// HTTP method (e.g. `POST`, `PUT`).
52    pub method: String,
53    /// Request path (e.g. `/hooks/github`).
54    pub path: String,
55    /// Filtered HTTP headers (lowercase keys).
56    pub headers: HashMap<String, String>,
57    /// Request body as a UTF-8 string.
58    pub body: String,
59    /// Unix timestamp (milliseconds) when the event was received.
60    pub received_at_ms: u64,
61    /// Optional webhook signature header value (e.g. `sha256=...`).
62    pub signature: Option<String>,
63    /// Optional source IP address.
64    pub source_ip: Option<String>,
65}
66
67/// Configuration for a webhook trigger listener.
68///
69/// # Example
70///
71/// ```
72/// use stygian_graph::ports::webhook::WebhookConfig;
73///
74/// let config = WebhookConfig {
75///     bind_address: "0.0.0.0:9090".into(),
76///     path_prefix: "/webhooks".into(),
77///     secret: Some("my-hmac-secret".into()),
78///     max_body_size: 1_048_576,
79/// };
80/// assert_eq!(config.max_body_size, 1_048_576);
81/// ```
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WebhookConfig {
84    /// Socket address to bind the listener to (e.g. `"0.0.0.0:9090"`).
85    pub bind_address: String,
86    /// URL path prefix for webhook routes (e.g. `"/webhooks"`).
87    pub path_prefix: String,
88    /// Optional shared secret for HMAC-SHA256 signature verification.
89    /// When set, requests without a valid signature are rejected.
90    pub secret: Option<String>,
91    /// Maximum request body size in bytes (default 1 MiB).
92    pub max_body_size: usize,
93}
94
95impl Default for WebhookConfig {
96    fn default() -> Self {
97        Self {
98            bind_address: "0.0.0.0:9090".into(),
99            path_prefix: "/webhooks".into(),
100            secret: None,
101            max_body_size: 1_048_576, // 1 MiB
102        }
103    }
104}
105
106/// Handle returned by [`WebhookTrigger::start_listener`] for managing the
107/// listener lifecycle.
108///
109/// Dropping the handle does **not** stop the listener — call
110/// [`WebhookTrigger::stop_listener`] explicitly for graceful shutdown.
111pub struct WebhookListenerHandle {
112    /// Opaque identifier for the running listener.
113    pub id: String,
114}
115
116// ─────────────────────────────────────────────────────────────────────────────
117// Port trait
118// ─────────────────────────────────────────────────────────────────────────────
119
120/// Port: accept inbound webhooks and emit [`WebhookEvent`]s.
121///
122/// Implementations bind an HTTP listener, verify signatures, enforce body-size
123/// limits, and forward valid events to registered callbacks.  The application
124/// layer maps events to pipeline executions.
125///
126/// All methods are `async` and implementations must be `Send + Sync + 'static`.
127#[async_trait]
128pub trait WebhookTrigger: Send + Sync + 'static {
129    /// Start the HTTP listener with the given configuration.
130    ///
131    /// Returns a [`WebhookListenerHandle`] that can be passed to
132    /// [`stop_listener`](Self::stop_listener) for graceful shutdown.
133    async fn start_listener(&self, config: WebhookConfig) -> Result<WebhookListenerHandle>;
134
135    /// Gracefully stop the listener identified by `handle`.
136    ///
137    /// In-flight requests should be drained before the listener shuts down.
138    async fn stop_listener(&self, handle: WebhookListenerHandle) -> Result<()>;
139
140    /// Wait for the next webhook event.
141    ///
142    /// Blocks until an event is received or the listener is stopped (returns
143    /// `Ok(None)` in the latter case).
144    async fn recv_event(&self) -> Result<Option<WebhookEvent>>;
145
146    /// Verify the HMAC-SHA256 signature for a webhook payload.
147    ///
148    /// Returns `true` if the signature is valid, `false` if it is invalid,
149    /// and `Ok(true)` if no secret is configured (verification is skipped).
150    ///
151    /// # Arguments
152    ///
153    /// * `secret` — The shared HMAC secret.
154    /// * `signature` — The signature header value (e.g. `sha256=<hex>`).
155    /// * `body` — The raw request body bytes.
156    fn verify_signature(&self, secret: &str, signature: &str, body: &[u8]) -> bool;
157}
158
159// ─────────────────────────────────────────────────────────────────────────────
160// Tests
161// ─────────────────────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_webhook_event_creation() {
169        let event = WebhookEvent {
170            method: "POST".into(),
171            path: "/hooks/github".into(),
172            headers: HashMap::new(),
173            body: r#"{"ref":"refs/heads/main"}"#.into(),
174            received_at_ms: 1_700_000_000_000,
175            signature: Some("sha256=abc".into()),
176            source_ip: None,
177        };
178        assert_eq!(event.method, "POST");
179        assert_eq!(event.path, "/hooks/github");
180        assert!(event.signature.is_some());
181    }
182
183    #[test]
184    fn test_webhook_config_default() {
185        let cfg = WebhookConfig::default();
186        assert_eq!(cfg.bind_address, "0.0.0.0:9090");
187        assert_eq!(cfg.path_prefix, "/webhooks");
188        assert!(cfg.secret.is_none());
189        assert_eq!(cfg.max_body_size, 1_048_576);
190    }
191
192    #[test]
193    fn test_webhook_event_serialisation() {
194        let event = WebhookEvent {
195            method: "POST".into(),
196            path: "/trigger".into(),
197            headers: [("x-hub-signature-256".into(), "sha256=abc".into())].into(),
198            body: "{}".into(),
199            received_at_ms: 0,
200            signature: None,
201            source_ip: Some("127.0.0.1".into()),
202        };
203        let json = serde_json::to_string(&event).unwrap();
204        let back: WebhookEvent = serde_json::from_str(&json).unwrap();
205        assert_eq!(back.method, "POST");
206        assert_eq!(back.source_ip.as_deref(), Some("127.0.0.1"));
207    }
208
209    #[test]
210    fn test_webhook_config_with_secret() {
211        let cfg = WebhookConfig {
212            secret: Some("my-secret".into()),
213            ..Default::default()
214        };
215        assert_eq!(cfg.secret.as_deref(), Some("my-secret"));
216    }
217}