io_jmap/rfc8620/event_source/
subscribe.rs1use core::{
69 mem,
70 sync::atomic::{AtomicBool, Ordering},
71};
72
73use alloc::{format, string::String, sync::Arc, vec::Vec};
74
75use io_http::{
76 coroutine::*,
77 rfc9110::{headers::HTTP_TRANSFER_ENCODING, request::HttpRequest},
78 rfc9112::{
79 chunk_stream::{
80 Http11ChunksReadStream, Http11ChunksReadStreamError, Http11ChunksReadStreamYield,
81 },
82 read_headers::{Http11HeadersRead, Http11HeadersReadError, Http11HeadersReadOutput},
83 },
84 sse::frame::{SseFrameParser, SseFrameParserYield},
85};
86use log::{debug, trace};
87use secrecy::{ExposeSecret, SecretString};
88use thiserror::Error;
89use url::Url;
90
91use crate::{
92 coroutine::*,
93 rfc8620::{
94 event_source::{JmapCloseAfter, JmapStateChange, JmapStateChangeParseError},
95 session::JmapSession,
96 },
97};
98
99#[derive(Debug)]
101pub enum JmapEventSourceYield {
102 Frame(JmapStateChange),
105 WantsRead,
107 WantsWrite(Vec<u8>),
109}
110
111#[derive(Debug, Error)]
113pub enum JmapEventSourceError {
114 #[error("JMAP event-source failed: HTTP {0}")]
116 HttpStatus(u16),
117 #[error("JMAP event-source failed: response must be Transfer-Encoding: chunked")]
119 NotChunked,
120 #[error("JMAP event-source failed: invalid URL {0}")]
122 InvalidUrl(String),
123 #[error("JMAP event-source failed: {0}")]
125 ReadHeaders(#[from] Http11HeadersReadError),
126 #[error("JMAP event-source failed: {0}")]
128 ReadChunks(#[from] Http11ChunksReadStreamError),
129 #[error("JMAP event-source failed: {0}")]
131 DecodeFrame(#[from] JmapStateChangeParseError),
132}
133
134pub struct JmapEventSource {
141 state: State,
142 shutdown: Arc<AtomicBool>,
143}
144
145impl JmapEventSource {
146 pub fn subscribe_url(
150 session: &JmapSession,
151 types: &[&str],
152 ping_seconds: u64,
153 close_after: JmapCloseAfter,
154 ) -> String {
155 let base = &session.event_source_url;
156 let types = types.join(",");
157 let sep = if base.contains('?') { '&' } else { '?' };
158 let close_after = close_after.as_str();
159 format!("{base}{sep}types={types}&closeafter={close_after}&ping={ping_seconds}")
160 }
161
162 pub fn new(
169 session: &JmapSession,
170 http_auth: &SecretString,
171 types: &[&str],
172 ping_seconds: u64,
173 close_after: JmapCloseAfter,
174 shutdown: Arc<AtomicBool>,
175 ) -> Result<Self, JmapEventSourceError> {
176 let url_str = Self::subscribe_url(session, types, ping_seconds, close_after);
177 let url = Url::parse(&url_str).map_err(|_| JmapEventSourceError::InvalidUrl(url_str))?;
178
179 let host = url.host_str().unwrap_or("localhost");
180 let request = HttpRequest::get(url.clone())
181 .header("Host", host)
182 .header("Accept", "text/event-stream")
183 .header("Authorization", http_auth.expose_secret());
184
185 debug!("prepare event source subscription request");
186 trace!("subscription url: {url}");
187
188 Ok(Self {
189 state: State::SendingRequest(request.to_http_11_vec()),
190 shutdown,
191 })
192 }
193}
194
195impl JmapCoroutine for JmapEventSource {
196 type Yield = JmapEventSourceYield;
197 type Return = Result<(), JmapEventSourceError>;
198
199 fn resume(&mut self, mut arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
205 if self.shutdown.load(Ordering::SeqCst) {
206 self.state = State::Done;
207 return JmapCoroutineState::Complete(Ok(()));
208 }
209
210 loop {
211 match &mut self.state {
212 State::SendingRequest(_) => {
213 let State::SendingRequest(bytes) = mem::replace(
214 &mut self.state,
215 State::ReadingHead(Http11HeadersRead::default()),
216 ) else {
217 unreachable!()
218 };
219 return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(bytes));
220 }
221 State::ReadingHead(head) => match head.resume(arg.take()) {
222 HttpCoroutineState::Yielded(HttpYield::WantsRead) => {
223 return JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead);
224 }
225 HttpCoroutineState::Yielded(HttpYield::WantsWrite(_)) => {
226 unreachable!("Http11HeadersRead never writes");
227 }
228 HttpCoroutineState::Complete(Err(err)) => {
229 return JmapCoroutineState::Complete(Err(err.into()));
230 }
231 HttpCoroutineState::Complete(Ok(Http11HeadersReadOutput {
232 response,
233 remaining,
234 keep_alive: _,
235 })) => {
236 if !response.status.is_success() {
237 return JmapCoroutineState::Complete(Err(
238 JmapEventSourceError::HttpStatus(*response.status),
239 ));
240 }
241 let chunked = response
242 .header(HTTP_TRANSFER_ENCODING)
243 .is_some_and(|enc| enc.eq_ignore_ascii_case("chunked"));
244 if !chunked {
245 return JmapCoroutineState::Complete(Err(
246 JmapEventSourceError::NotChunked,
247 ));
248 }
249 let mut chunks = Http11ChunksReadStream::default();
253 let pending = if remaining.is_empty() {
254 None
255 } else {
256 match chunks.resume(Some(&remaining)) {
257 HttpCoroutineState::Yielded(
258 Http11ChunksReadStreamYield::Frame { body },
259 ) => Some(body),
260 HttpCoroutineState::Yielded(
261 Http11ChunksReadStreamYield::WantsRead,
262 ) => None,
263 HttpCoroutineState::Complete(Ok(_)) => {
264 self.state = State::Done;
265 return JmapCoroutineState::Complete(Ok(()));
266 }
267 HttpCoroutineState::Complete(Err(err)) => {
268 return JmapCoroutineState::Complete(Err(err.into()));
269 }
270 }
271 };
272 self.state = State::Streaming {
273 chunks,
274 parser: SseFrameParser::default(),
275 pending,
276 };
277 }
280 },
281 State::Streaming {
282 chunks,
283 parser,
284 pending,
285 } => {
286 let parser_input = pending.take();
287 match parser.resume(parser_input.as_deref()) {
288 HttpCoroutineState::Yielded(SseFrameParserYield::Frame(frame)) => {
289 return match JmapStateChange::parse(&frame.data) {
290 Ok(change) => {
291 JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change))
292 }
293 Err(err) => JmapCoroutineState::Complete(Err(err.into())),
294 };
295 }
296 HttpCoroutineState::Yielded(SseFrameParserYield::WantsBytes) => {
297 match chunks.resume(arg.take()) {
298 HttpCoroutineState::Yielded(
299 Http11ChunksReadStreamYield::Frame { body },
300 ) => {
301 *pending = Some(body);
302 }
303 HttpCoroutineState::Complete(Ok(_)) => {
304 self.state = State::Done;
305 return JmapCoroutineState::Complete(Ok(()));
306 }
307 HttpCoroutineState::Yielded(
308 Http11ChunksReadStreamYield::WantsRead,
309 ) => {
310 return JmapCoroutineState::Yielded(
311 JmapEventSourceYield::WantsRead,
312 );
313 }
314 HttpCoroutineState::Complete(Err(err)) => {
315 return JmapCoroutineState::Complete(Err(err.into()));
316 }
317 }
318 }
319 HttpCoroutineState::Complete(never) => match never {},
320 }
321 }
322 State::Done => return JmapCoroutineState::Complete(Ok(())),
323 }
324 }
325 }
326}
327
328enum State {
330 SendingRequest(Vec<u8>),
332 ReadingHead(Http11HeadersRead),
334 Streaming {
337 chunks: Http11ChunksReadStream,
338 parser: SseFrameParser,
339 pending: Option<Vec<u8>>,
341 },
342 Done,
344}
345
346#[cfg(test)]
347mod tests {
348 use core::sync::atomic::AtomicBool;
349
350 use alloc::{
351 collections::BTreeMap,
352 format,
353 string::{String, ToString},
354 sync::Arc,
355 vec::Vec,
356 };
357
358 use secrecy::SecretString;
359
360 use crate::{
361 coroutine::*,
362 rfc8620::{event_source::subscribe::*, event_source::*, session::JmapSession},
363 };
364
365 fn synthetic_session() -> JmapSession {
366 JmapSession {
367 username: String::new(),
368 accounts: BTreeMap::new(),
369 primary_accounts: BTreeMap::new(),
370 capabilities: BTreeMap::new(),
371 api_url: "https://example.org/api".parse().unwrap(),
372 download_url: String::new(),
373 upload_url: String::new(),
374 event_source_url: String::new(),
375 state: String::new(),
376 }
377 }
378
379 #[test]
380 fn subscribe_url_appends_query_params() {
381 let session = JmapSession {
382 event_source_url: "https://jmap.example.org/events".into(),
383 ..synthetic_session()
384 };
385 let url = JmapEventSource::subscribe_url(
386 &session,
387 &["Email", "EmailDelivery"],
388 30,
389 JmapCloseAfter::No,
390 );
391 assert_eq!(
392 url,
393 "https://jmap.example.org/events?types=Email,EmailDelivery&closeafter=no&ping=30"
394 );
395 }
396
397 #[test]
398 fn subscribe_url_preserves_existing_query() {
399 let session = JmapSession {
400 event_source_url: "https://jmap.example.org/events?token=abc".into(),
401 ..synthetic_session()
402 };
403 let url = JmapEventSource::subscribe_url(&session, &[], 15, JmapCloseAfter::State);
404 assert_eq!(
405 url,
406 "https://jmap.example.org/events?token=abc&types=&closeafter=state&ping=15"
407 );
408 }
409
410 #[test]
415 fn streaming_head_leftover_is_chunk_decoded() {
416 let session = JmapSession {
417 event_source_url: "https://example.org/sse".into(),
418 ..synthetic_session()
419 };
420 let auth = SecretString::from("Bearer t".to_string());
421 let shutdown = Arc::new(AtomicBool::new(false));
422 let mut es = JmapEventSource::new(
423 &session,
424 &auth,
425 &["Email"],
426 30,
427 JmapCloseAfter::State,
428 shutdown,
429 )
430 .unwrap();
431
432 let JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(_)) = es.resume(None)
435 else {
436 panic!("expected initial WantsWrite");
437 };
438
439 let part1 = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"u1\":";
440 let part2 = "{\"Email\":\"s1\"}}}\n\n";
441 let chunked = format!(
442 "{:x}\r\n{part1}\r\n{:x}\r\n{part2}\r\n0\r\n\r\n",
443 part1.len(),
444 part2.len(),
445 );
446 let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/event-stream\r\n\r\n";
447 let mut wire: Vec<u8> = head.as_bytes().to_vec();
448 wire.extend_from_slice(chunked.as_bytes());
449
450 match es.resume(Some(&wire)) {
453 JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
454 assert_eq!(change.r#type, "StateChange");
455 assert_eq!(change.changed["u1"]["Email"], "s1");
456 }
457 other => panic!("expected Frame yield, got {other:?}"),
458 }
459 }
460}