//! Phoenix V2 wire codec — feature `"phoenix"` (implies `"channels"`, //! spends dependency slot #4 on `serde`/`serde_json`, ratified //! 2026-06-12). //! //! The wire format phoenix.js speaks: one JSON array per frame, //! //! ```text //! [join_ref, ref, topic, event, payload] //! ``` //! //! refs are strings or `null`; replies are `event: "phx_reply"` with //! `payload: {"status": "ok"|"error", "response": }`. //! //! # `Json`, not a blanket impl on `P` //! //! The ratified sketch said "JsonCodec blanket impl via serde". A //! literal `impl Encode for P` is //! coherence poison: features are additive across a dependency graph, //! so the moment ANY crate enables `"phoenix"`, every hand-written //! `Encode` impl in every other crate stops compiling (the compiler //! cannot prove a foreign type will never gain `Serialize`). That //! breaks the design's own promise that `"channels"`-only users supply //! their own codecs. The blanket therefore lives one newtype away: //! [`Json`] is V2-JSON for ANY serde-capable `T` — including //! `Json` for schemaless payloads — while the //! `Encode`/`Decode` namespace stays open. //! //! # Strictness //! //! Decoding is exactly as strict as `T`'s `Deserialize`: a phoenix.js //! `{}` join payload fails against a `T` with required fields (the //! socket closes `1002`). Use `#[serde(default)]`, `Option` fields, or //! `Json` where the client is loose. Encoding a `T` //! that fails `Serialize` (non-string map keys and friends) panics — //! that is a bug in the payload type, and the panic rides the //! documented channel-panic contract (linked teardown). use super::{ ChannelFrame, ChannelMessage, Decode, DecodeError, Encode, FrameRef, MessageRef, Status, EV_REPLY, }; use crate::ws::Message; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::{json, Map, Value}; /// V2-JSON payload wrapper: `Json` speaks the phoenix wire for any /// `T: Serialize + DeserializeOwned`. Derefs to `T`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Json(pub T); impl std::ops::Deref for Json { type Target = T; fn deref(&self) -> &T { &self.0 } } impl std::ops::DerefMut for Json { fn deref_mut(&mut self) -> &mut T { &mut self.0 } } impl From for Json { fn from(t: T) -> Self { Json(t) } } fn empty_object() -> Value { Value::Object(Map::new()) } fn to_value(t: &T) -> Value { serde_json::to_value(t).expect("phoenix codec: payload failed to serialize") } impl Encode for Json { fn encode(f: FrameRef<'_, Self>) -> Message { let (event, payload) = match f.message { MessageRef::Event { event, payload } => { (event, payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object)) } MessageRef::Reply { status, payload } => ( EV_REPLY, json!({ "status": match status { Status::Ok => "ok", Status::Error => "error", }, "response": payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object), }), ), }; let arr = json!([f.join_ref, f.reference, f.topic, event, payload]); Message::Text(arr.to_string()) } } impl Decode for Json { fn decode(msg: &Message) -> Result, DecodeError> { let Message::Text(s) = msg else { return Err(DecodeError("phoenix V2 frames are text".into())); }; let (join_ref, reference, topic, event, payload): ( Option, Option, String, String, Value, ) = serde_json::from_str(s).map_err(|e| DecodeError(e.to_string()))?; let message = if event == EV_REPLY { // Clients don't reply in practice; decoded for symmetry // (and ignored upstream). let status = match payload.get("status").and_then(Value::as_str) { Some("ok") => Status::Ok, _ => Status::Error, }; let response = payload.get("response").cloned().unwrap_or_else(empty_object); let payload = serde_json::from_value::(response) .map_err(|e| DecodeError(format!("reply response: {e}")))?; ChannelMessage::Reply { status, payload: Some(Json(payload)) } } else { let payload = serde_json::from_value::(payload) .map_err(|e| DecodeError(format!("payload for '{event}': {e}")))?; ChannelMessage::Event { event, payload: Json(payload) } }; Ok(ChannelFrame { join_ref, reference, topic, message }) } } #[cfg(test)] mod tests { use super::*; type V = Json; fn parse(m: &Message) -> Value { let Message::Text(s) = m else { panic!("not text") }; serde_json::from_str(s).unwrap() } #[test] fn decodes_phoenix_js_join_frame() { let m = Message::Text(r#"["3","3","room:lobby","phx_join",{"token":"t"}]"#.into()); let f = V::decode(&m).unwrap(); assert_eq!(f.join_ref.as_deref(), Some("3")); assert_eq!(f.reference.as_deref(), Some("3")); assert_eq!(f.topic, "room:lobby"); let ChannelMessage::Event { event, payload } = f.message else { panic!() }; assert_eq!(event, "phx_join"); assert_eq!(payload.0, json!({"token": "t"})); } #[test] fn decodes_heartbeat_null_refs_allowed() { let m = Message::Text(r#"[null,"7","phoenix","heartbeat",{}]"#.into()); let f = V::decode(&m).unwrap(); assert_eq!(f.join_ref, None); assert_eq!(f.topic, "phoenix"); } #[test] fn encodes_ok_reply_with_status_response_wrapper() { let p = Json(json!({"user_count": 2})); let m = V::encode(FrameRef { join_ref: Some("3"), reference: Some("4"), topic: "room:lobby", message: MessageRef::Reply { status: Status::Ok, payload: Some(&p) }, }); assert_eq!( parse(&m), json!(["3", "4", "room:lobby", "phx_reply", {"status": "ok", "response": {"user_count": 2}}]) ); } #[test] fn encodes_payloadless_reply_and_event_as_empty_object() { let m = V::encode(FrameRef { join_ref: None, reference: Some("9"), topic: "phoenix", message: MessageRef::Reply { status: Status::Ok, payload: None }, }); assert_eq!( parse(&m), json!([null, "9", "phoenix", "phx_reply", {"status": "ok", "response": {}}]) ); let m = V::encode(FrameRef { join_ref: Some("3"), reference: None, topic: "room:lobby", message: MessageRef::Event { event: "phx_close", payload: None }, }); assert_eq!(parse(&m), json!(["3", null, "room:lobby", "phx_close", {}])); } #[test] fn typed_payload_round_trip_and_strictness() { #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] struct Chat { body: String, } let m = Message::Text(r#"["1","2","room:a","new_msg",{"body":"hi"}]"#.into()); let f = Json::::decode(&m).unwrap(); let ChannelMessage::Event { payload, .. } = f.message else { panic!() }; assert_eq!(payload.0, Chat { body: "hi".into() }); // Missing required field: the codec is as strict as T. let m = Message::Text(r#"["1","2","room:a","new_msg",{}]"#.into()); assert!(Json::::decode(&m).is_err()); } #[test] fn garbage_and_binary_are_decode_errors() { assert!(V::decode(&Message::Text("not json".into())).is_err()); assert!(V::decode(&Message::Text(r#"{"a":1}"#.into())).is_err()); assert!(V::decode(&Message::Binary(vec![1, 2, 3])).is_err()); } }