Search code examples
rustserde

How customize serialization of a result-type using serde?


I have a struct which contains a Result field that I want to serialize

#[derive(Serialize, Deserialize)]
struct ServerResponse<D,E> {
   result : Result<D,E>,
   version : String,
   // More parameters here
}

I want to be able to serialize and deserialize this struct to the following-format.

If result is successful

{
   "result" : "my_result_data",
   "version" : "1.0",
}

If the result is not successful

{
   "error" : { "message" : "The request failed", "error-code" : 212 },
   "version" : "1.0"
}

Solution

  • I have one issue with your question: You want Err to be deserialized from a json object with the fields message and error-code, yet it's a generic parameter and there's no indication that it is constrained to any specific type.

    I've chosen to work around that issue by defining a new trait:

    pub trait ResponseError {
        fn message(&self) -> &str;
        fn error_code(&self) -> i32;
        fn from_parts(message: &str, code: i32) -> Self;
    }
    

    Any ServerResponse that wants to be serialized must E: ResponseError. That might entirely be the wrong thing to do, but with what limited information I have, it's the best thing I can do.

    The actual implementation is quite mechanic:

    1. Set up ServerResponse to delegate de/serialization to a set of custom functions:

      #[derive(Serialize, Deserialize, Debug)]
      #[serde(bound(
          serialize = "D: Serialize, E: ResponseError",
          deserialize = "D: DeserializeOwned, E: ResponseError"
      ))]
      struct ServerResponse<D, E> {
          #[serde(with = "untagged_ok_result")]
          result: Result<D, E>,
          version: String,
          // More parameters here
      }
      
    2. Scaffold said functions

      mod untagged_ok_result {
          pub fn serialize<'a, S, T, E>(v: &Result<T, E>, ser: S) -> Result<S::Ok, S::Error> { todo!() }
          pub fn deserialize<'de, D, T, E>(de: D) -> Result<Result<T, E>, D::Error> { todo!() }
      }
      
    3. Define a Rust type where derived De/Serialize matches whatever actual JSON format you want

      #[derive(Serialize, Deserialize)]
      #[serde(untagged)]
      #[serde(bound(deserialize = "D: Deserialize<'a>"))]
      enum Mresult<'a, D> {
          Ok(D),
          Err {
              #[serde(borrow)]
              message: Cow<'a, str>,
              #[serde(rename = "error-code")]
              error_code: i32,
          },
       }
      
    4. Implement de/serialization by converting between the std Result and the custom type:

      pub fn serialize<'a, S, T, E>(v: &Result<T, E>, ser: S) -> Result<S::Ok, S::Error>
      where
          S: Serializer,
          T: Serialize,
          E: ResponseError,
      {
          match v {
              Ok(v) => Mresult::Ok(v),
              Err(v) => Mresult::Err {
                  message: v.message().into(),
                  error_code: v.error_code(),
              },
          }
          .serialize(ser)
      }
      pub fn deserialize<'de, D, T, E>(de: D) -> Result<Result<T, E>, D::Error>
      where
          D: Deserializer<'de>,
          T: Deserialize<'de>,
          E: ResponseError,
      {
          Ok(match Mresult::deserialize(de)? {
              Mresult::Ok(v) => Ok(v),
              Mresult::Err {
                  message,
                  error_code,
              } => Err(ResponseError::from_parts(&message, error_code)),
          })
      }
      

    Playground

    (I was tempted to use the from/into attributes for a while, but those are container attributes, which causes other boilerplate. It might be an option for you anyway)