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 `@type` value from the JSON frame; `"<no @type>"` if absent.
37        type_name: String,
38    },
39}
40
41/// Promote a base-client [`WsFrame`] to a [`ChatWsFrame`].
42///
43/// - `StateChange` and `Response` are forwarded unchanged.
44/// - `Unknown { type_name: "ChatTypingEvent", raw }` → tries `serde_json::from_value`
45///   into [`ChatTypingEvent`]; on failure produces `Unknown` (not an error).
46/// - `Unknown { type_name: "ChatPresenceEvent", raw }` → same for [`ChatPresenceEvent`].
47/// - All other `Unknown` frames are forwarded as `ChatWsFrame::Unknown`.
48pub fn parse_chat_ws_frame(frame: WsFrame) -> ChatWsFrame {
49    match frame {
50        WsFrame::StateChange(sc) => ChatWsFrame::StateChange(sc),
51        WsFrame::Response(r) => ChatWsFrame::Response(r),
52        WsFrame::Unknown { type_name, raw } => match type_name.as_str() {
53            "ChatTypingEvent" => match serde_json::from_value::<ChatTypingEvent>(raw) {
54                Ok(evt) => ChatWsFrame::ChatTyping(evt),
55                Err(_) => ChatWsFrame::Unknown {
56                    type_name: "ChatTypingEvent".to_owned(),
57                },
58            },
59            "ChatPresenceEvent" => match serde_json::from_value::<ChatPresenceEvent>(raw) {
60                Ok(evt) => ChatWsFrame::ChatPresence(evt),
61                Err(_) => ChatWsFrame::Unknown {
62                    type_name: "ChatPresenceEvent".to_owned(),
63                },
64            },
65            _ => ChatWsFrame::Unknown { type_name },
66        },
67        // WsFrame is #[non_exhaustive]: forward any future base-client variants as Unknown.
68        _ => ChatWsFrame::Unknown {
69            type_name: "<unknown>".to_owned(),
70        },
71    }
72}
73
74/// Extension trait adding JMAP Chat ephemeral-event methods to [`WsSession`].
75///
76/// Import this trait to use: `use jmap_chat_client::ChatWsExt;`
77pub trait ChatWsExt {
78    /// Receive the next frame from the server, interpreted as a [`ChatWsFrame`].
79    ///
80    /// Returns `None` when the server has cleanly closed the connection.
81    /// Returns `Some(Err(...))` on transport failure; do not call again after
82    /// a transport error.
83    fn next_chat_frame(
84        &mut self,
85    ) -> impl std::future::Future<Output = Option<Result<ChatWsFrame, ClientError>>> + Send;
86
87    /// Subscribe to ephemeral events (typing indicators and/or presence updates).
88    ///
89    /// Sends a `ChatStreamEnable` frame to the server. A subsequent call replaces
90    /// the prior subscription entirely; re-send after every reconnect because
91    /// subscriptions are session-scoped.
92    ///
93    /// Spec: draft-atwood-jmap-chat-wss-00
94    fn send_stream_enable(
95        &mut self,
96        enable: &ChatStreamEnable,
97    ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
98
99    /// Stop all ephemeral event delivery.
100    ///
101    /// Sends a `ChatStreamDisable` frame. The server MUST stop delivery silently
102    /// even if no subscription is active.
103    ///
104    /// Spec: draft-atwood-jmap-chat-wss-00
105    fn send_stream_disable(
106        &mut self,
107    ) -> impl std::future::Future<Output = Result<(), ClientError>> + Send;
108}
109
110impl ChatWsExt for WsSession {
111    async fn next_chat_frame(&mut self) -> Option<Result<ChatWsFrame, ClientError>> {
112        self.next_frame().await.map(|r| r.map(parse_chat_ws_frame))
113    }
114
115    async fn send_stream_enable(&mut self, enable: &ChatStreamEnable) -> Result<(), ClientError> {
116        // Wrap in EphemeralMessage::Enable so the @type discriminant is included
117        // in the serialized output (ChatStreamEnable itself has no @type field).
118        let msg = EphemeralMessage::Enable(enable.clone());
119        let text =
120            serde_json::to_string(&msg).map_err(|e| ClientError::InvalidArgument(e.to_string()))?;
121        self.send_text(text).await
122    }
123
124    async fn send_stream_disable(&mut self) -> Result<(), ClientError> {
125        // ChatStreamDisable is #[non_exhaustive]; construct via deserialization.
126        let msg: EphemeralMessage = serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#)
127            .map_err(|e| {
128                ClientError::InvalidArgument(format!("ChatStreamDisable serialization: {e}"))
129            })?;
130        let text =
131            serde_json::to_string(&msg).map_err(|e| ClientError::InvalidArgument(e.to_string()))?;
132        self.send_text(text).await
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use jmap_types::Id;
140
141    /// Helper: build a WsFrame::Unknown with the given type_name and raw JSON.
142    fn unknown_frame(type_name: &str, json: &str) -> WsFrame {
143        let raw = serde_json::from_str(json).expect("test fixture must be valid JSON");
144        WsFrame::Unknown {
145            type_name: type_name.to_owned(),
146            raw,
147        }
148    }
149
150    /// Oracle: StateChange is forwarded unchanged.
151    /// Wire format from RFC 8620 §7.1.1.
152    #[test]
153    fn parse_state_change_forwarded() {
154        // Construct via JSON because StateChange is #[non_exhaustive].
155        let sc: jmap_base_client::StateChange =
156            serde_json::from_str(r#"{"changed":{"acc1":{"Chat":"s1"}}}"#)
157                .expect("test fixture must deserialize");
158        let frame = parse_chat_ws_frame(WsFrame::StateChange(sc));
159        match frame {
160            ChatWsFrame::StateChange(got) => {
161                assert_eq!(
162                    got.changed
163                        .get(&Id::from("acc1"))
164                        .and_then(|m| m.get("Chat"))
165                        .map(|s| s.as_ref()),
166                    Some("s1")
167                );
168            }
169            other => panic!("expected StateChange, got {other:?}"),
170        }
171    }
172
173    /// Oracle: Unknown { ChatTypingEvent } with valid JSON parses to ChatTyping.
174    /// Wire format from draft-atwood-jmap-chat-wss-00.
175    #[test]
176    fn parse_chat_typing_event_valid() {
177        let frame = unknown_frame(
178            "ChatTypingEvent",
179            r#"{"@type":"ChatTypingEvent","chatId":"c1","senderId":"u1","typing":true}"#,
180        );
181        let got = parse_chat_ws_frame(frame);
182        match got {
183            ChatWsFrame::ChatTyping(evt) => {
184                assert_eq!(evt.chat_id.as_ref(), "c1");
185                assert_eq!(evt.sender_id, "u1");
186                assert!(evt.typing);
187            }
188            other => panic!("expected ChatTyping, got {other:?}"),
189        }
190    }
191
192    /// Oracle: Unknown { ChatPresenceEvent } with valid JSON parses to ChatPresence.
193    /// Wire format from draft-atwood-jmap-chat-wss-00.
194    #[test]
195    fn parse_chat_presence_event_valid() {
196        let frame = unknown_frame(
197            "ChatPresenceEvent",
198            r#"{"@type":"ChatPresenceEvent","contactId":"u2","presence":"away"}"#,
199        );
200        let got = parse_chat_ws_frame(frame);
201        match got {
202            ChatWsFrame::ChatPresence(evt) => {
203                assert_eq!(evt.contact_id.as_ref(), "u2");
204                assert_eq!(evt.presence, "away");
205            }
206            other => panic!("expected ChatPresence, got {other:?}"),
207        }
208    }
209
210    /// Oracle: Unknown { ChatTypingEvent } with malformed JSON degrades to Unknown.
211    /// A bad server frame must not kill the entire WebSocket session.
212    #[test]
213    fn parse_chat_typing_malformed_degrades_to_unknown() {
214        let frame = WsFrame::Unknown {
215            type_name: "ChatTypingEvent".to_owned(),
216            raw: serde_json::json!({"missing": "required fields"}),
217        };
218        let got = parse_chat_ws_frame(frame);
219        match got {
220            ChatWsFrame::Unknown { type_name } => {
221                assert_eq!(type_name, "ChatTypingEvent");
222            }
223            other => panic!("expected Unknown, got {other:?}"),
224        }
225    }
226
227    /// Oracle: Unknown with unrecognized type_name is forwarded as Unknown.
228    /// RFC 8887 §4.3.1: clients SHOULD ignore unknown message types.
229    #[test]
230    fn parse_unknown_type_forwarded() {
231        let frame = unknown_frame("FutureEvent", r#"{"@type":"FutureEvent","foo":"bar"}"#);
232        let got = parse_chat_ws_frame(frame);
233        match got {
234            ChatWsFrame::Unknown { type_name } => assert_eq!(type_name, "FutureEvent"),
235            other => panic!("expected Unknown, got {other:?}"),
236        }
237    }
238
239    /// Oracle: send_stream_enable serializes to a ChatStreamEnable frame with @type.
240    /// Wire format from draft-atwood-jmap-chat-wss-00.
241    #[test]
242    fn send_stream_enable_serializes_with_at_type() {
243        let enable: ChatStreamEnable = serde_json::from_str(r#"{"dataTypes":["typing"]}"#)
244            .expect("test fixture must deserialize");
245        let msg = EphemeralMessage::Enable(enable);
246        let text = serde_json::to_string(&msg).expect("must serialize");
247        assert!(
248            text.contains("\"@type\":\"ChatStreamEnable\""),
249            "ChatStreamEnable frame must include @type discriminant; got: {text}"
250        );
251        assert!(
252            text.contains("\"dataTypes\""),
253            "ChatStreamEnable frame must include dataTypes field; got: {text}"
254        );
255    }
256
257    /// Oracle: send_stream_disable serializes to a ChatStreamDisable frame with @type.
258    /// Wire format from draft-atwood-jmap-chat-wss-00.
259    #[test]
260    fn send_stream_disable_serializes_with_at_type() {
261        // ChatStreamDisable is #[non_exhaustive]; construct via deserialization.
262        let msg: EphemeralMessage =
263            serde_json::from_str(r#"{"@type":"ChatStreamDisable"}"#).expect("must deserialize");
264        let text = serde_json::to_string(&msg).expect("must serialize");
265        assert!(
266            text.contains("\"@type\":\"ChatStreamDisable\""),
267            "ChatStreamDisable frame must include @type discriminant; got: {text}"
268        );
269    }
270}