コンテンツにスキップ

リクエストボディとスキーマ#

2024 iThome鉄人レース

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

これまでの紹介で、パスとクエリパラメータの処理方法を学びました。しかし現実世界では、しばしばより複雑なリクエストデータを処理する必要があります。

例えば、ユーザーが送信するフォームやアップロードするファイルなどです。APIにおいて最も一般的なのは、JSON形式のリクエストボディ(request body)です。

この記事では、Django Ninjaがどのようにリクエストボディを処理するかを探求し、スキーマを通じてデータを定義および検証する方法を紹介します。

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

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

👉 Django-Ninja-Tutorial


1. リクエストボディとは?#

リクエストボディとは、HTTPリクエストと共に送信されるデータを指し、通常はPOSTPUTなどの「リソース」の作成や更新を必要とするリクエストに使用されます。

これらのデータはURLには表示されず、JSONやその他の形式(XML、form-dataなど)としてリクエストの主体となります。

例えば、ユーザーが新しい記事を投稿する際、以下のようなJSON形式のリクエストボディを送信するかもしれません:

{
    "title": "私の最初の記事",
    "content": "これは忍者フォーラムでの私の最初の記事です。皆さんに気に入ってもらえますように!"
}

このリクエストボディにはtitlecontentの2つのフィールドが含まれており、Django Ninjaはこれらのデータを処理し、検証するのを支援します。


2. サンプルプロジェクトの変更#

サンプルプロジェクトに、リクエストボディを受信するAPIである「記事の追加」を作成します。

さらに、Djangoのpost appディレクトリの下に、Pythonモジュールschemas.pyを追加します。ここは、APIで使用されるすべてのスキーマを配置するための場所です。

├── NinjaForum
   ├── ...
├── post
   ├── api.py
   ├── schemas.py # このモジュールを追加
   ├── ...

具体的なコードについては、今後の説明で紹介します。

この記事から、ブランチ名に中国語を使用しないことにしました。なぜなら、中国語のブランチ名はGitHubに常に警告されるからです:

The head ref may contain hidden characters: ...

また、gitブランチの命名に中国語を使用する人はほとんどいないでしょう!当初中国語を使用した理由は、読者が読みやすいようにするためでした🥹。

ですので、このブランチからは「数字+英語」に変更します。例えば、この記事は「12-request-body」とします。ただし、PRのタイトルは引き続き中国語のまま維持します。


3. スキーマを使用したリクエストボディの定義と検証#

FastAPIと同様に、Django NinjaはPydantic BaseModelを使用してリクエストボディを処理します。

ただし、BaseModelという名前はDjangoのModelsと混同しやすいため、Django Ninjaはこれをスキーマに名前変更しました。

スキーマはBaseModelを継承しているため、両者の実際の内容は非常に近いです(Django Ninjaは少し手を加えています):

# Django Ninjaのソースコード
class Schema(BaseModel, metaclass=ResolverMetaclass):
    ...

プロジェクトに戻り、プロジェクト内の例を見てみましょう。これは「記事の追加」APIのリクエストボディのスキーマを定義するものです:

# post/schemas.py
from ninja import Schema

class CreatePostRequest(Schema):
    title: str
    content: str
    user_id: int

このスキーマは、ボディデータにこれら3つのフィールド(titlecontentuser_id)が必ず含まれていること、そしてデータの型も一致していることを要求します。

view関数でのスキーマの使用#

「リクエスト」スキーマが定義できたら、view関数の中で「関数の引数」として使用できます:

from post.schemas import CreatePostRequest
...

@router.post(path='/posts/')
def create_post(..., payload: CreatePostRequest):  # ここ
    ...

関数のpayloadパラメータの型ヒント(型ヒント)を、先ほど定義したCreatePostRequestに設定します。

このAPIにリクエストが送信されると、Django NinjaはCreatePostRequestというスキーマを通じて、ボディ内のデータを解析(parsing)し、検証します。

検証が成功した後、データをview関数のpayloadに渡します。この時、関数内部payloadパラメータは、本質的にスキーマ(すなわちPydantic BaseModel)オブジェクトとなります。

自動データ検証とエラーハンドリング#

リクエストボディにフィールドが欠けていたり、データの型が間違っていたりする場合、Django Ninjaは自動的に422レスポンスを返し、具体的なエラー情報を提供します:

{
    "detail": [
        {
            "type": "missing",
            "loc": [
                "body",
                "payload",
                "content"
            ],
            "msg": "Field required"
        }
    ]
}

エラーメッセージは、ボディにcontentというフィールドが欠けていることを示しています。


4. 任意の(Optional)フィールドとデフォルト値#

実際のAPI開発において、すべてのリクエストフィールドが必須であるとは限りません

Pydanticと型ヒントを通じて、任意のフィールドを定義することができます。仮に、現在の記事のコンテンツが完全に任意であるとしましょう:(contentフィールドに注目してください)

class CreatePostRequest(Schema):
    title: str
    content: str | None = None
    user_id: int

=演算子を使用して、contentフィールドのデフォルト値Noneに設定すると、そのフィールドは任意のフィールドになります。この時、contentの型ヒントもstr | Noneに変更する必要があります。

特筆すべきは、スキーマをリクエストに使用する場合、このように設定して検証を通過できたとしても、対応するDjangoモデルフィールド(つまりdbフィールド)がNULLを許可しているかに注意する必要があることです。そうしないとエラーが発生します:

django.db.utils.IntegrityError: NOT NULL constraint failed: post_post.content

フィールドを任意にするだけでなく、直接デフォルト値を与えることもできます。例えばここでの空文字列です。ユーザーが入力しなかった場合、自動的にデフォルト値が入力されます

class CreatePostRequest(Schema):
    title: str
    content: str = ''
    user_id: int

しかし、デフォルト値がNoneである場合を除き、スキーマ内でデフォルト値を与える行為は「非常に慎重に」行う必要があります。この部分については、「第18回:Pydantic Fieldの設定例とデフォルト値」で再度議論します。


5. Django Ninjaの引数判定順序#

view関数の引数には多くの種類があるのに、Django Ninjaはどうやってどれがどれに対応するかを知るのか、考えたことはありますか?

実際には、Django Ninjaはview関数のシグネチャに基づいて、パラメータの出所(パスパラメータ、クエリパラメータ、あるいはリクエストボディのいずれか)を自動的に判断します。その判定順序は以下の通りです:

  1. パスパラメータ:URL path内に定義された変数(例えば/items/{id}id)は、最優先でパスパラメータとして認識されます
  2. クエリパラメータ:関数内のその他の単数型パラメータ(例えばlistdictではなく、intfloatboolstr)は、パスパラメータとしてマークされていない場合、クエリパラメータとして認識されます。
  3. リクエストボディ:スキーマ型のパラメータのみが、リクエストボディと見なされます。

原則として、view関数は1つのスキーマパラメータしか持つことができません。1つのリクエストにはボディが1つしかないからです。


第2節の終わり#

本節の内容はほぼ終了しました。

この節では、Django Ninjaを使用してHTTPリクエストを処理する方法を学び、スキーマの基本的な使い方を紹介しました。

スキーマの使い方や変化はまだたくさんあり、ここはほんの「手始め」に過ぎません。第3節「HTTPレスポンス」では、スキーマの設定についてさらに多くを目にすることでしょう。

次の節に入る前に、小休止といくつかの準備を行いましょう。


小休止と準備#

次の節では、プロジェクトのAPIを実際に稼働させます。なぜ現在使用できないのか、先述した内容を覚えていますか?

  1. dbデータがない。
  2. スキーマが作成されていない。

私たちはすでにスキーマの使い方を学びました——まだ完全ではありませんが。それでは、「dbデータ」の問題も解決する必要があります。

Django Fixtures#

POST APIを呼び出して、ユーザーや記事のデータを手動で追加することもできますが、面倒すぎます!ましてや、プロジェクトには現在「ユーザーの追加」というAPIはまだありません

ですので、面倒なことはやめましょう。

直接Django fixturesを使用して、私が事前に定義したダミーデータをインポートします。

Django fixturesの紹介については、記事「Django Fixtureを使用してデータをインポート・エクスポートする」を参照してください。

次の記事の13-responseブランチの進捗の配下には、私がエクスポートしたfixturesデータがすでに確認できます:

  1. users.json
  2. posts.json

これらを使用したい場合は、直接順番にインポートするだけです:

python manage.py loaddata users.json
python manage.py loaddata posts.json

必ず先にusersをインポートしてください。そうしないと、記事に作者が存在せず、関連付けに失敗します

インポートが完了すると、AliceとBobという2人のユーザーと、彼らがそれぞれ投稿した30件の記事を取得できます。

ええと、私の最初のテスト記事が混ざっていますが、ご容赦ください😅。

インポートに成功したら、次へ進みましょう。