stygian_graph/ports/webhook.rs
1//! Webhook trigger port — accept inbound HTTP requests that start pipelines.
2//!
3//! Defines the [`WebhookTrigger`](crate::ports::webhook::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}