stasis/infrastructure/runtime/
http_webhook_event_publisher.rs1use 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}