Skip to main content

azisaba_graph/apis/
stream_api.rs

1/*
2 * Azisaba Graph API
3 *
4 * This file is maintained separately from the generated API implementation
5 * because OpenAPI Generator does not expose text/event-stream as a typed Rust
6 * stream.
7 */
8
9use futures_core::Stream;
10use futures_util::StreamExt;
11use reqwest;
12use serde::{Deserialize, Serialize};
13
14use super::{configuration, Error};
15use crate::{apis::ResponseContent, models};
16
17/// Typed errors returned while opening the event stream.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum StreamEventsError {
21    Status401(),
22    Status403(),
23    UnknownValue(serde_json::Value),
24}
25
26/// Opens the Server-Sent Events connection and yields deserialized Graph events.
27///
28/// Dropping the returned stream closes the underlying HTTP response. The stream
29/// ends when the server disconnects and does not reconnect automatically.
30pub async fn stream_events(
31    configuration: &configuration::Configuration,
32) -> Result<
33    impl Stream<Item = Result<models::StreamEvent, Error<StreamEventsError>>>,
34    Error<StreamEventsError>,
35> {
36    let uri = format!("{}/stream", configuration.base_path);
37    let mut request = configuration
38        .client
39        .request(reqwest::Method::GET, uri)
40        .header(reqwest::header::ACCEPT, "text/event-stream");
41
42    if let Some(ref user_agent) = configuration.user_agent {
43        request = request.header(reqwest::header::USER_AGENT, user_agent.clone());
44    }
45    if let Some(ref token) = configuration.bearer_access_token {
46        request = request.bearer_auth(token);
47    }
48
49    let response = configuration.client.execute(request.build()?).await?;
50    let status = response.status();
51
52    if status.is_client_error() || status.is_server_error() {
53        let content = response.text().await?;
54        let entity = serde_json::from_str(&content).ok();
55        return Err(Error::ResponseError(ResponseContent {
56            status,
57            content,
58            entity,
59        }));
60    }
61
62    let content_type = response
63        .headers()
64        .get(reqwest::header::CONTENT_TYPE)
65        .and_then(|value| value.to_str().ok())
66        .unwrap_or_default();
67    if !content_type
68        .to_ascii_lowercase()
69        .starts_with("text/event-stream")
70    {
71        return Err(Error::Io(std::io::Error::new(
72            std::io::ErrorKind::InvalidData,
73            format!("unexpected stream Content-Type: {content_type}"),
74        )));
75    }
76
77    let mut chunks = response.bytes_stream();
78    Ok(async_stream::try_stream! {
79        let mut parser = EventStreamParser::default();
80        while let Some(chunk) = chunks.next().await {
81            let chunk = chunk?;
82            for payload in parser.push(&chunk, false)? {
83                yield serde_json::from_str::<models::StreamEvent>(&payload)?;
84            }
85        }
86        for payload in parser.push(&[], true)? {
87            yield serde_json::from_str::<models::StreamEvent>(&payload)?;
88        }
89    })
90}
91
92#[derive(Default)]
93struct EventStreamParser {
94    buffer: Vec<u8>,
95    data: Vec<String>,
96}
97
98impl EventStreamParser {
99    fn push(&mut self, chunk: &[u8], end_of_stream: bool) -> std::io::Result<Vec<String>> {
100        self.buffer.extend_from_slice(chunk);
101        let mut payloads = Vec::new();
102
103        while let Some((line, consumed)) = next_line(&self.buffer, end_of_stream) {
104            let line = String::from_utf8(line.to_vec())
105                .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
106            self.buffer.drain(..consumed);
107            if let Some(payload) = self.consume_line(&line) {
108                payloads.push(payload);
109            }
110        }
111
112        if end_of_stream {
113            if let Some(payload) = self.dispatch() {
114                payloads.push(payload);
115            }
116        }
117        Ok(payloads)
118    }
119
120    fn consume_line(&mut self, line: &str) -> Option<String> {
121        if line.is_empty() {
122            return self.dispatch();
123        }
124        if line.starts_with(':') {
125            return None;
126        }
127
128        let (field, value) = match line.split_once(':') {
129            Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
130            None => (line, ""),
131        };
132        if field == "data" {
133            self.data.push(value.to_owned());
134        }
135        None
136    }
137
138    fn dispatch(&mut self) -> Option<String> {
139        if self.data.is_empty() {
140            return None;
141        }
142        Some(std::mem::take(&mut self.data).join("\n"))
143    }
144}
145
146fn next_line(buffer: &[u8], end_of_stream: bool) -> Option<(&[u8], usize)> {
147    for (index, byte) in buffer.iter().enumerate() {
148        match byte {
149            b'\n' => return Some((&buffer[..index], index + 1)),
150            b'\r' => {
151                if index + 1 == buffer.len() && !end_of_stream {
152                    return None;
153                }
154                let consumed = if buffer.get(index + 1) == Some(&b'\n') {
155                    index + 2
156                } else {
157                    index + 1
158                };
159                return Some((&buffer[..index], consumed));
160            }
161            _ => {}
162        }
163    }
164
165    if end_of_stream && !buffer.is_empty() {
166        Some((buffer, buffer.len()))
167    } else {
168        None
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::EventStreamParser;
175    use crate::models::StreamEvent;
176
177    const PLAYER: &str = r#"{
178        "id":"00000000-0000-0000-0000-000000000001",
179        "discordId":null,
180        "username":"player",
181        "status":"offline",
182        "currentServer":null,
183        "bio":null
184    }"#;
185
186    #[test]
187    fn parses_chunked_multiline_events_and_ignores_comments() {
188        let mut parser = EventStreamParser::default();
189        assert!(parser
190            .push(b": keep-alive\r\nda", false)
191            .unwrap()
192            .is_empty());
193        assert_eq!(
194            parser
195                .push(b"ta: {\"type\":\r\ndata: \"friend-added\"}\r\n\r\n", false)
196                .unwrap(),
197            vec!["{\"type\":\n\"friend-added\"}"]
198        );
199    }
200
201    #[test]
202    fn dispatches_the_last_event_when_the_connection_closes() {
203        let mut parser = EventStreamParser::default();
204        assert_eq!(
205            parser
206                .push(b"data: {\"type\":\"friend-added\"}", true)
207                .unwrap(),
208            vec!["{\"type\":\"friend-added\"}"]
209        );
210    }
211
212    #[test]
213    fn dispatches_structurally_identical_events_by_type() {
214        let payload = format!(
215            r#"{{"type":"friend-removed","data":{{"player":{PLAYER},"friend":{PLAYER}}}}}"#
216        );
217
218        assert!(matches!(
219            serde_json::from_str::<StreamEvent>(&payload).unwrap(),
220            StreamEvent::FriendRemoved(_)
221        ));
222    }
223
224    #[test]
225    fn rejects_unknown_event_types() {
226        let error =
227            serde_json::from_str::<StreamEvent>(r#"{"type":"unknown","data":{}}"#).unwrap_err();
228        assert!(error.to_string().contains("unknown variant"));
229    }
230}