70 %

Parsing Rust enums from JSON with Serde and tagged types

In Toast, we have an internal server on a domain socket that accepts JSON and converts that JSON to Rust structs. One of the structs looks like this, with an enum for the component and wrapper fields.

rust
pub enum ModuleSpec {
File {
path: PathBuf,
},
Source {
code: String,
},
}
pub struct SetDataForSlug {
slug: String,
component: Option<ModuleSpec>,
data: Option<serde_json::Value>,
wrapper: Option<ModuleSpec>,
}

There are a few ways to represent something like this in JSON.

  1. We could do nothing and hope that our parsing library (serde in this case) can figure out which type it should be based on the available fields.
  2. We can tag the types. "tagging" types means using some field to determine which type a json value will correspond to and should be parsed into.

Tagging Types

There are roughly three ways to tag types: externally, internally, and adjacent.

Externally tagged

An externally tagged type uses a value outside of the content to tell the parser what type the content is. In this case we use the key of an object, where the value is the type.

externally-tagged.json
JS
[
{
"File": {
"path": "/something.js"
}
},
{
"Source": {
"code": "..."
}
}
]

Internally tagged types

Internally tagging types places a field that explicitly tells us which type it is inside the object with the other fields. In this case, we use a type field.

internally-tagged.json
JS
[
{
"type": "file",
"path": "/something.js"
},
{
"type": "source",
"code": "..."
}
]

Adjacently tagged types

Adjacently tagged types have separate fields for the type and the content next to each other. In this case we have a type field next to a content field.

adjacently-tagged.json
JS
[
{
"type": "file",
"content": {
"path": "/something.js"
}
},
{
"type": "source",
"content": {
"code": "..."
}
}
]

Our Solution

We went with internally tagged types for Toast because they are a bit easier to write and understand for the people who will be writing them (JS devs). We also chose to overload a value field so that the name of the field is always the same, yielding one less thing to remember when swapping back and forth between the two modes. Example JSON payload looks like this:

SetDataForSlug.json
json
{
"slug": "/something",
"component": {
"mode": "source",
"value": "import { h } from 'preact'; export default props => <div>hi</div>"
},
"data": {
"some": "thing"
},
"wrapper": {
"mode": "filepath",
"value": "./some/where.js"
}
}

The Rust code using Serde requires us to make use of both field-level attributes for renaming and container-level attributes for specifying the internal tag field.

There are also a couple tests here. This example compiles and you can check it out on the Rust playground if you want.

rust
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "mode")]
pub enum ModuleSpec {
#[serde(rename = "filepath")]
File {
#[serde(rename = "value")]
path: PathBuf,
},
#[serde(rename = "source")]
Source {
#[serde(rename = "value")]
code: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct SetDataForSlug {
/// /some/url or some/url
pub slug: String,
pub component: Option<ModuleSpec>,
pub data: Option<serde_json::Value>,
pub wrapper: Option<ModuleSpec>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Result, Value};
#[test]
fn test_deserialize_all() -> Result<()> {
let data = r#"
{
"slug": "/something",
"component": {
"mode": "source",
"value": "import { h } from 'preact'; export default props => <div>hi</div>"
},
"data": {
"some": "thing"
},
"wrapper": {
"mode": "filepath",
"value": "./some/where.js"
}
}"#;
// Parse the string of data into serde_json::Value.
let v: Value = serde_json::from_str(data)?;
// Access parts of the data by indexing with square brackets.
let u: SetDataForSlug = serde_json::from_value(v).unwrap();
assert_eq!(
SetDataForSlug {
slug: String::from("/something"),
component: Some(ModuleSpec::Source {
code: String::from(
"import { h } from 'preact'; export default props => <div>hi</div>"
)
}),
data: Some(json!({
"some": "thing"
})),
wrapper: Some(ModuleSpec::File {
path: [".", "some", "where.js"].iter().collect::<PathBuf>()
})
},
u
);
Ok(())
}
#[test]
fn test_deserialize_without_data_and_wrapper() -> Result<()> {
let data = r#"
{
"slug": "/something",
"component": {
"mode": "source",
"value": "import { h } from 'preact'; export default props => <div>hi</div>"
}
}"#;
// Parse the string of data into serde_json::Value.
let v: Value = serde_json::from_str(data)?;
// Access parts of the data by indexing with square brackets.
let u: SetDataForSlug = serde_json::from_value(v).unwrap();
assert_eq!(
SetDataForSlug {
slug: String::from("/something"),
component: Some(ModuleSpec::Source {
code: String::from(
"import { h } from 'preact'; export default props => <div>hi</div>"
)
}),
data: None,
wrapper: None
},
u
);
Ok(())
}
}