Skip to main content

uptrakit_openapi_client/
sse.rs

1//! Lightweight Server-Sent Events (SSE) protocol parser.
2//!
3//! Parses an SSE byte stream (from `reqwest::Response::chunk()`) into
4//! typed [`RawSseEvent`] values. Follows the [SSE specification][spec]:
5//! fields are separated by `\n`, events are delimited by `\n\n`.
6//!
7//! [spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html
8
9/// A single parsed SSE event.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct RawSseEvent {
12    /// The `event:` field (defaults to `"message"` if omitted).
13    pub event_type: String,
14    /// The concatenated `data:` field(s).
15    pub data: String,
16    /// The `id:` field, if present.
17    pub id: Option<String>,
18}
19
20/// Errors that can occur while parsing an SSE stream.
21#[derive(Debug, thiserror::Error)]
22pub enum SseError {
23    #[error("stream read error: {0}")]
24    Transport(#[from] reqwest::Error),
25}
26
27/// Parse a `reqwest` streaming response into a stream of [`RawSseEvent`]s.
28///
29/// The returned stream yields one item per SSE event (delimited by blank
30/// lines). Comment lines (starting with `:`) are silently skipped.
31///
32/// Uses `reqwest::Response::chunk()` for incremental reads, which does not
33/// require the `stream` cargo feature on reqwest.
34pub fn parse_sse_stream(
35    response: reqwest::Response,
36) -> impl futures_util::Stream<Item = Result<RawSseEvent, SseError>> {
37    futures_util::stream::unfold(
38        (response, String::new()),
39        |(mut response, mut buffer)| async move {
40            loop {
41                // Check if we already have a complete event in the buffer.
42                if let Some(pos) = find_event_boundary(&buffer) {
43                    let event_text = buffer[..pos].to_string();
44                    // Skip past the double newline.
45                    let skip = if buffer[pos..].starts_with("\r\n\r\n") {
46                        4
47                    } else if buffer[pos..].starts_with("\n\n") {
48                        2
49                    } else {
50                        // "\r\r" case
51                        2
52                    };
53                    buffer = buffer[pos + skip..].to_string();
54
55                    if let Some(event) = parse_event(&event_text) {
56                        return Some((Ok(event), (response, buffer)));
57                    }
58                    // Empty event (e.g. just comments), continue parsing.
59                    continue;
60                }
61
62                // Need more data from the stream.
63                match response.chunk().await {
64                    Ok(Some(bytes)) => {
65                        let text = String::from_utf8_lossy(&bytes);
66                        buffer.push_str(&text);
67                    }
68                    Ok(None) => {
69                        // Stream ended. Try to parse any remaining buffered data.
70                        if !buffer.trim().is_empty() {
71                            let event_text = std::mem::take(&mut buffer);
72                            if let Some(event) = parse_event(&event_text) {
73                                return Some((Ok(event), (response, buffer)));
74                            }
75                        }
76                        return None;
77                    }
78                    Err(e) => {
79                        return Some((Err(SseError::Transport(e)), (response, buffer)));
80                    }
81                }
82            }
83        },
84    )
85}
86
87/// Find the position of the first event boundary (`\n\n`, `\r\n\r\n`, or `\r\r`).
88fn find_event_boundary(s: &str) -> Option<usize> {
89    // Check for `\r\n\r\n` first (most specific).
90    if let Some(pos) = s.find("\r\n\r\n") {
91        // But also check for earlier `\n\n`.
92        if let Some(nn_pos) = s.find("\n\n") {
93            return Some(nn_pos.min(pos));
94        }
95        return Some(pos);
96    }
97    if let Some(pos) = s.find("\n\n") {
98        return Some(pos);
99    }
100    s.find("\r\r")
101}
102
103/// Parse the text of a single event block into a [`RawSseEvent`].
104/// Returns `None` if the block contains no data fields.
105fn parse_event(text: &str) -> Option<RawSseEvent> {
106    let mut event_type = None;
107    let mut data_parts: Vec<&str> = Vec::new();
108    let mut id = None;
109
110    for line in text.lines() {
111        if line.starts_with(':') {
112            // Comment line — skip.
113            continue;
114        }
115
116        if let Some(value) = line.strip_prefix("event:") {
117            event_type = Some(value.trim().to_string());
118        } else if let Some(value) = line.strip_prefix("data:") {
119            data_parts.push(value.strip_prefix(' ').unwrap_or(value));
120        } else if let Some(value) = line.strip_prefix("id:") {
121            id = Some(value.trim().to_string());
122        }
123        // Other fields (e.g. `retry:`) are ignored.
124    }
125
126    if data_parts.is_empty() {
127        return None;
128    }
129
130    Some(RawSseEvent {
131        event_type: event_type.unwrap_or_else(|| "message".to_string()),
132        data: data_parts.join("\n"),
133        id,
134    })
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parse_single_event() {
143        let text = "event: output\ndata: hello world";
144        let event = parse_event(text).expect("should parse");
145        assert_eq!(event.event_type, "output");
146        assert_eq!(event.data, "hello world");
147        assert_eq!(event.id, None);
148    }
149
150    #[test]
151    fn parse_event_default_type() {
152        let text = "data: just data";
153        let event = parse_event(text).expect("should parse");
154        assert_eq!(event.event_type, "message");
155        assert_eq!(event.data, "just data");
156    }
157
158    #[test]
159    fn parse_event_with_id() {
160        let text = "event: completed\ndata: {\"status\":\"done\"}\nid: 42";
161        let event = parse_event(text).expect("should parse");
162        assert_eq!(event.event_type, "completed");
163        assert_eq!(event.data, "{\"status\":\"done\"}");
164        assert_eq!(event.id.as_deref(), Some("42"));
165    }
166
167    #[test]
168    fn parse_event_multi_data_lines() {
169        let text = "data: line1\ndata: line2\ndata: line3";
170        let event = parse_event(text).expect("should parse");
171        assert_eq!(event.data, "line1\nline2\nline3");
172    }
173
174    #[test]
175    fn parse_event_skips_comments() {
176        let text = ": this is a comment\nevent: output\ndata: payload";
177        let event = parse_event(text).expect("should parse");
178        assert_eq!(event.event_type, "output");
179        assert_eq!(event.data, "payload");
180    }
181
182    #[test]
183    fn parse_event_no_data_returns_none() {
184        let text = "event: output\n: comment only";
185        assert!(parse_event(text).is_none());
186    }
187
188    #[test]
189    fn find_boundary_double_newline() {
190        assert_eq!(find_event_boundary("data: hello\n\ndata: world"), Some(11));
191    }
192
193    #[test]
194    fn find_boundary_crlf() {
195        assert_eq!(
196            find_event_boundary("data: hello\r\n\r\ndata: world"),
197            Some(11)
198        );
199    }
200
201    #[test]
202    fn find_boundary_none() {
203        assert_eq!(find_event_boundary("data: hello\n"), None);
204    }
205}