Beispiele für API-Paginierung: Offset, Seite, Cursor, Keyset, Link-Header und HAL

Sechs Paginierungsarten über dieselben Daten, jede mit einer echten Antwort und einer Schleife, die alle Seiten abruft.

Beispiele zuletzt am gegen die API ausgeführt

APIs teilen lange Listen auf sechs gängige Arten in Seiten. Offset und Limit überspringen eine Anzahl Einträge. Seitennummer und -größe zählen Seiten, wie Spring Data es tut. Ein Cursor ist ein undurchsichtiges Token, wie Stripe es nutzt, und Keyset-Paginierung fragt nach den Einträgen nach der letzten ID. Manche APIs legen die nächste URL in einen Link-Header, wie GitHub, und HAL legt sie in Hypermedia-Links. https://api.testingapis.com/api/paginationanmelden zum Anzeigen liefert dieselben 100 Einträge in allen sechs Arten, damit du die Antworten nebeneinander vergleichen und einen Client gegen jede testen kannst. Kostenlos und ohne Schlüssel. Unten jede Art mit einer echten Antwort.

Die sechs Arten im Überblick

ArtRequestDie nächste Seite kommt ausGenutzt von
Offset / Limit/offset?offset=20&limit=10offset + limitViele SQL-basierte APIs
Seitennummer (ab 0)/page?page=2&size=10number + 1, bis last true istSpring Data
Cursor/cursor?limit=10&cursor=…next_cursor, bis has_more false istStripe
Keyset/keyset?after_id=20&limit=10next_after_idGroße Tabellen, Feeds
Link-Header (ab 1)/link?page=3&per_page=10Die URL mit rel="next" im Link-HeaderGitHub
HAL/hal?page=2&size=10_links.next.hrefSpring HATEOAS

Alle Pfade liegen unter /api/paginationanmelden zum Anzeigen. Seitengrößen reichen von 1 bis 100, Standard ist 10.

Offset und Limit

Die einfachste Art: offset Einträge überspringen und die nächsten limit zurückgeben. Die Gesamtzahl steht im Body und in X-Total-Count.

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/offset?offset=0&limit=2"
HTTP/1.1 200 OK
x-total-count: 100

{"data":[{"id":1,"name":"Smart Headset",...},{"id":2,"name":"Compact Speaker",...}],
 "pagination":{"offset":0,"limit":2,"total":100,"hasMore":true}}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Seitennummer und -größe

Der Envelope von Spring Data. Seiten beginnen bei 0, und genau darauf solltest du testen: Ein Client, der für Seiten ab 1 geschrieben ist, überspringt stillschweigend die erste Seite.

Terminal
$ curl "https://api.testingapis.com/api/pagination/page?page=0&size=2"

{"content":[{"id":1,...},{"id":2,...}],
 "pageable":{"pageNumber":0,"pageSize":2,"offset":0,"paged":true},
 "totalElements":100,"totalPages":50,"number":0,"size":2,"numberOfElements":2,
 "first":true,"last":false,"empty":false}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Cursor

Die Antwort enthält einen next_cursor. Schick ihn unverändert zurück, um die nächste Seite zu bekommen, und hör auf, wenn has_more false ist. Der Cursor ist zufällig base64-kodiertes JSON, ein Client muss ihn aber als undurchsichtig behandeln. Ein veränderter Cursor bekommt ein 400:

Terminal
$ curl "https://api.testingapis.com/api/pagination/cursor?limit=2"

{"object":"list","url":"/api/pagination/cursor",
 "data":[{"id":1,...},{"id":2,...}],
 "has_more":true,"next_cursor":"eyJsYXN0SWQiOjJ9"}

$ curl "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
HTTP/1.1 400 Bad Request

{"status":400,"error":"Bad Request","message":"Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
GET/api/pagination/cursor?limit=3
Dieser Request nutzt einen Endpunkt für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Keyset

Wie ein Cursor, aber lesbar: Frag nach Einträgen mit einer ID größer als die letzte, die du gesehen hast. Einfügungen und Löschungen auf früheren Seiten können die Ergebnisse nicht verschieben, deshalb nutzen Feeds und sehr große Tabellen diese Art.

Terminal
$ curl "https://api.testingapis.com/api/pagination/keyset?after_id=10&limit=2"

{"data":[{"id":11,"name":"Smart Lamp",...},{"id":12,"name":"Compact Headset",...}],
 "has_more":true,"next_after_id":12}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Der Body ist ein einfaches Array, und die Navigation steht im Link-Header (RFC 8288), mit absoluten URLs für next, prev, first und last. Eine Seite hinter dem Ende liefert ein leeres Array, kein 404.

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=2"
HTTP/1.1 200 OK
link: <https://api.testingapis.com/api/pagination/link?page=3&per_page=2>; rel="next",
      <https://api.testingapis.com/api/pagination/link?page=50&per_page=2>; rel="last",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="first",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="prev"
x-total-count: 100

[{"id":3,"name":"Deluxe Blender",...},{"id":4,"name":"Eco Bottle",...}]
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

HAL

Die Einträge liegen unter _embedded.items, jeder mit eigenem self-Link, und die Seitenlinks unter _links. Der Content-Type ist application/hal+json, den manche Clients nur als JSON parsen, wenn man es ihnen sagt.

Terminal
$ curl "https://api.testingapis.com/api/pagination/hal?page=0&size=2"

{"_embedded":{"items":[
   {"id":1,...,"_links":{"self":{"href":"/api/pagination/items/1"}}},
   {"id":2,...,"_links":{"self":{"href":"/api/pagination/items/2"}}}]},
 "_links":{"self":{"href":"/api/pagination/hal?page=0&size=2"},
           "first":{"href":"/api/pagination/hal?page=0&size=2"},
           "next":{"href":"/api/pagination/hal?page=1&size=2"},
           "last":{"href":"/api/pagination/hal?page=49&size=2"}},
 "page":{"size":2,"totalElements":100,"totalPages":50,"number":0}}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Jede Seite abrufen

Die meisten Paginierungsfehler zeigen sich, wenn ein Client die ganze Liste durchläuft: Er hört eine Seite zu früh auf, hängt auf der letzten Seite in einer Schleife oder zählt Seiten ab der falschen Zahl. Dieses Skript holt alle 100 Einträge zweimal, einmal per Cursor und einmal per Link-Header. Beide Male endet es bei ID 100, nichts wurde also übersprungen oder doppelt geholt.

paginate.mjs
const BASE = "https://api.testingapis.com/api/pagination";

// Cursor: follow next_cursor until has_more is false.
async function allByCursor(limit = 25) {
  const items = [];
  let cursor;
  do {
    const url = new URL(`${BASE}/cursor`);
    url.searchParams.set("limit", limit);
    if (cursor) url.searchParams.set("cursor", cursor);
    const page = await (await fetch(url)).json();
    items.push(...page.data);
    cursor = page.has_more ? page.next_cursor : undefined;
  } while (cursor);
  return items;
}

// Link header: follow rel="next" until there is none.
async function allByLinkHeader(perPage = 25) {
  const items = [];
  let url = `${BASE}/link?per_page=${perPage}`;
  while (url) {
    const res = await fetch(url);
    items.push(...(await res.json()));
    const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
    url = next?.[1];
  }
  return items;
}

const byCursor = await allByCursor();
const byLink = await allByLinkHeader();
console.log("cursor:", byCursor.length, "items, last id", byCursor.at(-1).id);
console.log("link:  ", byLink.length, "items, last id", byLink.at(-1).id);
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Ausgabe
cursor: 100 items, last id 100
link:   100 items, last id 100

Welche Art du wählen solltest

  • Offset oder Seitennummer, wenn Nutzer zu Seite N springen und sich die Daten langsam ändern. Eine zwischen zwei Requests eingefügte oder gelöschte Zeile verschiebt jede spätere Seite, ein Eintrag kann also fehlen oder doppelt erscheinen.
  • Cursor oder Keyset für Feeds, Endlos-Scrollen und Exporte, wo sich die Daten ändern, während jemand blättert. Direkt zu Seite 40 springen geht nicht.
  • Link-Header oder HAL, wenn Clients den URLs folgen sollen, die der Server ihnen gibt, statt eigene zu bauen. Dann kann der Server das Paginierungsschema ändern, ohne sie zu brechen.