「カーソルページング」プロファイル
はじめに
これは、JSON:API仕様のプロファイルの仕様です。
このプロファイルのURLはhttps://jsonapi.dokyumento.jp/profiles/ethanresnick/cursor-pagination/
です。
カーソルベースのページング(キーセットページングとも呼ばれる)は、一般的なページング戦略であり、「オフセット-リミット」ページングの多くの欠点を回避します。
たとえば、「オフセット-リミット」ページングでは、クライアントがページング中に以前のページのアイテムが削除されると、後続の結果はすべて1つずつ前方にシフトされます。そのため、クライアントが次のページを要求すると、スキップされ、表示されない結果が1つあります。逆に、クライアントがページング中に結果リストに結果が追加されると、クライアントは異なるページで同じ結果を複数回見ることがあります。カーソルベースのページングでは、これらの可能性をどちらも防ぐことができます。
カーソルベースのページングは、ほとんどの実装において、大規模なデータセットに対してより優れたパフォーマンスを発揮します。
カーソルベースのページングをサポートするために、この仕様では、3つのクエリパラメータ(page[size]
、page[before]
、page[after]
)と、レスポンスボディでクライアントにページングリンクとカーソルを提供する方法を定義します。
たとえば、このリクエストは、カーソルabcde
の後の次の100人の人を取得します。
GET /people?page[size]=100&page[after]=abcde
page[after]
をpage[before]
に置き換えると、クライアントは逆方向にページングできます。
あるいは、カーソルabcde
とfghij
(排他的)の間のすべての人を見つけるために、クライアントは次のように要求できます。
GET /people?page[after]=abcde&page[before]=fghij
他の組み合わせも可能であり、これらのパラメータについては以下で詳しく説明します。
仕様
概念
ソートの要件
**ページングは、順序付けられた結果リストにのみ適用されます。**基礎となるデータが変更されない限り、この順序はリクエスト間で変更されません。これにより、結果がページ間で恣意的に移動することがなくなります。
クライアントのページングされたリクエストに、結果を部分的にしかソートしない?sort
クエリパラメータが含まれている場合、サーバーは、そのデータのページングをサポートする場合、クライアントが要求したソート制約と一致する追加のソート制約を適用**しなければなりません**。
たとえば、クライアントがGET /people?sort=age&page[size]=10
を要求したとします。複数の人が同じ年齢の場合、それらの相対的な順序は定義されておらず(リクエスト間で異なる可能性があります)、ページングが不可能になります。そのため、リクエストを満たすには、サーバーは?sort=age
を含むすべてのページングされたリクエストを、クライアントが代わりに年齢でソートし、それに続いて一意のフィールドまたはフィールドの組み合わせ(例:?sort=age,id
)でソートするように要求したかのように処理しなければなりません。
同様に、ページングされるコレクションに自然な順序またはクライアントが要求した順序がない場合(関係にあるリソース識別子オブジェクトのセットなど)、サーバーはページングをサポートする場合、順序を割り当て**なければなりません**。
サーバーは、クライアントがサーバーが効率的にページングできない方法で結果をソートするように要求した場合、ページングリクエストを拒否しても**かまいません**。その場合、サーバーはサポートされていないソートエラーのルールに従ってリクエストを拒否**しなければなりません**。
カーソル
「カーソル」とは、サーバーが任意の方法を使用して作成する文字列であり、結果リストをカーソルより前にあるもの、カーソルより後に来るもの、そして場合によってはカーソル上に来る1つの結果に分割します。
たとえば、ページングされる結果リストが次のようになっているとします。
[
{ "type": "examples", "id": "1" },
{ "type": "examples", "id": "5" },
{ "type": "examples", "id": "7" },
{ "type": "examples", "id": "8" },
{ "type": "examples", "id": "9" }
]
このリストに対して、サーバーは「id = 5」をエンコードする方法として、カーソル文字列abcde
を生成する場合があります。そのカーソルを使用すると、最初の結果はカーソルの前に、2番目の結果はカーソル上に、他の結果はカーソルの後に来ることになります。
結果リストは、クライアントのページングリクエスト間で変更される**場合があります**。たとえば、参照するリソースが削除されると、"id": "5"
を持つ結果は結果セットから削除される可能性があります。その場合、カーソルabcde
はもはやどの結果にも該当しなくなりますが、同じ結果がその前後に来ます。
まれに、サーバーは、クライアントのページングリクエスト間で結果リストが変更されることを受け入れられないと判断する場合があります。そのような場合、サーバーはカーソルにクライアントまたはそのセッションを一意に識別する情報をエンコードし、その識別子を使用して、特定の時点の単一の「スナップショット」から一貫した結果を返す**場合があります**。
クエリパラメータ
page[size]
page[size]
パラメータは、クライアントがレスポンスで表示したい結果の数を示します。
page[size]
が指定されている場合、それは正の整数**でなければなりません**。1この要件が満たされない場合(例:page[size]
が負の場合)、サーバーは無効なクエリパラメータエラーのルールに従って応答**しなければなりません**。
ページングをサポートする各エンドポイントについて、サーバーは、そのエンドポイントへのページングされたリクエストに応答して送信する結果の最大数を定義しても**かまいません**。これは「最大ページサイズ」と呼ばれます。サーバーが特定のエンドポイントの最大ページサイズを選択しない場合、それは暗黙的に無限大です。
page[size]
がサーバー定義の最大ページサイズを超える場合、サーバーは最大ページサイズ超過エラーのルールに従って応答**しなければなりません**。
page[size]
が省略されている場合、サーバーは「デフォルトのページサイズ」を選択**しなければなりません**。このデフォルトサイズは、1から最大ページサイズまでの整数**でなければなりません**。
page[size]
の値、またはpage[size]
が省略されている場合はデフォルトのページサイズは、「使用されたページサイズ」と呼ばれます。
有効なページングされたリクエストでは、返されるページングアイテムの数は「使用されたページサイズ」と等しく**なければなりません**。ただし、結果リストに少なくともその数のアイテムがあり、page[after]
および/またはpage[before]
パラメータ(存在する場合)の制約を満たす場合に限ります。
page[after]
とpage[before]
page[after]
とpage[before]
パラメータはどちらもオプションであり、どちらも提供されている場合、カーソルを値として受け取ります。値が無効なカーソルである場合、サーバーは無効なクエリパラメータエラーのルールに従って応答**しなければなりません**。
page[after]
パラメータは、通常、クライアントによって次のページを取得するために送信されますが、page[before]
は前のページを取得するために使用されます。
より正式には、page[after]
が提供されている場合、返されるページングされたデータの最初のアイテムは、結果リストでカーソルの直後にあるアイテム**でなければなりません**。(例外として、結果リストにカーソルの後に来るアイテムがない場合、返されるページングされたデータは空の配列**でなければなりません**。)
page[before]
が提供されている場合、ページングされたデータで返される最後のアイテムは、ページングされていない結果リストでカーソルに最も近い、しかしまだカーソルの前のアイテム**でなければなりません**。(上記と同様に、結果リストにカーソルの前に来るアイテムがない場合、返されるページングされたデータは空の配列**でなければなりません**。)
たとえば、ページングされる結果リストが再び次のようになっているとします。
[
{ "type": "examples", "id": "1" },
{ "type": "examples", "id": "5" },
{ "type": "examples", "id": "7" },
{ "type": "examples", "id": "8" },
{ "type": "examples", "id": "9" }
]
さらに、カーソルxxx
が"id": "9"
のエントリに該当し、カーソルabcde
が"id": "5"
のエントリに該当するとします。
次に、たとえば、リクエストが次の場合
GET /example-data?page[after]=abcde&page[size]=2
レスポンスには次のものが含まれます。
{
"links": {
"prev": "/example-data?page[before]=yyy&page[size]=2",
"next": "/example-data?page[after]=zzz&page[size]=2"
},
"data": [
// the pagination item metadata is optional below.
{ "type": "examples", "id": "7", "meta": { "page": { "cursor": "yyy" } } },
{ "type": "examples", "id": "8", "meta": { "page": { "cursor": "zzz" } } }
]
}
あるいは、リクエストが次の場合
GET /example-data?page[before]=xxx&page[size]=3
レスポンスには次のものが含まれます。
{
"links": {
"prev": "/example-data?page[before]=abcde&page[size]=3",
"next": "/example-data?page[after]=zzz&page[size]=3"
},
"data": [
// again, optional pagination item metadata is allowed for each item here.
{ "type": "examples", "id": "5" },
{ "type": "examples", "id": "7" },
{ "type": "examples", "id": "8" }
]
}
page[before]=xxx
は、レスポンスのページングされたデータの最後のアイテムが"id": "8"
のエントリになるようにし、そのアイテムより上のページングされたデータのアイテム数は、使用されたページサイズによって制御されることに注意してください。
page[after]
とpage[before]
の両方の省略
クライアントのページングされたリクエストにpage[after]
パラメータもpage[before]
パラメータも含まれていない場合、返されるページングされたデータは、結果リストの最初のアイテムから開始する**必要があります**。(結果リストが空の場合、ページングされたデータは空の配列**でなければなりません**。)
page[after]
とpage[before]
の組み合わせ
クライアントは、同じリクエストでpage[after]
とpage[before]
パラメータを一緒に使用しても**かまいません**。これらは「範囲ページングリクエスト」と呼ばれ、クライアントはpage[after]
カーソルの直後から開始し、page[before]
カーソルまで続くすべての結果を要求しています。
サーバーは、そのようなリクエストをサポートする必要はありません。サーバーがこれらのリクエストをサポートしないことを選択した場合、範囲ページングがサポートされていないエラーのルールに従って応答**しなければなりません**。
範囲ページングリクエストでは、サーバーは、そのエンドポイントの最大ページサイズをデフォルトのページサイズとして使用**しなければなりません**。つまり、「使用されたページサイズ」は、page[size]
パラメータの値、または最大ページサイズになります。
page[after]
とpage[before]
の両方の制約を満たす結果の数が、使用済みのページサイズを超える場合、サーバーは、page[before]
パラメーターが提供されていない場合と同じページ分けされたデータで応答**しなければならない**。ただし、この場合、サーバーはクライアントにページ分けされたデータに要求されたすべての結果が含まれていないことを示すために、ページングメタデータに"rangeTruncated": true
を追加**しなければならない**。
たとえば、上記の例データとカーソルを考慮して、クライアントが次の要求を行うとします。
GET /example-data?page[after]=abcde&page[before]=xxx
次に、サーバーの最大ページサイズが1より大きいと仮定すると、応答には次の内容が含まれます。
{
"links": {
"prev": "/example-data?page[before]=yyy",
"next": "/example-data?page[after]=zzz"
},
"data": [
{ "type": "examples", "id": "7" },
{ "type": "examples", "id": "8" }
]
}
ただし、サーバーの最大ページサイズが1の場合、またはクライアントが要求にpage[size]=1
を含めた場合、応答には次の内容が含まれます。
{
"meta": {
"page": { "rangeTruncated": true }
},
"links": {
"prev": "/example-data?page[before]=yyy&page[size]=1",
"next": "/example-data?page[after]=yyy&page[size]=1"
},
"data": [
{ "type": "examples", "id": "7" }
]
}
ドキュメント構造
用語
このプロファイルでは、次の用語を使用して、異なるドキュメント要素を参照します。
-
ページ分けされたデータ:JSON:APIレスポンスドキュメント内の配列で、ページ分けされる結果の完全なリストから抽出された結果を保持します。常に
data
キーの値です。プライマリデータがページ分けされている場合、ドキュメントの最上位レベルのdata
キーの値はページ分けされたデータです。リレーションシップのリソース識別子オブジェクトがページ分けされている場合、リレーションシップオブジェクト内のdata
キーの値はページ分けされたデータです。 -
ページングメタデータ:ページ分けされたデータ(とページングリンク)の兄弟である
meta
オブジェクトのpage
メンバー。 -
ページングアイテムメタデータ:ページ分けされたデータアイテムの最上位レベルにある
meta
オブジェクトのpage
メンバー。
これらの用語を示すために、次の例ではさまざまな要素にラベル付けされています。
GET /people?page[size]=1
{
// "pagination links" (for the top-level `data`)
"links": { },
"meta": {
// "pagination metadata"
"page": {}
},
// "paginated data"
"data": [
// a "pagination item"
{
"type": "people",
"id": "1",
// "pagination item metadata" in `page`.
"meta": { "page": {} },
"attributes": {},
"relationships": {
"friends": {
// "pagination links".
// would be non-empty in practice to indicate that the server
// has chosen to paginate this relationship, even though the
// client hasn't explicitly asked, which is allowed.
"links": {},
// another instance of "paginated data"
"data": [
// a "pagination item", with (empty) "pagination item metadata".
{ "type": "people", "id": "3", "meta": { "page": { } } }
]
}
}
}
]
}
page
メタオブジェクトメンバー
このプロファイルは、JSON:APIで定義されたすべてのmeta
オブジェクトにpage
メンバーを予約します。(これらのpage
メンバーのそれぞれはこのプロファイルで定義された要素を構成するため、エイリアスを付けることができます。)
page
メンバーが存在する場合、その値としてオブジェクトを保持**しなければならない**。それ以外の値は認識されない。これらのさまざまなpage
オブジェクト内の認識されたキー/値はこの仕様全体で定義されています。
アイテムカーソル
サーバーは、ページングアイテムのメタデータにcursor
メンバーを使用して、いくつかの、またはすべてのページングアイテムをクライアントに送信することを選択**してもよい**。存在する場合、このメンバーは(応答時に)、“一致する”アイテムのカーソルを保持**しなければならない**。クライアントはこのカーソルを使用して、このアイテムからページングを行うことができます。
たとえば、応答には次の内容が含まれる場合があります。
{
// top-level links, meta, etc. omitted.
// more people would likely be in the response as well.
"data": [{
"type": "people",
"id": "3",
"meta": {
"page": { "cursor": "someOpaqueString" }
}
//...
}]
}
この応答により、クライアントはpage[before]=someOpaqueString
またはpage[after]=someOpaqueString
を使用して、いずれの方向にもperson 3からページングを行うことができます。
リンク
JSON:APIは、4種類のページングリンクを許可します。prev
、next
、first
、last
。
これらの計算コストが低い場合は、サーバーがfirst
とlast
リンクを含めることを**推奨**します。
ただし、サーバーは、応答内のページ分けされたデータの各インスタンスに対して、prev
リンクとnext
リンクを含める**必要があります**。
要求にpage[before]
パラメーターが含まれていない場合、サーバーは次のページが存在するかどうかを判断し、存在しない場合はnext
リンクとしてnull
を返す**必要があります**。
要求にpage[after]
パラメーターが含まれていない場合、サーバーは前のページが存在するかどうかを判断し、存在しない場合はprev
リンクとしてnull
を返す**必要があります**。
その他の場合、サーバーは、現在の応答がそれぞれ最初または最後のページであることを安価に判断できる場合は、これらのリンクをnull
に設定**する必要があります**。
ただし、サーバーが前の結果(prev
リンクを計算する場合)または後続の結果(next
リンクを計算する場合)が存在するかどうかを簡単に判断できない場合、ページ分けされたデータとして空の配列を返すURIをこれらのリンクで使用**してもよい**。
たとえば、次の要求を想像してください。
GET /example-data?page[before]=xyz
この要求を満たすために、サーバーは、カーソルより前のレコードのみを見つけるために、完全なexample-data
結果リストをフィルタリングするクエリを発行する可能性が高いです。サーバーは、これらのクエリ結果から、カーソル後に追加の結果があるかどうかを知ることができず、それを知る安価な方法がない可能性があります。
そのため、この場合、サーバーは、page[after]
パラメーターが応答のページ分けされたデータの最後のアイテムのアイテムカーソルに設定されているnext
URIを返すだけです。
クライアントがこのリンクを取得すると、ページ分けされたデータとして空の配列を受け取る場合(その場合、最後に到達したことがわかります)、または後続の結果を取得します。
注:一般的に、サーバーは、
page[after]
が使用されている場合に前のページが存在するかどうかを判断し、page[before]
が使用されている場合に後続のページが存在するかどうかを判断する方がコストがかかります。幸いなことに、page[after]
が使用されている場合、クライアントは通常、前のページ(次のページのみ)を気にしません。そして、page[before]
が使用されている場合も逆です。したがって、サーバーが空のページに対応する可能性のあるリンクを返すことを許可することにより、サーバーは必要とされないクエリをスキップできることがよくあります。
コレクションサイズ
ページングメタデータには、ページ分けされている結果のリスト内のアイテムの総数を示す整数を含むtotal
メンバーが含まれていても**よい**。
たとえば、GET /people?page[size]=2
への応答には、次の内容が含まれる場合があります。
{
"meta": {
"page": { "total": 200 }
},
// links omitted
"data": [
{
"type": "people",
// ...
},
{
"type": "people",
// ...
}
]
}
ページングメタデータには、estimatedTotal
メンバーが含まれていても**よい**。存在する場合、このメンバーの値はオブジェクト**でなければならない**。そのオブジェクトには、bestGuess
という1つのキーが含まれていても**よい**。存在する場合、bestGuess
は、完全な結果リストのサイズに関するサーバーの最良の推定値を示す整数を含んで**いなければならない**。
正確な合計を計算することがコストがかかる場合、サーバーはtotal
の代わりにestimatedTotal
を使用することを選択する場合があります。
エラーケース
サポートされていないソートエラー
サーバーは、400 Bad Request
を送信することでこのエラーに応答**しなければならない**。レスポンスドキュメントには、sort
パラメーターをエラーのsource
として識別し、次のtype
リンクを持つエラーオブジェクトが含まれて**いなければならない**。
https://jsonapi.dokyumento.jp/profiles/ethanresnick/cursor-pagination/unsupported-sort
最大ページサイズ超過エラー
サーバーは、400 Bad Request
を送信することでこのエラーに応答**しなければならない**。レスポンスドキュメントには、次のエラーオブジェクトが含まれて**いなければならない**。
- エラーの
source
としてpage[size]
を識別する。 - エラーオブジェクトの
meta
オブジェクトのpage
要素のmaxSize
メンバーに最大ページサイズを整数で提供する。 -
次の
type
リンクを含む。https://jsonapi.dokyumento.jp/profiles/ethanresnick/cursor-pagination/max-size-exceeded
このプロファイルのpage
要素にエイリアスが付けられていない場合、エラーオブジェクトは次のようになります。
{
"status": "400",
"meta": {
"page": { "maxSize": 100 }
},
"title": "Page size requested is too large.",
"detail": "You requested a size of 200, but 100 is the maximum.",
"source": {
"parameter": "page[size]"
},
"links": {
"type": ["https://jsonapi.dokyumento.jp/profiles/ethanresnick/cursor-pagination/max-size-exceeded"]
}
}
無効なパラメーター値エラー
サーバーは、エラーオブジェクトのsource
メンバーで問題のあるパラメーターを識別するエラーオブジェクトをレスポンスドキュメントに含む400 Bad Request
を送信することで、このエラーに応答**しなければならない**。
たとえば、サーバーは次のものを送信する場合があります。
{
"errors": [{
"title": "Invalid Parameter.",
"detail": "page[size] must be a positive integer; got 0",
"source": { "parameter": "page[size]" },
"status": "400"
}]
}
範囲ページングがサポートされていないエラー
サーバーは、400 Bad Request
を送信することでこのエラーに応答**しなければならない**。レスポンスドキュメントには、次のtype
リンクを持つエラーオブジェクトが含まれて**いなければならない**。
https://jsonapi.dokyumento.jp/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported
注記
バージョン履歴
このプロファイルの変更履歴を表示する編集者への連絡先
Ethan Resnickethan.resnick@gmail.com
https://ethanresnick.com/
13104398032