Safe JSON representations with Rust

Table of contents

  1. JSON responses
  2. Simple Rust representation
    1. Happy cases
    2. Unhappy cases
  3. Enum representation
    1. Custom serialization
  4. Sample project 🦀

One of the reasons why I like serverless technologies is that code is a liability. I can push a lot of the logic to handle and process requests as possible to the cloud provider, and focus on the core business logic.

However, I am still liable for the code that I need to write. The Rust programming language is quite interesting for this, as it’s designed for highly safe systems. You might already know some of its features on the memory safety side, but it also offer other useful patterns for building safe applications.

JSON responses

For example, let’s say we are building an application that returns a JSON response to the end user. Its representation will be different based on whether we return a success or not.

If the operation is a success, we’ll return a data attribute containing the object’s properties:

{
  "success": true,
  "data": {
    "id": "88819696-4fe0-4631-a0b6-76b18310474b",
    "name": "Red shoes",
    "price": 39.95
  }
}

However, if there is any issue, we’ll return an error error:

{
    "success": false,
    "error": "Something went wrong"
}

Simple Rust representation

To represent the response before serializing it into a JSON object, we could create a struct with all possible properties, and mark both data and error as optional.

use serde::Serialize;

#[derive(Serialize)]
pub struct AllFieldsResponse {
    success: bool,

    #[serde(skip_serializing_if = "Option::is_none")]
    data: Option<Data>,

    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>
}

We’re using the serde crate to mark our struct as serializable. This mean we’ll be able to transform it easily into a JSON object using serde_json later.

If you’re new to Rust, you might be wondering what are those Option<> types we use for the fields. An Option either contain some value or none. As those values depends on if the response marks a success or not, they might not be present in the response.

This is why we use skip_serializing_if for these fields. With serde, we can prevent serializing fields based on the result of a function. If the Option is None (doesn’t contain a value), we will skip this field in the JSON object.

Happy cases

Doing like so works pretty well to represent both possibles states. We can manually transform a Result<> from another operation into our struct.

use serde_json::to_string_pretty;

fn main() {
    // Run a command that returns a Result<Data, Error> object.
    let data_res = some_operation();

    // Create a response from the result
    let response = match data_res {
        // If the operation was a success
        Ok(data) => AllFieldsResponse {
            success: true,
            data: Some(data),
            error: None,
        },
        // If the operation triggered an error
        Err(_) => AllFieldsResponse {
            success: false,
            data: None,
            error: Some("Something went wrong".to_string())
        },
    };

    // Print the response
    println!("{}", to_string_pretty(&response).unwrap());
}

Unhappy cases

However, the problem with representing our responses this way is that a developer could accidentally create a response that would be valid in Rust, but invalid from the end users.

use serde_json::to_string_pretty;

fn main() {
    // Create a default data object
    let data = Data::default();

    // Create a response that is not a success, but contains data!
    let invalid_response = AllFieldsResponse {
        success: false,
        data: Some(data),
        error: Some("Something went wrong".to_string()),
    };

    // Print the response
    println!("{}", to_string_pretty(&response).unwrap());
}

Here, we have a response that’s marked as unsuccessful, contains some data and and error message. From the end-user’s perspective, this is an undefined behavior compared to the original specification.

Enum representation

We could argue that it’s up to the developer to ensure they’re writing code that will behave as expected, but can we do better? If we go back to what we want to send as responses, there are two possible states:

  • The operation is a success and we return a data object; or
  • The operation was unsuccessful and we return an error message.

In Rust, we can just represent this as an Enum. Contrary to many programming languages, we can use Enum to represent variants that hold data.

pub enum ConditionalFieldsResponse {
    Success(Data),
    Error(String),
}

We have two variants here: a success that contains a Data value, and an error that contains a String (our error message). From a developer point of view, that means they can only represent valid responses.

For reference, here’s how we would map the Result<> of the operation into a response:

use serde_json::to_string_pretty;

fn main() {
    // Run a command that returns a Result<Data, Error> object.
    let data_res = some_operation();

    // Create a response from the result
    let response = match data_res {
        // If the operation was a success
        Ok(data) => ConditionalFieldsResponse::Success(data),
        // If the operation triggered an error
        Err(_) => ConditionalFieldsResponse::Error("Something went wrong".to_string()),
    };

    // Print the response
    println!("{}", to_string_pretty(&response).unwrap());
}

Custom serialization

However, we’re still missing one thing. If you run the code above, you will get an error as our ConditionalFieldsResponse enum doesn’t implement Serialize!

the serde crate supports some enum representation patterns out of the box. In this case, we need to have a custom representation as the success key is a boolean value.

Thankfully, we can create our own implementation of the Serialize trait:

use serde::{ser::SerializeStruct, Serialize};

impl Serialize for ConditionalFieldsResponse {
    /// Custom serializer for ConditionalFieldsResponse
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        match self {
            ConditionalFieldsResponse::Success(data) => {
                let mut s = serializer.serialize_struct("ConditionalFieldsResponse", 2)?;
                s.serialize_field("success", &true)?;
                s.serialize_field("data", data)?;
                s.end()
            }
            ConditionalFieldsResponse::Error(error) => {
                let mut s = serializer.serialize_struct("ConditionalFieldsResponse", 2)?;
                s.serialize_field("success", &false)?;
                s.serialize_field("error", error)?;
                s.end()
            }
        }
    }
}

Sample project 🦀

If you want to see a sample implementation that you can just download and run, I’ve created a GitHub repository.