「カーソルページング」プロファイル

はじめに

これは、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]に置き換えると、クライアントは逆方向にページングできます。

あるいは、カーソルabcdefghij(排他的)の間のすべての人を見つけるために、クライアントは次のように要求できます。

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" }
  ]
}

ドキュメント構造

用語

このプロファイルでは、次の用語を使用して、異なるドキュメント要素を参照します。

  1. ページ分けされたデータ:JSON:APIレスポンスドキュメント内の配列で、ページ分けされる結果の完全なリストから抽出された結果を保持します。常にdataキーの値です。プライマリデータがページ分けされている場合、ドキュメントの最上位レベルのdataキーの値はページ分けされたデータです。リレーションシップのリソース識別子オブジェクトがページ分けされている場合、リレーションシップオブジェクト内のdataキーの値はページ分けされたデータです。

  2. ページングリンク:ページ分けされたデータの兄弟であるlinksオブジェクト。

  3. ページングメタデータ:ページ分けされたデータ(とページングリンク)の兄弟であるmetaオブジェクトのpageメンバー

  4. ページングアイテム:ページ分けされたデータ配列のエントリ。

  5. ページングアイテムメタデータ:ページ分けされたデータアイテムの最上位レベルにある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種類のページングリンクを許可します。prevnextfirstlast

これらの計算コストが低い場合は、サーバーがfirstlastリンクを含めることを**推奨**します。

ただし、サーバーは、応答内のページ分けされたデータの各インスタンスに対して、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

注記

  1. 技術的には、URIは一連の文字なので、クエリパラメーター(page[size]を含む)の値は常に文字列です。「値は正の整数でなければならない」というテキストは、より正確には、値が正規表現^[0-9]+$に一致する文字列でなければならないことを意味し、それを10進数として解釈する**必要がある**ことを意味します。

バージョン履歴

このプロファイルの変更履歴を表示する

編集者への連絡先

Ethan Resnick
ethan.resnick@gmail.com
https://ethanresnick.com/
13104398032