quicktypeとdaciteでJSONとPythonのdataclassの変換を手軽に書く

## 背景
Python で外部サービスとAPI連携する際、JSON をパースするシチュエーションは多々あるかと思います。
パースって退屈ですし、ハッカソンのような特にスピード求められる状況ではあまり時間をかけたくないですよね。
今回は JSON を手軽に Python の dataclass に変換する方法を紹介します。

なお、この記事で紹介するコードは GitHub で公開しています。
https://github.com/gaiax/quicktype-dacite-demo

## 利用するライブラリ
タイトルの通り quicktype と dacite というライブラリを主に利用します。

### quicktype
JSON などのサンプルデータの型を推測し、対応する言語で出力するライブラリ。
 https://github.com/quicktype/quicktype 

npm パッケージとして公開されていますが、Web UI として手軽に使用することもできます。Web UI であってもサーバーを経由せず、クライアントで処理は完結しているとのことです。[^1]
https://app.quicktype.io/ から利用できます。

[^1]: https://github.com/quicktype/quicktype#installation

### dacite
Python の dict を dataclass に変換するライブラリ。
https://github.com/konradhalas/dacite

通常、dict から dataclass に変換するとなるとこうなるはずです。

```python3
from dataclasses import dataclass


@dataclass
class Data:
    hoge: int
    fuga: str


d = {"hoge": 10, "fuga": "Python"}

Data(**d)
# Data(hoge=10, fuga='Python')
```

これでも十分使えますが、ネストされた辞書はうまく扱えません。

```python3
from dataclasses import dataclass


@dataclass
class NestedData:
    foo: int
    bar: str


@dataclass
class Data:
    hoge: int
    fuga: str
    nested_data: NestedData


d = {
    "hoge": 10,
    "fuga": "Python",
    "nested_data": {
        "foo": 20,
        "bar": "Ruby"
    }
}

Data(**d)
# Data(hoge=10, fuga='Python', nested_data={'foo': 20, 'bar': 'Ruby'})
# nested_data は NestedData クラスではなく単に辞書が格納される
```

dacite は、このような通常は対応しきれないネストされた辞書の変換を可能にします。

```python3
from dataclasses import dataclass
from dacite import from_dict


@dataclass
class NestedData:
    foo: int
    bar: str


@dataclass
class Data:
    hoge: int
    fuga: str
    nested_data: NestedData


d = {
    "hoge": 10,
    "fuga": "Python",
    "nested_data": {
        "foo": 20,
        "bar": "Ruby"
    }
}

from_dict(data_class=Data, data=d)
# Data(hoge=10, fuga='Python', nested_data=NestedData(foo=20, bar='Ruby'))
# nested_data に NestedData クラスのインスタンスが格納されている！
```

非常に簡単に辞書と dataclass を変換できるようになります。
その他にも、記事執筆時点 (2020/06/10) では下記のような機能があります。

- 型チェック
- Optional, Union 対応
- List 対応
- 型ごとにフックの設定
- キャスト
- strict mode

ちなみに、 dataclass から辞書への変換は Python 標準ライブラリの `dataclasses.asdict` に dataclass のインスタンスを渡すことで可能です。

## 実際に変換してみる
既にここまでの説明で実装イメージ湧いた方もおられると思いますが、実際に dataclass の定義から JSON と dataclass の変換を試してみます。
ここでは例として connpass の Event API の返り値を dataclass に変換してみます。

### JSON データの準備

弊社では connpass の `Gaiax Technical Meetups` というグループで、エンジニア向けに入門ハンズオンや交流会を開催しています。 ~~唐突な宣伝~~
今回は API ではそのグループに絞ってデータを用意します。

記事を見やすくするためにイベント数を2つに絞って取得します。

`GET https://connpass.com/api/v1/event/?series_id=3109&count=2`

```json:connpass.json
{
  "results_start": 1,
  "results_returned": 2,
  "results_available": 35,
  "events": [
    {
      "event_id": 175102,
      "title": "【オンライン対談】Google Apps Script 活用トーク #6",
      "catch": "Google Apps Script の最新情報や活用事例のオンライン対談対談",
      "description": "<h1>GAS本 クラウドファンディング目標金額達成キャンペーン！</h1>\n<p>「最新の『Google Apps Script完全入門』をいち早く世に届けたい！」をファンディングに成功した「Google Apps Script完全入門」の著者であるプランノーツの高橋さんに登壇頂きます。<br>\n<a href=\"https://camp-fire.jp/projects/view/249472\" rel=\"nofollow\">https://camp-fire.jp/projects/view/249472</a></p>\n<p>今回、目標金額達成を祝して本イベント限定キャンペーンとして、イベント参加者の中から抽選で「Google Apps Script完全入門」第１版を5名の方々にプレゼントします！！！</p>\n<h1>詳細内容</h1>\n<p>Technical Meetup でも好評で定番となりました「Google Apps Script 活用ミートアップ」を形式を変えつつ第六弾を開催！</p>\n<p>多くの企業が活用しはじめている Google の G Suite（Google Apps）。そんな G Suite を拡張するスクリプト言語 Google Apps Script の最新情報や活用事例をご紹介！</p>\n<p>IT企業を中心に、PC に直接インストールするオフィスソフトウェアからウェブアプリケーション型のオフィスソフトウェアである G Suite への移行が進んでいます。複数人同時編集が行えたり、ファイルは自動的にクラウドに保存されたり、今日求められている働き方を支援する機能がいっぱいです。</p>\n<p>そんな G Suite の機能を拡張するのが Google Apps Script。多くの人にとっておなじみの JavaScript 言語をベースとしていることもあり、歴史は浅いながら活用事例は勢い良く増えてきています。</p>\n<p>※参考</p>\n<p>過去のイベントの様子は、下記のURLよりごらんください！</p>\n<p><a href=\"http://gaiax.hatenablog.com/archive\" rel=\"nofollow\">http://gaiax.hatenablog.com/archive</a></p>\n<h1>タイムテーブル</h1>\n<ul>\n<li>19:15 配信スタート</li>\n<li>19:30 対談スタート</li>\n<li>20:30 質疑応答</li>\n<li>20:50 アンケート</li>\n<li>21:00 クローズ</li>\n</ul>\n<h1>対談予定</h1>\n<h3>株式会社プランノーツ 代表取締 高橋宣成氏</h3>\n<p>電気通信大学大学院電子情報学研究課修了後、30歳までサックスプレイヤーとして活動し、モバイルコンテンツ業界・電子書籍業界でプロデューサー、マーケターなど経験。日本におけるビジネスマンの働き方、生産性、IT活用などに課題を感じ、2015年に独立し起業。Excel,VBA,GSuite,GAS,クラウドなどによるシステム・ツール開発、コンサルティング、研修、執筆など務める。\nオンライン学習サービス「LinkedInラーニング」トレーナー,コミュニティ「ノンプログラマーのためのスキルアップ研究会」主宰。\n自身が運営するブログ <a href=\"https://tonari-it.com/\" rel=\"nofollow\">「いつも隣にITのお仕事」</a> は、月間96万PVの人気を誇る。</p>\n<h3>株式会社ガイアックス ソーシャルメディアソリューション事業部 松下庄悟</h3>\n<p>1995年生まれ 神奈川県出身。中学生時代にRPGツクールとClick&Createに出会いゲームプログラミングを通じてプログラミングの門をたたく。 その後、横浜医療情報専門学校に入学する。在学中は各所のインターンでWebサービス開発などに従事。2018年ガイアックスに入社。現在はソーシャルメディアマーケティング事業部で開発と動画制作の傍ら技術同人誌の執筆や同人映像制作などをしている。</p>\n<p>最近AppMakerに関する同人誌を発行</p>\n<ul>\n<li>Booth <a href=\"https://godan.booth.pm/items/2035726\" rel=\"nofollow\">https://godan.booth.pm/items/2035726</a></li>\n<li>bookwalker  <a href=\"https://bookwalker.jp/de809d7a5b-1b32-4973-a3d6-61019cf222f2/\" rel=\"nofollow\">https://bookwalker.jp/de809d7a5b-1b32-4973-a3d6-61019cf222f2/</a></li>\n</ul>\n<h1>注意事項</h1>\n<p>本イベントは、エンジニアのノウハウを共有することを目的としており、飲食のみを目的とした参加はお断りをさせて頂いております。ついては、身元確認のため、受付時に名刺交換をお願いしております。何卒ご協力をお願いします。</p>\n<p>募集LTについて、テーマと関係しないLT、本イベントのテーマにそぐわない過度な宣伝（Google 社の G Suite は除く）が含まれたトークはお断りしています。</p>\n<p>登壇や聴講をする全ての参加者の方々には会議での<a href=\"http://ja.confcodeofconduct.com/\" rel=\"nofollow\">行動規範 (Conference Code of Conduct) </a>の遵守をお願いしています。重大な各種ハラスメント行為であるとイベント運営側が判断した場合、イベントからの退去処分も含めたいかなる対応を取ることをご了承下さい。</p>",
      "event_url": "https://gaiax.connpass.com/event/175102/",
      "started_at": "2020-05-29T19:30:00+09:00",
      "ended_at": "2020-05-29T22:00:00+09:00",
      "limit": null,
      "hash_tag": "GAS活",
      "event_type": "participation",
      "accepted": 45,
      "waiting": 0,
      "updated_at": "2020-05-08T14:09:19+09:00",
      "owner_id": 28874,
      "owner_nickname": "xtetsuji",
      "owner_display_name": "OGATA Tetsuji",
      "place": "オンライン",
      "address": "オンライン",
      "lat": null,
      "lon": null,
      "series": {
        "id": 3109,
        "title": "Gaiax Technical Meetups",
        "url": "https://gaiax.connpass.com/"
      }
    },
    {
      "event_id": 173835,
      "title": "【初心者向け】Flutter 入門オンラインハンズオン",
      "catch": "Flutterをさわってみたい方向けのハンズオンイベント",
      "description": "<h1>オンライン開催ついて</h1>\n<p>今回は、オンライン開催となります。イベントの当日に、オンライン参加URLをメールにてご連絡します。</p>\n<h1>詳細内容</h1>\n<p>iPhone/Andriodのアプリを、ひとつの環境で開発することができるFlutter。すこしずつ事例も増えてきたとはいえ、まだまだ触れたことのないひとも多いのではないでしょうか。</p>\n<p>そこで、実際にFlutterを利用してネイティブアプリを開発・リリースしているエンジニアが、初心者向けに簡単なアプリケーションの作り方をライブコーディング中心にハンズオンで解説します！</p>\n<p>※参考</p>\n<p>過去のイベントの様子は、下記のURLよりごらんください！</p>\n<p><a href=\"http://gaiax.hatenablog.com/archive\" rel=\"nofollow\">http://gaiax.hatenablog.com/archive</a></p>\n<h1>こんな人はご参加ください</h1>\n<ul>\n<li>Flutterを知ってて（聞いたことあるあって）興味がある</li>\n<li>iPhone/Android開発に興味がある</li>\n<li>Swift / kotlin で開発経験があって、Flutterも知っておきたい</li>\n<li>WEBサービスなど何かしらの開発経験がある</li>\n</ul>\n<h1>タイムテーブル</h1>\n<ul>\n<li>19:15 受付</li>\n<li>19:30 ハンズオンスタート</li>\n<li>21:30 アンケート</li>\n</ul>\n<h1>参加者に準備頂きたいこと</h1>\n<ul>\n<li>一緒に手を動かしたい方<ul>\n<li>下記のQiitaの記事などを参考に環境を整えておいてください</li>\n<li><a href=\"https://qiita.com/tomy0610/items/896dc8ec9ba95c33194f\" rel=\"nofollow\">https://qiita.com/tomy0610/items/896dc8ec9ba95c33194f</a></li>\n</ul>\n</li>\n</ul>\n<h1>必要な経験・スキルセット</h1>\n<p>なにかしらの開発経験がある人を対象にしています。\n開発環境の構築や、プログラミング言語や仕組み基本の解説はありません。</p>\n<h1>注意事項</h1>\n<p>本イベントは、エンジニアのノウハウを共有することを目的としており、、テーマと関係しないLT、本イベントのテーマにそぐわない過度な宣伝が含まれたトークはお断りしています。</p>\n<p>イベントのレポートをブログや各種メディアにて発信するために、開催風景の写真や動画など素材として利用させて頂きますので、ご了承ください。</p>\n<p>登壇や聴講をする全ての参加者には、会議での<a href=\"https://ja.confcodeofconduct.com/\" rel=\"nofollow\">行動規範 (Conference Code of Conduct)</a> に従ってください。重大な各種ハラスメント行為であるとイベント運営側が判断した場合、イベントからの退去処分も含めたいかなる対応を取ることをご了承下さい。</p>",
      "event_url": "https://gaiax.connpass.com/event/173835/",
      "started_at": "2020-05-07T19:30:00+09:00",
      "ended_at": "2020-05-07T22:00:00+09:00",
      "limit": null,
      "hash_tag": "Flutterハンズオン",
      "event_type": "participation",
      "accepted": 106,
      "waiting": 0,
      "updated_at": "2020-05-07T21:44:47+09:00",
      "owner_id": 11134,
      "owner_nickname": "norinux",
      "owner_display_name": "norinux",
      "place": "オンライン",
      "address": "オンライン",
      "lat": null,
      "lon": null,
      "series": {
        "id": 3109,
        "title": "Gaiax Technical Meetups",
        "url": "https://gaiax.connpass.com/"
      }
    }
  ]
}

```

### quicktype で dataclass を定義
今回は手軽に試したいので、 Web UI で dataclass を定義します。
https://app.quicktype.io/  にアクセスして、左側の入力欄に先ほど用意した JSON データをそのまま貼り付けます。

![quicktype_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177433/04f91bb9-58d2-885c-6ae6-3c575d975d07.png)

Python を選択して `Classes only` にチェックを入れると、右側に自動で変換結果が表示されるはずです。
あとで dacite で変換するときのために `Transform property names to be Pythonic` にもチェック入れておきます。

ちなみに` Classes only` をオフにすると `to_dict()` メソッドなども定義してくれます。ただ、カラム変更が生じる度に各メソッドを書き換える必要があります。
それらの変換処理を dacite に任せることで、コーディングの負担が緩和できるということですね。

また quicktype はよくできていて、配列で含まれたり含まれなかったりするプロパティには `Optional` をつけてくれます。試しに quicktype 上の JSON で、2つある events のうちどちらかひとつの `event_id` カラムを削除してみてください。

デフォルトでは、生成される dataclass のクラス名は `Welcome` となるので、適宜変更しておきましょう。
この記事では `Connpass` とします。

最後に変換後の dataclass をまるっとコピーして、ファイルとして保存します。
ここでは dataclass のクラス名にあわせて `connpass.py` に保存しておきます。

### dacite で dataclass に変換する
dataclass の定義ができたので、 dacite を使って JSON から dataclass に変換してみましょう。

もとの JSON は `connpass.json` としてファイルに保存しておく前提で、下記のようなコードになります。

```python3:main.py
import json
from dataclasses import dataclass
from datetime import datetime

from dacite import Config, from_dict

from connpass import Connpass


def run():
    with open("connpass.json", "r") as f:
        data = json.load(f)
    connpass = from_dict(Connpass, data, Config({datetime: datetime.fromisoformat}))
    print(connpass.events)


if __name__ == "__main__":
    run()
```

実際の変換はこの行で行っています。

```python3
connpass = from_dict(Connpass, data, Config({datetime: datetime.fromisoformat}))
```

`Config` が肝で dacite のキャスト機能を使って型変換を行っています。
type が `datetime` のプロパティに `datetime.fromisoformat` をくぐらせています。

これを書かないと `datetime` のプロパティに `str`  を渡してしまうことになり `dacite.exceptions.WrongTypeError` が送出されます。

```python3
connpass = from_dict(Connpass, data)
# dacite.exceptions.WrongTypeError: wrong value type for field "events.started_at" - should be "datetime" instead of value "2020-05-29T19:30:00+09:00" of type "str"
```

### `__post_init__()` で変換する

ちなみに今回はキャストで事足りましたが、同じ型だけどプロパティによって変換処理が異なる場合は Union で受け入れつつ `__post_init__()` で手動で変換させるという手もあります。

```python3:post_init_example.py
# {"text": "hello world", "created_at": "Thu Jun 04 11:27:06 +0900 2020",  "timestamp": 1591237626}
from typing import Union
from datetime import datetime

@dataclass
class Sample:
    text: str
    created_at: Union[str, datetime]
    timestamp: Union[int, datetime]

    def __post_init__(self):
        self.__convert_created_at()
        self.__convert_timestamp()
  
    def __convert_created_at(self):
        if type(self.created_at) is str:
            self.created_at =datetime.strptime(self.created_at, "%a %b %d %H:%M:%S %z %Y")

    def __convert_timestamp(self):
        if type(self.timestamp) is int:
            self.timestamp = datetime.fromtimestamp(self.timestamp)
```

### 少しリファクタする

毎回 `connpass = from_dict(Connpass, data, Config({datetime: datetime.fromisoformat}))` と呼び出すのは苦痛なので `Connpass` クラスに変換処理を移します。

```python3:connpass.py
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Any, Dict, List

from dacite import Config, from_dict

# 省略

@dataclass
class Connpass:
    results_start: int
    results_returned: int
    results_available: int
    events: List[Event]

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "Connpass":
        return from_dict(cls, data, config=Config({datetime: datetime.fromisoformat}))

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)
```

そうするとこのように呼び出せます。使いやすくなりました。

```python3
connpass = Connpass.from_dict(data)
exported_data = connpass.to_dict()
```

## まとめ

以上で dacite と quicktype を使って JSON と dataclass の変換を手軽に実装する方法を紹介しました。

GitHub で公開しているリポジトリには、簡単なサーバーを立てて動作を確認する例も含めています。
https://github.com/gaiax/quicktype-dacite-demo

補完も効くようになるので、素の辞書をいじるのに比べると非常に開発体験が向上しました。
めでたしめでたし。