Skip to main content

ironflow_api/routes/
events.rs

1//! SSE endpoint for real-time event streaming.
2
3use std::convert::Infallible;
4use std::str::FromStr;
5use std::time::Duration;
6
7use axum::extract::{Query, State};
8use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
9use futures_util::stream::{Stream, StreamExt};
10use serde::Deserialize;
11use serde::de::{self, Deserializer};
12use tokio_stream::wrappers::BroadcastStream;
13use uuid::Uuid;
14
15use crate::state::AppState;
16use ironflow_auth::extractor::Authenticated;
17use ironflow_engine::notify::Event;
18
19pub use ironflow_store::entities::EventKind;
20
21/// Deserialize a comma-separated string into `Option<Vec<EventKind>>`.
22fn deserialize_comma_event_kinds<'de, D>(
23    deserializer: D,
24) -> Result<Option<Vec<EventKind>>, D::Error>
25where
26    D: Deserializer<'de>,
27{
28    let opt: Option<String> = Option::deserialize(deserializer)?;
29    match opt {
30        None => Ok(None),
31        Some(raw) => {
32            let kinds: Result<Vec<EventKind>, _> = raw
33                .split(',')
34                .map(|s| s.trim())
35                .filter(|s| !s.is_empty())
36                .map(EventKind::from_str)
37                .collect();
38            kinds.map(Some).map_err(de::Error::custom)
39        }
40    }
41}
42
43/// Query parameters for the SSE events endpoint.
44///
45/// Both fields are optional. When set, only matching events are streamed.
46///
47/// # Examples
48///
49/// ```
50/// use ironflow_api::routes::events::EventsQuery;
51///
52/// let query = EventsQuery {
53///     run_id: None,
54///     types: None,
55/// };
56/// ```
57#[derive(Debug, Deserialize)]
58pub struct EventsQuery {
59    /// Only stream events related to this run.
60    pub run_id: Option<Uuid>,
61    /// Comma-separated list of event types to include (e.g. `?types=run_status_changed,step_completed`).
62    #[serde(default, deserialize_with = "deserialize_comma_event_kinds")]
63    pub types: Option<Vec<EventKind>>,
64}
65
66/// `GET /api/v1/events` -- Server-Sent Events stream.
67///
68/// Streams domain events in real time. Supports optional filtering:
69/// - `?run_id=<uuid>` -- only events for that run
70/// - `?types=run_status_changed,step_completed` -- only those event types
71///
72/// Each SSE message has:
73/// - `event:` set to the event type (e.g. `run_status_changed`)
74/// - `data:` JSON-serialized event payload
75///
76/// A keep-alive comment is sent every 30 seconds.
77///
78/// # Errors
79///
80/// Returns 401 if the request is not authenticated.
81pub async fn events(
82    auth: Authenticated,
83    State(state): State<AppState>,
84    Query(query): Query<EventsQuery>,
85) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
86    let receiver = state.event_sender.subscribe();
87    // Provider Account events describe admin-only resources.
88    let is_admin = auth.is_admin();
89    let type_filter = query.types;
90
91    let stream = BroadcastStream::new(receiver).filter_map(move |result: Result<Event, _>| {
92        let type_filter = type_filter.clone();
93        let run_id_filter = query.run_id;
94        async move {
95            let event = result.ok()?;
96
97            if !is_admin
98                && matches!(
99                    event,
100                    Event::ProviderAccountUpdated(_) | Event::ProviderAccountUsageUpdated(_)
101                )
102            {
103                return None;
104            }
105
106            if let Some(ref rid) = run_id_filter
107                && event.run_id() != Some(*rid)
108            {
109                return None;
110            }
111
112            if let Some(ref kinds) = type_filter {
113                let event_type = event.event_type();
114                if !kinds.iter().any(|k| k.as_str() == event_type) {
115                    return None;
116                }
117            }
118
119            let data = serde_json::to_string(&event).ok()?;
120            let sse_event = SseEvent::default().event(event.event_type()).data(data);
121
122            Some(Ok::<_, Infallible>(sse_event))
123        }
124    });
125
126    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30)))
127}
128
129#[cfg(test)]
130mod tests {
131    use std::collections::HashMap;
132    use std::sync::Arc;
133    use std::time::Duration;
134
135    use axum::Router;
136    use axum::routing::get;
137    use chrono::Utc;
138    use ironflow_auth::jwt::AccessToken;
139    use ironflow_core::providers::claude::ClaudeCodeProvider;
140    use ironflow_engine::engine::Engine;
141    use ironflow_engine::notify::{Event, RunStatusChangedEvent, UserSignedInEvent};
142    use ironflow_store::memory::InMemoryStore;
143    use ironflow_store::models::RunStatus;
144    use rust_decimal::Decimal;
145    use tokio::io::AsyncBufReadExt;
146    use tokio::io::BufReader;
147    use tokio::net::TcpListener;
148    use tokio::sync::broadcast;
149    use tokio::time::{sleep, timeout};
150    use uuid::Uuid;
151
152    use super::events;
153    use crate::state::AppState;
154
155    fn test_state() -> AppState {
156        let store = Arc::new(InMemoryStore::new());
157        let provider = Arc::new(ClaudeCodeProvider::new());
158        let engine = Arc::new(Engine::new(store.clone(), provider));
159        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
160            secret: "test-secret".to_string(),
161            access_token_ttl_secs: 900,
162            refresh_token_ttl_secs: 604800,
163            cookie_domain: None,
164            cookie_secure: false,
165        });
166        let (event_sender, _) = broadcast::channel::<Event>(16);
167        AppState::new(
168            store,
169            engine,
170            jwt_config,
171            "test-worker-token".to_string(),
172            event_sender,
173        )
174    }
175
176    fn sample_run_event(run_id: Uuid) -> Event {
177        Event::RunStatusChanged(RunStatusChangedEvent {
178            run_id,
179            workflow_name: "deploy".to_string(),
180            from: RunStatus::Running,
181            to: RunStatus::Completed,
182            error: None,
183            cost_usd: Decimal::ZERO,
184            duration_ms: 1000,
185            labels: HashMap::new(),
186            at: Utc::now(),
187        })
188    }
189
190    fn sample_user_event() -> Event {
191        Event::UserSignedIn(UserSignedInEvent {
192            user_id: Uuid::now_v7(),
193            username: "alice".to_string(),
194            at: Utc::now(),
195        })
196    }
197
198    fn make_auth_token(state: &AppState) -> String {
199        let user_id = Uuid::now_v7();
200        let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
201        format!("Bearer {}", token.0)
202    }
203
204    /// Start a real TCP server and return (address, sender, auth header).
205    async fn start_sse_server(state: AppState) -> (String, broadcast::Sender<Event>, String) {
206        let sender = state.event_sender.clone();
207        let auth = make_auth_token(&state);
208        let app = Router::new()
209            .route("/events", get(events))
210            .with_state(state);
211
212        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
213        let addr = listener.local_addr().unwrap().to_string();
214        tokio::spawn(async move {
215            axum::serve(listener, app).await.unwrap();
216        });
217        (addr, sender, auth)
218    }
219
220    /// Connect to the SSE endpoint with auth and return a line reader.
221    async fn connect_sse(addr: &str, query: &str, auth: &str) -> BufReader<tokio::net::TcpStream> {
222        let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
223        let (reader, mut writer) = stream.into_split();
224
225        use tokio::io::AsyncWriteExt;
226        writer
227            .write_all(
228                format!(
229                    "GET /events{query} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
230                )
231                .as_bytes(),
232            )
233            .await
234            .unwrap();
235
236        BufReader::new(reader.reunite(writer).unwrap())
237    }
238
239    /// Read all available data from the SSE stream until `needle` is found
240    /// in the accumulated text, or timeout.
241    async fn read_until_contains(
242        reader: &mut BufReader<tokio::net::TcpStream>,
243        needle: &str,
244        dur: Duration,
245    ) -> String {
246        let mut accumulated = String::new();
247        let result = timeout(dur, async {
248            loop {
249                let mut line = String::new();
250                let n = reader.read_line(&mut line).await.unwrap();
251                if n == 0 {
252                    break;
253                }
254                accumulated.push_str(&line);
255                if accumulated.contains(needle) {
256                    break;
257                }
258            }
259        })
260        .await;
261        if result.is_err() {
262            panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
263        }
264        accumulated
265    }
266
267    /// Wait until the SSE handler has subscribed to the broadcast channel.
268    ///
269    /// Sending before a receiver exists makes `broadcast::Sender::send` return
270    /// `SendError`, so a fixed sleep is racy on slow runners. Poll the receiver
271    /// count instead.
272    async fn wait_for_subscriber(sender: &broadcast::Sender<Event>) {
273        let ready = timeout(Duration::from_secs(5), async {
274            while sender.receiver_count() == 0 {
275                sleep(Duration::from_millis(5)).await;
276            }
277        })
278        .await;
279        assert!(ready.is_ok(), "SSE handler did not subscribe in time");
280    }
281
282    #[tokio::test]
283    async fn sse_stream_receives_events() {
284        let state = test_state();
285        let (addr, sender, auth) = start_sse_server(state).await;
286        let mut reader = connect_sse(&addr, "", &auth).await;
287
288        wait_for_subscriber(&sender).await;
289
290        let run_id = Uuid::now_v7();
291        sender.send(sample_run_event(run_id)).unwrap();
292
293        let text =
294            read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
295
296        assert!(text.contains("run_status_changed"));
297        assert!(text.contains(&run_id.to_string()));
298    }
299
300    #[tokio::test]
301    async fn sse_filters_by_run_id() {
302        let state = test_state();
303        let (addr, sender, auth) = start_sse_server(state).await;
304
305        let target_run = Uuid::now_v7();
306        let other_run = Uuid::now_v7();
307
308        let mut reader = connect_sse(&addr, &format!("?run_id={target_run}"), &auth).await;
309        wait_for_subscriber(&sender).await;
310
311        sender.send(sample_run_event(other_run)).unwrap();
312        sender.send(sample_run_event(target_run)).unwrap();
313
314        let text =
315            read_until_contains(&mut reader, &target_run.to_string(), Duration::from_secs(5)).await;
316
317        assert!(text.contains(&target_run.to_string()));
318        assert!(!text.contains(&other_run.to_string()));
319    }
320
321    #[tokio::test]
322    async fn sse_filters_by_event_type() {
323        let state = test_state();
324        let (addr, sender, auth) = start_sse_server(state).await;
325
326        let mut reader = connect_sse(&addr, "?types=user_signed_in", &auth).await;
327        wait_for_subscriber(&sender).await;
328
329        let run_id = Uuid::now_v7();
330        sender.send(sample_run_event(run_id)).unwrap();
331        sender.send(sample_user_event()).unwrap();
332
333        let text = read_until_contains(&mut reader, "user_signed_in", Duration::from_secs(5)).await;
334
335        assert!(text.contains("user_signed_in"));
336        assert!(!text.contains("run_status_changed"));
337    }
338
339    #[tokio::test]
340    async fn sse_returns_correct_content_type() {
341        let state = test_state();
342        let (addr, _sender, auth) = start_sse_server(state).await;
343        let mut reader = connect_sse(&addr, "", &auth).await;
344
345        let text =
346            read_until_contains(&mut reader, "text/event-stream", Duration::from_secs(5)).await;
347
348        assert!(text.contains("text/event-stream"));
349    }
350
351    #[tokio::test]
352    async fn sse_rejects_unauthenticated() {
353        let state = test_state();
354        let (addr, _sender, _auth) = start_sse_server(state).await;
355        // Connect without auth header
356        let stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
357        let (reader, mut writer) = stream.into_split();
358
359        use tokio::io::AsyncWriteExt;
360        writer
361            .write_all(
362                format!(
363                    "GET /events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
364                )
365                .as_bytes(),
366            )
367            .await
368            .unwrap();
369
370        let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
371        let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
372
373        assert!(text.contains("401"));
374    }
375}