Skip to main content

stasis/infrastructure/runtime/
http_webhook_event_publisher.rs

1use async_trait::async_trait;
2use serde::Serialize;
3
4use crate::domain::errors::{Result, StasisError};
5use crate::domain::runtime::delivery_endpoint::{DeliveryEndpoint, DeliveryProtocol};
6use crate::domain::runtime::outbox::{OutboxEvent, RuntimeEventType};
7use crate::ports::outbound::runtime::endpoint_transport_publisher::EndpointTransportPublisher;
8use crate::ports::outbound::runtime::event_publisher::EventPublisher;
9
10#[derive(Clone)]
11pub struct HttpWebhookEventPublisher {
12    client: reqwest::Client,
13    endpoint_url: String,
14    authorization_bearer: Option<String>,
15}
16
17impl HttpWebhookEventPublisher {
18    pub fn new(endpoint_url: impl Into<String>) -> Self {
19        Self {
20            client: reqwest::Client::new(),
21            endpoint_url: endpoint_url.into(),
22            authorization_bearer: None,
23        }
24    }
25
26    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
27        self.authorization_bearer = Some(token.into());
28        self
29    }
30
31    async fn publish_to_url(&self, endpoint_url: &str, event: &OutboxEvent) -> Result<()> {
32        let endpoint_url = validate_webhook_target(endpoint_url)?;
33        let payload = WebhookRuntimeEvent::from(event);
34        let mut request = self.client.post(endpoint_url).json(&payload);
35
36        if let Some(token) = &self.authorization_bearer {
37            request = request.bearer_auth(token);
38        }
39
40        let response = request.send().await.map_err(|e| {
41            StasisError::PortFailure(format!("publish webhook request failed: {e}"))
42        })?;
43
44        if !response.status().is_success() {
45            return Err(StasisError::PortFailure(format!(
46                "publish webhook returned non-success status: {}",
47                response.status()
48            )));
49        }
50
51        Ok(())
52    }
53}
54
55fn validate_webhook_target(target: &str) -> Result<reqwest::Url> {
56    let trimmed = target.trim();
57    if trimmed.is_empty() {
58        return Err(StasisError::PortFailure(
59            "webhook target policy rejected: target must not be empty".to_string(),
60        ));
61    }
62
63    let url = reqwest::Url::parse(trimmed).map_err(|e| {
64        StasisError::PortFailure(format!(
65            "webhook target policy rejected: target must be an absolute URL ({e})"
66        ))
67    })?;
68
69    if !matches!(url.scheme(), "http" | "https") {
70        return Err(StasisError::PortFailure(format!(
71            "webhook target policy rejected: unsupported scheme '{}'",
72            url.scheme()
73        )));
74    }
75
76    if url.host_str().is_none() {
77        return Err(StasisError::PortFailure(
78            "webhook target policy rejected: target must include host".to_string(),
79        ));
80    }
81
82    Ok(url)
83}
84
85#[derive(Clone)]
86pub struct HttpWebhookTransportPublisher {
87    client: reqwest::Client,
88    authorization_bearer: Option<String>,
89}
90
91impl HttpWebhookTransportPublisher {
92    pub fn new() -> Self {
93        Self {
94            client: reqwest::Client::new(),
95            authorization_bearer: None,
96        }
97    }
98
99    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
100        self.authorization_bearer = Some(token.into());
101        self
102    }
103}
104
105impl Default for HttpWebhookTransportPublisher {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111#[async_trait]
112impl EndpointTransportPublisher for HttpWebhookTransportPublisher {
113    fn supports(&self, protocol: &DeliveryProtocol) -> bool {
114        matches!(protocol, DeliveryProtocol::HttpWebhook)
115    }
116
117    async fn publish_to_endpoint(
118        &self,
119        endpoint: &DeliveryEndpoint,
120        event: &OutboxEvent,
121    ) -> Result<()> {
122        let publisher = HttpWebhookEventPublisher {
123            client: self.client.clone(),
124            endpoint_url: endpoint.target.clone(),
125            authorization_bearer: self.authorization_bearer.clone(),
126        };
127
128        publisher.publish_to_url(&endpoint.target, event).await
129    }
130}
131
132#[derive(Debug, Serialize)]
133struct WebhookRuntimeEvent {
134    event_id: String,
135    event_type: &'static str,
136    job_id: String,
137    thread_id: Option<String>,
138    correlation_id: String,
139    causation_id: String,
140    trace_id: String,
141    sttp_input_node_id: String,
142    sttp_output_node_id: Option<String>,
143    execution_id: Option<String>,
144    occurred_at: String,
145    message: Option<String>,
146}
147
148impl From<&OutboxEvent> for WebhookRuntimeEvent {
149    fn from(value: &OutboxEvent) -> Self {
150        let event_type = match value.event.event_type {
151            RuntimeEventType::JobSucceeded => "job_succeeded",
152            RuntimeEventType::JobRetryScheduled => "job_retry_scheduled",
153            RuntimeEventType::JobDeadLettered => "job_dead_lettered",
154        };
155
156        Self {
157            event_id: value.event_id.clone(),
158            event_type,
159            job_id: value.event.job_id.clone(),
160            thread_id: value.event.thread_id.clone(),
161            correlation_id: value.event.correlation_id.clone(),
162            causation_id: value.event.causation_id.clone(),
163            trace_id: value.event.trace_id.clone(),
164            sttp_input_node_id: value.event.sttp_input_node_id.clone(),
165            sttp_output_node_id: value.event.sttp_output_node_id.clone(),
166            execution_id: value.event.execution_id.clone(),
167            occurred_at: value.event.occurred_at.to_rfc3339(),
168            message: value.event.message.clone(),
169        }
170    }
171}
172
173#[async_trait]
174impl EventPublisher for HttpWebhookEventPublisher {
175    async fn publish(&self, event: &OutboxEvent) -> Result<()> {
176        self.publish_to_url(&self.endpoint_url, event).await
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use tokio::io::{AsyncReadExt, AsyncWriteExt};
183    use tokio::net::TcpListener;
184
185    use chrono::Utc;
186
187    use crate::domain::errors::StasisError;
188    use crate::domain::runtime::outbox::{
189        OutboxEvent, OutboxStatus, RuntimeEvent, RuntimeEventType,
190    };
191    use crate::ports::outbound::runtime::event_publisher::EventPublisher;
192
193    use super::{HttpWebhookEventPublisher, WebhookRuntimeEvent, validate_webhook_target};
194
195    fn sample_event() -> OutboxEvent {
196        OutboxEvent {
197            event_id: "evt-1".to_string(),
198            status: OutboxStatus::Pending,
199            publish_attempts: 0,
200            published_at: None,
201            next_attempt_at: None,
202            last_publish_error: None,
203            event: RuntimeEvent {
204                event_type: RuntimeEventType::JobSucceeded,
205                job_id: "job-1".to_string(),
206                thread_id: Some("thread-1".to_string()),
207                correlation_id: "corr-1".to_string(),
208                causation_id: "cause-1".to_string(),
209                trace_id: "trace-1".to_string(),
210                sttp_input_node_id: "sttp:in:1".to_string(),
211                sttp_output_node_id: Some("sttp:out:1".to_string()),
212                execution_id: Some("exec-1".to_string()),
213                input_memory_query_id: None,
214                input_memory_query_fingerprint: None,
215                output_memory_node_id: None,
216                retrieval_path: None,
217                occurred_at: Utc::now(),
218                message: Some("ok".to_string()),
219            },
220        }
221    }
222
223    async fn spawn_webhook_server(
224        expected_auth: Option<&'static str>,
225        success_status: &'static str,
226    ) -> (String, tokio::task::JoinHandle<Option<String>>) {
227        let listener = TcpListener::bind("127.0.0.1:0")
228            .await
229            .expect("listener should bind");
230        let addr = listener.local_addr().expect("listener should have local addr");
231
232        let server_task = tokio::spawn(async move {
233            let (mut socket, _) = listener.accept().await.expect("socket should accept");
234
235            let mut request = Vec::new();
236            let mut buf = [0_u8; 1024];
237            loop {
238                let read = socket.read(&mut buf).await.expect("socket should read");
239                if read == 0 {
240                    break;
241                }
242                request.extend_from_slice(&buf[..read]);
243                if request.windows(4).any(|window| window == b"\r\n\r\n") {
244                    break;
245                }
246            }
247
248            let request_text = String::from_utf8_lossy(&request);
249            let auth_header = request_text.lines().find_map(|line| {
250                let (name, value) = line.split_once(':')?;
251                if name.eq_ignore_ascii_case("authorization") {
252                    Some(value.trim().to_string())
253                } else {
254                    None
255                }
256            });
257
258            let status_line = if let Some(expected) = expected_auth {
259                if auth_header.as_deref() == Some(expected) {
260                    "200 OK"
261                } else {
262                    "401 Unauthorized"
263                }
264            } else {
265                success_status
266            };
267
268            let response = format!(
269                "HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
270            );
271            socket
272                .write_all(response.as_bytes())
273                .await
274                .expect("response should write");
275
276            auth_header
277        });
278
279        (format!("http://{addr}"), server_task)
280    }
281
282    #[test]
283    fn maps_outbox_event_to_webhook_payload() {
284        let event = sample_event();
285
286        let payload = WebhookRuntimeEvent::from(&event);
287        assert_eq!(payload.event_id, "evt-1");
288        assert_eq!(payload.event_type, "job_succeeded");
289        assert_eq!(payload.job_id, "job-1");
290        assert_eq!(payload.execution_id.as_deref(), Some("exec-1"));
291    }
292
293    #[tokio::test]
294    async fn publish_includes_bearer_header_when_configured() {
295        let (endpoint_url, server_task) = spawn_webhook_server(Some("Bearer test-token"), "200 OK").await;
296        let event = sample_event();
297        let publisher = HttpWebhookEventPublisher::new(endpoint_url).with_bearer_token("test-token");
298
299        let result = publisher.publish(&event).await;
300        assert!(result.is_ok(), "publish should succeed with valid auth header");
301
302        let auth_header = server_task.await.expect("server task should complete");
303        assert_eq!(auth_header.as_deref(), Some("Bearer test-token"));
304    }
305
306    #[tokio::test]
307    async fn publish_fails_closed_when_auth_is_required_but_missing() {
308        let (endpoint_url, _server_task) =
309            spawn_webhook_server(Some("Bearer required-token"), "200 OK").await;
310        let event = sample_event();
311        let publisher = HttpWebhookEventPublisher::new(endpoint_url);
312
313        let result = publisher.publish(&event).await;
314        let Err(StasisError::PortFailure(message)) = result else {
315            panic!("expected publish failure for missing auth header");
316        };
317        assert!(
318            message.contains("non-success status: 401 Unauthorized"),
319            "unexpected error message: {message}"
320        );
321    }
322
323    #[tokio::test]
324    async fn publish_fails_on_non_success_status() {
325        let (endpoint_url, _server_task) = spawn_webhook_server(None, "503 Service Unavailable").await;
326        let event = sample_event();
327        let publisher = HttpWebhookEventPublisher::new(endpoint_url);
328
329        let result = publisher.publish(&event).await;
330        let Err(StasisError::PortFailure(message)) = result else {
331            panic!("expected publish failure for non-success status");
332        };
333        assert!(
334            message.contains("non-success status: 503 Service Unavailable"),
335            "unexpected error message: {message}"
336        );
337    }
338
339    #[tokio::test]
340    async fn publish_fails_when_endpoint_is_unreachable() {
341        let event = sample_event();
342        let publisher = HttpWebhookEventPublisher::new("http://127.0.0.1:1");
343
344        let result = publisher.publish(&event).await;
345        let Err(StasisError::PortFailure(message)) = result else {
346            panic!("expected publish failure for unreachable endpoint");
347        };
348        assert!(
349            message.contains("publish webhook request failed"),
350            "unexpected error message: {message}"
351        );
352    }
353
354    #[test]
355    fn target_policy_accepts_http_and_https_absolute_urls() {
356        assert!(validate_webhook_target("https://example.com/hook").is_ok());
357        assert!(validate_webhook_target("http://example.com/hook").is_ok());
358    }
359
360    #[test]
361    fn target_policy_rejects_non_http_schemes() {
362        let result = validate_webhook_target("tcp://example.com:9000");
363        let Err(StasisError::PortFailure(message)) = result else {
364            panic!("expected policy rejection for non-http scheme");
365        };
366        assert!(
367            message.contains("unsupported scheme 'tcp'"),
368            "unexpected error message: {message}"
369        );
370    }
371
372    #[test]
373    fn target_policy_rejects_non_absolute_urls() {
374        let result = validate_webhook_target("/relative/hook");
375        let Err(StasisError::PortFailure(message)) = result else {
376            panic!("expected policy rejection for non-absolute URL");
377        };
378        assert!(
379            message.contains("target must be an absolute URL"),
380            "unexpected error message: {message}"
381        );
382    }
383}