Rust JSON and Data Serialization – A Beginner’s Guide

Introduction

With over seven million monthly downloads, Rust’s Serde library is one of the most downloaded Rust crates. Owing to the popularity of JSON as a web serialization format, a related package, serde_json, is almost as popular.

Serde’s documentation tells us what Serde is and where it gets its name: “Serde is a framework for serializing and deserializing Rust data structures efficiently and generically.” If you’re coming to Rust from another language, as many of us do, you may find the word “generically” interesting here. In the Python world, for example, you select your tool based on the format you want. There’s a library for JSON that’s part of the standard library, another for YAML that’s installed with pip, etc.

This is partly true of Serde as well. Serde works with format-specific crates such as serde_json. Serde supports many other formats, including MessagePack, Avro, YAML, TOML, Python Pickle, and web forms (x-www-form-urlencoded). (The Serde documentation gives a partial list of supported formats).

Serde allows you to work with these various formats by providing two core traits, Serialize and Deserialize. It also provides a derive macro so you can quickly implement these traits.

This article will begin with a step-by-step walkthrough showing the most common use case: serializing and deserializing JSON data.

Setting Up the Project

The sample serialization program in the Serde documentation runs fine in the Rust Playground. Still, because that environment includes several dependencies pre-installed, it glosses over the steps we need to add Serde to our program.

We begin at the command line, using cargo init serde-demo to set up a binary Rust project. From there, we want to execute these commands:

cd serde-demo
cargo add serde --features derive
cargo add serde_json

At this point, our Cargo.toml file should look something like this, though the version numbers may differ slightly:

[package]
name = "serde-demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"

Next, let’s change the contents of src/main.rs to the code below. This sample is still a basic one, but we’ve modified the Serde example to show how we can serialize a couple of basic types:

use serde::{Serialize, Deserialize};
use serde_json;

#[derive(Serialize, Deserialize, Debug)]
struct Person {
    name: String,
    height_inches:  u8,
    books: Vec<String>
}

fn main() {
    let person = Person{
        name: "John".to_string(), 
        height_inches: 73, 
        books: vec!["At the Existentialist Cafe".to_string()]
    };

    // Convert the Person to a JSON string.
    let serialized = serde_json::to_string(&person).unwrap();

    println!("serialized = {}", serialized);

    // Convert the JSON string back to a Person.
    let deserialized: Person = serde_json::from_str(&serialized).unwrap();

    println!("deserialized = {:?}", deserialized);
}

If we now build and run the program with cargo run, we’ll see the following output:

serialized = {"name":"John","height_inches":73,"books":["At the Existentialist Cafe"]}
deserialized = Person { name: "John", height_inches: 73, books: ["At the Existentialist Cafe"] }

Discussion

We declare a Person struct on lines 5-9, and we don’t have to do much to make this serializable. On line 4, we take advantage of Serde’s derive functionality to add the code to serialize and deserialize it. (On the same line, we also make it debuggable using Rust’s built-in derive macro so that we can print it easily):

#[derive(Serialize, Deserialize, Debug)]

Inside the main function, we declare a Person object and serialize it to a JSON string using serde_json::to_string on line 19. This returns a Result object, so we use unwrap to get the string. From a JSON string, we can also go the other way back to a Person object using serde_json::from_str on line 24.

Working With Nested Types

The Person struct is very simple, consisting of a string, an integer, and a vector — all basic Rust types so far. Serde can also handle more nested types correctly, so long as the nested types also use the derive macro in the same way.

For example, we can add an enum type to our Person object to show what kind of programmer they are. Shown in bold are the changes we need to make to main.rs:

#[derive(Serialize, Deserialize, Debug)]
enum ProgrammerType {
    Brilliant,
    Plodding,
    Clueless
}

#[derive(Serialize, Deserialize, Debug)]
struct Person {
    name: String,
    height_inches:  u8,
    books: Vec<String>,
    programmer_type: ProgrammerType
}


fn main() {
    let person = Person{
        name: "John".to_string(), 
        height_inches: 73, 
        books: vec!["At the Existentialist Cafe".to_string()], 
        programmer_type: ProgrammerType::Plodding
    };

We chose an enum here to illustrate that enums are also supported, but the same rule would apply to a struct: nested types have to use derive to implement the same code as in the outermost (containing) type, so in our case, we add #[derive(Serialize, Deserialize, Debug)].

A Web-Based Example: Using Serde With Axum

As you might expect, the ability to serialize Rust objects to JSON and back is commonplace in Web applications. We can illustrate this with a simple example that uses one popular web framework for Rust, Axum.

As with most web frameworks, Axum features the ability to route web requests to handler functions. (In the case of Axum, part of Rust’s asynchronous runtime, Tokio, the functions are asynchronous.) Beyond this, Axum’s “extractors” make it easy to parse requests and return data in various formats. We’ll focus on getting it to work in a simple example using Serde.

To get started, we first replace the contents of Cargo.toml with the following code:

# Cargo.toml

[package]
name = "serde-demo"
version = "0.2.0"
edition = "2021"

[dependencies]
axum = "0.6.10"
serde = { version = "1.0.152", features = ["derive"] }
tokio = { version = "1.26.0", features = ["full"] }

This adds the Axum and Tokio dependencies we’ll need and removes the explicit support for serde_json. (Serde_json is important enough in the Rust ecosystem that it’s already included as a dependency by Axum, so we don’t need to include it explicitly here).

Next, we’ll replace the contents of our main.rs file with a small web application that shows how we can use serde Serialize and Deserialize in Axum. Here’s the code, which we’ll discuss in detail shortly:

// main.rs

// This is needed for CreateUser, though it's not clear why
#![allow(dead_code)]

use serde::{Serialize, Deserialize};
use axum::{Router, extract::Json, extract::Path};
use axum::routing::{get, post};
use std::net::SocketAddr;

// Input to post, user must supply password
#[derive(Deserialize, Debug)]
struct CreateUser {
    email: String,
    password: String,
}

// Output from post.  Password is now hidden, and a user ID is added
#[derive(Serialize)]
struct User {
    email: String,
    id: u32
}

#[tokio::main]
async fn main() {
    // build our application with a get and post route
    let app = Router::new()
        .route("/user", post(create_user))
        .route("/user/:user_id", get(get_user));

    // run it
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn get_user(Path(user_id) : Path<u32>) -> Json<User>  {
    // Actually look up user from id, etc.
    Json(User{email: "everyman@ubiquitous.com".to_string(), id: user_id})
}

async fn create_user(Json(payload): Json<CreateUser>) {
    // Check for existing, save to database, etc.
    println!("Inside create_user:  {:?}", payload);
}

Taking it from the top, we disable dead code checking to avoid a warning on CreateUser, though we do use that class, so it’s unclear why we need it. On lines 11-23, we define two User structures that our application’s route handlers will need. CreateUser is input into the POST handler, so it needs to have the Deserialize attribute applied to convert JSON to a Rust object. The User struct, in contrast, is returned from a handler, so it needs #[derive(Serialize)] to allow it to be converted to JSON.

Our main method is on lines 25-39. Note that our main method and the methods we’ll be calling our asynchronous. All we need to do in the main method is set up our route handler and start our web app, listening on port 3000.

Finally, our route handling functions are defined on lines 41-49. As you can see, we’ve simplified a lot here to keep our application simple (i.e., we’re not storing or reading from a database). With this in mind, no matter which user_id we request, we get a user with the same email address, “everyone@ubiquitous.com.” Similarly, all we do in create_user is log the payload to the console.

More to the point of our serde discussion, note that we return Json<User>, a serializable serde object, from get_user. In create_user, we pass a deserializable Json<CreateUser> to our POST handler.

The Json structure that we’re wrapping it with looks interesting. If you’re using VS Code and the rust-analyzer extension, you can navigate to the definition of it by placing your cursor on “Json” and pressing F12. Though the code is a bit intricate for a beginner, at a high level, the generic Json struct implements two traits, “FromRequest” and “IntoResponse.” Their implementation uses serde_json, with the net effect being that you can pass or return your Json<YourSerdeObject> from any handler.

Serde for Web Form Data

As we’ve seen, Axum’s integration with Serde for serialization and deserialization of JSON data makes accepting and returning strongly typed structures in web handers quite simple to do. In addition to JSON data, Axum also relies on Serde for processing web form data, which by default is sent with the mime type “application/x-www-form-urlencoded.” You can find a simple example here. Finally, there are some third-party extractors featured on the Axum ecosystem page.

Closing Thoughts

In contrast to Python’s “batteries included” philosophy, Rust features a relatively lean standard library, with many of the “batteries” outside of the core project in third-party crates. As our overview of Serde has shown, however, this has not prevented certain standards from emerging. In the world of web development, Serde is one such standard that makes working with strongly typed data structures fast and efficient both at runtime and during development.

Leave a Comment