paild tech blog

法人カードのクラウド型発行・管理サービスpaild

serdeを理解する –– アトリビュート理解編

前回はserdeの基本を理解することができましたので、次はserdeに登場するアトリビュートのうち、実務上よく利用するものを紹介したいと思います。具体的にはいくつかのアトリビュートと、実務上はenumシリアライズ・デシリアライズで毎回迷いごとが発生してドキュメントを確認する傾向にあるので、それを確認していきます。

rename_allrename

たとえばよく利用するケースとして、Rustは変数名やフィールド名はすべてsnake_caseですが、フロントエンド側ではcamelCaseとして受け取りたいという場合があります。何も設定せずにそのままRustのシリアライズ結果をJSONで返すとスネークケースで返ってしまいますが、rename_allアトリビュートを使用するとこれを調整することができます。

また、renameアトリビュートを用いると、フィールドの名前を別の名前に変換させることができます。この変換はシリアライズ、デシリアライズそれぞれ指定することもできます。何も指定しない場合、両方とも名前を変更します。

下記は、構造体のすべてのフィールドをキャメルケースに変更するケースと(rename_allを使用)、構造体のフィールドのうち一部を別名に変更する例(renameを使用)を示したものです。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RenameAllFields {
    rename_this: i32,
    rename_that: bool,
}

#[derive(Serialize, Deserialize)]
struct RenameOnlySpecificField {
    #[serde(rename = "wow")]
    renamed_field: i32,
    do_not_rename: bool,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let all_fields = RenameAllFields {
        rename_this: 1,
        rename_that: true,
    };
    println!("{}", serde_json::to_string_pretty(&all_fields)?);

    let specific_field = RenameOnlySpecificField {
        renamed_field: 42,
        do_not_rename: false,
    };
    println!("{}", serde_json::to_string_pretty(&specific_field)?);

    Ok(())
}

出力結果は次のようになります。

{
  "renameThis": 1,
  "renameThat": true
}
{
  "wow": 42,
  "do_not_rename": false
}

skip

skipは特定のフィールドにつけておくと、そのフィールドのシリアライズやデシリアライズをさせない(スキップさせる)ことができるアトリビュートです。たとえば秘匿情報が含まれるなどして特定のフィールドをJSONとして返させたくない場合に、そのフィールドに#[serde(skip)]というアトリビュートをセットしておくと、そのフィールドは読み飛ばしてくれます[*1]

簡単な例として、

  • あるyamlファイルに読み込み先のファイルのパス情報が書いてあるが、後で読み込ませるようにしたい&実は別の形式のファイルで一気に読み込ませるのが難しい。
  • 読み込み先のファイルの設定は追加情報として同一構造体に保持させたい。

という(半ば無理矢理な)条件があったとします。この例を実現するためには、

  1. まずそのファイルのパス情報を読み込んでおく。
  2. しかるべきタイミングでloadというメソッドを呼び出し、構造体の内部情報を書き換える。

という処理順序を実現する必要があったとしましょう。

まず設定ファイルですが、次のように2つのファイルが用意されているとします。

# config/path.yaml
path: config/application_config.yaml
# config/application_config.yaml
host: localhost
port: 9090
env: dev

次に構造体とそれに関連して必要になる実装を用意します。下記のように実装できるでしょう。

use std::{collections::HashMap, error::Error, fmt::Display, path::PathBuf};

use serde::{Deserialize, Serialize};
use serde_yaml::Value;

#[derive(Debug)]
pub struct EasyError {
    details: String,
}

impl Display for EasyError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}", self.details)
    }
}

impl Error for EasyError {
    fn description(&self) -> &str {
        &self.details
    }
}

#[derive(Debug, Eq, PartialEq, Hash)]
pub struct Attribute(String);

#[derive(Debug)]
pub enum ConfigValue {
    String(String),
    Number(u64),
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
    path: PathBuf,
    #[serde(skip)]
    extra_config: HashMap<Attribute, ConfigValue>,
}

impl Config {
    pub fn load(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        let rdr = std::fs::File::open(&self.path)?;
        let deserialized: HashMap<String, Value> = serde_yaml::from_reader(&rdr)?;
        for (k, v) in deserialized {
            match v {
                Value::String(s) => {
                    self.extra_config
                        .insert(Attribute(k), ConfigValue::String(s));
                }
                Value::Number(n) => {
                    self.extra_config.insert(
                        Attribute(k),
                        ConfigValue::Number(n.as_u64().ok_or(EasyError {
                            details: "数値ではないものが入っているかもしれません。".into(),
                        })?),
                    );
                }
                _ => {}
            }
        }
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let rdr = std::fs::File::open("config/path.yaml").unwrap();
    let mut config_base: Config = serde_yaml::from_reader(&rdr)?;
    println!("{:?}", config_base);

    config_base.load()?;
    println!("{:?}", config_base);

    Ok(())
}

重要な点として、extra_configは最初Configyamlからデシリアライズした(つまり、config/path.yamlを読み込んだ)時点では読み込まれません。フィールドとしても元となる設定ファイルに存在しない関係で、読み飛ばしを起こさせたいでしょう。そこで#[serde(skip)]の出番、というわけです。

これを実行すると、次のようなログを得られます。config/path.yamlファイルにはextra_configというフィールドは一切ないものの正常に一度pathを読み込めており、その後追加のloadメソッドの呼び出しにより、extra_configが後から埋められました。

Config { path: "config/application_config.yaml", extra_config: {} }
Config { path: "config/application_config.yaml", extra_config: {Attribute("env"): String("dev"), Attribute("port"): Number(9090), Attribute("host"): String("localhost")} }

flatten

serdeはネストさせた構造体であっても簡単に扱うことができますが、場合によっては、構造体の表現上はネストさせたフィールドを、JSON上ではネストさせずに扱いたいケースがあると思います。こういう場合にflattenが使用できます。ページネーションは実装方法によってはそうなるもののひとつで、たとえば次のように実装できます。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct Item {
    price: u32,
}

#[derive(Serialize, Deserialize, Debug)]
struct Items {
    items: Vec<Item>,
    #[serde(flatten)]
    pagination: Pagination,
}

#[derive(Serialize, Deserialize, Debug)]
struct Pagination {
    offset: u64,
    limit: u64,
    total: u64,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let items = Items {
        items: vec![Item { price: 100 }, Item { price: 200 }],
        pagination: Pagination {
            offset: 0,
            limit: 10,
            total: 100,
        },
    };

    println!("{}", serde_json::to_string_pretty(&items)?);

    Ok(())
}

flattenを指定すると、特定のフィールドの子構造体ないしは値(HashMapなど)を平坦化して、親となる構造体にそのまま展開できます。上述のコードを展開すると次のように、Pagination構造体のフィールドがItemsの一部として展開されます。

{
  "items": [
    {
      "price": 100
    },
    {
      "price": 200
    }
  ],
  "offset": 0,
  "limit": 10,
  "total": 100
}

serdeの公式ドキュメントには、型定義をとくにしないJSONを、HashMapなフィールドに#[serde(flatten)]を適用することにより実現した例があります。たまに有用なケースなので押さえておくとよいと思います。

serde.rs

serialize_with / deserialize_with

_withを使うと、特定のフィールドのシリアライズ・デシリアライズ時に、特定の関数を呼び出して処理を行わせることができます。今回はdeserialize_withを使った簡単な例を示しておきます。

この例では、"1000 JPY"のような値がpriceというフィールドに入ったJSONを渡すものとします。ただRustの構造体上では、価格それ自体と単位は、それぞれvaluecurrencyに分けたいという要件があるとしましょう。serdeがデフォルトで提供する機能ではこうしたデシリアライズは不可能ですが、専用の処理をする関数(parse_with_currency)をひとつ噛ませることで、こうしたケースにも対応することができます。

#![allow(unused)]

use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize, Debug)]
struct Price {
    value: u64,
    currency: String,
}

#[derive(Deserialize, Debug)]
struct Item {
    #[serde(deserialize_with = "parse_with_currency")]
    price: Price,
}

fn parse_with_currency<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Price, D::Error> {
    let s = String::deserialize(deserializer)?;
    let mut split = s.splitn(2, ' ');
    let value = split.next().unwrap().parse().unwrap();
    let unit = split.next().unwrap().to_string();
    Ok(Price {
        value,
        currency: unit,
    })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let json = json!({
        "price": "1000 JPY"
    });
    let item: Item = serde_json::from_value(json)?;
    println!("{:?}", item);
    Ok(())
}

これを実行すると次のようにデシリアライズできます。

Item { price: Price { value: 1000, currency: "JPY" } }

なお、シリアライズ時の特殊ケースへの対応はserialize_withでできます。

New Typeの扱い

serdeはとくに特別な指定なくNew Typeを綺麗に扱ってくれます。たとえばUserIdというNew Typeを用意したとして、このNew Typeを元のString型としてJSONシリアライズする際に返したり、あるいはJSONidフィールドの文字列はUserIdにデシリアライズして値を格納したりすることができます。

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

#[derive(Serialize, Deserialize, Debug)]
struct UserId(String);

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: UserId,
    name: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let user = User {
        id: UserId("aaa".into()),
        name: "paild".into(),
    };
    println!("{}", serde_json::to_string_pretty(&user)?);

    let json = json!({
        "id": "aaa",
        "name": "paild"
    });
    let user = serde_json::from_value::<User>(json)?;
    println!("{:?}", user);

    Ok(())
}

ちなみに、New Type Patternというわけではありませんが、下記のように構造体に名前付きでひとつだけフィールドを持つものを綺麗に扱うには#[serde(transparent)]を使用します。

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

#[derive(Serialize, Deserialize, Debug)]
#[serde(transparent)]
struct UserId {
    value: String,
}

#[derive(Serialize, Deserialize, Debug)]
struct User {
    id: UserId,
    name: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let user = User {
        id: UserId {
            value: "aaa".into(),
        },
        name: "paild".into(),
    };
    println!("{}", serde_json::to_string_pretty(&user)?);

    let json = json!({
        "id": "aaa",
        "name": "paild"
    });
    let user = serde_json::from_value::<User>(json)?;
    println!("{:?}", user);

    Ok(())
}

JSONへのシリアライズ時にはvalueというフィールド名はなくなり文字列が直に登録され、構造体へのデシリアライズ時にはvalueフィールドに元のJSONの文字列が入っていることが確かめられます。

{
  "id": "aaa",
  "name": "paild"
}
User { id: UserId { value: "aaa" }, name: "paild" }

仮に#[serde(transparent)]を指定しなかった場合、そもそも期待されるJSONが下記のようになります。こうならないようにするのが#[serde(transparent)]です。

{
  "id": {
    "value": "aaa"
  },
  "name": "paild"
}

enumの表現

Rustで何かのデータを表現する際、enumは外せません。enumはひとつの型に対して複数のヴァリアント(variant)を持ち得るわけですが、ヴァリアントひとつひとつは情報を特別に持つことになります。

たとえば、タスクの終了時のステータスを表現するTaskStatusというenumを定義したとしましょう。このenumは成功(TaskStatus::Succeeded)と失敗(TaskStatus::Failed)のヴァリアントを持つとします。各々のヴァリアントはそれぞれ、「成功」と「失敗」という別々の情報を持っているといえます。

一方で、Rustの型表現の上ではヴァリアントは型にはならないため、TaskStatusという単一の型で表現されるに過ぎません。型情報からはヴァリアントは落とされてしまうのです。他方、JSON上はヴァリアントの情報を特別視して扱いたいことが多いでしょう。serdeはシリアライズする際にこのギャップを上手に埋め合わせる必要がありますが、このギャップの埋め方にいくつか方法が用意されています。それを今回は紹介します。

enumの表現は一通り網羅的にこの記事で説明しますが、改めて公式ドキュメントも確認されるとよいでしょう。

serde.rs

Externally Tagged

ヴァリアント名をそのままフィールド名として使用させてしまう方法です。オブジェクトの「外に」ヴァリアントの名前がタグ付けされます。serdeはデフォルトでこの方式を採ります。

まず結果からですが、次のJSONシリアライズされます。

[
  {
    "executed_by": "admin",
    "status": {
      "Succeeded": {
        "finished_at": "2020-01-01 12:00:00"
      }
    }
  },
  {
    "executed_by": "admin",
    "status": {
      "Failed": {
        "failed_at": "2020-01-01 12:00:00",
        "reason": "Task failed because of timeout"
      }
    }
  }
]

これを実現するために、次のようなコードを書きました。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize)]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { failed_at: String, reason: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tasks = vec![
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Succeeded {
                finished_at: "2020-01-01 12:00:00".into(),
            },
        },
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Failed {
                failed_at: "2020-01-01 12:00:00".into(),
                reason: "Task failed because of timeout".into(),
            },
        },
    ];
    println!("{}", serde_json::to_string_pretty(&tasks)?);
    Ok(())
}

たとえばrename_allと組み合わせてタグ名称をスネークケースにしたり、Taskstatusフィールドにflattenを適用して、statusというフィールド名を使用しないようにすることも応用として可能です。こうした場合には、多少このExternally Taggedを利用しやすくなるケースが出てくるかもしれません。

また、serdeはあらゆるenumのヴァリアントの表現方法に対応しています。今回は構造体タイプのヴァリアントを例示しましたが、他にもタプル、New Type、Unit(中に何もデータを持たないもの)にも対応しています[*2]

ただJSONとしてはどこか不恰好というか、"Succeeded""Failed"といったタグはできればオブジェクトの中に特定のフィールドとともにしまわれていて欲しい感じがするかもしれません。それを実現する手段として、次の「Internally Tagged」が利用できます。

Internally Tagged

先ほどのヴァリアント名によるタグ付け情報を、オブジェクトの「内部に」特定のフィールド名を紐付けて移動してしまう方法です。やはり同様に、先にJSONの結果を示します。kindという新しいフィールドがオブジェクトの中に追加され、そこにヴァリアント名が入るように変化しました。

[
  {
    "executed_by": "admin",
    "status": {
      "kind": "Succeeded",
      "finished_at": "2020-01-01 12:00:00"
    }
  },
  {
    "executed_by": "admin",
    "status": {
      "kind": "Failed",
      "failed_at": "2020-01-01 12:00:00",
      "reason": "Task failed because of timeout"
    }
  }
]

これを実現するために、次のように実装を修正しました。具体的には、#[serde(tag = "kind")]というアトリビュートTaskStatusに付与しました。tag = "<name>"という指定により、<name>のタグ付けをすることができるようになります。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "kind")]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { failed_at: String, reason: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tasks = vec![
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Succeeded {
                finished_at: "2020-01-01 12:00:00".into(),
            },
        },
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Failed {
                failed_at: "2020-01-01 12:00:00".into(),
                reason: "Task failed because of timeout".into(),
            },
        },
    ];
    println!("{}", serde_json::to_string_pretty(&tasks)?);
    Ok(())
}

Adjacently Tagged

公式ドキュメントによればHaskellでよく使われる方法らしいです。

まずはJSONの出力結果を見てもらうのが早いと思うので、先に結果を示しておきます。

[
  {
    "executed_by": "admin",
    "status": {
      "kind": "Succeeded",
      "data": {
        "finished_at": "2020-01-01 12:00:00"
      }
    }
  },
  {
    "executed_by": "admin",
    "status": {
      "kind": "Failed",
      "data": {
        "failed_at": "2020-01-01 12:00:00",
        "reason": "Task failed because of timeout"
      }
    }
  }
]

kindはInternally Taggedと同様でヴァリアント名を持っています。これに加えて、先ほどはオブジェクトに直に記載されていたTaskStatus::Succeededfinished_atTaskStatus::Failedfailed_atreasondataというフィールドによって包まれています。kinddataというタグ名、コンテンツの2つの組み合わせがJSONデータ上で隣り合わせに(adjacently)置かれるのがこのパターンの特徴です。

これを実現するには、Internally Taggedと同様にまずはtagアトリビュートを定義するのと、加えてcontentを追加で定義するとこの方式を利用できます。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { failed_at: String, reason: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tasks = vec![
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Succeeded {
                finished_at: "2020-01-01 12:00:00".into(),
            },
        },
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Failed {
                failed_at: "2020-01-01 12:00:00".into(),
                reason: "Task failed because of timeout".into(),
            },
        },
    ];
    println!("{}", serde_json::to_string_pretty(&tasks)?);
    Ok(())
}

Untagged

最後にタグをまったくつけない方法としてuntaggedというものがあります。これを使うとenumのヴァリアント名の情報は一切破棄され、中に持つデータのフィールドのみが使用されます。これもやはり、結果のJSONを見た方が早いので出力結果を載せておきます。

[
  {
    "executed_by": "admin",
    "status": {
      "finished_at": "2020-01-01 12:00:00"
    }
  },
  {
    "executed_by": "admin",
    "status": {
      "failed_at": "2020-01-01 12:00:00",
      "reason": "Task failed because of timeout"
    }
  }
]

これを実現するには次のようなコードを書きます。#[serde(untagged)]とするだけです。

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { failed_at: String, reason: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tasks = vec![
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Succeeded {
                finished_at: "2020-01-01 12:00:00".into(),
            },
        },
        Task {
            executed_by: "admin".into(),
            status: TaskStatus::Failed {
                failed_at: "2020-01-01 12:00:00".into(),
                reason: "Task failed because of timeout".into(),
            },
        },
    ];
    println!("{}", serde_json::to_string_pretty(&tasks)?);
    Ok(())
}

罠:Untagged特有の挙動

Untaggedさせたいケースはさほど多くはないと思うのですが、デシリアライズさせたいJSONと照らし合わせた時に、Rustの構造体側でenumを使用するときれいに行くために利用したくなるケースがあると思います。一方でUntaggedにはハマりどころがあり、これで時間を溶かすケースがままあるため、最後に紹介しておきます。

これはデシリアライズ限定での意外なハマり方なのですが、ヴァリアントの中の構造がまったく同じ場合はまずヴァリアントを区別できず、上手にデシリアライズできません。まったく同じ構造をもつヴァリアント同士の場合、enum上の定義順で先に来るものが優先して適用されるようです。

下記はTaskStatus::Failedのフィールドを、タスクが終了した時刻のみ残し、かつフィールド名をfinished_atに変更したコードです。

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

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { finished_at: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let source = json!([
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-01 12:00:00"
        }
      },
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-02 12:00:00",
        }
      }
    ]);
    let tasks: Vec<Task> = serde_json::from_value(source)?;
    println!("{:#?}", tasks);

    Ok(())
}

このコードを実行すると、すべてTaskStatus::Succeededとして認識されます。

[
    Task {
        executed_by: "admin",
        status: Succeeded {
            finished_at: "2020-01-01 12:00:00",
        },
    },
    Task {
        executed_by: "admin",
        status: Succeeded {
            finished_at: "2020-01-02 12:00:00",
        },
    },
]

まったく同じ構造を持つヴァリアントを取り違えるだけではありません。先ほどのコードに、もともとTaskStatus::Failedにいたreasonというフィールドを戻したとしても、やはり同様のことが起きます。

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

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum TaskStatus {
    Succeeded { finished_at: String },
    Failed { finished_at: String, reason: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let source = json!([
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-01 12:00:00"
        }
      },
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-01 12:00:00",
          "reason": "Task failed because of timeout"
        }
      }
    ]);
    let tasks: Vec<Task> = serde_json::from_value(source)?;
    println!("{:?}", tasks);

    Ok(())
}

期待値としては、そもそもfinished_atreasonがいるのだから、Task::SucceededTask::Failedの2つが出力されるはずだろう、と思われるはずです。しかし現実には、

[
    Task {
        executed_by: "admin",
        status: Succeeded {
            finished_at: "2020-01-01 12:00:00",
        },
    },
    Task {
        executed_by: "admin",
        status: Succeeded {
            finished_at: "2020-01-01 12:00:00",
        },
    },
]

なんと、両者共にTaskStatus::Succeededとしてデシリアライズされてしまっています。serdeはフィールドとenumのヴァリアントを最初から順番に辿ってデシリアライズをかけ、一番最初にヒットしたものを使用します。タグがない以上、どうしてもこのような挙動になってしまうことは理解できます。ドキュメントには一応それらしい文章は書いてあるため仕様といえば仕様なのでしょうが、知らないと数時間溶かします(溶かしたことがあります)。

これを回避するには、残念ながらヴァリアントの定義順序に気をつける以外に方法がないようです。今回の場合は、TaskStatus::FailedTaskStatus::Succeededの後ろにあることが問題であったため、TaskStatus::Failedを前に定義してやれば解決できます。つまり、下記のコードが解決します。

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

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    executed_by: String,
    status: TaskStatus,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum TaskStatus {
    Failed { finished_at: String, reason: String },
    Succeeded { finished_at: String },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let source = json!([
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-01 12:00:00"
        }
      },
      {
        "executed_by": "admin",
        "status": {
          "finished_at": "2020-01-01 12:00:00",
          "reason": "Task failed because of timeout"
        }
      }
    ]);
    let tasks: Vec<Task> = serde_json::from_value(source)?;
    println!("{:#?}", tasks);

    Ok(())
}

これにより無事にデシリアライズできました。

[
    Task {
        executed_by: "admin",
        status: Succeeded {
            finished_at: "2020-01-01 12:00:00",
        },
    },
    Task {
        executed_by: "admin",
        status: Failed {
            finished_at: "2020-01-01 12:00:00",
            reason: "Task failed because of timeout",
        },
    },
]

untaggedを用いる場合、ユニットテストをきちんと書くことや、instaをはじめとしたスナップショットテストで実行結果を追跡し、おかしなものが紛れ込まないかを確かめるのが大事そうです。

ソースコード

ソースコードは下記のGitHubリポジトリにすべて置いてあります。

github.com

*1:もっとも、そうした秘匿情報をクライアントに返す可能性がある構造体にそもそも含んでいるのは事故の元です。DTOを利用して詰め替えるなどした方が良いでしょう

*2:Rustのenumのヴァリアントの形式についてはこのドキュメントを参照: https://doc.rust-lang.org/beta/reference/items/enumerations.html