use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::VERSION;
pub const PROTO_VERSION: &str = "1";
pub const CLIENT_VERSION: &str = VERSION;
pub const DEFAULT_CLIENT_CAPABILITIES: &[&str] = &["streaming", "batch", "heartbeat", "receipts"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
ConnectionInit,
ConnectionAck,
Request,
Response,
Notification,
Subscribe,
Next,
Error,
Complete,
Unsubscribe,
Ping,
Pong,
ReceiptResponse,
Disconnect,
Status,
}
impl MessageType {
pub fn as_str(self) -> &'static str {
match self {
Self::ConnectionInit => "connection_init",
Self::ConnectionAck => "connection_ack",
Self::Request => "request",
Self::Response => "response",
Self::Notification => "notification",
Self::Subscribe => "subscribe",
Self::Next => "next",
Self::Error => "error",
Self::Complete => "complete",
Self::Unsubscribe => "unsubscribe",
Self::Ping => "ping",
Self::Pong => "pong",
Self::ReceiptResponse => "receipt_response",
Self::Disconnect => "disconnect",
Self::Status => "status",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ErrorObject {
pub code: i64,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Envelope {
pub proto: String,
#[serde(rename = "type")]
pub msg_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Map<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Map<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub receipt: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl Envelope {
pub fn to_wire_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub fn new_request_id() -> String {
Uuid::new_v4().simple().to_string()
}
pub fn new_request(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "request".into(),
method: Some(method.into()),
params: if params.is_empty() {
None
} else {
Some(params)
},
id: Some(new_request_id()),
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_notification(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "notification".into(),
method: Some(method.into()),
params: if params.is_empty() {
None
} else {
Some(params)
},
id: None,
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_subscribe(method: impl Into<String>, params: Map<String, Value>) -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "subscribe".into(),
method: Some(method.into()),
params: if params.is_empty() {
None
} else {
Some(params)
},
id: Some(new_request_id()),
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_unsubscribe(id: impl Into<String>) -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "unsubscribe".into(),
method: None,
params: None,
id: Some(id.into()),
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_connection_init() -> Envelope {
let mut params = Map::new();
params.insert("client_version".into(), json!(CLIENT_VERSION));
params.insert("client_name".into(), json!("soothe-client-rust"));
params.insert("accept_proto".into(), json!([PROTO_VERSION]));
params.insert("capabilities".into(), json!(DEFAULT_CLIENT_CAPABILITIES));
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "connection_init".into(),
method: None,
params: Some(params),
id: None,
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_ping() -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "ping".into(),
method: None,
params: None,
id: None,
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_pong() -> Envelope {
Envelope {
proto: PROTO_VERSION.to_string(),
msg_type: "pong".into(),
method: None,
params: None,
id: None,
result: None,
error: None,
payload: None,
receipt: None,
extra: Map::new(),
}
}
pub fn new_disconnect() -> Envelope {
new_notification("disconnect", Map::new())
}
pub fn decode_message(text: &str) -> Result<Value, serde_json::Error> {
let trimmed = text.trim();
if trimmed.contains('\n') {
for line in trimmed.lines() {
let line = line.trim();
if !line.is_empty() {
return serde_json::from_str(line);
}
}
}
serde_json::from_str(trimmed)
}
pub fn expand_wire_messages(msg: Value) -> Vec<Value> {
let Some(obj) = msg.as_object() else {
return vec![msg];
};
if obj.get("type").and_then(|v| v.as_str()) != Some("event_batch") {
return vec![msg];
}
match obj.get("events").and_then(|v| v.as_array()) {
Some(events) if !events.is_empty() => events.clone(),
_ => vec![msg],
}
}
pub fn unwrap_next(msg: &Value) -> Value {
let Some(obj) = msg.as_object() else {
return msg.clone();
};
if obj.get("type").and_then(|v| v.as_str()) != Some("next") {
return msg.clone();
}
let Some(payload) = obj.get("payload").and_then(|p| p.as_object()) else {
return msg.clone();
};
if let Some(data) = payload.get("data") {
if data.is_object() {
return data.clone();
}
}
msg.clone()
}
pub fn as_str(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
pub fn params_map(pairs: &[(&str, Value)]) -> Map<String, Value> {
let mut m = Map::new();
for (k, v) in pairs {
m.insert((*k).to_string(), v.clone());
}
m
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_id_is_32_hex() {
let id = new_request_id();
assert_eq!(id.len(), 32);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn connection_init_shape() {
let env = new_connection_init();
assert_eq!(env.msg_type, "connection_init");
assert_eq!(env.proto, "1");
let params = env.params.unwrap();
assert_eq!(params["client_name"], json!("soothe-client-rust"));
}
#[test]
fn expand_event_batch() {
let batch = json!({
"type": "event_batch",
"events": [{"type":"event","mode":"messages"}, {"type":"status","state":"idle"}]
});
let expanded = expand_wire_messages(batch);
assert_eq!(expanded.len(), 2);
}
#[test]
fn unwrap_next_returns_payload_data() {
let frame = json!({
"type": "next",
"payload": {
"mode": "event",
"data": {"type": "event", "mode": "messages", "data": [{"content": "hi"}]}
}
});
let inner = unwrap_next(&frame);
assert_eq!(inner["mode"], json!("messages"));
assert_eq!(inner["type"], json!("event"));
}
}