Sending JSON over websockets in Rust

I like the JSON format but I feel it’s important to contain it to the job of passing a message from one system to another. Ideally the sender generates the text automatically from some strongly-typed object and the receiver does the reverse. If any required parts are missing or a field is the wrong type the entire message is rejected. Skipping this step and operating directly on the fields, as you can do in Javascript or with NSJSONSerialization, relies on the programmer not stuffing it up. I usually stuff it up.

I’m working on software that needs to send regular status updates over a TCP connection to a central server. JSON is a nice human-readable choice for the data format. Websockets are great because I can lean on an Apache proxy to provide LetsEncrypt-signed TLS, and unlike a raw TCP stream the communication is already packetised into discrete messages.

I felt like using Rust but had no idea how hard it would be compared with a dynamically typed language, so I prototyped it. It turned out relatively straightforward thanks to a couple of excellent crates, serde_json and ws-rs:

[dependencies]
serde = "1.0.9"
serde_json = "1.0"
serde_derive = "1.0.9"
ws = "0.7.3"

This is the data model, which is similar to what I have planned for the real app. To exercise the parser I made sure it has a few tricky bits: an enum with associated values, an array, and a struct of one type within another. I didn’t need to tweak them at all. The default implementations of Serialize and Deserialize cope perfectly.

/// A file in the playlist
#[derive(Serialize, Deserialize, Debug)]
struct PlaylistItem {
    filename: String,
    duration: f64
}
 
/// Current playback status of the player
#[derive(Serialize, Deserialize, Debug)]
enum Playback {
    Stopped,
    Playing { item: usize, position: f64 },
    Paused { item: usize, position: f64 }
}
 
/// Overall state of the audio player
#[derive(Serialize, Deserialize, Debug)]
struct PlayerState {
    station_name: Option<String>,
    playback_state: Playback,
    playlist: Vec<PlaylistItem>
}

The None type of Option is represented as a JSON null, and the serde parser will quietly provide a None if the field is missing entirely in the JSON. While serde_json supports various enum formats, without any customisation I already get a very nice representation for the overall PlayerState. (serde_json’s output is minified.)

{
"station_name": null,
"playback_state": {
    "Playing": {
    "item": 1,
    "position": 8
    }
},
"playlist": [
    {
    "filename": "jinglebells.mp3",
    "duration": 242.5
    },
    {
    "filename": "hark_ye.mp3",
    "duration": 147
    },
    {
    "filename": "hakuna.mp3",
    "duration": 158.3
    }
]
}

Very little is required on the server. The listen function provided by ws-rs blocks, using an mio event loop under the hood to handle an arbitrary number of connected clients. Right now I’m only providing a handler for each incoming message. If I can parse the JSON I do so and print the debug representation of the PlayerState.

fn main() {
    listen("0.0.0.0:5556", |out| {
        move |msg: Message| {
            if let Ok(text) = msg.into_text() {
                match serde_json::from_str::<PlayerState>(&text) {
                    Ok(status) => println!("Received status:\n{:?}\n", status),
                    Err(e) => println!("Could not parse status: {}\n", e)
                }
            }
            Ok(())
        }
    }).unwrap()
}

As I hoped, the PlayerState is fully filled in from the JSON:

Received status:
PlayerState { station_name: None, playback_state: Playing { item: 1, position: 8 }, playlist: [PlaylistItem { filename: "jinglebells.mp3", duration: 242.5 }, PlaylistItem { filename: "hark_ye.mp3", duration: 147 }, PlaylistItem { filename: "hakuna.mp3", duration: 158.3 }] }

Over in the client it needs to juggle both the websocket connection and a timer to send the JSON at intervals.

Based on the ws-rs guide, I’ve made a struct that represents the established connection. The Handler trait lets me respond to messages and all the websocket lifecycle events by implementing the relevant functions.

struct Client {
    out: Sender
}
impl Handler for Client {
    fn on_open(&mut self, _: Handshake) -> Result<()> {
        println!("Connected");
        let sender = self.out.clone();
        thread::spawn(move || {
            send_updates(sender);
        });
        Ok(())
    }
}

Since I don’t care about messages from the server I only implement on_open and use it to start a new thread for sending updates. ws-rs takes care of thread safety—all I need to do is clone the Sender and give it to my new thread where it can use it to send messages. For this test I’ve created a hard-coded status and simulated playback by increasing the position on every update.

fn send_updates(sender: Sender) {
    // Create a basic state where beginning playback on the first item
    let mut state = PlayerState {
        station_name: None,
        playback_state: Playback::Playing { item: 1, position: 0.0 },
        playlist: vec![
            PlaylistItem { filename: "jinglebells.mp3".to_string(), duration: 242.5 },
            PlaylistItem { filename: "hark_ye.mp3".to_string(), duration: 147.0 },
            PlaylistItem { filename: "hakuna.mp3".to_string(), duration: 158.3 }
        ]
    };
 
    loop {
        // Serialise the updated status and send it
        let json = serde_json::to_string(&state).unwrap();
        println!("Transmitting JSON: {}", json);
        let _ = sender.send(json);
 
        // Send a new update every two seconds
        thread::sleep(time::Duration::from_millis(2000));
        
        // Increase the position by 2.0 seconds for the next update
        if let Playback::Playing{item, position} = state.playback_state {
            state.playback_state = Playback::Playing {
                item: item,
                position: position + 2.0
            };
        }
    }
}
 
fn main() {
    connect("ws://127.0.0.1:5556", |out| Client { out: out } ).unwrap();
}

Similarly to listen, connect blocks for the duration of a complete connection, after which it returns and the program terminates. This disposes of the update thread, which would normally need to be cleaned up properly.

All this makes me very happy. I love using enums in data models and enums with associated values are even better. In my experience translating enums to numbers or string placeholders can be tedious and a great source of bugs. Having them transparently travel through the JSON means I can use them heavily and be guaranteed that they will have valid values at the other end. And if I did want to create a browser interface it would be easy to replicate the format in Javascript, albeit with none of the safety. I think I’m going to carry on with Rust for this project.

In the meantime Swift 4 finally has good support for JSON encoding so I am looking forward to using that at work.