RustArena

Step 4 of 6

Enums in JSON

Enums are where serde shines: a message that can be one of several kinds maps naturally onto a Rust enum, and match then forces you to handle every kind. The question is how the variant name appears in JSON. serde supports four representations.

The default: externally tagged

use serde::Serialize;

#[derive(Serialize)]
enum Shape {
    Circle { r: f64 },
    Square(f64),
    Empty,
}

fn main() {
    for s in [Shape::Circle { r: 1.5 }, Shape::Square(2.0), Shape::Empty] {
        println!("{}", serde_json::to_string(&s).unwrap());
    }
}
{"Circle":{"r":1.5}}
{"Square":2.0}
"Empty"

A variant with data becomes an object with one key, the variant name. A unit variant (no data) becomes just a string. That makes unit-only enums perfect for fields with a fixed set of values, instead of a String that could hold anything:

use serde::Deserialize;

#[derive(Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
enum Status {
    Open,
    Closed,
}

fn main() {
    let s: Status = serde_json::from_str(r#""closed""#).unwrap();
    println!("{}", s == Status::Closed);
    match serde_json::from_str::<Status>(r#""archived""#) {
        Ok(_) => println!("parsed"),
        Err(e) => println!("{e}"),
    }
}
true
unknown variant `archived`, expected `open` or `closed` at line 1 column 10

On an enum, rename_all renames the variants.

Internally tagged: #[serde(tag = "type")]

Most JSON APIs put the kind inside the object: {"type": "purchase", "user": "ana", "cents": 1299}. That's the internally tagged representation:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
    Login { user: String },
    Purchase { user: String, cents: u64 },
    PasswordReset { user: String },
}

fn main() {
    let e = Event::PasswordReset { user: String::from("ana") };
    println!("{}", serde_json::to_string(&e).unwrap());

    let text = r#"{"type": "purchase", "user": "bo", "cents": 1299}"#;
    match serde_json::from_str::<Event>(text).unwrap() {
        Event::Login { user } => println!("{user} logged in"),
        Event::Purchase { user, cents } => println!("{user} spent {cents} cents"),
        Event::PasswordReset { user } => println!("{user} reset a password"),
    }
}
{"type":"password_reset","user":"ana"}
bo spent 1299 cents

The other two

Common mistakes

Your task

Each line of standard input is one JSON event: {"type": "login", "user": "ana"}, {"type": "purchase", "user": "bo", "cents": 1299} or {"type": "logout", "user": "ana"}. Define the internally tagged enum and print three lines: the number of logins, the total revenue in dollars with two decimals, and how many lines couldn't be parsed (skip empty lines):

logins: 2
revenue: 15.49
skipped: 1

Challenge 2

Tickets come as a JSON array: [{"id": 1, "status": "open"}, {"id": 2, "status": "closed"}]. Make Status a lowercase unit enum, derive what's needed, and write fn open_ids(json: &str) -> Result<Vec<u32>, serde_json::Error> returning the ids of open tickets in order. A status other than open, closed or blocked must make the whole parse fail.

Challenge 3

A game server's commands use adjacent tagging: {"cmd": "Move", "args": {"x": 1, "y": -2}}, {"cmd": "Say", "args": "gg"} and {"cmd": "Quit"}. Derive both traits on Command with tag = "cmd" and content = "args", then write fn encode(cmds: &[Command]) -> String that serializes the whole slice as one JSON array.

Previous: Shaping the JSON: rename, default, skip Next: Dynamic JSON with Value and json!