Skip to main content

vertigo/websocket_collection/
ws_message_from.rs

1//! Server (WebSocket) messages, tagged by variant name, e.g.:
2//! `{ "init": { ... } }`, `{ "set": { "query_id", "model_id", "model" } }`,
3//! `{ "delete": { "query_id", "model_id" } }`.
4//! The `list` and `model` fields are raw `JsJson` (flexible shape from the backend).
5//!
6//! From [`crate::JsJson`] (e.g. from [`crate::WebsocketMessage::Message`]): [`WsServerMessageFrom::from_js_json`].
7//!
8//! ## Protocol source of truth
9//!
10//! Mirrors the server's data-package enum: the server emits one of `init` / `set` /
11//! `delete` / `batch` / `message` per frame, with `query_id` identifying the
12//! subscription. When the server adds or renames a variant, sync this enum and
13//! `from_js_json` will report the unknown tag by name instead of failing silently.
14
15use crate::{AutoJsJson, JsJson};
16
17use super::types::WebsocketQueryId;
18
19#[derive(Debug, Clone, AutoJsJson, PartialEq)]
20pub struct WsInitDataModel {
21    pub id: String,
22    pub model: JsJson,
23}
24
25#[derive(Debug, Clone, AutoJsJson, PartialEq)]
26pub struct WsInitData {
27    pub query_id: WebsocketQueryId,
28    pub list: Vec<WsInitDataModel>,
29}
30
31#[derive(Debug, Clone, AutoJsJson, PartialEq)]
32pub struct WsSetData {
33    pub query_id: WebsocketQueryId,
34    pub model_id: String,
35    pub model: JsJson,
36}
37
38#[derive(Debug, Clone, AutoJsJson, PartialEq)]
39pub struct WsDeleteData {
40    pub query_id: WebsocketQueryId,
41    pub model_id: String,
42}
43
44#[derive(Debug, Clone, AutoJsJson, PartialEq)]
45pub enum WsMessageKind {
46    Log,
47    Error,
48}
49
50#[derive(Debug, Clone, AutoJsJson, PartialEq)]
51#[js_json(rename_all = "camelCase")]
52pub struct WsMessageData {
53    pub query_id: WebsocketQueryId,
54    pub kind: WsMessageKind,
55    pub message: String,
56    pub code: Option<u16>,
57}
58
59#[derive(Debug, Clone, AutoJsJson, PartialEq)]
60pub enum WsServerMessageFrom {
61    #[js_json(rename = "init")]
62    Init(WsInitData),
63    #[js_json(rename = "set")]
64    Set(WsSetData),
65    #[js_json(rename = "delete")]
66    Delete(WsDeleteData),
67    #[js_json(rename = "batch")]
68    Batch(Vec<WsServerMessageFrom>),
69    #[js_json(rename = "message")]
70    Message(WsMessageData),
71}
72
73/// Inspects the top-level shape of a server message and returns the variant tag (the
74/// single object key) when present — used purely for diagnostics on parse failure.
75fn unknown_tag(json: &JsJson) -> Option<String> {
76    match json {
77        JsJson::Object(map) if map.len() == 1 => map.keys().next().cloned(),
78        _ => None,
79    }
80}
81
82impl WsServerMessageFrom {
83    /// Decodes a message already as `JsJson` (as delivered by the WebSocket driver).
84    ///
85    /// On parse failure, enriches the error with the top-level tag the server used
86    /// (e.g. `initOne`) so log readers can tell *which* variant is missing rather
87    /// than just "value did not match any variant".
88    pub(crate) fn from_js_json(json: JsJson) -> Result<Self, String> {
89        crate::from_json::<Self>(json.clone()).map_err(|err| match unknown_tag(&json) {
90            Some(tag) => format!("unknown variant `{tag}` (server/client protocol drift): {err}"),
91            None => err,
92        })
93    }
94
95    /// `Batch` has no single id (it carries multiple sub-messages); all other variants do.
96    pub(crate) fn query_id(&self) -> Option<WebsocketQueryId> {
97        match self {
98            WsServerMessageFrom::Init(d) => Some(d.query_id.clone()),
99            WsServerMessageFrom::Set(d) => Some(d.query_id.clone()),
100            WsServerMessageFrom::Delete(d) => Some(d.query_id.clone()),
101            WsServerMessageFrom::Message(d) => Some(d.query_id.clone()),
102            WsServerMessageFrom::Batch(_) => None,
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::{collections::BTreeMap, error::Error};
110
111    use crate::{JsJson, JsJsonContext, JsJsonDeserialize, JsJsonNumber, to_json};
112
113    use super::{super::types::WebsocketQueryId, *};
114
115    fn ctx() -> JsJsonContext {
116        JsJsonContext::new("")
117    }
118
119    fn sample_model_js() -> JsJson {
120        JsJson::Object(BTreeMap::from([
121            ("name".into(), JsJson::String("Test".into())),
122            ("ref_id".into(), JsJson::String("1".into())),
123            ("id".into(), JsJson::Number(JsJsonNumber(1.0))),
124            ("flag".into(), JsJson::False),
125            ("owner_id".into(), JsJson::Number(JsJsonNumber(1.0))),
126        ]))
127    }
128
129    #[test]
130    fn ws_server_message_init_round_trips() -> Result<(), Box<dyn Error>> {
131        let msg = WsServerMessageFrom::Init(WsInitData {
132            query_id: WebsocketQueryId("q1".into()),
133            list: vec![WsInitDataModel {
134                id: "1".into(),
135                model: sample_model_js(),
136            }],
137        });
138        let json = to_json(msg.clone());
139        let back = crate::from_json::<WsServerMessageFrom>(json)?;
140        assert_eq!(back, msg);
141
142        Ok(())
143    }
144
145    #[test]
146    fn ws_server_message_set_round_trips() -> Result<(), Box<dyn Error>> {
147        let msg = WsServerMessageFrom::Set(WsSetData {
148            query_id: WebsocketQueryId("q1".into()),
149            model_id: "6".into(),
150            model: sample_model_js(),
151        });
152        let json = to_json(msg.clone());
153        let back = crate::from_json::<WsServerMessageFrom>(json)?;
154        assert_eq!(back, msg);
155
156        Ok(())
157    }
158
159    #[test]
160    fn ws_server_message_delete_round_trips() -> Result<(), Box<dyn Error>> {
161        let msg = WsServerMessageFrom::Delete(WsDeleteData {
162            query_id: WebsocketQueryId("query_0".into()),
163            model_id: "7".into(),
164        });
165        let json = to_json(msg.clone());
166        let back = crate::from_json::<WsServerMessageFrom>(json)?;
167        assert_eq!(back, msg);
168
169        Ok(())
170    }
171
172    #[test]
173    fn from_js_json_reports_unknown_variant_by_tag() -> Result<(), Box<dyn Error>> {
174        // Simulates server drift (e.g. a new `InitOne` variant) — the parser must report
175        // the offending tag in the error so logs are actionable, and must not panic.
176        let raw = JsJson::Object(BTreeMap::from([(
177            "initOne".into(),
178            JsJson::Object(BTreeMap::from([
179                ("queryId".into(), JsJson::String("query_0".into())),
180                ("modelId".into(), JsJson::String("1".into())),
181                ("model".into(), JsJson::Number(JsJsonNumber(1.0))),
182            ])),
183        )]));
184
185        let err = WsServerMessageFrom::from_js_json(raw)
186            .err()
187            .ok_or_else(|| "expected error".to_string())?;
188        assert!(
189            err.contains("`initOne`"),
190            "error must name the unknown tag, got: {err}"
191        );
192
193        Ok(())
194    }
195
196    #[test]
197    fn ws_server_message_init_matches_from_json_on_jsjson() -> Result<(), Box<dyn Error>> {
198        let msg = WsServerMessageFrom::Init(WsInitData {
199            query_id: WebsocketQueryId("query_0".into()),
200            list: vec![WsInitDataModel {
201                id: "1".into(),
202                model: JsJson::Number(JsJsonNumber(1.0)),
203            }],
204        });
205        let json = to_json(msg.clone());
206        let from_public = crate::from_json::<WsServerMessageFrom>(json.clone())?;
207        let from_trait = WsServerMessageFrom::from_json(ctx(), json)?;
208        assert_eq!(from_public, msg);
209        assert_eq!(from_trait, msg);
210
211        Ok(())
212    }
213}