コンテンツにスキップ

カスタムページネーション#

2024 iThome鉄人レース

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

前回はDjango Ninjaの組み込みページネーションを紹介し、それを使用してシンプルなページネーション機能を実装しました。

組み込みのPageNumberPaginationは確かに便利ですが、多くの場合、依然としてカスタマイズ機能が必要になります。

この目的を達成するためには、カスタムページネーションクラスを作成する必要があります。

しかし心配しないでください。このカスタマイズはゼロから始めるわけではありません。Django Ninjaが提供する基本ページネーションクラスを継承し、それに独自の「加工」を施します。

この記事では、その方法をお教えします。

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

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

👉 Django-Ninja-Tutorial


カスタマイズ要件#

基本的なページネーションに加えて、以下のことも行いたいと思います:

  • クライアントに各ページの表示データ数を選択できるようにする。選択可能な範囲は1から100までとする。
  • レスポンスに現在のページネーション情報を示す2つのフィールドを追加する:
    • 現在のページ数(page)。
    • 各ページの表示数(per_page)。

これは間違いなく非常によくある要件です。カスタムページネーションクラスを通じて、これらの機能を実現します。

前置きはこれくらいにして、さっそく始めましょう!

実装:カスタムページネーションクラス#

ページネーション(ページネーションクラス)は通常、プロジェクト全体で使用されるため、Djangoアプリのディレクトリに配置するのは適していません。しかし、exception handlersのように、プロジェクトのapi.pyに配置することもできません。循環参照を引き起こす可能性があるからです。

そこで、プロジェクトディレクトリNinjaForumに新しいPythonモジュール——pagination.pyを作成しました。

この新しいモジュールに、直接CustomPaginationという名前のページネーションクラスを以下のように記述します:

from typing import Any

from django.db.models.query import QuerySet
from ninja import Field, Schema
from ninja.pagination import PaginationBase

class CustomPagination(PaginationBase):
    class Input(Schema):
        page: int = Field(1, ge=1)
        per_page: int = Field(10, ge=1, le=100)

    class Output(Schema):
        items: list
        page: int = Field(examples=[1])
        per_page: int = Field(examples=[10])
        total: int = Field(examples=[100])

    def paginate_queryset(
        self,
        queryset: QuerySet,
        pagination: Input,
        **params: Any,
    ) -> dict[str, Any]:
        start = (pagination.page - 1) * pagination.per_page
        end = start + pagination.per_page
        return {
            'items': queryset[start:end],
            'page': pagination.page,
            'per_page': pagination.per_page,
            'total': queryset.count(),
        }

このページネーションクラスは、クエリパラメータを通じて——pageper_page——ページサイズとページ数を決定することを許可します。さらに、レスポンスには追加のページネーション情報として同名の新しいフィールドが2つ追加されています。


カスタムページネーションクラスの解説#

コードの詳細は多く見えますが、注意深く読むと、実は理解するのが難しくないことがわかります。

スペースの都合上、いくつかの重要なポイントのみを取り上げます。

ポイント1:全体構造は継承されたクラス——PaginationBaseに由来する#

最初の疑問はおそらく「えっ、ページネーションクラスをこのように書くってどうやってわかるの?」でしょう。

その通り、私たちはもちろん知りません。だから公式ドキュメントソースコードを見る必要があります。

公式ドキュメントから、PaginationBaseというクラスを継承する必要があることがわかります。しかし、ドキュメントにおけるこのクラスの説明はまだ少し簡素であるため、より具体的な情報を理解するにはソースコードを見る必要があります。

そして、クラス内のいくつかの属性やメソッドを模倣し、オーバーライドする——だいたいそんな感じです。

ポイント2:入力スキーマ#

class Input(Schema):
    page: int = Field(1, ge=1)
    per_page: int = Field(10, ge=1, le=100)
ご想像の通り、このスキーマはページネーションに関連するURLクエリパラメータ定義と検証に使用されます。

さらに、Inputクラスはページネーションロジックを実装する一部として、paginate_querysetメソッドに引数として渡されます。

Inputの各属性は、1つのクエリパラメータを代表します(ページネーション関連に限定)。そして同様にFieldを使用して詳細を設定できます!

ここでのFieldはPydanticのFieldであり、第18回で詳しく紹介しました。これを使用すると、各パラメータにデフォルト値ドキュメントの例、そして基本的な検証ルールを設定できます。

この例では、pageのデフォルト値は1であり、1以上でなければなりません。per_pageのデフォルト値は10であり、1から100の間でなければなりません。これにより、ページネーションパラメータが常に合理的な範囲内にあることを保証できます。

同じ理屈がOutputにも適用されます。これはHTTPレスポンスの「あるべき」フォーマットを決定し、ページネーションレスポンスのスキーマに相当します。

ポイント3:paginate_querysetメソッド#

このメソッドはすべてのページネーションクラスの核心であり、具体的なページネーションロジックを実装します。

最初の引数はselfであり、これが「インスタンスメソッド」であることがわかります。

最も注目すべき第2引数——querysetです。これは実際にはview関数のreturn値であり、型は必ずQuerySetでなければなりません。

paginate_querysetは、私たちがおなじみの「スライスとインデックス」を利用して、渡されたQuerySetを「分割」します。これはDjangoがQuerySetのために独自に実装した機能であり、動作はPythonのlisttupleなどのコンテナに似ています。

クライアントにレスポンスを返す際、スライスされたQuerySetカスタムレスポンスフォーマットが得られます。


カスタムページネーションのテスト#

上記のカスタムクラスを書き終えたら、view関数に@paginate(CustomPagination)を1行追加するだけです。ここではコードを省略します。

直接結果を見てみましょう!クエリパラメータとして/?page=2&per_page=5(2ページ目、各ページ5件)を使用しました:

非常に理想的です!

では、各ページの数を100以上に設定するとどうなるでしょうか?

{
    "detail": [
        {
            "type": "less_than_equal",
            "loc": [
                "query",
                "per_page"
            ],
            "msg": "Input should be less than or equal to 100",
            "ctx": {
                "le": 100
            }
        }
    ]
}

答えは422レスポンスです。


ページネーション機能のまとめ#

この2つの記事を通じて、シンプルな組み込み機能から複雑なカスタムページネーションクラスまで、Django Ninjaでページネーションを実装する方法を紹介しました。

プロジェクトの要件に応じて、自分に合ったページネーション戦略を選択し、各レスポンスをユーザーに最適な方法で提示することができます。


なぜ「マルチステータスコードレスポンス」は実用的ではないのか?#

第13回と第21回に残した伏線を覚えていますか?

〈第13回:レスポンス(一)Django NinjaにおけるHTTPレスポンス処理〉で私はこう言いました:

しかし、この「マルチステータスコードレスポンス」設定は実務ではあまり実用的ではないと思います。なぜでしょうか?それについては後ほど話します。

復習のために、「マルチステータスコードレスポンス」とは以下の使い方を指します:

@api.post(
    ...,
    response={200: Token, 401: Message, 402: Message}  # ここ
)

そして、view関数の中で状況に応じて異なるreturnを返します。

私は〈第21回:エラー処理(上)HttpErrorとカスタムHTTPレスポンス〉でまたこう言いました:

確かにこれは良さそうで、直感的にも合っています。以前Django REST frameworkを書いていた時もこのように書いていました。

しかし、Django Ninjaにおいて、この書き方を「ページネーションデコレータ」と組み合わせて使用すると、壁にぶつかります

まだ時期尚早なので、後続の〈第25回:ページネーション(下)カスタムページネーションクラス〉でこの点について詳しく説明します。

ついに来ましたね!

理由は簡単で、本文の「ポイント3:paginate_querysetメソッド」のこの一文が鍵です:

最も注目すべきは第2引数——querysetです。これは実際にはview関数のreturn値であり、型は必ずQuerySetでなければなりません。

なぜなら、paginate_querysetメソッドでは、第2引数の型が必ずQuerySetでなければならないからです!

paginate_querysetの内部では、この引数をQuerySetと見なして使用し、操作します。渡されたものがQuerySetではない場合、ページネーションロジックはエラーになります

「マルチステータスコードレスポンス」とpaginate_querysetメソッドの衝突#

しかし、マルチステータスコードのレスポンスのreturn型は必ずしもQuerySetであるとは限りません——tupleである可能性が高いです。

簡単な例を挙げればわかります。「記事リストの取得」APIを次のように変更します:

@api.get(
    path="/posts",
    response={200: list[PostResponse], 404: ErrorMessage}
)
@paginate(CustomPagination)
def get_posts(...) -> QuerySet[Post] | tuple[int, dict]:
    posts = Post.objects.all()
    if not posts.exists():
        return 404, {"message": "条件に一致する記事が見つかりません"}
    return posts

この例は、「マルチステータスコードレスポンス」とページネーションの間の衝突を明確に示しています:

  • クエリ結果が正常な場合、view関数はQuerySet(すなわちposts)をreturnし、それをページネーションに渡します。すべては正常に機能します。
  • 記事が見つからない場合、view関数はtupleを返そうとします。なぜなら、Django Ninjaの「非200レスポンス」にはステータスコードが必要なので、QuerySetではなくtupleになるからです。

これにより、paginate_querysetメソッドでエラーが発生します。このメソッドはQuerySetを受け取ることを想定しており、その後の内部操作もそれを前提としているからです。


プロジェクト内のすべてのAPIにページネーションがない場合、「非200」レスポンスを処理するために「マルチステータスコードレスポンス」を使用することは完全に可能です。

しかし、たった1つでもページネーションが必要なAPIがある場合、そのページネーション付きのAPIは上述の衝突を避けるために、第21回で言及した方法——raise HttpErrorに変更する必要があります。

プロジェクト全体の一貫性を考慮すると、他のAPIもraise HttpErrorという方法を採用すべきです。

そして、ページネーションの要件は非常に一般的であるため、「マルチステータスコードレスポンス」は役立たずになってしまうのです。