Conditional required-field validation in Serde

Viewed 307

Can I specify that a field must be present (not null) if (and only if) another field has a certain value?

Here is an example (see the comment):

#[derive(Serialize, Deserialize, Debug)]
pub struct Params {
    pub id: i32,
    pub foo: i32,
    pub bar: Option<i32>, // this must be Some if "foo == 5"
}

I'm also interested in cases where foo is an enum.

2 Answers

You can do this by writing your own deserializer.

This is how the code works in a few bullet points:

  1. Use a struct InnerParams with all the same fields like Params and make it derive Deserialize, so you don't have to write that by hand.
  2. Deserialize using the code generated for InnerParams
  3. Do your custom validation on the deserialzied InnerParams, when they're invalid return a custom error
  4. If valid map to Params

Code:

use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize};

#[derive(Serialize, Debug)]
pub struct Params {
    pub id: i32,
    pub foo: i32,
    pub bar: Option<i32>, // this must be Some if "foo == 5"
}

impl<'de> Deserialize<'de> for Params {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        // Create our contract for internal use
        #[derive(Deserialize)]
        struct InnerParams {
            id: i32,
            foo: i32,
            bar: Option<i32>,
        }

        // Deserialize the contract
        let inner = InnerParams::deserialize(deserializer)?;

        // Do the additional validation, if it fails return an error
        if inner.foo == 5 && inner.bar.is_none() {
            return Err(D::Error::custom("bar should be set when foo equals '5'"));
        }

        // Return the actual contract
        Ok(Params {
            id: inner.id,
            foo: inner.foo,
            bar: inner.bar,
        })
    }
}

You can only perform validation by manually implementing Deserialize. You can still use the derive to perform the heavy lifting:

#[derive(Deserialize, Debug)]
#[serde(remote = "Self")]
pub struct Params {
    pub id: i32,
    pub foo: i32,
    pub bar: Option<i32>, // this must be Some if "foo == 5"
}

impl<'de> Deserialize<'de> for Params {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let this = Self::deserialize(deserializer)?;

        if this.foo == 5 && this.bar.is_none() {
            return Err(D::Error::custom("bar should be set when foo equals '5'"));
        }

        Ok(this)
    }
}

Another option is to have two structs, one to deserialize (ParamsUnchecked) and a second which is checked that all constraints hold (Params). You can then use TryFrom to convert between them.

#[derive(Deserialize, Debug)]
#[serde(try_from = "ParamsUnchecked")]
pub struct Params {
    pub id: i32,
    pub foo: i32,
    pub bar: Option<i32>, // this must be Some if "foo == 5"
}

#[derive(Deserialize, Debug)]
pub struct ParamsUnchecked {
    pub id: i32,
    pub foo: i32,
    pub bar: Option<i32>,
}

impl TryFrom<ParamsUnchecked> for Params {
    type Error = String;
    
    fn try_from(pu: ParamsUnchecked) -> Result<Self, Self::Error> {
        if pu.foo == 5 && pu.bar.is_none() {
            return Err("bar should be set when foo equals '5'".to_string());
        }
        Ok(Self {
            id: pu.id,
            foo: pu.foo,
            bar: pu.bar,
        })
    }
}
Related