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
- Adjacently tagged,
#[serde(tag = "t", content = "c")]:{"t": "Square", "c": 2.0}. The tag and the data sit side by side; works for every kind of variant. - Untagged,
#[serde(untagged)]: no tag at all; when reading, serde tries each variant in order and takes the first that fits. Handy for "a number or a string" fields, but error messages get vague ("data did not match any variant").
Common mistakes
- Internally tagged tuple variants.
#[serde(tag = "type")]can't put a tag inside a bare number, so serializingSquare(2.0)fails at run time withcannot serialize tagged newtype variant Shape::Square containing a float(a tuple variant with two or more fields is already rejected at compile time). Use struct variants (Square { side: f64 }) or adjacent tagging. - Forgetting
rename_allon the enum and getting"PasswordReset"when the API expects"password_reset": the error saysunknown variant, and lists the names it expected, which is the clue. - Stringly typed fields. A
status: Stringaccepts"Open","open "and"opne". An enum rejects all but the real values at the edge of your program.
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!