Page title
Section title
Generating Rust from JSON Typedef schemas
JSON Type Definition, aka RFC 8927, is an easy-to-learn, standardized way to define a schema for JSON data. You can use JSON Typedef to portably validate data across programming languages, create dummy data, generate code, and more.
This article is about how you can use JSON Typedef to generate Rust code from
schemas. If you’re interested in generating code in other languages, see this
article on jtd-codegen
. The rest of this article focuses
on using jtd-codegen
with Rust in particular.
Generating Rust with jtd-codegen
As a prerequisite, you need to first install jtd-codegen
. Installation
instructions are available here.
You can generate Rust with jtd-codegen
using the --rust-out
option, whose
value must be a directory that jtd-codegen
can generate code into.
For example, if you have this schema in schemas/user.jtd.json
:
{
"properties": {
"id": { "type": "string" },
"createdAt": { "type": "timestamp" },
"karma": { "type": "int32" },
"isAdmin": { "type": "boolean" }
}
}
Then you can generate Rust code into the src/user
directory by running:
jtd-codegen schemas/user.jtd.json --rust-out src/user
Which will output something like:
📝 Writing Rust code to: src/user
📦 Generated Rust code.
📦 Root schema converted into type: User
And you should see code along these lines in src/user/mod.rs
:
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct User {
#[serde(rename = "createdAt")]
pub createdAt: DateTime<FixedOffset>,
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "isAdmin")]
pub isAdmin: bool,
#[serde(rename = "karma")]
pub karma: i32,
}
Note: at the time of writing, generated code is not always formatted in a
pretty way. If you require pretty-formatted code, it’s recommended that you use
a code formatter on jtd-codegen
-generated code.
Using generated Rust code
jtd-codegen
will always output code into a mod.rs
inside the directory you
specify with --rust-out
. In the previous example, we outputted code into
src/user
, so we can import it like so:
use user::User;
The generated Rust code is meant to be used with the serde_json
crate. To use the generated types, pass them
as a parameter to serde_json::from_str
/ serde_json::to_string
(or whatever
variant of those methods is relevant to you).
For example:
// To read in JSON, do something like:
let input_json = "...";
let user: User = serde_json::from_str(input_json)?;
// To write out JSON, do something like:
serde_json::to_string(&user)?;
In the example above, we directly serde_json::from_str
unvalidated input into
the jtd-codegen
-generated type. In many cases, this is perfectly fine to do.
However, there are is a caveat when doing this: the errors serde_json
produces
are Rust-specific and low-level.
You can address this issue (if it is an issue for your use-case) by first
validating the input against a JTD validation implementation, such as the jtd
crate. What you would do is:
- Parse the input into a
serde_json::Value
, rather than the generated type. - Validate that the parsed
serde_json::Value
is valid against the schema you generated your types from. If there are validation errors, you can return those, because JTD validation errors are standardized and platform-independent. - If the input is valid, then parse the
Value
into your generated type. You can do this usingserde_json::from_value
.
This solution lets you produce portable validation errors. It does, however, come at the cost of requiring you to process the input JSON document tree twice.
Customizing Rust output
Rust code generation supports the following metadata properties shared across
all languages supported by jtd-codegen
:
-
description
customizes the documentation comment to put on a type or property in generated code. For example, this schema:{ "metadata": { "description": "A user in our system" }, "properties": { "name": { "metadata": { "description": "The user's name" }, "type": "string" }, "isAdmin": { "metadata": { "description": "Whether the user is an admin" }, "type": "boolean" } } }
Generates into:
use serde::{Deserialize, Serialize}; /// A user in our system #[derive(Serialize, Deserialize)] pub struct User { /// Whether the user is an admin #[serde(rename = "isAdmin")] pub isAdmin: bool, /// The user's name #[serde(rename = "name")] pub name: String, }
-
enumDescription
is likedescription
, but for the members of anenum
. The keys ofenumDescription
should correspond to the values in the schema’senum
, and the values should be descriptions for those values. For example, this schema:{ "metadata": { "enumDescription": { "PENDING": "The job is waiting to be processed.", "IN_PROGRESS": "The job is being processed.", "DONE": "The job has been processed." } }, "enum": ["PENDING", "IN_PROGRESS", "DONE"] }
Generates into:
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub enum Status { /// The job has been processed. #[serde(rename = "DONE")] Done, /// The job is being processed. #[serde(rename = "IN_PROGRESS")] InProgress, /// The job is waiting to be processed. #[serde(rename = "PENDING")] Pending, }
Additionally, Rust code generation supports the following Rust-specific options:
-
rustType
overrides the type thatjtd-codegen
should generate.jtd-codegen
will not generate any code for schemas withrustType
, and instead use the value ofrustType
as-is.It is your responsibility to ensure that the value of
rustType
is valid code.jtd-codegen
will not attempt to validate its value.For example, this schema:
{ "properties": { "name": { "type": "string" }, "isAdmin": { "metadata": { "rustType": "MyCustomType" }, "type": "boolean" } } }
Generates into:
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct OverrideDemo { #[serde(rename = "isAdmin")] pub isAdmin: MyCustomType, #[serde(rename = "name")] pub name: String, }
Generated Rust code
This section details the sort of Rust code that jtd-codegen
will generate.
Code generated from “Empty” schemas
“Empty” schemas will be converted into a
Rust Option<serde_json::Value>
:
{}
Generates into:
use serde_json::{Value};
pub type Empty = Option<Value>;
Code generated from “Ref” schemas
“Ref” schemas will be converted into a reference to the definition being referred to:
{
"definitions": {
"example": { "type": "string" }
},
"ref": "example"
}
Generates into:
pub type Ref = Example;
pub type Example = String;
Code generated from “Type” schemas
“Type” schemas will be converted into the following types:
JSON Typedef type | Rust type |
---|---|
boolean |
bool |
string |
String |
timestamp |
chrono::DateTime<chrono::FixedOffset> |
float32 |
f32 |
float64 |
f64 |
int8 |
i8 |
uint8 |
u8 |
int16 |
i16 |
uint16 |
u16 |
int32 |
i32 |
uint32 |
u32 |
For example,
{
"properties": {
"boolean": { "type": "boolean" },
"string": { "type": "string" },
"timestamp": { "type": "timestamp" },
"float32": { "type": "float32" },
"float64": { "type": "float64" },
"int8": { "type": "int8" },
"uint8": { "type": "uint8" },
"int16": { "type": "int16" },
"uint16": { "type": "uint16" },
"int32": { "type": "int32" },
"uint32": { "type": "uint32" }
}
}
Generates into:
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Type {
#[serde(rename = "boolean")]
pub boolean: bool,
#[serde(rename = "float32")]
pub float32: f32,
#[serde(rename = "float64")]
pub float64: f64,
#[serde(rename = "int16")]
pub int16: i16,
#[serde(rename = "int32")]
pub int32: i32,
#[serde(rename = "int8")]
pub int8: i8,
#[serde(rename = "string")]
pub string: String,
#[serde(rename = "timestamp")]
pub timestamp: DateTime<FixedOffset>,
#[serde(rename = "uint16")]
pub uint16: u16,
#[serde(rename = "uint32")]
pub uint32: u32,
#[serde(rename = "uint8")]
pub uint8: u8,
}
Code generated from “Enum” schemas
“Enum” schemas will be converted into a
Rust enum
:
{
"enum": ["PENDING", "IN_PROGRESS", "DONE"]
}
Generates into:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub enum Enum {
#[serde(rename = "DONE")]
Done,
#[serde(rename = "IN_PROGRESS")]
InProgress,
#[serde(rename = "PENDING")]
Pending,
}
Code generated from “Elements” schemas
“Elements” schemas will be converted
into a Rust Vec<T>
, where T
is the type of the elements of the array:
{
"elements": {
"type": "string"
}
}
Generates into:
pub type Elements = Vec<String>;
Code generated from “Properties” schemas
“Properties” schemas will be
converted into a Rust struct
. Optional properties will be wrapped with
Optional
and a skip_serializing_if
on Option::is_none
, so they will be
omitted from JSON if set to None
.
{
"properties": {
"name": { "type": "string" },
"isAdmin": { "type": "boolean" }
},
"optionalProperties": {
"middleName": { "type": "string" }
},
"additionalProperties": true
}
Generates into:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Properties {
#[serde(rename = "isAdmin")]
pub isAdmin: bool,
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "middleName")]
#[serde(skip_serializing_if = "Option::is_none")]
pub middleName: Option<Box<String>>,
}
Code generated from “Values” schemas
“Values” schemas will be converted into
a Rust HashMap<String, T>
, where T
is the type of the values of the object:
{
"values": {
"type": "string"
}
}
Generates into:
use std::collections::{HashMap};
pub type Values = HashMap<String, String>;
Code generated from “Discriminator” schemas
“Discriminator” schemas will be
converted into a Rust enum
, and each mapping will be a member of that enum
.
A set of tags on the discriminator tells serde to use an adjacently tagged
representation
for the enum.
{
"discriminator": "eventType",
"mapping": {
"USER_CREATED": {
"properties": {
"id": { "type": "string" }
}
},
"USER_PAYMENT_PLAN_CHANGED": {
"properties": {
"id": { "type": "string" },
"plan": { "enum": ["FREE", "PAID"] }
}
},
"USER_DELETED": {
"properties": {
"id": { "type": "string" },
"softDelete": { "type": "boolean" }
}
}
}
}
Generates into:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(tag = "eventType")]
pub enum Discriminator {
#[serde(rename = "USER_CREATED")]
UserCreated(DiscriminatorUserCreated),
#[serde(rename = "USER_DELETED")]
UserDeleted(DiscriminatorUserDeleted),
#[serde(rename = "USER_PAYMENT_PLAN_CHANGED")]
UserPaymentPlanChanged(DiscriminatorUserPaymentPlanChanged),
}
#[derive(Serialize, Deserialize)]
pub struct DiscriminatorUserCreated {
#[serde(rename = "id")]
pub id: String,
}
#[derive(Serialize, Deserialize)]
pub struct DiscriminatorUserDeleted {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "softDelete")]
pub softDelete: bool,
}
#[derive(Serialize, Deserialize)]
pub enum DiscriminatorUserPaymentPlanChangedPlan {
#[serde(rename = "FREE")]
Free,
#[serde(rename = "PAID")]
Paid,
}
#[derive(Serialize, Deserialize)]
pub struct DiscriminatorUserPaymentPlanChanged {
#[serde(rename = "id")]
pub id: String,
#[serde(rename = "plan")]
pub plan: DiscriminatorUserPaymentPlanChangedPlan,
}
Section title
-
-
-
-
Tooling
-
Advanced Concepts
-
Language-Specific Documentation