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
8pub const SUBPROTOCOL_V1_JSON: &str = "actioncable-v1-json";
10
11#[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#[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}