Skip to main content

ably_chat/
types.rs

1//! Forward-compatible domain types owned by the ergonomic crate (ADR-0007).
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6/// Opaque, user-defined JSON metadata. Not interpreted by Ably; treat as
7/// untrusted input when reading.
8pub type Metadata = serde_json::Map<String, serde_json::Value>;
9
10/// Defines a transparent `String` newtype with the shared identifier ergonomics.
11///
12/// The `ord` marker documents intent: identifiers that are region-scoped (e.g.
13/// [`Serial`]) deliberately omit `Ord`; if a keyed id later needs ordering, add
14/// a separate derive rather than flipping this marker.
15macro_rules! string_newtype {
16    ($(#[$m:meta])* $name:ident, ord = $ord:tt) => {
17        $(#[$m])*
18        #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19        #[serde(transparent)]
20        pub struct $name(String);
21        impl $name {
22            /// Borrows the underlying string.
23            pub fn as_str(&self) -> &str { &self.0 }
24            /// Consumes the newtype, returning the owned string.
25            pub fn into_string(self) -> String { self.0 }
26        }
27        impl std::fmt::Display for $name {
28            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29                f.write_str(&self.0)
30            }
31        }
32        impl From<String> for $name {
33            fn from(s: String) -> Self { Self(s) }
34        }
35        impl From<&str> for $name {
36            fn from(s: &str) -> Self { Self(s.to_owned()) }
37        }
38        impl std::borrow::Borrow<str> for $name {
39            fn borrow(&self) -> &str { &self.0 }
40        }
41    };
42}
43
44string_newtype!(
45    /// A message's unique, region-scoped identifier.
46    ///
47    /// Intentionally does **not** implement `Ord`: serials are region-scoped and
48    /// not globally ordered (ADR-0007).
49    Serial,
50    ord = false
51);
52
53string_newtype!(
54    /// The name of a chat room.
55    RoomName,
56    ord = false
57);
58
59/// Milliseconds since the Unix epoch.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(transparent)]
62pub struct Timestamp(i64);
63
64impl Timestamp {
65    /// Returns the raw milliseconds-since-epoch value.
66    pub fn as_millis(&self) -> i64 {
67        self.0
68    }
69
70    /// Converts to a `chrono` UTC datetime, if representable.
71    #[cfg(feature = "chrono")]
72    pub fn to_chrono(&self) -> Option<chrono::DateTime<chrono::Utc>> {
73        chrono::DateTime::from_timestamp_millis(self.0)
74    }
75}
76
77impl From<i64> for Timestamp {
78    fn from(v: i64) -> Self {
79        Self(v)
80    }
81}
82
83/// The action that produced a message version.
84///
85/// Forward-compatible: an unknown wire value is preserved in [`Other`] rather
86/// than failing deserialization (ADR-0007).
87///
88/// [`Other`]: MessageAction::Other
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(from = "String", into = "String")]
91pub enum MessageAction {
92    /// `message.create`
93    Create,
94    /// `message.update`
95    Update,
96    /// `message.delete`
97    Delete,
98    /// An unrecognised action value, preserved verbatim.
99    Other(String),
100}
101
102impl From<String> for MessageAction {
103    fn from(s: String) -> Self {
104        match s.as_str() {
105            "message.create" => Self::Create,
106            "message.update" => Self::Update,
107            "message.delete" => Self::Delete,
108            _ => Self::Other(s),
109        }
110    }
111}
112
113impl From<MessageAction> for String {
114    fn from(a: MessageAction) -> String {
115        match a {
116            MessageAction::Create => "message.create".into(),
117            MessageAction::Update => "message.update".into(),
118            MessageAction::Delete => "message.delete".into(),
119            MessageAction::Other(s) => s,
120        }
121    }
122}
123
124/// History/versions ordering. Query-only; serialized as a lowercase string by
125/// the dispatch layer, never via serde.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum Direction {
128    /// Oldest first.
129    Forwards,
130    /// Newest first (the default).
131    Backwards,
132}
133
134/// The reaction aggregation model.
135///
136/// Forward-compatible: an unknown wire value is preserved in [`Other`] rather
137/// than failing deserialization (ADR-0007).
138///
139/// [`Other`]: ReactionType::Other
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(from = "String", into = "String")]
142pub enum ReactionType {
143    /// At most one reaction per client.
144    Unique,
145    /// At most one of each named reaction per client.
146    Distinct,
147    /// Repeatable and counted.
148    Multiple,
149    /// An unrecognised reaction type, preserved verbatim.
150    Other(String),
151}
152
153impl From<String> for ReactionType {
154    fn from(s: String) -> Self {
155        match s.as_str() {
156            "unique" => Self::Unique,
157            "distinct" => Self::Distinct,
158            "multiple" => Self::Multiple,
159            _ => Self::Other(s),
160        }
161    }
162}
163
164impl From<ReactionType> for String {
165    fn from(t: ReactionType) -> String {
166        match t {
167            ReactionType::Unique => "unique".into(),
168            ReactionType::Distinct => "distinct".into(),
169            ReactionType::Multiple => "multiple".into(),
170            ReactionType::Other(s) => s,
171        }
172    }
173}
174
175/// A chat message in the V4 REST representation.
176#[derive(Clone, Debug, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct Message {
179    /// The message's unique, region-scoped identifier.
180    pub serial: Serial,
181    /// Details of the latest create/update/delete version of this message.
182    pub version: MessageVersion,
183    /// The text content of the message.
184    pub text: String,
185    /// The client ID of the user who created the message.
186    pub client_id: String,
187    /// The action that produced this message version.
188    pub action: MessageAction,
189    /// Arbitrary user-defined metadata.
190    #[serde(default)]
191    pub metadata: Metadata,
192    /// Arbitrary user-defined string headers.
193    #[serde(default)]
194    pub headers: BTreeMap<String, String>,
195    /// User claim attached by the server, present only when the publishing
196    /// token carried a matching `ably.room.<roomName>` claim.
197    #[serde(default)]
198    pub user_claim: Option<String>,
199    /// Milliseconds since the Unix epoch at which the message was created.
200    pub timestamp: Timestamp,
201    /// Summary of reactions on this message. Absent groups default to empty.
202    #[serde(default)]
203    pub reactions: ReactionSummary,
204}
205
206/// Details of the latest create/update/delete version of a message.
207#[derive(Clone, Debug, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct MessageVersion {
210    /// Unique identifier of this message version.
211    pub serial: Serial,
212    /// Milliseconds since the Unix epoch at which this version was created.
213    pub timestamp: Timestamp,
214    /// Client ID of the user who performed the update or deletion.
215    #[serde(default)]
216    pub client_id: Option<String>,
217    /// Optional description supplied with an update or deletion.
218    #[serde(default)]
219    pub description: Option<String>,
220    /// Optional metadata supplied with an update or deletion.
221    #[serde(default)]
222    pub metadata: Option<BTreeMap<String, String>>,
223}
224
225/// Occupancy metrics for a room.
226#[derive(Clone, Copy, Debug, Default, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct Occupancy {
229    /// The number of connections to the room.
230    pub connections: u64,
231    /// The number of members currently present in the room.
232    pub presence_members: u64,
233}
234
235/// Summary of reactions on a message, grouped by reaction type. Each map is
236/// keyed by the reaction name (e.g. an emoji). Absent groups default to empty.
237#[derive(Clone, Debug, Default, Deserialize)]
238pub struct ReactionSummary {
239    /// `unique` reactions, keyed by reaction name.
240    #[serde(default)]
241    pub unique: BTreeMap<String, ClientIdList>,
242    /// `distinct` reactions, keyed by reaction name.
243    #[serde(default)]
244    pub distinct: BTreeMap<String, ClientIdList>,
245    /// `multiple` reactions, keyed by reaction name.
246    #[serde(default)]
247    pub multiple: BTreeMap<String, ClientIdCounts>,
248}
249
250/// Aggregated set of client IDs for a `unique`/`distinct` reaction.
251#[derive(Clone, Debug, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct ClientIdList {
254    /// Total number of clients that applied this reaction.
255    pub total: u64,
256    /// The client IDs that applied this reaction.
257    #[serde(default)]
258    pub client_ids: Vec<String>,
259    /// Whether the `client_ids` list was truncated.
260    #[serde(default)]
261    pub clipped: bool,
262}
263
264/// Aggregated per-client counts for a `multiple` reaction.
265#[derive(Clone, Debug, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct ClientIdCounts {
268    /// Total count across all clients (sum of per-client counts).
269    pub total: u64,
270    /// Map of client ID to that client's reaction count.
271    #[serde(default)]
272    pub client_ids: BTreeMap<String, u64>,
273    /// Total count contributed by unidentified clients.
274    #[serde(default)]
275    pub total_unidentified: u64,
276    /// Whether the `client_ids` map was truncated.
277    #[serde(default)]
278    pub clipped: bool,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn serial_roundtrips_through_json() {
287        let s: Serial = serde_json::from_str("\"01abc@def:001\"").unwrap();
288        assert_eq!(s.as_str(), "01abc@def:001");
289        assert_eq!(serde_json::to_string(&s).unwrap(), "\"01abc@def:001\"");
290    }
291
292    #[test]
293    fn timestamp_is_epoch_millis() {
294        let t: Timestamp = serde_json::from_str("1700000000000").unwrap();
295        assert_eq!(t.as_millis(), 1_700_000_000_000);
296    }
297
298    #[test]
299    fn unknown_action_is_captured_not_rejected() {
300        let a: MessageAction = serde_json::from_str("\"message.future\"").unwrap();
301        assert_eq!(a, MessageAction::Other("message.future".into()));
302        let c: MessageAction = serde_json::from_str("\"message.create\"").unwrap();
303        assert_eq!(c, MessageAction::Create);
304        // Known variants round-trip to their wire string.
305        assert_eq!(
306            serde_json::to_string(&MessageAction::Delete).unwrap(),
307            "\"message.delete\""
308        );
309    }
310
311    #[test]
312    fn unknown_reaction_type_is_captured_not_rejected() {
313        let r: ReactionType = serde_json::from_str("\"future\"").unwrap();
314        assert_eq!(r, ReactionType::Other("future".into()));
315        let d: ReactionType = serde_json::from_str("\"distinct\"").unwrap();
316        assert_eq!(d, ReactionType::Distinct);
317        assert_eq!(
318            serde_json::to_string(&ReactionType::Multiple).unwrap(),
319            "\"multiple\""
320        );
321    }
322
323    #[test]
324    fn message_deserializes_from_wire_camelcase() {
325        // Byte-faithful to openapi/ably-chat-rest.yaml (camelCase, nested version,
326        // reactions omitted).
327        let wire = r#"{
328            "serial": "01726585978590-001@abcdefghij:001",
329            "version": {
330                "serial": "01726585978590-001@abcdefghij:001",
331                "timestamp": 1700000000000,
332                "clientId": "alice",
333                "description": "edited"
334            },
335            "text": "hello",
336            "clientId": "alice",
337            "action": "message.create",
338            "metadata": {"priority": "high"},
339            "headers": {"topic": "announcements"},
340            "timestamp": 1700000000000,
341            "userClaim": "room-claim"
342        }"#;
343        let msg: Message = serde_json::from_str(wire).unwrap();
344        assert_eq!(msg.serial.as_str(), "01726585978590-001@abcdefghij:001");
345        assert_eq!(msg.text, "hello");
346        assert_eq!(msg.client_id, "alice");
347        assert_eq!(msg.action, MessageAction::Create);
348        assert_eq!(msg.timestamp.as_millis(), 1_700_000_000_000);
349        assert_eq!(msg.version.client_id.as_deref(), Some("alice"));
350        assert_eq!(msg.version.description.as_deref(), Some("edited"));
351        assert_eq!(msg.metadata["priority"], serde_json::json!("high"));
352        assert_eq!(msg.headers["topic"], "announcements");
353        assert_eq!(msg.user_claim.as_deref(), Some("room-claim"));
354        // Absent reactions default to empty groups.
355        assert!(msg.reactions.unique.is_empty());
356        assert!(msg.reactions.distinct.is_empty());
357        assert!(msg.reactions.multiple.is_empty());
358    }
359
360    #[test]
361    fn reaction_summary_deserializes_camelcase_fields() {
362        let wire = r#"{
363            "unique": {"👍": {"total": 2, "clientIds": ["alice", "bob"], "clipped": false}},
364            "multiple": {"🎉": {"total": 5, "clientIds": {"alice": 3, "bob": 2}, "totalUnidentified": 1}}
365        }"#;
366        let summary: ReactionSummary = serde_json::from_str(wire).unwrap();
367        let thumbs = &summary.unique["\u{1f44d}"];
368        assert_eq!(thumbs.total, 2);
369        assert_eq!(thumbs.client_ids, vec!["alice", "bob"]);
370        assert!(!thumbs.clipped);
371        let party = &summary.multiple["\u{1f389}"];
372        assert_eq!(party.total, 5);
373        assert_eq!(party.client_ids["alice"], 3);
374        assert_eq!(party.total_unidentified, 1);
375        assert!(summary.distinct.is_empty());
376    }
377
378    #[test]
379    fn occupancy_deserializes() {
380        let occ: Occupancy =
381            serde_json::from_str(r#"{"connections": 3, "presenceMembers": 2}"#).unwrap();
382        assert_eq!(occ.connections, 3);
383        assert_eq!(occ.presence_members, 2);
384    }
385
386    #[cfg(feature = "chrono")]
387    #[test]
388    fn timestamp_converts_to_chrono() {
389        let t = Timestamp::from(1_700_000_000_000);
390        assert_eq!(t.to_chrono().unwrap().timestamp_millis(), 1_700_000_000_000);
391    }
392}