pydanticで任意のキー名を持つJSONを検証する

2024/12/05に公開

この記事は Money Forward Kansai Advent Calendar 2024 12月5日の記事です。


前置き

こんにちは。Money ForwardでMLエンジニアをやっているMarisaka Mozzです。最近の楽しみは大阪の駅前ビルでランチの店を開拓することです。今のところ ネスパ がお気に入りです。

pydanticはJSONのスキーマを定義して、その定義に沿っているかどうかを検証できる便利なPythonのライブラリです。一般的な使用用途としては、APIの出力フォーマットなどをpydanticで定義することがよくあります。こうすることで、クライアントにJSONを返す前に、ちゃんと定義されたスキーマに則ったJSONが作成できているかを検証できるわけですね。

通常は下記のように定義すると思います。

from pydantic import BaseModel

class MyModel(BaseModel):
    key1: str
    key2: int

my_model = MyModel.model_validate({"key1": "foo", "key2": 123})
my_model.model_dump()
# {'key1': 'foo', 'key2': 123}

この場合、キー(上記の例であれば「key1」や「key2」)はあらかじめ決めておかなければなりません。

しかし、世の中にはあらかじめキーの名前を決めておきたくない、あるいは、決めることが難しい場合があります。例えば、

  • キーの名前はなんでもいいんだけど、その中に入っている値は文字列だけを許したい
  • 入れ子構造を2段階までにしたい

というようなケースです。そのような場合にはどのようにすれば良いでしょうか?pydanticのRootModelを使ってそれを実現することができます。

ここから本題

RootModelを使えば、キーと値のペアではなく、値だけを定義することができます。例えば、文字列の値を定義する場合は以下のように実装します。

from pydantic import RootModel

StringValue = RootModel[str]
StringValue.model_validate("foo")  # OK
StringValue.model_validate(123)    # ValidationError

キーや値に文字列だけを許可するJSONであれば、以下のように定義できます。この時、事前にキー名を想定していない、つまり、任意のキー名を許容することに注目してください。

StringModel = RootModel[dict[str, str]]
StringModel.model_validate({"foo": "bar"})  # OK
StringModel.model_validate({"foo": 123})    # ValidationError

値は何でもいいんだけど入れ子構造を2段階までにしたい場合は、以下のように定義できます。

from typing import Union

LeafValue = RootModel[Union[str, int, float, bool, None]]
LeafModel = RootModel[dict[str, LeafValue]]
NestedModel = RootModel[dict[str, Union[LeafValue, LeafModel]]]

NestedModel.model_validate({"foo": 123})  # OK
NestedModel.model_validate({"foo": 123, "bar": {"hoge": "fuga"}})  # OK
NestedModel.model_validate({"key1": {"key2": {}}})  # ValidationError

発展

さらに発展として、キーや値に文字列だけを許可したいが、もしもintやfloatの場合はValidationErrorとせず、文字列に変換して設定したいという場合は、上記の最初の例で作成したStringValueを継承したクラスを作って以下のように実現できます。

from pydantic import model_validator

class MyStringValue(StringValue):
    @model_validator(mode="before")
    def convert_to_str(cls, v):
        if isinstance(v, (int, float)):
            return str(v)
        return v

MyStringModel = RootModel[dict[str, MyStringValue]]
my_model = MyStringModel.model_validate({"foo": 123})  # OK
my_model.model_dump()  # {'foo': '123'}

@model_validator のパラメータに mode="before"とすることで、文字列かどうかの検証のにintやfloatを文字列に変換していることがわかります。

まとめ

というわけで、RootModelを使えば柔軟にJSONのスキーマを定義できることがわかりました。うまく使えば様々な場面に応用できそうな気がします。

pydanticはv2になってからかなり仕様が変わってしまった関係で、ChatGPTやGitHub Copilotに聞いてもv1の古い実装が提案されることが多く、なかなか正しい情報に辿り着くことが難しかったりします。今回の情報がお役に立てれば幸いです。

Money Forward Developers

Discussion