絞り込み、並べ替え、ページネーション

3 ページ目まで誰も気づかない、一覧エンドポイントのバグ。

一覧のエンドポイントは単純に見えます。商品を求めれば商品が返る、それだけです。ところが一覧が 1 回のレスポンスに収まらないほど大きくなり、ユーザーは価格順に並べたい、カテゴリーで絞り込みたい、名前で検索したいと言い出し、 その機能の 1 つ 1 つがそれぞれの境界ケースを持つ小さなコードになります。一覧のバグがやっかいなのは、レスポンスがいつも もっともらしく見えることです。間違った 10 個の商品が載ったページも、10 個の商品が載ったページには変わりありません。

このレッスンでは、初期データに 20 個の商品を持つ /api/Products と、/api/paginationdocs 以下のデモ用エンドポイントを使います。同じパラメーターはこの API のすべての一覧で使え、API リファレンスに説明があります。

パラメーターここでの働き
limitページサイズ。1 〜 100。指定しないとすべてが返る。
page1 から始まるページ番号。limit と一緒に使う。
offsetこの件数のレコードを飛ばす。page の代わりに使える。
sort, order任意のフィールドで並べ替える。asc(既定)または desc。
qtitle や description のようなテキストフィールドを、大文字と小文字を区別せずに検索する。
任意のフィールド名大文字と小文字を区別しない完全一致のフィルター。?category=books など。

ページネーション

1 ページ 3 件で商品を求め、ボディだけでなくヘッダーも見てください。

GET/api/Products?limit=3&page=1

20 個の商品を 3 件ずつに分けると 7 ページになり、最後のページは 2 件です。バグを探すならまず最後のページです。ページが 埋まっていない唯一のケースだからです。

ターミナル
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7

[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]

正しく 7 ページあり、最後のページには商品 19 と 20 があります。ここでの古典的なバグは、ページ数を整数の割り算(20 / 3 = 6)で計算してしまうことで、そうするとページをたどっていく人には最後の 2 つの商品が見えなくなります。

範囲外の値

次に、ドキュメントで無効とされている値や、まったく触れられていない値を送ります。この API はそれらを次のように扱います。

結果
?limit=3&page=8    200, x-page: 8, body []
?limit=3&page=0    200, x-page: 1, same as page 1
?limit=0           200, x-per-page: 1
?limit=-1          200, x-per-page: 1
?limit=101         200, x-per-page: 100
?limit=abc         200, x-per-page: 20 (limit ignored, all 20 returned)

サーバーは決して拒否しません。不正な値をいちばん近い有効な値に丸めるか、無視します。これはまっとうな設計で、クライアントにも 親切ですが、テストでは記録しておくべきです。もう一方のやり方(有効な範囲を説明する 400)も同じくらいよくあるからです。危険 なのは、丸めがクライアントのバグを隠してしまう場合です。テンプレートの誤りで limit=abc を送ったクライアントは、ここでは 20 件すべてを受け取ります。200 万行あるテーブルなら、同じ振る舞いはサーバーがそのすべてを 返そうとすることを意味します。

最後を超えたページは、200 と空の一覧を返します。ほとんどの API はそうしていて、何も返らなくなるまでページを進めるクライアント もそれを期待しています。空のページに 404 を返すのも筋は通りますが、500 は通りません。

抜けも重複もないこと

ページネーションでいちばん大事なテストは、説明するのもいちばん簡単です。すべてのページをたどって ID を集め、すべてのレコードをちょうど 1 回ずつ受け取ったかを確かめます。集めた件数を X-Total-Count と比べます。

JavaScript
async function allPages(path, limit) {
  const seen = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
    const items = await res.json();
    if (items.length === 0) break;
    seen.push(...items.map((item) => item.id));
  }
  return seen;
}

const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);

1、3、7、そして一覧全体より大きいものなど、いくつかのページサイズで実行してください。オフセット計算の 1 つずれの誤りは、特定のサイズでしか現れないことがよくあります。API が page と offset の両方に対応しているなら、limit=5&page=3 と limit=5&offset=10 が同じレコードを返すことも確かめます。

このテストが確実に通るのは、実行中に誰もデータを変えない場合だけです。この API のような共有サーバーでは、誰かが変えるかも しれません。そこからページ番号のいちばんの弱点が見えてきますが、それは後で扱います。

並べ替え

価格の降順で並べ替え、最初の要素だけでなく結果全体の順序を確かめます。

GET/api/Products?sort=price&order=desc&limit=5

最初の 5 件は 249.99、199.99、89.99、89.99、59.99 の順で返ります。商品 3 と 8 は同じ価格です。同じ値はページネーションに 影響します。サーバーが同順位を決まった方法で並べないと、同じ商品が 1 ページ目の最後と 2 ページ目の最初の両方に出たり、どちらにも出なかったりします。ほとんどのデータベースは、クエリに 2 つ目の並べ替えキー(たいてい ID)を加えない限り、等しい行の順序を何も約束しません。

カテゴリーで並べ替えると、この API の扱い方がわかります。各カテゴリーの中で、商品は昇順でも降順でも ID の順に並んだままです。

結果(id:カテゴリー)
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...

?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...

これは安定していて、望ましい振る舞いです。テストするには、重複の多いフィールドで並べ替え、一覧を 2 回取得し(小さな limit でページをたどることも)、順序が決して変わらないことを確かめます。

どの API でも試したい並べ替えのケースはほかにもあります。

  • 大文字と小文字が混ざった文字列。「apple」は「Banana」の前か後か。
  • 文字列として保存された数値。「10」が「9」より前に来てしまう。
  • 並べ替えのフィールドが null や欠落しているレコード。先頭に来るか、最後に来るか、消えるか。
  • 異なるタイムゾーンの日付。

無視されるパラメーター

パラメーターを間違えたときに何が起きるかは、どの機能そのものよりも大きな問題の種です。

結果
?sort=colour&limit=5            200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways      200, sorted ascending
?categry=books                  200, all 20 products
?rating.rate=4.3                200, all 20 products

どれも 200 と、もっともらしく見える一覧を返します。存在しないフィールドでの並べ替えは捨てられます。sideways という順序は昇順になります。スペルを間違えたフィルター categry は無視されるので、クライアントは本を求めたのに 20 個すべての商品を受け取りました。ドット記法での入れ子のフィールドによる 絞り込みは対応しておらず、これも無視されます。

実際にはスペルを間違えたフィルターがいちばん厄介です。「顧客 42 の注文」を表示する画面が、誰かが名前を変えたフィルターパラメーターの上に作られているとします。名前が変わった後は、すべての 顧客がすべての注文を見ることになり、その間ずっと API は 200 を返します。フィルターをテストするときは、フィルターが ほとんどのレコードを除外するはずのケースを必ず含め、本当に除外されたかを確かめてください。「返ったすべての商品が books カテゴリーである」とだけ確かめるテストは空の一覧でも通るので、件数も確かめます。

この API のフィルターは完全一致で、大文字と小文字を区別しません。?category=Electronics は電子機器の 5 つの商品を返し、?category=electron は空の一覧を返します。真偽値のフィールドも同じように動きます。

GET/api/Products?inStock=false

q による検索は部分一致です。ここでエンコーディングが重要になってきます。検索語には空白、アンパサンド、パーセント記号、 クォートが含まれ、どれも URL の中では特別な意味を持つからです。

結果(ID)
?q=YOGA              19
?q=yoga+mat          19
?q=yoga%20mat        19
?q=100%25            6   (description contains "100% organic cotton")
?q=%27               11,13   (titles and descriptions with an apostrophe)
?q=                  all 20
?q=a&q=b             []
?category=books&category=home    []

空白は + でも %20 でも使えます。% そのものは %25 として送る必要があり、「100% organic cotton」の T シャツを正しく見つけます。アポストロフィの検索も動いて 2 冊の本を返します。これは、検索が文字列をつなげて SQL を組み立てていないことの小さなしるしです(それがなぜ大事かはセキュリティのレッスンで扱います)。空白をエンコードせずに送ると、curl はリクエストを送ることそのものを拒否します。

最後の 2 行には補足が必要です。URL でパラメーターを繰り返すのは正当なことで、API の扱いはまちまちです。最初の値が勝つ、 最後の値が勝つ、両方の値を OR で組み合わせる、1 つの文字列につなげる、などです。ここでは q や category を繰り返すと空の一覧が返るので、値が books,home のようにつなげられて何にも一致しなくなったのだと考えられます。製品の画面でいつか 2 つのカテゴリーを選べるようにするなら、ここには見つけられるのを待っているバグがあります。

すべてを組み合わせる

1 回のリクエストで絞り込み、並べ替え、ページ分けをし、正しい順序で適用されることを確かめます。まず絞り込み、次に並べ替え、最後にページの切り出しです。

GET/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2

2 ページ目には商品 3 と 4(89.99 と 59.99)があり、X-Total-Count は 20 ではなく、電子機器の商品の数である 5 です。総数がフィルターを無視していたら、クライアントは 3 ページしかない一覧に「1 / 10 ページ」と表示してしまいます。総数は、誰かが更新し忘れた別のクエリで数えられていることが多いので、これはよくある間違いです。

ページ番号が壊れる理由

ページ番号やオフセットは、一覧の中の位置を表します。「20 件飛ばして 10 件ください」です。変わらないデータならそれで問題 ありません。データが変わると、位置がずれます。

  1. 新しい順に並んだ一覧の 1 ページ目(項目 1 〜 10)を取得する。
  2. 誰かが新しい項目を作る。それが先頭に入り、すべてが 1 つずつ下にずれる。
  3. 2 ページ目(位置 11 〜 20)を取得する。位置 11 は、さっきまで 10 番目だった項目なので、それを 2 回見ることになる。

項目を削除するとその逆になります。すべてが 1 つ上にずれ、1 つの項目が一度も表示されません。ニュースフィードなら小さな不便で済みますが、ページをたどってすべての請求書を書き出す処理なら、 請求書が 1 通書き出しから漏れ、誰も気づきません。

カーソルによるページネーションは、どこまで進んだかではなく、どこで止まったかを表すことでこれを避けます。各レスポンスが、 次のリクエストのためのトークンを渡してくれます。

GET/api/pagination/cursor?limit=3
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
レスポンス
{
  "object": "list",
  "url": "/api/pagination/cursor",
  "data": [
    { "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
    { "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
    { "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
  ],
  "has_more": true,
  "next_cursor": "eyJsYXN0SWQiOjN9"
}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

next_cursor を ?cursor= として返し、has_moreが false になるまで続けます。無効なカーソルには、はっきりした 400 が返ります。「Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged.」

キーセットによるページネーションは、包み紙を外した同じ考え方です。クライアントは最後に見た ID を送り、サーバーはその後のレコードを返します。

GET/api/pagination/keyset?after_id=10&limit=3
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
レスポンス
{
  "data": [
    { "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
    { "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
    { "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
  ],
  "has_more": true,
  "next_after_id": 13
}

3 つ目のスタイルは、GitHub の API のように、ページへのリンクを Link ヘッダーに入れます。ボディはただの配列のままです。

ターミナル
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
      <http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

ページ数は正しい(100 件を 3 件ずつで 34 ページ)のですが、URL を見てください。リクエストは https に送ったのに、どのリンクも http を指しています。これは本物のバグで、しかもよくあるものです。アプリケーションが HTTPS を処理するプロキシの裏で動いていて、自分自身にはただの HTTP が見えるので、見えたとおりにリンクを組み立ててしまうのです。 これらのリンクをたどるクライアントは、リダイレクトされるか、もっと厳しい設定なら次のリクエストを暗号化せずに送ってしまいます。 API が生成するすべての URL のスキームとホストを確かめるのは、これを見つけられる安上がりなテストです。

どのスタイルのページネーションでも、中心となるテストは同じです。最後までたどり、ID を集め、抜けと重複を探し、最後のページで 何が起きるかを確かめます。カーソルとキーセットでは、たどっている途中でレコードを 1 つ追加し、元のレコードがやはりちょうど 1 回ずつ見えることも確かめます。

演習

/api/Booksサインインして表示 を選び、各手順の結果を書き留めてください。

  1. X-Total-Count から本の数を調べ、limit=4 で一覧をたどる。すべての ID を 1 回ずつ受け取りましたか。
  2. genre で並べ替える。2 回取得し、limit=3 でページをまたいでも取得する。各ジャンルの中の順序は安定していますか。
  3. ジャンルで絞り込み、price の降順で並べ替え、2 ページ目を求める。X-Total-Count は絞り込んだ後の件数ですか。
  4. フィルターの名前をわざと間違える。何が返ってきますか。
  5. /api/pagination/offsetサインインして表示 と /api/pagination/linkサインインして表示 を最後までたどる。最後のページをどこで知らせていますか。2 つの総数は一致しますか。

意外な結果があるたびに、それがバグなのか、ドキュメントの不足なのか、それでも疑問を呈したい設計上の選択なのかを判断してください。

練習とその先へ