ws_kit/codec.rs
1//! Message codec.
2//!
3//! [`Codec`] provides `encode`/`decode` for WebSocket text frames. A blanket
4//! implementation via `serde_json` is provided for any `Serialize + DeserializeOwned`.
5//!
6//! ## The wire message model: [`Frame`]
7//!
8//! WebSocket (RFC 6455) carries two data frame kinds: **text** (must be valid
9//! UTF-8) and **binary** (opaque bytes). Since 0.4.0 ws-kit models both in
10//! [`Frame`]:
11//!
12//! - [`Frame::Text`] — UTF-8 payloads; the only kind [`Codec`] and
13//! [`JsonCodec`] operate on.
14//! - [`Frame::Binary`] — opaque bytes, carried verbatim.
15//!
16//! # Security posture (binary is never text)
17//!
18//! A `Frame::Binary` payload is **never parsed as text or JSON** by any
19//! ws-kit code path. `Codec::decode`/`JsonCodec::decode` take `&str` — the
20//! type system makes it impossible to feed binary bytes into the JSON
21//! decoder without an explicit, reviewer-visible UTF-8 conversion. Rooms,
22//! the hub, and (0.4.0) the Redis adapter move binary payloads opaquely:
23//! no sniffing, no best-effort decoding, no shell-out to text heuristics.
24//! Conversely, text is *guaranteed* UTF-8 end to end — a `Frame::Text` can
25//! only be constructed from a `String`, so downstream JSON decode cannot
26//! hit invalid-UTF-8 surprises.
27//!
28//! Authentication is unaffected by frame kind: `TokenExtractor` reads the
29//! HTTP upgrade request (headers/query), never frame payloads.
30
31use bytes::Bytes;
32use serde::{de::DeserializeOwned, Serialize};
33
34use crate::error::WsError;
35
36/// A wire message: the union of WebSocket text and binary data frames.
37///
38/// Conversions are zero-copy where the transport already owns the data:
39/// `String`, `&str`, and `Bytes` all move into a `Frame` without
40/// re-encoding. `Vec<u8>` and borrowed `&[u8]` copy once.
41///
42/// `Frame` implements `Serialize`/`Deserialize`, so it can ride the generic
43/// [`crate::hub::BroadcastHub<Frame>`]; in JSON form binary payloads are
44/// represented as byte arrays and — per the security posture below — are
45/// only ever decoded back into `Frame::Binary`, never parsed as text.
46///
47/// # Example
48///
49/// ```
50/// use ws_kit::codec::Frame;
51///
52/// let text = Frame::from("hello"); // &str — one UTF-8 copy
53/// let owned = Frame::from(String::from("hi")); // String — zero-copy move
54/// let bin = Frame::from(vec![0u8, 1, 2]); // Vec<u8> — becomes Binary
55/// assert!(matches!(text, Frame::Text(_)));
56/// assert!(matches!(bin, Frame::Binary(_)));
57/// ```
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub enum Frame {
60 /// A UTF-8 text frame (WebSocket opcode 0x1).
61 Text(String),
62 /// An opaque binary frame (WebSocket opcode 0x2). Never parsed as
63 /// text/JSON by ws-kit — see the [security posture](self#security-posture-binary-is-never-text).
64 Binary(Bytes),
65}
66
67impl Frame {
68 /// Construct a `Binary` frame from anything `Bytes` converts from.
69 pub fn binary(data: impl Into<Bytes>) -> Self {
70 Frame::Binary(data.into())
71 }
72
73 /// Payload length in bytes (UTF-8 length for text).
74 pub fn len(&self) -> usize {
75 match self {
76 Frame::Text(s) => s.len(),
77 Frame::Binary(b) => b.len(),
78 }
79 }
80
81 /// True when the payload is empty.
82 pub fn is_empty(&self) -> bool {
83 self.len() == 0
84 }
85
86 /// True for [`Frame::Text`].
87 pub fn is_text(&self) -> bool {
88 matches!(self, Frame::Text(_))
89 }
90
91 /// True for [`Frame::Binary`].
92 pub fn is_binary(&self) -> bool {
93 matches!(self, Frame::Binary(_))
94 }
95
96 /// Payload as bytes (UTF-8 for text), without consuming the frame.
97 pub fn as_bytes(&self) -> &[u8] {
98 match self {
99 Frame::Text(s) => s.as_bytes(),
100 Frame::Binary(b) => b.as_ref(),
101 }
102 }
103
104 /// Take the payload as owned bytes, consuming the frame.
105 pub fn into_bytes(self) -> Bytes {
106 match self {
107 Frame::Text(s) => Bytes::from(s),
108 Frame::Binary(b) => b,
109 }
110 }
111
112 /// Interpret the payload as UTF-8 text (binary is rejected, not sniffed).
113 pub fn into_text(self) -> Result<String, WsError> {
114 match self {
115 Frame::Text(s) => Ok(s),
116 Frame::Binary(_) => Err(WsError::InvalidMessage),
117 }
118 }
119}
120
121impl From<String> for Frame {
122 fn from(s: String) -> Self {
123 Frame::Text(s)
124 }
125}
126
127impl From<&str> for Frame {
128 fn from(s: &str) -> Self {
129 Frame::Text(s.to_string())
130 }
131}
132
133impl From<Bytes> for Frame {
134 fn from(b: Bytes) -> Self {
135 Frame::Binary(b)
136 }
137}
138
139impl From<Vec<u8>> for Frame {
140 fn from(v: Vec<u8>) -> Self {
141 Frame::Binary(Bytes::from(v))
142 }
143}
144
145impl From<&[u8]> for Frame {
146 fn from(v: &[u8]) -> Self {
147 Frame::Binary(Bytes::copy_from_slice(v))
148 }
149}
150
151/// Trait for encoding/decoding WebSocket messages.
152///
153/// The default blanket implementation uses JSON via `serde_json`.
154///
155/// # Example
156///
157/// ```
158/// use ws_kit::codec::Codec;
159/// use serde::{Serialize, Deserialize};
160///
161/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
162/// struct Msg { text: String }
163///
164/// let msg = Msg { text: "hello".into() };
165/// let s = msg.encode();
166/// let decoded = Msg::decode(&s).unwrap();
167/// assert_eq!(msg, decoded);
168/// ```
169pub trait Codec: Sized {
170 /// Encode `self` into a JSON string.
171 ///
172 /// This implementation serializes via `serde_json::to_string`.
173 /// Panics are avoided — if serialization fails the error is surfaced
174 /// through `WsError::InvalidMessage` in the `Result` variant. The
175 /// infallible `encode` variant below unwraps for convenience; use
176 /// [`Codec::encode_result`] when you need error handling.
177 fn encode(&self) -> String
178 where
179 Self: Serialize,
180 {
181 serde_json::to_string(self).unwrap_or_default()
182 }
183
184 /// Encode with error handling.
185 fn encode_result(&self) -> Result<String, WsError>
186 where
187 Self: Serialize,
188 {
189 serde_json::to_string(self).map_err(|_| WsError::InvalidMessage)
190 }
191
192 /// Decode `Self` from a JSON string.
193 fn decode(s: &str) -> Result<Self, WsError>
194 where
195 Self: DeserializeOwned,
196 {
197 serde_json::from_str(s).map_err(|_| WsError::InvalidMessage)
198 }
199}
200
201/// Blanket implementation for all serde types.
202impl<T> Codec for T where T: Serialize + DeserializeOwned {}
203
204/// JSON codec helper for types that don't want to implement [`Codec`] directly
205/// or where you need associated functions without trait dispatch.
206pub struct JsonCodec;
207
208impl JsonCodec {
209 /// Encode a value to JSON string.
210 pub fn encode<T: Serialize>(value: &T) -> Result<String, WsError> {
211 serde_json::to_string(value).map_err(|_| WsError::InvalidMessage)
212 }
213
214 /// Decode a value from JSON string.
215 pub fn decode<T: DeserializeOwned>(s: &str) -> Result<T, WsError> {
216 serde_json::from_str(s).map_err(|_| WsError::InvalidMessage)
217 }
218
219 /// Encode pretty-printed JSON (for debugging).
220 pub fn encode_pretty<T: Serialize>(value: &T) -> Result<String, WsError> {
221 serde_json::to_string_pretty(value).map_err(|_| WsError::InvalidMessage)
222 }
223}
224
225// Tests exercise failure paths and invariants directly; unwrap/expect,
226// slicing, and panicking asserts are acceptable here — violations
227// surface as test failures, not production panics.
228#[allow(
229 clippy::unwrap_used,
230 clippy::expect_used,
231 clippy::indexing_slicing,
232 clippy::panic
233)]
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use serde::{Deserialize, Serialize};
238
239 #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
240 struct Payload {
241 id: u32,
242 msg: String,
243 }
244
245 #[test]
246 fn codec_roundtrip_trait() {
247 let p = Payload {
248 id: 42,
249 msg: "hello".into(),
250 };
251 let s = p.encode();
252 let d = Payload::decode(&s).unwrap();
253 assert_eq!(p, d);
254 }
255
256 #[test]
257 fn codec_roundtrip_result() {
258 let p = Payload {
259 id: 1,
260 msg: "world".into(),
261 };
262 let s = p.encode_result().unwrap();
263 let d = Payload::decode(&s).unwrap();
264 assert_eq!(p, d);
265 }
266
267 #[test]
268 fn codec_invalid_decode() {
269 let err = Payload::decode("not json").unwrap_err();
270 assert_eq!(err, WsError::InvalidMessage);
271 }
272
273 #[test]
274 fn json_codec_helpers() {
275 let p = Payload {
276 id: 7,
277 msg: "test".into(),
278 };
279 let s = JsonCodec::encode(&p).unwrap();
280 let d: Payload = JsonCodec::decode(&s).unwrap();
281 assert_eq!(p, d);
282
283 let pretty = JsonCodec::encode_pretty(&p).unwrap();
284 assert!(pretty.contains('\n'));
285 }
286
287 #[test]
288 fn encode_vec() {
289 let v = vec![1, 2, 3];
290 let s = v.encode();
291 let d: Vec<i32> = Vec::decode(&s).unwrap();
292 assert_eq!(v, d);
293 }
294
295 // --- Frame ---
296
297 #[test]
298 fn frame_constructors_and_kinds() {
299 assert!(Frame::from("hello".to_string()).is_text());
300 assert!(Frame::from("hello").is_text());
301 assert!(Frame::from(Bytes::from_static(b"\x00\x01")).is_binary());
302 assert!(Frame::from(vec![1u8, 2]).is_binary());
303 assert!(Frame::from(&[1u8, 2][..]).is_binary());
304 assert!(Frame::binary(Vec::new()).is_binary());
305 }
306
307 #[test]
308 fn frame_len_and_bytes() {
309 let t = Frame::from("héllo"); // multibyte: len is bytes
310 assert_eq!(t.len(), 6);
311 assert_eq!(t.as_bytes(), "héllo".as_bytes());
312 assert!(!t.is_empty());
313
314 let b = Frame::from(vec![0u8; 4]);
315 assert_eq!(b.len(), 4);
316 assert!(Frame::from(String::new()).is_empty());
317 }
318
319 #[test]
320 fn frame_into_bytes_and_text() {
321 let t = Frame::from("abc".to_string());
322 assert_eq!(&t.into_bytes()[..], b"abc");
323
324 let b = Frame::from(vec![9u8]);
325 assert_eq!(b.into_bytes()[0], 9);
326
327 let t2 = Frame::from("text".to_string()).into_text();
328 assert_eq!(t2.unwrap(), "text");
329
330 // Binary is rejected as text — never sniffed/coerced.
331 let b2 = Frame::from(vec![0xffu8, 0xfe]).into_text();
332 assert_eq!(b2.unwrap_err(), WsError::InvalidMessage);
333 }
334
335 #[test]
336 fn frame_zero_copy_moves() {
337 let s = String::from("owned");
338 let f = Frame::from(s);
339 assert_eq!(f, Frame::Text("owned".to_string()));
340 let v = vec![1u8; 8];
341 let f2 = Frame::from(v);
342 assert_eq!(f2.len(), 8);
343 }
344
345 #[test]
346 fn frame_clone_eq() {
347 let f = Frame::from("dup".to_string());
348 assert_eq!(f.clone(), f);
349 assert_ne!(
350 Frame::from("a".to_string()),
351 Frame::from(Bytes::from_static(b"a"))
352 );
353 }
354}