Skip to main content

jmap_base_client/
sse.rs

1//! SSE types and frame parser for JMAP push notifications.
2//! Spec: RFC 8620 §7.3 (Push via Server-Sent Events)
3//! Wire format: RFC 8895 (Server-Sent Events)
4
5use std::collections::HashMap;
6
7use jmap_types::{Id, State};
8
9use crate::push;
10
11/// A parsed SSE frame: the event and the `id:` line value (if any).
12///
13/// # `id` field semantics
14///
15/// RFC 8895 §9.2 distinguishes three id states:
16/// - A frame with no `id:` field → last event ID is **unchanged**.
17/// - A frame with a bare `id:` field (no value) → last event ID is **reset**.
18/// - A frame with `id: <value>` → last event ID is **updated** to `<value>`.
19///
20/// This implementation conflates the first two cases: both produce `id: None`.
21/// Callers implementing reconnect with `Last-Event-ID` should treat `None` as
22/// "no change" and retain the previously-seen ID. The "reset" semantic is not
23/// representable without a tri-state type; this simplification is intentional
24/// for JMAP, where bare `id:` reset frames are rare in practice.
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct SseFrame {
28    /// The parsed event payload from this SSE block.
29    pub event: SseEvent,
30    /// The value of the `id:` line if present (RFC 8895 §9.2); `None` when
31    /// the `id:` field is absent or bare (see the type-level docs for the
32    /// rationale behind conflating those two states).
33    pub id: Option<String>,
34}
35
36/// A parsed SSE event from a JMAP event source (RFC 8620 §7.3).
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum SseEvent {
40    /// A "state" event: maps accountId → (typeName → newState).
41    ///
42    /// Triggers a `/changes` call for each type listed. Wire format:
43    /// `{"@type":"StateChange","changed":{"<accountId>":{"<TypeName>":"<state>"}}}`
44    StateChange(push::StateChange),
45    /// Unrecognized event type, keepalive, or parse failure.
46    ///
47    /// `event_type` carries the value of the SSE `event:` field for
48    /// diagnostics — e.g. `"ping"` for a keepalive, `"state"` when the
49    /// state payload failed to parse.  An empty string means the frame had
50    /// no `event:` field (a keepalive comment or bare data).
51    ///
52    /// Callers should silently ignore this variant; log `event_type` when
53    /// debugging unexpected parse failures.
54    Unknown {
55        /// Value of the SSE `event:` field. Empty string when the frame had
56        /// no `event:` line (e.g. a keepalive comment or bare data block).
57        event_type: String,
58        /// Raw concatenated value of the SSE `data:` field(s), with the
59        /// leading space stripped per RFC 8895 §9.1.
60        data: String,
61    },
62}
63
64/// Parse a single SSE block (the text between two blank lines) into an [`SseFrame`].
65///
66/// Returns an [`SseFrame`] with `event = SseEvent::Unknown` for empty blocks,
67/// keepalives, or unrecognized event types. Never panics. Malformed `data:`
68/// JSON is silently ignored and returns `Unknown` rather than propagating an
69/// error.
70///
71/// `SseFrame::id` carries the value of the `id:` line, if present. Callers
72/// should track this and send it as `Last-Event-ID` on reconnect per RFC 8620
73/// §7.3.
74pub fn parse_sse_block(block: &str) -> SseFrame {
75    let mut event_type: Option<&str> = None;
76    let mut data_lines: Vec<&str> = Vec::new();
77    let mut id: Option<String> = None;
78
79    for line in block.lines() {
80        // RFC 8895 §9.1: if value starts with U+0020 SPACE, remove exactly that one space.
81        if let Some(value) = line.strip_prefix("event:") {
82            event_type = Some(value.strip_prefix(' ').unwrap_or(value));
83        } else if let Some(value) = line.strip_prefix("data:") {
84            data_lines.push(value.strip_prefix(' ').unwrap_or(value));
85        } else if let Some(value) = line.strip_prefix("id:") {
86            let v = value.strip_prefix(' ').unwrap_or(value);
87            id = if v.is_empty() {
88                None
89            } else {
90                Some(v.to_owned())
91            };
92        }
93        // Comments (lines starting with ':') and unknown fields are silently ignored.
94    }
95
96    let event = match event_type {
97        Some("state") => match data_lines.as_slice() {
98            [] => SseEvent::Unknown {
99                event_type: "state".to_owned(),
100                data: String::new(),
101            }, // no data: lines
102            [single] => parse_state_data("state", single),
103            _ => parse_state_data("state", &data_lines.join("\n")),
104        },
105        Some(t) => SseEvent::Unknown {
106            event_type: t.to_owned(),
107            data: data_lines.join("\n"),
108        },
109        None => SseEvent::Unknown {
110            event_type: String::new(),
111            data: data_lines.join("\n"),
112        },
113    };
114
115    SseFrame { event, id }
116}
117
118/// Parse the data payload of a "state" event.
119///
120/// `event_type` is passed through to `SseEvent::Unknown` on failure so
121/// callers can distinguish a parse error on a "state" event from a parse
122/// error on some other type.
123///
124/// Accepts both the bare `{"changed":{...}}` shape and the shape with
125/// `"@type":"StateChange"` per RFC 8620 §7.3 (StateChange object definition).
126/// The `@type` field is stripped before deserialization; only `changed` is used.
127fn parse_state_data(event_type: &str, data: &str) -> SseEvent {
128    match try_parse_state_change(data) {
129        Some(sc) => SseEvent::StateChange(sc),
130        None => SseEvent::Unknown {
131            event_type: event_type.to_owned(),
132            data: data.to_owned(),
133        },
134    }
135}
136
137/// Try to parse a StateChange payload; returns `None` on any parse failure.
138///
139/// Strips the optional `@type` discriminator (RFC 8620 §7.3) before
140/// extracting `changed`. Any remaining unknown top-level keys are captured
141/// in `StateChange.extra` per the workspace extras-preservation policy.
142fn try_parse_state_change(data: &str) -> Option<push::StateChange> {
143    let mut v = serde_json::from_str::<serde_json::Value>(data).ok()?;
144    let obj = v.as_object_mut()?;
145    // Strip the optional "@type":"StateChange" discriminator before
146    // extracting the typed field; it must not leak into `extra`.
147    obj.remove("@type");
148    let changed_val = obj.remove("changed")?;
149    let changed =
150        serde_json::from_value::<HashMap<Id, HashMap<String, State>>>(changed_val).ok()?;
151    // Whatever remains is vendor / site extension data; preserve it.
152    let extra = std::mem::take(obj);
153    Some(push::StateChange { changed, extra })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /// Oracle: spec §7 "state" event format.
161    #[test]
162    fn parse_state_event() {
163        let block = "event: state\ndata: {\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
164        let SseFrame { event, .. } = parse_sse_block(block);
165        match event {
166            SseEvent::StateChange(sc) => {
167                assert_eq!(
168                    sc.changed
169                        .get("acc1")
170                        .and_then(|m| m.get("Message"))
171                        .map(|s| s.as_ref()),
172                    Some("s42"),
173                    "changed[acc1][Message] must equal s42"
174                );
175            }
176            other => panic!("expected StateChange, got {other:?}"),
177        }
178    }
179
180    /// Oracle: spec §7 "state" event format — @type field is present.
181    /// The @type field must be accepted and ignored; only "changed" matters.
182    #[test]
183    fn parse_state_event_with_type_field() {
184        let block = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
185        let SseFrame { event, .. } = parse_sse_block(block);
186        match event {
187            SseEvent::StateChange(sc) => {
188                assert_eq!(
189                    sc.changed
190                        .get("acc1")
191                        .and_then(|m| m.get("Message"))
192                        .map(|s| s.as_ref()),
193                    Some("s42"),
194                    "changed[acc1][Message] must equal s42"
195                );
196            }
197            other => panic!("expected StateChange, got {other:?}"),
198        }
199    }
200
201    /// Oracle: RFC 8895 §9 — unrecognized event type must yield Unknown.
202    #[test]
203    fn parse_unknown_event() {
204        let block = "event: ping\ndata: {}";
205        let SseFrame { event, .. } = parse_sse_block(block);
206        assert!(
207            matches!(event, SseEvent::Unknown { .. }),
208            "unrecognized event type must yield Unknown"
209        );
210    }
211
212    /// Oracle: RFC 8895 §9 — empty block (keepalive) must yield Unknown.
213    #[test]
214    fn parse_empty_block() {
215        let SseFrame { event, id } = parse_sse_block("");
216        assert!(
217            matches!(event, SseEvent::Unknown { .. }),
218            "empty block must yield Unknown"
219        );
220        assert!(id.is_none(), "empty block must have no id");
221    }
222
223    /// Oracle: security requirement §G — malformed JSON in data must yield
224    /// Unknown, never panic or propagate an error.
225    #[test]
226    fn parse_malformed_data_json() {
227        let block = "event: state\ndata: not-json";
228        let SseFrame { event, .. } = parse_sse_block(block);
229        assert!(
230            matches!(event, SseEvent::Unknown { .. }),
231            "malformed JSON must yield Unknown, not panic or error"
232        );
233    }
234
235    /// Oracle: RFC 8895 §9 — `id:` line value must be returned in `SseFrame::id`.
236    #[test]
237    fn parse_id_line() {
238        let block = "id: evt-42\nevent: state\ndata: {\"changed\":{}}";
239        let SseFrame { event, id } = parse_sse_block(block);
240        assert_eq!(id.as_deref(), Some("evt-42"), "id must be evt-42");
241        assert!(
242            matches!(event, SseEvent::StateChange(_)),
243            "must still parse as StateChange"
244        );
245    }
246
247    /// Oracle: RFC 8895 §9 — multiple `data:` lines must be joined with `\n`.
248    ///
249    /// Two data: lines are collected and joined. If only the first line were
250    /// used, a complete single-line state JSON would parse as StateChange. Because
251    /// the second data: line is appended (joined with '\n'), the combined
252    /// string is invalid JSON, so the result must be Unknown — proving both
253    /// lines are captured.
254    #[test]
255    fn parse_multiline_data() {
256        // First data: line alone is a complete, valid state JSON object.
257        // Second data: line appends "extra", making the joined string invalid JSON.
258        // Result must be Unknown (not StateChange), proving both lines are joined.
259        let block = concat!(
260            "event: state\n",
261            "data: {\"changed\":{\"acc1\":{\"Message\":\"s1\"}}}\n",
262            "data: extra"
263        );
264        let SseFrame { event, .. } = parse_sse_block(block);
265        assert!(
266            matches!(event, SseEvent::Unknown { .. }),
267            "both data: lines must be joined: first-line-valid JSON + second line = Unknown"
268        );
269    }
270
271    /// Verify SseEvent does not contain Typing or Presence variants.
272    /// This match will fail to compile if either variant is ever reintroduced.
273    #[test]
274    fn sse_event_no_typing_or_presence() {
275        let e = SseEvent::Unknown {
276            event_type: String::new(),
277            data: String::new(),
278        };
279        match e {
280            SseEvent::StateChange(_) => {}
281            SseEvent::Unknown { .. } => {}
282        }
283    }
284}