コンテンツにスキップ

複数フィールド間の検証#


2024 iThome鉄人レース

これはDjango Ninjaシリーズチュートリアルの第20回です。

前回は単一フィールドのカスタム検証について解説しましたが、この記事では複数フィールド間の検証について議論します。

複数フィールド間の検証も、API開発において非常によく見られる要件です。例えば、アカウント登録時に「パスワード」と「パスワード(確認用)」の2つのフィールドの内容が同じであることを保証したり、日付期間を選択する際に、開始日が終了日より後にならないようにすることなどです。

これらの検証シナリオは、単一フィールドの検証だけでは実現できません。全体的なデータの一貫性と正確性を確保するために、複数のフィールド間の論理的な関係を同時にチェックする必要があるからです。

この記事では、Pydanticを通じて複数フィールド間の検証要件を実現する方法を、「パスワードの確認」を例に挙げ、この機能の実際の応用を紹介します。

この記事のすべてのコード変更は、このPRを参照してください。

GitHubサンプルプロジェクト#

👉 Django-Ninja-Tutorial


複数フィールド間の検証と関心の分離#

実は、単一フィールドであれ複数フィールド間であれ、カスタム検証を必ずしもPydanticを通じて行う必要はありません

理論的には、データ検証はview関数内で直接行うことができます。例えば、入力されたフィールドの値を取り出し、手動でその正当性を検証します。複数フィールド間の検証も同様です。

しかし、手軽である一方で「大ざっぱ」な方法でもあり、検証ロジックがごく単純な場合にしか向きません。

Pydanticでデータを検証することには、明確なメリットがあります。関心の分離です。

関心の分離#

関心の分離(Separation of Concerns)は一種の設計原則です。プログラム内の異なる機能の役割を、独立したモジュールやレイヤーに分割することを主張します。

各モジュールは主に1つの具体的な方向や目標に集中することで、複数の異なる機能を密結合させることを避けます。このような分割により、プログラムのテスト、保守、拡張が容易になります。

関心の分離に従えば、データ検証のロジックはview関数内で行うのではなく、スキーマに集中させるべきです。

そうすることで、viewはコアビジネスロジックの処理に集中でき、データ検証は専門のコンポーネントに任せることができます。

Pydanticの検証メカニズムを通じて、関心の分離を実現し、データ検証とビジネスロジックを分離できます。これはコードの構造を向上させるだけでなく、開発プロセスをより明確で安定したものにします。


新しい要件:パスワードの確認#

非常にシンプルでありながら、複数フィールド間の検証の価値を十分に説明できる機能であるパスワードの確認を実装します。

まず、前回終了時点の「ユーザー追加」APIのリクエストスキーマの内容を振り返ってみましょう:

class CreateUserRequest(Schema):
    username: str = Field(examples=['Alice'])
    email: str = Field(examples=['alice@example.com'])
    password: str = Field(min_length=8, examples=['password123'])
    bio: str | None = Field(
        default=None, examples=['Hello, I am Alice.'])
    ...

このスキーマのデザインは、明らかに不十分です。

なぜなら、ユーザー登録時にパスワードは通常2回入力する必要があり、2回目の役割は「確認」だからです——大事なことなので2回言いますよね!

そのため、新しくconfirm_passwordフィールドを追加し、passwordとの複数フィールド間の検証:両者の内容が同じであることを確認する必要があります。

その中の検証ロジックは非常にシンプルですが、これはまさに複数フィールド間の検証における絶好の見せ場です。


複数フィールド間の検証の実装:model_validatorの使用#

Pydantic v2では、複数フィールド間の検証を処理するために@model_validatorデコレータが導入されました。これはPydantic v1の@root_validatorの改良と代替です。

ここでいうmodelとは、PydanticのBaseModel、つまり今回のスキーマを指します。DjangoのModelsではありません。

@model_validatorを通じて「ユーザー追加」APIを強化し、「パスワードの確認」機能を追加します。

修正後のコードを直接見てみましょう:

class CreateUserRequest(Schema):
    ...
    password: str = Field(min_length=8, examples=['password123'])
    confirm_password: str = Field(
        min_length=8, examples=['password123'])
    ...

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        if self.password != self.confirm_password:
            raise ValueError('パスワードと確認用パスワードは一致する必要があります')
        return self

重要なポイントの解説#

  • 新しくconfirm_passwordフィールドを追加しました。
  • @model_validator(mode='after')デコレータを使用して、複数フィールド間の検証メソッドを定義しました。
    • modeにはbefore、after、wrapの3種類があります。詳細な部分が多いですが、紙幅の都合上、この記事では展開できません(番外編で補足するかもしれません)。
    • 今は、ほとんどの場合はafterモードを使用するとだけ知っておいてください。この時の検証メソッドは「インスタンスメソッド」であり、selfパラメータはスキーマのインスタンス自体(inputデータから初期化されたもの)を表します。
  • 検証メソッドcheck_passwords_matchは、passwordconfirm_passwordフィールドを比較し、フィールドの内容が異なる場合はValueErrorをスローします。
  • 前述の通り、ロジックは非常にシンプルですが、確かに2つのフィールド間の検証を実現しています。
  • 複数フィールド間の検証は、すべての単一フィールドの検証が完了した後にのみ実行されます。

関心の分離の実際の応用#

お気づきでしょうか。今回の「パスワードの確認」機能の追加実装において、view関数は全く変更されていません!——これこそが関心の分離の原則の体現です。

検証ロジックを直接view関数内で実装する(viewとスキーマの両方を変更する必要がある)のと比較して、この実装方法は間違いなくよりクリーンで、疎結合です。


検証失敗時のHTTPレスポンス#

最後に、データ検証に失敗した時、どのようなHTTPレスポンスが得られるかを見てみましょう。

最初の2つは前回の記事ですでに触れましたが、相互に比較し復習するために、ここに再度リストアップします。

パスワードの長さ制限違反#

レスポンス結果は以下の通りです:

{
    "detail": [
        {
            "type": "string_too_short",
            "loc": [
                "body",
                "payload",
                "password"
            ],
            "msg": "String should have at least 8 characters",
            "ctx": {
                "min_length": 8
            }
        }
    ]
}

これはDjango NinjaがPydanticの検証エラーを捕捉した際に返す「システムレベル」のレスポンスであり、ステータスコードは422です。

「数字を含める必要がある」ルール違反#

入力されたパスワードに数字が含まれていない場合、レスポンス結果は以下のようになります:

{
    "detail": [
        {
            "type": "value_error",
            "loc": [
                "body",
                "payload",
                "password"
            ],
            "msg": "Value error, パスワードには少なくとも1つの数字を含める必要があります",
            "ctx": {
                "error": "パスワードには少なくとも1つの数字を含める必要があります"
            }
        }
    ]
}

なんだかほとんど同じですよね?その通りです。これもDjango Ninjaの自動レスポンスフォーマットだからです——エラーメッセージの中に私たちが独自に定義した内容が含まれている点を除いては。

ValueErrorをスローした場合のレスポンス#

しかし実際には、これは私たちが検証メソッド内でスローしたのがValueErrorであるため、Django Ninjaが自動的に処理してくれているからです。

確認用パスワードが一致しない場合にも同様のレスポンスが発生します:

{
    "detail": [
        {
            "type": "value_error",
            "loc": [
                "body",
                "payload"
            ],
            "msg": "Value error, パスワードと確認用パスワードは一致する必要があります",
            "ctx": {
                "error": "パスワードと確認用パスワードは一致する必要があります"
            }
        }
    ]
}

では、スローされるのがDjangoのValidationErrorや、私たちが独自に定義したエラーなど、別の種類のエラーだった場合、Django Ninjaは依然として自動的に処理してくれるのでしょうか?

答えは:いいえ

結果は「500 Internal Server Error」になります。これが次々回の記事のテーマです。


まとめと次のステップ#

この記事では、@model_validatorを通じて複数フィールド間の検証要件を実現し、同時に関心の分離の原則を実践する方法を紹介しました。

この2つの記事を読み終えた時点で、Django Ninjaのデータ検証に対する理解は、すでに大半の人を超えているはずです。

次は、データ検証に失敗した時に、APIの使用体験を向上させるために、どのようにエレガントにエラーを処理し、レスポンスを返すかについて深く探求していきます。