2
\$\begingroup\$

I have a struct which, depending on options, I want to serialize to json with different subsets of the fields. However, my existing solution leads to a large amount of hard-to-maintain boilerplate. I'm sure there must be an elegant solution, but as a newcomer to rust I'm not sure what it is.

The context of the code is that it is for an API. It queries data (called observations in the code below) from a database (using diesel), then serves it with rocket.rs. Here, I am focused on the layer that formats the JSON object. The core querying code is shared, but depending on parameters passed to the API, some fields should be optionally excluded. The code shared below is that formatting code that selects a subset of fields and converts it to a JSON-serializable object.

Approaches I've considered:

  1. Converting the struct to a new struct with a subset of the fields, and converting that to a serde_json::Value. This is my current solution. However, this leads to a ton of boilerplate.
  2. Making the fields be Options. This won't work, as it will add unnecessary "field": null strings to the serialized result (which increases the size of the end result, defeating the purpose of optionally excluding fields).
  3. Implementing a custom serialize method instead of just using #[derive(Serialize)]. However, as far as I can tell, there's no way to pass options into the call to serialize.

What's the best-practice way to serialize a struct to JSON with a dynamic subset of the fields? Feedback is welcome on other aspects of the code too, but I see that as the biggest flaw in the code.


Example code (as you can see, it has a lot of boilerplate).

use serde_json::json;

// Main function transforms the data into something JSON-serializable based on the options
// It doesn't matter what the return type is as long it's JSON-serializable
pub fn query_observations(include_ids : bool, include_mission_name : bool) -> Vec<serde_json::Value> {
    let observations : Vec<ObservationWithMetadata> = vec![]; // in the real world, this gets read from somewhere

    observations.drain(..).map( |observation| {
        if include_ids && include_mission_name {
            json!(observation)
        } else if include_ids {
            json!(ObservationWithIds::from(observation))
        } else if include_mission_name {
            json!(ObservationWithMissionName::from(observation))
        } else {
            json!(Observation::from(observation))
        }
    }).collect()
}

// All the different variants...

#[derive(Serialize, Deserialize, Debug)]
pub struct ObservationWithMetadata {
    pub id : i64,
    pub mission_id : String,
    pub mission_name : Option<String>,

    pub timestamp : f64,
    pub altitude : Option<f64>,
    pub latitude : Option<f64>,
    pub longitude : Option<f64>,
    pub pressure : Option<f64>,
    pub speed_x : Option<f64>,
    pub speed_y : Option<f64>,
    pub temperature : Option<f64>,
    pub humidity : Option<f64>
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Observation {
    pub timestamp : f64,
    pub altitude : Option<f64>,
    pub latitude : Option<f64>,
    pub longitude : Option<f64>,
    pub pressure : Option<f64>,
    pub speed_x : Option<f64>,
    pub speed_y : Option<f64>,
    pub temperature : Option<f64>,
    pub humidity : Option<f64>
}

impl From<ObservationWithMetadata> for Observation {
    fn from(observation: ObservationWithMetadata) -> Self {
        Observation {
            timestamp: observation.timestamp,
            altitude: observation.altitude,
            latitude: observation.latitude,
            longitude: observation.longitude,
            pressure: observation.pressure,
            speed_x: observation.speed_x,
            speed_y: observation.speed_y,
            temperature: observation.temperature,
            humidity: observation.humidity
        }
    }
}

#[allow(dead_code)]
#[derive(Serialize, Deserialize, Debug)]
pub struct ObservationWithIds {
    pub id : i64,
    pub mission_id : String,

    pub timestamp : f64,
    pub altitude : Option<f64>,
    pub latitude : Option<f64>,
    pub longitude : Option<f64>,
    pub pressure : Option<f64>,
    pub speed_x : Option<f64>,
    pub speed_y : Option<f64>,
    pub temperature : Option<f64>,
    pub humidity : Option<f64>
}

impl From<ObservationWithMetadata> for ObservationWithIds {
    fn from(observation: ObservationWithMetadata) -> Self {
        ObservationWithIds {
            id: observation.id,
            mission_id: observation.mission_id,

            timestamp: observation.timestamp,
            altitude: observation.altitude,
            latitude: observation.latitude,
            longitude: observation.longitude,
            pressure: observation.pressure,
            speed_x: observation.speed_x,
            speed_y: observation.speed_y,
            temperature: observation.temperature,
            humidity: observation.humidity
        }
    }
}

#[allow(dead_code)]
#[derive(Serialize, Deserialize, Debug)]
pub struct ObservationWithMissionName {
    pub mission_name : Option<String>,

    pub timestamp : f64,
    pub altitude : Option<f64>,
    pub latitude : Option<f64>,
    pub longitude : Option<f64>,
    pub pressure : Option<f64>,
    pub speed_x : Option<f64>,
    pub speed_y : Option<f64>,
    pub temperature : Option<f64>,
    pub humidity : Option<f64>
}

impl From<ObservationWithMetadata> for ObservationWithMissionName {
    fn from(observation: ObservationWithMetadata) -> Self {
        ObservationWithMissionName {
            mission_name: observation.mission_name,

            timestamp: observation.timestamp,
            altitude: observation.altitude,
            latitude: observation.latitude,
            longitude: observation.longitude,
            pressure: observation.pressure,
            speed_x: observation.speed_x,
            speed_y: observation.speed_y,
            temperature: observation.temperature,
            humidity: observation.humidity
        }
    }
}
\$\endgroup\$
1

1 Answer 1

1
\$\begingroup\$

In this review I will only consider the code as it is written and ignoring the relevant questions

Looking at this closure here:

    observations.drain(..).map( |observation| {
        if include_ids && include_mission_name {
            json!(observation)
        } else if include_ids {
            json!(ObservationWithIds::from(observation))
        } else if include_mission_name {
            json!(ObservationWithMissionName::from(observation))
        } else {
            json!(Observation::from(observation))
        }
    }).collect()

I think this will be clearer with a match statement

    observations.drain(..).map( |observation| {
        match (include_ids,include_mission_name) {
            (true, true) => json!(observation),
            (true, false) => json!(ObservationWithIds::from(observation)),
            (false, true) => json!(ObservationWithMissionName::from(observation)),
            (false, false) => json!(Observation::from(observation)),
        }
    }).collect()

To make it even more readable and less prone to usage errors, use the type system to verify the mode parameters. The boolean parameters could be replaced with custom enum types:

#[derive(Clone, Copy,PartialEq, Eq)]
pub enum IdsMode{
    Ids,
    NoIds,
}
#[derive(Clone, Copy,PartialEq, Eq)]
pub enum MissionName {
    Named,
    Anonymous,
}

And the function signature will make sure the parameters never can be mixed up when used.

// Main function transforms the data into something JSON-serializable based on the options
// It doesn't matter what the return type is as long it's JSON-serializable
pub fn query_observations(include_ids : IdsMode, include_mission_name : MissionName) -> Vec<serde_json::Value> {
    let mut observations : Vec<ObservationWithMetadata> = vec![]; // in the real world, this gets read from somewhere

    observations.drain(..).map( |observation| {
        match (include_ids,include_mission_name) {
            (IdsMode::Ids, MissionName::Named) => json!(observation),
            (IdsMode::Ids, MissionName::Anonymous) => json!(ObservationWithIds::from(observation)),
            (IdsMode::NoIds, MissionName::Named) => json!(ObservationWithMissionName::from(observation)),
            (IdsMode::NoIds, MissionName::Anonymous) => json!(Observation::from(observation)),
        }
    }).collect()
}

Then refactor the entire function above, split the part handling the vector and a single observation. When you have a separate function (as the closure above) write a few test cases to generate the expected encoded json strings from a few ObservationWithMetadata objects

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.