Skip to main content

actioncable/
v1_json.rs

1use serde::{Deserialize, Serialize};
2use serde_json::value::RawValue;
3
4use crate::error::{DisconnectReason, Error};
5use crate::message::Message;
6use crate::protocol::{Command, Incoming, Kind, Protocol};
7
8/// The subprotocol every Rails Action Cable server speaks.
9pub const SUBPROTOCOL_V1_JSON: &str = "actioncable-v1-json";
10
11/// The `actioncable-v1-json` protocol: JSON objects in text frames, keyed by `command` going
12/// out and by `type` coming in.
13#[derive(Debug, Clone, Copy, Default)]
14pub struct V1Json;
15
16impl Protocol for V1Json {
17    fn subprotocol(&self) -> &str {
18        SUBPROTOCOL_V1_JSON
19    }
20
21    fn encode(&self, command: &Command) -> Result<Vec<u8>, Error> {
22        Ok(serde_json::to_vec(&Outgoing {
23            command: command.name.as_str(),
24            identifier: &command.identifier,
25            data: command.data.as_deref(),
26        })?)
27    }
28
29    fn decode(&self, payload: &[u8]) -> Result<Incoming, Error> {
30        let frame: Frame = serde_json::from_slice(payload)?;
31        let kind = match frame.r#type.as_deref() {
32            Some("welcome") => Kind::Welcome,
33            Some("ping") => Kind::Ping,
34            Some("disconnect") => Kind::Disconnect,
35            Some("confirm_subscription") => Kind::Confirmation,
36            Some("reject_subscription") => Kind::Rejection,
37            _ => Kind::Message,
38        };
39
40        Ok(Incoming {
41            kind,
42            identifier: frame.identifier.unwrap_or_default(),
43            message: frame.message.map(|raw| Message::from_json(raw.get())),
44            reason: frame.reason.as_deref().map(DisconnectReason::parse),
45            reconnect: frame.reconnect,
46        })
47    }
48}
49
50#[derive(Serialize)]
51struct Outgoing<'a> {
52    command: &'a str,
53    identifier: &'a str,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    data: Option<&'a str>,
56}
57
58/// Anything without a recognized type is a channel message, which is how the server sends
59/// them: an identifier and a message, and no type at all.
60#[derive(Deserialize)]
61struct Frame {
62    r#type: Option<String>,
63    identifier: Option<String>,
64    message: Option<Box<RawValue>>,
65    reason: Option<String>,
66    #[serde(default)]
67    reconnect: bool,
68}
69
70#[cfg(test)]
71#[allow(
72    clippy::unwrap_used,
73    clippy::expect_used,
74    reason = "a test that can't have what it asked for has nothing left to assert"
75)]
76mod tests {
77    use super::*;
78    use crate::protocol::CommandName;
79
80    #[test]
81    fn the_subprotocol_is_rails_default() {
82        assert_eq!("actioncable-v1-json", V1Json.subprotocol());
83    }
84
85    #[test]
86    fn commands_encode_keyed_by_command() {
87        let commands = [
88            (
89                Command::subscribe(r#"{"channel":"RoomChannel"}"#),
90                r#"{"command":"subscribe","identifier":"{\"channel\":\"RoomChannel\"}"}"#,
91            ),
92            (
93                Command::unsubscribe(r#"{"channel":"RoomChannel"}"#),
94                r#"{"command":"unsubscribe","identifier":"{\"channel\":\"RoomChannel\"}"}"#,
95            ),
96            (
97                Command::message(r#"{"channel":"RoomChannel"}"#, r#"{"action":"speak"}"#),
98                r#"{"command":"message","identifier":"{\"channel\":\"RoomChannel\"}","data":"{\"action\":\"speak\"}"}"#,
99            ),
100        ];
101
102        for (command, encoded) in commands {
103            assert_eq!(
104                encoded,
105                String::from_utf8(V1Json.encode(&command).unwrap()).unwrap()
106            );
107        }
108        assert_eq!("message", CommandName::Message.to_string());
109    }
110
111    #[test]
112    fn frames_decode_keyed_by_type() {
113        let room = r#"{"channel":"RoomChannel"}"#;
114        let frames = [
115            (r#"{"type":"welcome"}"#, Incoming::new(Kind::Welcome)),
116            (
117                r#"{"type":"ping","message":1755400000}"#,
118                Incoming {
119                    message: Some(Message::from_json("1755400000")),
120                    ..Incoming::new(Kind::Ping)
121                },
122            ),
123            (
124                r#"{"type":"disconnect","reason":"server_restart","reconnect":true}"#,
125                Incoming {
126                    reason: Some(DisconnectReason::ServerRestart),
127                    reconnect: true,
128                    ..Incoming::new(Kind::Disconnect)
129                },
130            ),
131            (
132                r#"{"type":"confirm_subscription","identifier":"{\"channel\":\"RoomChannel\"}"}"#,
133                Incoming {
134                    identifier: room.to_string(),
135                    ..Incoming::new(Kind::Confirmation)
136                },
137            ),
138            (
139                r#"{"type":"reject_subscription","identifier":"{\"channel\":\"RoomChannel\"}"}"#,
140                Incoming {
141                    identifier: room.to_string(),
142                    ..Incoming::new(Kind::Rejection)
143                },
144            ),
145            (
146                r#"{"identifier":"{\"channel\":\"RoomChannel\"}","message":{"body":"Hello!"}}"#,
147                Incoming {
148                    identifier: room.to_string(),
149                    message: Some(Message::from_json(r#"{"body":"Hello!"}"#)),
150                    ..Incoming::new(Kind::Message)
151                },
152            ),
153            (
154                r#"{"type":"something_new","identifier":"x","message":"anything"}"#,
155                Incoming {
156                    identifier: "x".to_string(),
157                    message: Some(Message::from_json(r#""anything""#)),
158                    ..Incoming::new(Kind::Message)
159                },
160            ),
161        ];
162
163        for (payload, expected) in frames {
164            assert_eq!(
165                expected,
166                V1Json.decode(payload.as_bytes()).unwrap(),
167                "{payload}"
168            );
169        }
170    }
171
172    #[test]
173    fn garbage_does_not_decode() {
174        assert!(matches!(V1Json.decode(b"not json"), Err(Error::Json(_))));
175    }
176
177    #[test]
178    fn kinds_display_as_the_wire_types() {
179        let kinds = [
180            (Kind::Welcome, "welcome"),
181            (Kind::Ping, "ping"),
182            (Kind::Disconnect, "disconnect"),
183            (Kind::Confirmation, "confirm_subscription"),
184            (Kind::Rejection, "reject_subscription"),
185            (Kind::Message, "message"),
186        ];
187
188        for (kind, name) in kinds {
189            assert_eq!(name, kind.to_string());
190        }
191    }
192}