Skip to main content

jmap_chat_client/
ws.rs

1//! WebSocket frame types and extension trait for JMAP Chat.
2//!
3//! Wraps [`WsFrame`] with chat-specific variants for
4//! [`ChatTypingEvent`] and
5//! [`ChatPresenceEvent`], which arrive as
6//! [`WsFrame::Unknown`] on the base transport.
7//!
8//! Spec: draft-atwood-jmap-chat-wss-00
9
10use jmap_base_client::{ClientError, WsFrame, WsSession};
11use jmap_chat_types::{ChatPresenceEvent, ChatStreamEnable, ChatTypingEvent, EphemeralMessage};
12
13/// A parsed frame from the JMAP Chat WebSocket, including chat-specific variants.
14///
15/// Marked `#[non_exhaustive]` because the spec may define additional `@type`
16/// values in future revisions.
17#[non_exhaustive]
18#[derive(Debug, Clone)]
19pub enum ChatWsFrame {
20    /// RFC 8620 §7.1 StateChange — one or more object types have changed state.
21    StateChange(jmap_base_client::StateChange),
22    /// RFC 8887 Response — reply to a JMAP request sent on this connection.
23    Response(jmap_types::JmapResponse),
24    /// Ephemeral typing indicator (draft-atwood-jmap-chat-wss-00).
25    /// Delivered only after a `ChatStreamEnable` subscription has been sent.
26    ChatTyping(ChatTypingEvent),
27    /// Ephemeral presence update (draft-atwood-jmap-chat-wss-00).
28    /// Delivered only after a `ChatStreamEnable` subscription has been sent.
29    ChatPresence(ChatPresenceEvent),
30    /// Unrecognized `@type` — ignored per forward-compatibility rules
31    /// (clients SHOULD ignore unknown message types per RFC 8887 §4.3.1).
32    ///
33    /// Also produced when a known chat type fails to deserialize — `type_name`
34    /// will be `"ChatTypingEvent"` or `"ChatPresenceEvent"` in that case.
35    Unknown {
36        /// The unknown-frame discriminant value. Possible sources:
37        /// - The verbatim `@type` string from the underlying
38        ///   [`WsFrame::Unknown`] when the chat-side parser does not
39        ///   recognise it (typical: future chat-extension events).
40        /// - `"<no @type>"` (the literal string) when the underlying
41        ///   `WsFrame::Unknown` itself reported an absent `@type` field —
42        ///   that sentinel originates in `jmap-base-client` and
43        ///   propagates through unchanged.
44        /// - `"ChatTypingEvent"` or `"ChatPresenceEvent"` when a known
45        ///   chat-event type failed payload deserialization (see the
46        ///   parent variant's doc comment above).
47        /// - `"<unknown>"` when the underlying [`WsFrame`] is a future
48        ///   non-`Unknown` variant added by `jmap-base-client` after
49        ///   `parse_chat_ws_frame` was last updated.
50        ///
51        /// Callers wishing to distinguish "server sent JSON without
52        /// `@type`" from "this parser doesn't recognise the value"
53        /// must match on the literal strings; the field intentionally
54        /// flattens both cases into one `String` to keep the variant
55        /// shape stable across future spec edits.
56        type_name: String,
57    },
58}
59
60/// Promote a base-client [`WsFrame`] to a [`ChatWsFrame`].
61///
62/// - `StateChange` and `Response` are forwarded unchanged.
63/// - `Unknown { type_name: "ChatTypingEvent", raw }` → tries `serde_json::from_value`
64///   into [`ChatTypingEvent`]; on failure produces `Unknown` (not an error).
65/// - `Unknown { type_name: "ChatPresenceEvent", raw }` → same for [`ChatPresenceEvent`].
66/// - All other `Unknown` frames are forwarded as `ChatWsFrame::Unknown`.
67pub fn parse_chat_ws_frame(frame: WsFrame) -> ChatWsFrame {
68    match frame {
69        WsFrame::StateChange(sc) => ChatWsFrame::StateChange(sc),
70        WsFrame::Response(r) => ChatWsFrame::Response(r),
71        WsFrame::Unknown { type_name, raw } => match type_name.as_str() {
72            "ChatTypingEvent" => serde_json::from_value::<ChatTypingEvent>(raw)
73                .map(ChatWsFrame::ChatTyping)
74                .unwrap_or_else(|_| ChatWsFrame::Unknown {
75                    type_name: "ChatTypingEvent".to_owned(),
76                }),
77            "ChatPresenceEvent" => serde_json::from_value::<ChatPresenceEvent>(raw)
78                .map(ChatWsFrame::ChatPresence)
79                .unwrap_or_else(|_| ChatWsFrame::Unknown {
80                    type_name: "ChatPresenceEvent".to_owned(),
81                }),
82            _ => ChatWsFrame::Unknown { type_name },
83        },
84        // WsFrame is #[non_exhaustive]: forward any future base-client variants as Unknown.
85        _ => ChatWsFrame::Unknown {
86            type_name: "<unknown>".to_owned(),
87        },
88    }
89}
90
91/// Extension trait adding JMAP Chat ephemeral-event methods to [`WsSession`].
92///
93/// Import this trait to use: `use jmap_chat_client::ChatWsExt;`
94///
95/// This trait is **sealed**: implementations outside this crate are not
96/// permitted. The crate adds an `impl` only for
97/// [`jmap_base_client::WsSession`]. Sealing prevents downstream
98/// divergence and keeps adding methods to the trait a non-breaking
99/// change.
100pub trait ChatWsExt: sealed::Sealed {
101    /// Receive the next frame from the server, interpreted as a [`ChatWsFrame`].
102    ///
103    /// Returns `None` when the server has cleanly closed the connection.
104    /// Returns `Some(Err(...))` on transport failure; do not call again after
105    /// a transport error.
106    fn next_chat_frame(
107        &mut self,
108    ) -> impl std::future::Future<Output = Option<Result<ChatWsFrame, ClientError>>> + Send;
109
110    /// Subscribe to ephemeral events (typing indicators and/or presence updates).
111    ///
112    /// Sends a `ChatStreamEnable` frame to the server. A subsequent call replaces
113    /// the prior subscription entirely; re-send after every reconnect because
114    /// subscriptions are session-scoped.
115    ///
116    /// Spec: draft-atwood-jmap-chat-wss-00
117    fn send_stream_enable(
118        &mut self,
119        enable: &ChatStreamEnable,
120    ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
121
122    /// Stop all ephemeral event delivery.
123    ///
124    /// Sends a `ChatStreamDisable` frame. The server MUST stop delivery silently
125    /// even if no subscription is active.
126    ///
127    /// Spec: draft-atwood-jmap-chat-wss-00
128    fn send_stream_disable(
129        &mut self,
130    ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
131}
132
133mod sealed {
134    /// Sealing-trait for [`super::ChatWsExt`] — see the trait's rustdoc.
135    pub trait Sealed {}
136    impl Sealed for ::jmap_base_client::WsSession {}
137}
138
139impl ChatWsExt for WsSession {
140    async fn next_chat_frame(&mut self) -> Option<Result<ChatWsFrame, ClientError>> {
141        self.next_frame().await.map(|r| r.map(parse_chat_ws_frame))
142    }
143
144    async fn send_stream_enable(&mut self, enable: &ChatStreamEnable) -> Result<(), ClientError> {
145        // Wrap in EphemeralMessage::Enable so the @type discriminant is included
146        // in the serialized output (ChatStreamEnable itself has no @type field).
147        let msg = EphemeralMessage::Enable(enable.clone());
148        // serde_json::to_string failure on a typed Serialize value is an
149        // internal-invariant bug (the struct is built locally, not caller
150        // input). Surface as ClientError::Parse to preserve the structured
151        // serde_json::Error rather than dropping it into a String via
152        // InvalidArgument.
153        let text = serde_json::to_string(&msg).map_err(ClientError::from_parse)?;
154        self.send_text(text).await
155    }
156
157    async fn send_stream_disable(&mut self) -> Result<(), ClientError> {
158        // ChatStreamDisable is #[non_exhaustive]; construct via deserialization
159        // of a hardcoded literal. A failure here is an internal-invariant bug
160        // (the literal is built byte-for-byte above), so ClientError::Parse
161        // preserves the structured error for debugging without conflating it
162        // with caller-supplied InvalidArgument.
163        let msg: EphemeralMessage = serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#)
164            .map_err(ClientError::from_parse)?;
165        let text = serde_json::to_string(&msg).map_err(ClientError::from_parse)?;
166        self.send_text(text).await
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use jmap_chat_types::{Presence, SenderId};
174    use jmap_types::Id;
175
176    /// Helper: build a WsFrame::Unknown with the given type_name and raw JSON.
177    fn unknown_frame(type_name: &str, json: &str) -> WsFrame {
178        let raw = serde_json::from_str(json).expect("test fixture must be valid JSON");
179        WsFrame::Unknown {
180            type_name: type_name.to_owned(),
181            raw,
182        }
183    }
184
185    /// Oracle: StateChange is forwarded unchanged.
186    /// Wire format from RFC 8620 §7.1.1.
187    #[test]
188    fn parse_state_change_forwarded() {
189        // Construct via JSON because StateChange is #[non_exhaustive].
190        let sc: jmap_base_client::StateChange =
191            serde_json::from_str(r#"{"changed":{"acc1":{"Chat":"s1"}}}"#)
192                .expect("test fixture must deserialize");
193        let frame = parse_chat_ws_frame(WsFrame::StateChange(sc));
194        match frame {
195            ChatWsFrame::StateChange(got) => {
196                assert_eq!(
197                    got.changed
198                        .get(&Id::from("acc1"))
199                        .and_then(|m| m.get("Chat"))
200                        .map(|s| s.as_ref()),
201                    Some("s1")
202                );
203            }
204            other => panic!("expected StateChange, got {other:?}"),
205        }
206    }
207
208    /// Oracle: Unknown { ChatTypingEvent } with valid JSON parses to ChatTyping.
209    /// Wire format from draft-atwood-jmap-chat-wss-00.
210    #[test]
211    fn parse_chat_typing_event_valid() {
212        let frame = unknown_frame(
213            "ChatTypingEvent",
214            r#"{"@type":"ChatTypingEvent","chatId":"c1","senderId":"u1","typing":true}"#,
215        );
216        let got = parse_chat_ws_frame(frame);
217        match got {
218            ChatWsFrame::ChatTyping(evt) => {
219                assert_eq!(evt.chat_id.as_ref(), "c1");
220                assert_eq!(evt.sender_id, SenderId::Contact("u1".to_owned()));
221                assert!(evt.typing);
222            }
223            other => panic!("expected ChatTyping, got {other:?}"),
224        }
225    }
226
227    /// Oracle: Unknown { ChatPresenceEvent } with valid JSON parses to ChatPresence.
228    /// Wire format from draft-atwood-jmap-chat-wss-00.
229    #[test]
230    fn parse_chat_presence_event_valid() {
231        let frame = unknown_frame(
232            "ChatPresenceEvent",
233            r#"{"@type":"ChatPresenceEvent","contactId":"u2","presence":"away"}"#,
234        );
235        let got = parse_chat_ws_frame(frame);
236        match got {
237            ChatWsFrame::ChatPresence(evt) => {
238                assert_eq!(evt.contact_id.as_ref(), "u2");
239                assert_eq!(evt.presence, Presence::Away);
240            }
241            other => panic!("expected ChatPresence, got {other:?}"),
242        }
243    }
244
245    /// Oracle: Unknown { ChatTypingEvent } with malformed JSON degrades to Unknown.
246    /// A bad server frame must not kill the entire WebSocket session.
247    #[test]
248    fn parse_chat_typing_malformed_degrades_to_unknown() {
249        let frame = WsFrame::Unknown {
250            type_name: "ChatTypingEvent".to_owned(),
251            raw: serde_json::json!({"missing": "required fields"}),
252        };
253        let got = parse_chat_ws_frame(frame);
254        match got {
255            ChatWsFrame::Unknown { type_name } => {
256                assert_eq!(type_name, "ChatTypingEvent");
257            }
258            other => panic!("expected Unknown, got {other:?}"),
259        }
260    }
261
262    /// Oracle: Unknown with unrecognized type_name is forwarded as Unknown.
263    /// RFC 8887 §4.3.1: clients SHOULD ignore unknown message types.
264    #[test]
265    fn parse_unknown_type_forwarded() {
266        let frame = unknown_frame("FutureEvent", r#"{"@type":"FutureEvent","foo":"bar"}"#);
267        let got = parse_chat_ws_frame(frame);
268        match got {
269            ChatWsFrame::Unknown { type_name } => assert_eq!(type_name, "FutureEvent"),
270            other => panic!("expected Unknown, got {other:?}"),
271        }
272    }
273
274    /// Oracle: send_stream_enable serializes to a ChatStreamEnable frame with @type.
275    /// Wire format from draft-atwood-jmap-chat-wss-00.
276    #[test]
277    fn send_stream_enable_serializes_with_at_type() {
278        let enable: ChatStreamEnable = serde_json::from_str(r#"{"dataTypes":["typing"]}"#)
279            .expect("test fixture must deserialize");
280        let msg = EphemeralMessage::Enable(enable);
281        let text = serde_json::to_string(&msg).expect("must serialize");
282        assert!(
283            text.contains("\"@type\":\"ChatStreamEnable\""),
284            "ChatStreamEnable frame must include @type discriminant; got: {text}"
285        );
286        assert!(
287            text.contains("\"dataTypes\""),
288            "ChatStreamEnable frame must include dataTypes field; got: {text}"
289        );
290    }
291
292    /// Oracle: send_stream_disable serializes to a ChatStreamDisable frame with @type.
293    /// Wire format from draft-atwood-jmap-chat-wss-00.
294    #[test]
295    fn send_stream_disable_serializes_with_at_type() {
296        // ChatStreamDisable is #[non_exhaustive]; construct via deserialization.
297        let msg: EphemeralMessage =
298            serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#).expect("must deserialize");
299        let text = serde_json::to_string(&msg).expect("must serialize");
300        assert!(
301            text.contains("\"@type\":\"ChatStreamDisable\""),
302            "ChatStreamDisable frame must include @type discriminant; got: {text}"
303        );
304    }
305}