Skip to main content

dora_message/
ws_protocol.rs

1//! WebSocket message protocol for the control plane.
2//!
3//! Three message types over JSON text frames:
4//!
5//! - **Request** (client -> server): `{"id": "uuid", "method": "...", "params": {...}}`
6//! - **Response** (server -> client): `{"id": "uuid", "result": {...}}` or `{"id": "uuid", "error": "..."}`
7//! - **Event** (either direction): `{"event": "...", "payload": {...}}`
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use uuid::Uuid;
12
13/// A request from client to server, expecting a response with the same `id`.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WsRequest {
16    pub id: Uuid,
17    pub method: String,
18    pub params: Value,
19}
20
21/// A response from server to client, matching a request `id`.
22///
23/// # Serde invariant
24///
25/// `result: Some(Value::Null)` and `result: None` are **equivalent on the
26/// wire**. Both serialize to a JSON object without a `result` field
27/// (the `Some(Null)` case via the standard serde rule that JSON `null`
28/// deserializes back as `None` for `Option<T>`). This is intentional and
29/// matches JSON-RPC convention. Pinned by a unit test below
30/// (`ws_response_some_null_equals_none_on_wire`).
31///
32/// If you need to distinguish "no result" from "result is JSON null"
33/// across the wire, change the field type to `Option<Option<Value>>`
34/// and use `serde_with::rust::double_option`. This is a deliberately
35/// non-default choice; do not make it without coordination.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct WsResponse {
38    pub id: Uuid,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub result: Option<Value>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub error: Option<String>,
43}
44
45impl WsResponse {
46    pub fn ok(id: Uuid, result: Value) -> Self {
47        Self {
48            id,
49            result: Some(result),
50            error: None,
51        }
52    }
53
54    pub fn err(id: Uuid, error: String) -> Self {
55        Self {
56            id,
57            result: None,
58            error: Some(error),
59        }
60    }
61}
62
63/// A fire-and-forget event in either direction.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct WsEvent {
66    pub event: String,
67    pub payload: Value,
68}
69
70/// Discriminated union for parsing any incoming WS text frame.
71///
72/// Uses `#[serde(untagged)]` which tries variants in order.
73/// Discriminating fields (order matters):
74/// 1. `Request` — matched first because it has a required `method` field
75/// 2. `Response` — has `id` + optional `result`/`error` (no `method`)
76/// 3. `Event` — has `event` field, no `id` or `method`
77///
78/// A message with both `method` and `result` would match `Request`.
79/// This is correct because responses never have a `method` field.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(untagged)]
82pub enum WsMessage {
83    Request(WsRequest),
84    Response(WsResponse),
85    Event(WsEvent),
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use serde_json::json;
92
93    #[test]
94    fn ws_request_roundtrip() {
95        let id = Uuid::new_v4();
96        let req = WsRequest {
97            id,
98            method: "control".into(),
99            params: json!({"foo": 42}),
100        };
101        let json = serde_json::to_string(&req).unwrap();
102        let decoded: WsRequest = serde_json::from_str(&json).unwrap();
103        assert_eq!(decoded.id, id);
104        assert_eq!(decoded.method, "control");
105        assert_eq!(decoded.params, json!({"foo": 42}));
106    }
107
108    #[test]
109    fn ws_response_ok_roundtrip() {
110        let id = Uuid::new_v4();
111        let resp = WsResponse::ok(id, json!({"status": "running"}));
112        let json = serde_json::to_string(&resp).unwrap();
113        let decoded: WsResponse = serde_json::from_str(&json).unwrap();
114        assert_eq!(decoded.id, id);
115        assert_eq!(decoded.result, Some(json!({"status": "running"})));
116        assert!(decoded.error.is_none());
117    }
118
119    #[test]
120    fn ws_response_err_roundtrip() {
121        let id = Uuid::new_v4();
122        let resp = WsResponse::err(id, "something failed".into());
123        let json = serde_json::to_string(&resp).unwrap();
124        let decoded: WsResponse = serde_json::from_str(&json).unwrap();
125        assert_eq!(decoded.id, id);
126        assert!(decoded.result.is_none());
127        assert_eq!(decoded.error.as_deref(), Some("something failed"));
128    }
129
130    #[test]
131    fn ws_event_roundtrip() {
132        let evt = WsEvent {
133            event: "log".into(),
134            payload: json!({"line": "hello"}),
135        };
136        let json = serde_json::to_string(&evt).unwrap();
137        let decoded: WsEvent = serde_json::from_str(&json).unwrap();
138        assert_eq!(decoded.event, "log");
139        assert_eq!(decoded.payload, json!({"line": "hello"}));
140    }
141
142    #[test]
143    fn ws_message_dispatches_to_response() {
144        let id = Uuid::new_v4();
145        let json = json!({"id": id, "result": {"ok": true}}).to_string();
146        let msg: WsMessage = serde_json::from_str(&json).unwrap();
147        assert!(matches!(msg, WsMessage::Response(_)));
148    }
149
150    #[test]
151    fn ws_message_dispatches_to_event() {
152        let json = json!({"event": "log", "payload": {}}).to_string();
153        let msg: WsMessage = serde_json::from_str(&json).unwrap();
154        assert!(matches!(msg, WsMessage::Event(_)));
155    }
156
157    #[test]
158    fn ws_response_ok_helper() {
159        let id = Uuid::new_v4();
160        let resp = WsResponse::ok(id, json!("done"));
161        assert_eq!(resp.result, Some(json!("done")));
162        assert!(resp.error.is_none());
163    }
164
165    #[test]
166    fn ws_response_err_helper() {
167        let id = Uuid::new_v4();
168        let resp = WsResponse::err(id, "bad".into());
169        assert!(resp.result.is_none());
170        assert_eq!(resp.error.as_deref(), Some("bad"));
171    }
172
173    #[test]
174    fn ws_request_nil_id_rejected() {
175        // Malformed JSON (missing required fields) -> serde error
176        let bad = r#"{"id": null}"#;
177        assert!(serde_json::from_str::<WsRequest>(bad).is_err());
178    }
179
180    #[test]
181    fn ws_message_unknown_shape() {
182        // JSON that matches neither Request, Response, nor Event
183        let bad = json!({"random": "stuff"}).to_string();
184        assert!(serde_json::from_str::<WsMessage>(&bad).is_err());
185    }
186
187    /// Pins the documented serde invariant on `WsResponse`:
188    /// `result: Some(Value::Null)` and `result: None` are equivalent on the
189    /// wire. A `Some(Null)` value serializes as `result: null` (because
190    /// `skip_serializing_if = "Option::is_none"` only checks the outer
191    /// Option), and serde's default Option<T> deserializer turns JSON
192    /// `null` back into `None`. The roundtrip is therefore lossy in one
193    /// direction by design.
194    ///
195    /// Discovered by property testing
196    /// (`prop_response_roundtrip` failure on commit fddbe7b). Documented
197    /// in `WsResponse`'s doc comment as intentional. Do NOT delete this
198    /// test without updating the docs and the proptest strategy.
199    #[test]
200    fn ws_response_some_null_equals_none_on_wire() {
201        let id = Uuid::new_v4();
202        let with_some_null = WsResponse {
203            id,
204            result: Some(serde_json::Value::Null),
205            error: None,
206        };
207        let with_none = WsResponse {
208            id,
209            result: None,
210            error: None,
211        };
212
213        // Both serialize the same way: result field is omitted entirely
214        // for None, and serialized as `null` for Some(Null). They are
215        // structurally different but indistinguishable as JSON values.
216        let json_some = serde_json::to_string(&with_some_null).unwrap();
217        let json_none = serde_json::to_string(&with_none).unwrap();
218        assert!(
219            json_some.contains("\"result\":null") || json_some == json_none,
220            "expected Some(Null) to serialize as result:null or be omitted; got {json_some}"
221        );
222
223        // Both deserialize to the same in-memory representation: None.
224        let from_some: WsResponse = serde_json::from_str(&json_some).unwrap();
225        let from_none: WsResponse = serde_json::from_str(&json_none).unwrap();
226        assert_eq!(from_some.result, None, "Some(Null) must round-trip to None");
227        assert_eq!(from_none.result, None);
228    }
229
230    // --- Property tests (proptest) ---
231    //
232    // These tests exercise the JSON serde roundtrip for each WsMessage
233    // variant over generated inputs. The key properties are:
234    //
235    //   1. serialize -> deserialize is a fixed point for each variant
236    //      (the value is preserved)
237    //   2. when wrapped in the untagged WsMessage enum, the original
238    //      variant is preserved across roundtrip (no variant escaping)
239    //
240    // These catch two classes of bugs that the hand-written tests miss:
241    //   - variant confusion in untagged enums (a Request deserializing
242    //     as a Response, or similar)
243    //   - field-ordering sensitivity in serde
244    //   - edge cases in string/number/array handling that nobody
245    //     thought to add to the hand-written suite
246
247    use proptest::prelude::*;
248
249    /// Strategy for arbitrary JSON values with bounded recursion depth.
250    fn arb_json_value() -> impl Strategy<Value = serde_json::Value> {
251        use serde_json::Value;
252        let leaf = prop_oneof![
253            Just(Value::Null),
254            any::<bool>().prop_map(Value::Bool),
255            any::<i64>().prop_map(|n| Value::Number(n.into())),
256            // f64 strategy: only generate values that are guaranteed to
257            // round-trip exactly through JSON. Arbitrary f64s near the edge
258            // of precision (e.g., 1e120) lose 1 ULP through the string
259            // representation; that's a property of JSON itself, not dora.
260            // We restrict to values within the i32 range cast to f64, which
261            // are always representable losslessly.
262            (any::<i32>()).prop_map(|n| serde_json::Number::from_f64(f64::from(n))
263                .map(Value::Number)
264                .unwrap_or(Value::Null)),
265            "[a-zA-Z0-9 _-]{0,32}".prop_map(Value::String),
266        ];
267        leaf.prop_recursive(
268            3,  // depth
269            16, // max total nodes
270            4,  // items per collection
271            |inner| {
272                prop_oneof![
273                    prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
274                    prop::collection::hash_map("[a-zA-Z][a-zA-Z0-9_]{0,8}", inner, 0..4)
275                        .prop_map(|m| Value::Object(m.into_iter().collect())),
276                ]
277            },
278        )
279    }
280
281    fn arb_method() -> impl Strategy<Value = String> {
282        "[a-z][a-z_]{0,16}".prop_map(|s| s.to_string())
283    }
284
285    fn arb_event() -> impl Strategy<Value = String> {
286        "[a-z][a-z_]{0,16}".prop_map(|s| s.to_string())
287    }
288
289    fn arb_ws_request() -> impl Strategy<Value = WsRequest> {
290        (any::<u128>(), arb_method(), arb_json_value()).prop_map(|(id, method, params)| WsRequest {
291            id: Uuid::from_u128(id),
292            method,
293            params,
294        })
295    }
296
297    fn arb_ws_response() -> impl Strategy<Value = WsResponse> {
298        // Filter `Some(Value::Null)` out of the `result` field: by the
299        // documented serde invariant on WsResponse, `Some(Null)` is
300        // equivalent to `None` on the wire and would not survive a
301        // structural roundtrip. The equivalence itself is pinned by the
302        // `ws_response_some_null_equals_none_on_wire` unit test, so this
303        // filter does not hide a bug — it scopes the property to inputs
304        // that are *expected* to roundtrip identically.
305        (
306            any::<u128>(),
307            prop::option::of(arb_json_value().prop_filter("not Value::Null", |v| !v.is_null())),
308            prop::option::of("[a-z0-9 ]{0,32}".prop_map(|s| s.to_string())),
309        )
310            .prop_map(|(id, result, error)| WsResponse {
311                id: Uuid::from_u128(id),
312                result,
313                error,
314            })
315    }
316
317    fn arb_ws_event() -> impl Strategy<Value = WsEvent> {
318        (arb_event(), arb_json_value()).prop_map(|(event, payload)| WsEvent { event, payload })
319    }
320
321    proptest! {
322        #![proptest_config(ProptestConfig::with_cases(2000))]
323
324        /// WsRequest roundtrips through JSON.
325        #[test]
326        fn prop_request_roundtrip(req in arb_ws_request()) {
327            let json = serde_json::to_string(&req).unwrap();
328            let decoded: WsRequest = serde_json::from_str(&json)
329                .expect("request must deserialize");
330            prop_assert_eq!(decoded.id, req.id);
331            prop_assert_eq!(decoded.method, req.method);
332            prop_assert_eq!(decoded.params, req.params);
333        }
334
335        /// WsResponse roundtrips through JSON.
336        #[test]
337        fn prop_response_roundtrip(resp in arb_ws_response()) {
338            let json = serde_json::to_string(&resp).unwrap();
339            let decoded: WsResponse = serde_json::from_str(&json)
340                .expect("response must deserialize");
341            prop_assert_eq!(decoded.id, resp.id);
342            prop_assert_eq!(decoded.result, resp.result);
343            prop_assert_eq!(decoded.error, resp.error);
344        }
345
346        /// WsEvent roundtrips through JSON.
347        #[test]
348        fn prop_event_roundtrip(evt in arb_ws_event()) {
349            let json = serde_json::to_string(&evt).unwrap();
350            let decoded: WsEvent = serde_json::from_str(&json)
351                .expect("event must deserialize");
352            prop_assert_eq!(decoded.event, evt.event);
353            prop_assert_eq!(decoded.payload, evt.payload);
354        }
355
356        /// A WsRequest wrapped in WsMessage must round-trip as WsMessage::Request.
357        /// This is the critical property for the untagged enum: variants must
358        /// not "escape" to other variants across a serde roundtrip.
359        #[test]
360        fn prop_message_request_does_not_escape(req in arb_ws_request()) {
361            let msg = WsMessage::Request(req.clone());
362            let json = serde_json::to_string(&msg).unwrap();
363            let decoded: WsMessage = serde_json::from_str(&json)
364                .expect("wsmessage must deserialize");
365            match decoded {
366                WsMessage::Request(r) => {
367                    prop_assert_eq!(r.id, req.id);
368                    prop_assert_eq!(r.method, req.method);
369                    prop_assert_eq!(r.params, req.params);
370                }
371                other => prop_assert!(
372                    false,
373                    "Request escaped to other variant: {:?}",
374                    other
375                ),
376            }
377        }
378
379        /// A WsResponse wrapped in WsMessage must round-trip as WsMessage::Response.
380        #[test]
381        fn prop_message_response_does_not_escape(resp in arb_ws_response()) {
382            let msg = WsMessage::Response(resp.clone());
383            let json = serde_json::to_string(&msg).unwrap();
384            let decoded: WsMessage = serde_json::from_str(&json)
385                .expect("wsmessage must deserialize");
386            match decoded {
387                WsMessage::Response(r) => {
388                    prop_assert_eq!(r.id, resp.id);
389                    prop_assert_eq!(r.result, resp.result);
390                    prop_assert_eq!(r.error, resp.error);
391                }
392                other => prop_assert!(
393                    false,
394                    "Response escaped to other variant: {:?}",
395                    other
396                ),
397            }
398        }
399
400        /// A WsEvent wrapped in WsMessage must round-trip as WsMessage::Event.
401        #[test]
402        fn prop_message_event_does_not_escape(evt in arb_ws_event()) {
403            let msg = WsMessage::Event(evt.clone());
404            let json = serde_json::to_string(&msg).unwrap();
405            let decoded: WsMessage = serde_json::from_str(&json)
406                .expect("wsmessage must deserialize");
407            match decoded {
408                WsMessage::Event(e) => {
409                    prop_assert_eq!(e.event, evt.event);
410                    prop_assert_eq!(e.payload, evt.payload);
411                }
412                other => prop_assert!(
413                    false,
414                    "Event escaped to other variant: {:?}",
415                    other
416                ),
417            }
418        }
419    }
420}