stasis-rs 0.2.1

Durable AI orchestration framework with runtime jobs, lineage, and memory integration
Documentation
use async_trait::async_trait;
use serde::Serialize;

use crate::domain::errors::{Result, StasisError};
use crate::domain::runtime::delivery_endpoint::{DeliveryEndpoint, DeliveryProtocol};
use crate::domain::runtime::outbox::{OutboxEvent, RuntimeEventType};
use crate::ports::outbound::runtime::endpoint_transport_publisher::EndpointTransportPublisher;
use crate::ports::outbound::runtime::event_publisher::EventPublisher;

#[derive(Clone)]
pub struct HttpWebhookEventPublisher {
    client: reqwest::Client,
    endpoint_url: String,
    authorization_bearer: Option<String>,
}

impl HttpWebhookEventPublisher {
    pub fn new(endpoint_url: impl Into<String>) -> Self {
        Self {
            client: reqwest::Client::new(),
            endpoint_url: endpoint_url.into(),
            authorization_bearer: None,
        }
    }

    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
        self.authorization_bearer = Some(token.into());
        self
    }

    async fn publish_to_url(&self, endpoint_url: &str, event: &OutboxEvent) -> Result<()> {
        let endpoint_url = validate_webhook_target(endpoint_url)?;
        let payload = WebhookRuntimeEvent::from(event);
        let mut request = self.client.post(endpoint_url).json(&payload);

        if let Some(token) = &self.authorization_bearer {
            request = request.bearer_auth(token);
        }

        let response = request.send().await.map_err(|e| {
            StasisError::PortFailure(format!("publish webhook request failed: {e}"))
        })?;

        if !response.status().is_success() {
            return Err(StasisError::PortFailure(format!(
                "publish webhook returned non-success status: {}",
                response.status()
            )));
        }

        Ok(())
    }
}

fn validate_webhook_target(target: &str) -> Result<reqwest::Url> {
    let trimmed = target.trim();
    if trimmed.is_empty() {
        return Err(StasisError::PortFailure(
            "webhook target policy rejected: target must not be empty".to_string(),
        ));
    }

    let url = reqwest::Url::parse(trimmed).map_err(|e| {
        StasisError::PortFailure(format!(
            "webhook target policy rejected: target must be an absolute URL ({e})"
        ))
    })?;

    if !matches!(url.scheme(), "http" | "https") {
        return Err(StasisError::PortFailure(format!(
            "webhook target policy rejected: unsupported scheme '{}'",
            url.scheme()
        )));
    }

    if url.host_str().is_none() {
        return Err(StasisError::PortFailure(
            "webhook target policy rejected: target must include host".to_string(),
        ));
    }

    Ok(url)
}

#[derive(Clone)]
pub struct HttpWebhookTransportPublisher {
    client: reqwest::Client,
    authorization_bearer: Option<String>,
}

impl HttpWebhookTransportPublisher {
    pub fn new() -> Self {
        Self {
            client: reqwest::Client::new(),
            authorization_bearer: None,
        }
    }

    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
        self.authorization_bearer = Some(token.into());
        self
    }
}

impl Default for HttpWebhookTransportPublisher {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl EndpointTransportPublisher for HttpWebhookTransportPublisher {
    fn supports(&self, protocol: &DeliveryProtocol) -> bool {
        matches!(protocol, DeliveryProtocol::HttpWebhook)
    }

    async fn publish_to_endpoint(
        &self,
        endpoint: &DeliveryEndpoint,
        event: &OutboxEvent,
    ) -> Result<()> {
        let publisher = HttpWebhookEventPublisher {
            client: self.client.clone(),
            endpoint_url: endpoint.target.clone(),
            authorization_bearer: self.authorization_bearer.clone(),
        };

        publisher.publish_to_url(&endpoint.target, event).await
    }
}

#[derive(Debug, Serialize)]
struct WebhookRuntimeEvent {
    event_id: String,
    event_type: &'static str,
    job_id: String,
    thread_id: Option<String>,
    correlation_id: String,
    causation_id: String,
    trace_id: String,
    sttp_input_node_id: String,
    sttp_output_node_id: Option<String>,
    execution_id: Option<String>,
    occurred_at: String,
    message: Option<String>,
}

impl From<&OutboxEvent> for WebhookRuntimeEvent {
    fn from(value: &OutboxEvent) -> Self {
        let event_type = match value.event.event_type {
            RuntimeEventType::JobSucceeded => "job_succeeded",
            RuntimeEventType::JobRetryScheduled => "job_retry_scheduled",
            RuntimeEventType::JobDeadLettered => "job_dead_lettered",
        };

        Self {
            event_id: value.event_id.clone(),
            event_type,
            job_id: value.event.job_id.clone(),
            thread_id: value.event.thread_id.clone(),
            correlation_id: value.event.correlation_id.clone(),
            causation_id: value.event.causation_id.clone(),
            trace_id: value.event.trace_id.clone(),
            sttp_input_node_id: value.event.sttp_input_node_id.clone(),
            sttp_output_node_id: value.event.sttp_output_node_id.clone(),
            execution_id: value.event.execution_id.clone(),
            occurred_at: value.event.occurred_at.to_rfc3339(),
            message: value.event.message.clone(),
        }
    }
}

#[async_trait]
impl EventPublisher for HttpWebhookEventPublisher {
    async fn publish(&self, event: &OutboxEvent) -> Result<()> {
        self.publish_to_url(&self.endpoint_url, event).await
    }
}

#[cfg(test)]
mod tests {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    use chrono::Utc;

    use crate::domain::errors::StasisError;
    use crate::domain::runtime::outbox::{
        OutboxEvent, OutboxStatus, RuntimeEvent, RuntimeEventType,
    };
    use crate::ports::outbound::runtime::event_publisher::EventPublisher;

    use super::{HttpWebhookEventPublisher, WebhookRuntimeEvent, validate_webhook_target};

    fn sample_event() -> OutboxEvent {
        OutboxEvent {
            event_id: "evt-1".to_string(),
            status: OutboxStatus::Pending,
            publish_attempts: 0,
            published_at: None,
            next_attempt_at: None,
            last_publish_error: None,
            event: RuntimeEvent {
                event_type: RuntimeEventType::JobSucceeded,
                job_id: "job-1".to_string(),
                thread_id: Some("thread-1".to_string()),
                correlation_id: "corr-1".to_string(),
                causation_id: "cause-1".to_string(),
                trace_id: "trace-1".to_string(),
                sttp_input_node_id: "sttp:in:1".to_string(),
                sttp_output_node_id: Some("sttp:out:1".to_string()),
                execution_id: Some("exec-1".to_string()),
                input_memory_query_id: None,
                input_memory_query_fingerprint: None,
                output_memory_node_id: None,
                retrieval_path: None,
                occurred_at: Utc::now(),
                message: Some("ok".to_string()),
            },
        }
    }

    async fn spawn_webhook_server(
        expected_auth: Option<&'static str>,
        success_status: &'static str,
    ) -> (String, tokio::task::JoinHandle<Option<String>>) {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("listener should bind");
        let addr = listener.local_addr().expect("listener should have local addr");

        let server_task = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.expect("socket should accept");

            let mut request = Vec::new();
            let mut buf = [0_u8; 1024];
            loop {
                let read = socket.read(&mut buf).await.expect("socket should read");
                if read == 0 {
                    break;
                }
                request.extend_from_slice(&buf[..read]);
                if request.windows(4).any(|window| window == b"\r\n\r\n") {
                    break;
                }
            }

            let request_text = String::from_utf8_lossy(&request);
            let auth_header = request_text.lines().find_map(|line| {
                let (name, value) = line.split_once(':')?;
                if name.eq_ignore_ascii_case("authorization") {
                    Some(value.trim().to_string())
                } else {
                    None
                }
            });

            let status_line = if let Some(expected) = expected_auth {
                if auth_header.as_deref() == Some(expected) {
                    "200 OK"
                } else {
                    "401 Unauthorized"
                }
            } else {
                success_status
            };

            let response = format!(
                "HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
            );
            socket
                .write_all(response.as_bytes())
                .await
                .expect("response should write");

            auth_header
        });

        (format!("http://{addr}"), server_task)
    }

    #[test]
    fn maps_outbox_event_to_webhook_payload() {
        let event = sample_event();

        let payload = WebhookRuntimeEvent::from(&event);
        assert_eq!(payload.event_id, "evt-1");
        assert_eq!(payload.event_type, "job_succeeded");
        assert_eq!(payload.job_id, "job-1");
        assert_eq!(payload.execution_id.as_deref(), Some("exec-1"));
    }

    #[tokio::test]
    async fn publish_includes_bearer_header_when_configured() {
        let (endpoint_url, server_task) = spawn_webhook_server(Some("Bearer test-token"), "200 OK").await;
        let event = sample_event();
        let publisher = HttpWebhookEventPublisher::new(endpoint_url).with_bearer_token("test-token");

        let result = publisher.publish(&event).await;
        assert!(result.is_ok(), "publish should succeed with valid auth header");

        let auth_header = server_task.await.expect("server task should complete");
        assert_eq!(auth_header.as_deref(), Some("Bearer test-token"));
    }

    #[tokio::test]
    async fn publish_fails_closed_when_auth_is_required_but_missing() {
        let (endpoint_url, _server_task) =
            spawn_webhook_server(Some("Bearer required-token"), "200 OK").await;
        let event = sample_event();
        let publisher = HttpWebhookEventPublisher::new(endpoint_url);

        let result = publisher.publish(&event).await;
        let Err(StasisError::PortFailure(message)) = result else {
            panic!("expected publish failure for missing auth header");
        };
        assert!(
            message.contains("non-success status: 401 Unauthorized"),
            "unexpected error message: {message}"
        );
    }

    #[tokio::test]
    async fn publish_fails_on_non_success_status() {
        let (endpoint_url, _server_task) = spawn_webhook_server(None, "503 Service Unavailable").await;
        let event = sample_event();
        let publisher = HttpWebhookEventPublisher::new(endpoint_url);

        let result = publisher.publish(&event).await;
        let Err(StasisError::PortFailure(message)) = result else {
            panic!("expected publish failure for non-success status");
        };
        assert!(
            message.contains("non-success status: 503 Service Unavailable"),
            "unexpected error message: {message}"
        );
    }

    #[tokio::test]
    async fn publish_fails_when_endpoint_is_unreachable() {
        let event = sample_event();
        let publisher = HttpWebhookEventPublisher::new("http://127.0.0.1:1");

        let result = publisher.publish(&event).await;
        let Err(StasisError::PortFailure(message)) = result else {
            panic!("expected publish failure for unreachable endpoint");
        };
        assert!(
            message.contains("publish webhook request failed"),
            "unexpected error message: {message}"
        );
    }

    #[test]
    fn target_policy_accepts_http_and_https_absolute_urls() {
        assert!(validate_webhook_target("https://example.com/hook").is_ok());
        assert!(validate_webhook_target("http://example.com/hook").is_ok());
    }

    #[test]
    fn target_policy_rejects_non_http_schemes() {
        let result = validate_webhook_target("tcp://example.com:9000");
        let Err(StasisError::PortFailure(message)) = result else {
            panic!("expected policy rejection for non-http scheme");
        };
        assert!(
            message.contains("unsupported scheme 'tcp'"),
            "unexpected error message: {message}"
        );
    }

    #[test]
    fn target_policy_rejects_non_absolute_urls() {
        let result = validate_webhook_target("/relative/hook");
        let Err(StasisError::PortFailure(message)) = result else {
            panic!("expected policy rejection for non-absolute URL");
        };
        assert!(
            message.contains("target must be an absolute URL"),
            "unexpected error message: {message}"
        );
    }
}