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}