コンテンツにスキップ

HttpErrorとカスタムレスポンス#

2024 iThome鉄人レース

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

ソフトウェア開発において、エラー処理は無視できない——しかしよく無視される——側面です。

大げさに言えば、エラー処理は「うまくやっても褒められないが、うまくやらないとシステムが悲惨なことになる」という課題です。

心配はいりません、私たちはやはり可能な限りベストを尽くすのみです。

Django Ninjaはデータ検証にPydanticを使用しており、失敗した場合はデフォルトで「422 Unprocessable Entity」を返します。

しかし、実際のビジネス要件チームの開発習慣に合わせるために、時々「400 Bad Request」や他のステータスコードを返す必要があります。

要するに、どのような理由であれ、Django Ninjaのデフォルトの422レスポンスを使用せずにエラーメッセージ、フォーマット、およびレスポンスのステータスコードをカスタマイズしたいのです——言わざるを得ませんが、この定型レスポンスは情報量が多く、構造も少し複雑です。様々な状況に互換性を持たせる必要があるためです。

この記事では、Django Ninjaに組み込まれているHttpErrorを使用して、エラー処理とレスポンスをカスタマイズする方法を紹介します。

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

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

👉 Django-Ninja-Tutorial


Django Ninjaの自動エラー処理#

前回の記事で言及しましたが、スキーマの検証メソッド内でValueErrorをスローすると、Django Ninjaはそれを自動的に捕捉してレスポンスを返します

実は、Django Ninjaが自動的に処理するのはValueErrorだけではありません。以下の種類のエラーにも対応しています:

  • pydantic.ValidationError:Pydanticからの検証エラー。これが、スキーマフィールドに問題がある際に直接422レスポンスを受け取る理由です。
  • さらに、Django Ninjaは内部にninja.errors.ValidationErrorを組み込んでおり、これらのエラーも同様に422を返します。
  • ninja.errors.HttpError:これがこの記事のテーマであり、以下で紹介します。

これらはすべてDjango Ninjaが自動的に捕捉するエラーですが、それぞれが定型の422レスポンスを出すわけではありません——3つ目は違います。


新しい要件:検証失敗時に400レスポンスに変更する#

同様に「ユーザー追加」APIを例にして、新しい要件を実現します:パスワードの確認が一致しない場合、422ではなく「400 Bad Request」を返します。

どうやるのが一番簡単でしょうか?

答え:Django NinjaのHttpErrorを使用します。

以下はスキーマのコードの変更点です。たった2行変更しただけです!

...
from ninja.errors import HttpError  # 1行目

class CreateUserRequest(Schema):
    ...

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

そうです、とても簡単です!

検証メソッドがスローするエラーをValueErrorからHttpErrorに置き換えるだけです。

注目すべきは、HttpErrorインスタンスの初期化には2つのパラメータが必要で、1つ目がHTTPステータスコード、2つ目がエラーメッセージであるということです。

レスポンス内容#

同様の検証失敗で、レスポンスがどのように異なるか見てみましょう:

// 400 Bad Request
{
    "detail": "パスワードと確認用パスワードは一致する必要があります"
}

私たちが慣れ親しんだフォーマット——エラーメッセージのみ——になりました。

前回の記事で、ValueErrorをスローした際のレスポンスと比較してみましょう:

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

差はとても大きいですよね?


検証メソッド内でHttpErrorを使うべきではない理由#

スキーマの検証メソッド内で直接HttpErrorをスローするのは便利な方法です。なぜなら、それによってレスポンスの処理を簡略化できるからです。

追加でエラーを捕捉したり、手動でレスポンスフォーマットを指定したりする必要はありません。検証に失敗した時、APIは私たちが定義したステータスコードとエラーメッセージを直接返し、シンプルで便利です。

しかし、このようにするのは実は適切ではありません。テストのしやすさが低下したり、レスポンスの柔軟性が制限されたりするなど、いくつかの問題があります。しかし、その中で最も重要なのは、前回の記事で言及した——「関心の分離」です。

「関心の分離」への違反#

このやり方は「関心の分離」の原則に違反しています。

検証ロジックの役割はデータの正確性をチェックすることであり、レスポンスはview関数が担当すべきです。

レスポンスのロジックを検証処理に混ぜると、本来は独立しているはずの検証とレスポンスが密結合になり、責務が曖昧になってコードを保守しにくくなります。

したがって、検証メソッド内でHttpErrorを使用すると、見かけ上は要件を簡単に実現できるように見えますが、アーキテクチャ設計の観点から考えると、レスポンスの処理をview関数内に配置する方が、より合理的な選択です。

心配しないでください。次回の記事では別の方法に変えますが、この記事の主役はあくまでHttpErrorです。


HttpErrorの典型的なユースケース:view関数内での使用#

スキーマの中でHttpErrorを使用するよりも、それをview関数の中に置いて実行する方が、正しい道と言えます。

以下が典型的なユースケースです。

データ検証ロジックは可能な限りスキーマ内に配置すべきですが、すべての検証がスキーマに任せるのに適しているわけではありません

例えば、ユーザーのemailフィールドは「一意性」を持っています——重複してはいけません。そのため、ユーザーが入力したemailがDB内のデータと重複していないかを事前に確認し、重複している場合は「409 Conflict」を直接返すようにしたいのです。

これも間違いなく一種の検証ですが、そこには「データベースのクエリ」が含まれています。

データベースのクエリを伴うこのような検証は、スキーマの中よりも、view関数内で行うのがより適しています。なぜなら、データベースのクエリは比較的重い動的な操作であり、スキーマの静的なデータチェックとは本質的に異なるからです。

そのため、このような要件を処理するためにview関数内でHttpErrorを使用することがより一般的です。

新しく追加するコードは以下の通りです:

@router.post(...)
def create_user(..., payload: CreateUserRequest):
    """
    ユーザー追加
    """
    if User.objects.filter(email=payload.email).exists():
        raise HttpError(409, 'ユーザーのemailが既に存在します')
    ...

上記は「事前のクエリ」ですが、以下の書き方と結果としては似ています:

try:
    user.save()
except IntegrityError:  # Django ORMの一意性エラー
    raise HttpError(409, 'ユーザーのemailが既に存在します')

単に一つが事前に検証してエラーをスローするものであり、もう一つが事後にエラーを捕捉する(その後で再びスローする)ものであるという違いだけです。

検証失敗時のレスポンス:

// 409 Conflict
{
    "detail": "ユーザーのemailが既に存在します"
}

確かに悪くないですね!


409レスポンスを直接returnしない理由#

ここで、次のように考えるかもしれません:

それなら、エラーメッセージを含むPythonの辞書を直接returnすればよいのでは? なぜわざわざview関数内でraise HttpErrorする必要があるの?

このアイデアの大まかなコードは以下のようになります:

@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
    """
    ユーザー追加
    """
    if User.objects.filter(email=payload.email).exists():
        return 409, {"detail": "ユーザーのemailが既に存在します"}
    ...

この方がより直感的ではありませんか?

これは良い質問です。

重要なポイントの解説#

まず、このコードの中にどのような重要なポイントがあるかを見てみましょう:

  • response={201: dict, 409: dict}:第13回で言及した「複数のステータスコードでのレスポンス」が、ここで役立ちます!
  • raiseの代わりにreturnを使用する。
  • エラーメッセージのフォーマットを検証したい場合は、スキーマを定義できます。この例は単なる簡易版です。

確かに良さそうに見えますし、直感にも合っています。実は私が以前Django REST frameworkを書いていた時は、いつもこのように書いていました。

しかし、Django Ninjaにおいてこの書き方をすると、「ページネーション(分頁)デコレータ」を使用する時に壁にぶつかってしまいます

今はまだその時期ではないので、後の「第25回:ページネーション(下)カスタムページネーションクラス」で、この件について明確に説明します。

要するに、現段階では、類似の状況ではやはりraise HttpErrorの方が適切であるとだけ知っておいてください。


まとめ#

この記事では、デフォルトの422を避けるために、Django Ninjaに組み込まれているHttpErrorを使用してエラーレスポンスをカスタマイズする方法を学びました。

そして、なぜHttpErrorがスキーマ内で使用するのに適していないのか(一時的にそうしましたが😅)、代わりにview関数内に配置すべきであるのかを説明しました。

次回は、スキーマがスローするエラーを改善し、グローバルエラー処理のメカニズムを探求し、Django Ninjaが提供するexception_handlerデコレータを使用して、APIのエラー処理能力をさらに向上させます。