uptrakit_openapi_client/
update_output_stream.rs1use crate::sse::{self, RawSseEvent, SseError};
8use crate::types_impl::update_history::{OutputLineSSE, UpdateCompletedSSE};
9use crate::{ClientError, Result, UptrakitClient};
10use rootcause::prelude::*;
11
12#[derive(Debug, Clone)]
14pub enum UpdateOutputEvent {
15 Output(OutputLineSSE),
17 Completed(UpdateCompletedSSE),
19}
20
21#[derive(Debug, thiserror::Error)]
23pub enum StreamError {
24 #[error("SSE transport error: {0}")]
25 Sse(#[from] SseError),
26
27 #[error("failed to parse SSE event data: {0}")]
28 Parse(#[from] serde_json::Error),
29}
30
31impl UptrakitClient {
32 pub async fn stream_update_output(
39 &self,
40 id: &uuid::Uuid,
41 ) -> Result<impl futures_util::Stream<Item = std::result::Result<UpdateOutputEvent, StreamError>>>
42 {
43 let url = format!(
44 "{}{}",
45 self.base_url,
46 crate::paths::update_history::output_stream(id)
47 );
48
49 let mut req = self
50 .http
51 .get(&url)
52 .header("Accept", "text/event-stream")
53 .timeout(std::time::Duration::from_secs(86400));
56
57 if let Some(token) = &self.token {
58 req = req.bearer_auth(token);
59 }
60
61 let resp = req.send().await.context_to()?;
62
63 let status = resp.status();
64 if status == reqwest::StatusCode::UNAUTHORIZED {
65 bail!(ClientError::NotAuthenticated);
66 }
67 if status == reqwest::StatusCode::NOT_FOUND {
68 let text = resp.text().await.context_to()?;
69 let message = crate::extract_error_message(&text);
70 bail!(ClientError::NotFound(message));
71 }
72 if status.is_client_error() || status.is_server_error() {
73 let text = resp.text().await.context_to()?;
74 let message = crate::extract_error_message(&text);
75 bail!(ClientError::Api { status, message });
76 }
77
78 let raw_stream = sse::parse_sse_stream(resp);
79
80 let typed_stream = futures_util::StreamExt::filter_map(raw_stream, |result| async move {
81 match result {
82 Ok(event) => parse_typed_event(event),
83 Err(e) => Some(Err(StreamError::Sse(e))),
84 }
85 });
86
87 Ok(typed_stream)
88 }
89}
90
91fn parse_typed_event(
93 event: RawSseEvent,
94) -> Option<std::result::Result<UpdateOutputEvent, StreamError>> {
95 match event.event_type.as_str() {
96 "output" => {
97 let parsed: std::result::Result<OutputLineSSE, _> = serde_json::from_str(&event.data);
98 Some(parsed.map(UpdateOutputEvent::Output).map_err(Into::into))
99 }
100 "completed" => {
101 let parsed: std::result::Result<UpdateCompletedSSE, _> =
102 serde_json::from_str(&event.data);
103 Some(parsed.map(UpdateOutputEvent::Completed).map_err(Into::into))
104 }
105 _ => {
106 None
108 }
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 #![expect(
115 clippy::assertions_on_result_states,
116 reason = "test assertions — assert!(result.is_err()) is idiomatic in tests"
117 )]
118
119 use super::*;
120 use crate::sse::RawSseEvent;
121
122 #[test]
123 fn parse_output_event() {
124 let event = RawSseEvent {
125 event_type: "output".to_string(),
126 data: r#"{"id":"01234567-89ab-cdef-0123-456789abcdef","text":"hello\n","stream":"stdout","timestamp":"2025-01-01T00:00:00Z","seq":0}"#.to_string(),
127 id: None,
128 };
129 let result = parse_typed_event(event).expect("should produce event");
130 let typed = result.expect("should parse");
131 assert!(matches!(typed, UpdateOutputEvent::Output(ref o) if o.text == "hello\n"));
132 }
133
134 #[test]
135 fn parse_completed_event() {
136 let event = RawSseEvent {
137 event_type: "completed".to_string(),
138 data: r#"{"status":"completed","error":null}"#.to_string(),
139 id: None,
140 };
141 let result = parse_typed_event(event).expect("should produce event");
142 let typed = result.expect("should parse");
143 assert!(matches!(typed, UpdateOutputEvent::Completed(ref c) if c.status == "completed"));
144 }
145
146 #[test]
147 fn parse_unknown_event_returns_none() {
148 let event = RawSseEvent {
149 event_type: "ping".to_string(),
150 data: "{}".to_string(),
151 id: None,
152 };
153 assert!(parse_typed_event(event).is_none());
154 }
155
156 #[test]
157 fn parse_malformed_data_returns_error() {
158 let event = RawSseEvent {
159 event_type: "output".to_string(),
160 data: "not json".to_string(),
161 id: None,
162 };
163 let result = parse_typed_event(event).expect("should produce event");
164 assert!(result.is_err());
165 }
166}