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

これはDjango Ninjaシリーズチュートリアルの第21回です。
ソフトウェア開発において、エラー処理は無視できない——しかしよく無視される——側面です。
大げさに言えば、エラー処理は「うまくやっても褒められないが、うまくやらないとシステムが悲惨なことになる」という課題です。
心配はいりません、私たちはやはり可能な限りベストを尽くすのみです。
Django Ninjaはデータ検証にPydanticを使用しており、失敗した場合はデフォルトで「422 Unprocessable Entity」を返します。
しかし、実際のビジネス要件やチームの開発習慣に合わせるために、時々「400 Bad Request」や他のステータスコードを返す必要があります。
要するに、どのような理由であれ、Django Ninjaのデフォルトの422レスポンスを使用せずに、エラーメッセージ、フォーマット、およびレスポンスのステータスコードをカスタマイズしたいのです——言わざるを得ませんが、この定型レスポンスは情報量が多く、構造も少し複雑です。様々な状況に互換性を持たせる必要があるためです。
この記事では、Django Ninjaに組み込まれているHttpErrorを使用して、エラー処理とレスポンスをカスタマイズする方法を紹介します。
すべてのコード変更は、このPRを参照してください。
GitHubサンプルプロジェクト#
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つ目がエラーメッセージであるということです。
レスポンス内容#
同様の検証失敗で、レスポンスがどのように異なるか見てみましょう:
私たちが慣れ親しんだフォーマット——エラーメッセージのみ——になりました。
前回の記事で、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レスポンスを直接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のエラー処理能力をさらに向上させます。