Filtern, Sortieren und Paginierung

Die Bugs in Listen-Endpunkten, die niemand vor Seite 3 bemerkt.

Ein Listen-Endpunkt sieht einfach aus: Produkte anfragen, Produkte bekommen. Dann wächst die Liste über das hinaus, was in eine Antwort passt, Nutzer wollen sie nach Preis sortiert, nach Kategorie gefiltert und nach Namen durchsucht, und jede dieser Funktionen ist ein kleines Stück Code mit eigenen Grenzfällen. Listen-Bugs sind tückisch, weil die Antwort immer plausibel aussieht. Eine Seite mit den falschen zehn Produkten ist immer noch eine Seite mit zehn Produkten.

Diese Lektion nutzt /api/Products, das in seinen Startdaten 20 Produkte hat, und die Demo-Endpunkte unter /api/paginationdocs. Dieselben Parameter funktionieren bei jeder Liste dieser API, und die API-Referenz beschreibt sie.

ParameterWas er hier tut
limitSeitengröße, 1 bis 100. Ohne ihn bekommst du alles.
pageSeitennummer ab 1, zusammen mit limit genutzt.
offsetÜberspringt so viele Datensätze, als Alternative zu page.
sort, orderSortiert nach beliebigem Feld, asc (Standard) oder desc.
qSuche ohne Beachtung der Groß- und Kleinschreibung in Textfeldern wie title und description.
jeder FeldnameExakter Filter ohne Beachtung der Groß- und Kleinschreibung, etwa ?category=books.

Paginierung

Frag nach drei Produkten pro Seite und sieh dir neben dem Body auch die Header an:

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

Zwanzig Produkte in Seiten zu drei ergeben sieben Seiten, die letzte mit zwei. Die letzte Seite ist der erste Ort für die Bug-Suche, weil sie der einzige Fall ist, in dem die Seite nicht voll ist:

Terminal
$ 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",...}]

Richtig: sieben Seiten, und die letzte enthält die Produkte 19 und 20. Ein klassischer Bug ist hier, die Seitenzahl mit Ganzzahldivision zu berechnen (20 / 3 = 6), was die letzten beiden Produkte vor allen versteckt, die sich durch die Seiten klicken.

Werte außerhalb des Bereichs

Schick als Nächstes Werte, die laut Dokumentation ungültig sind oder gar nicht erwähnt werden. So geht die API damit um:

Ergebnisse
?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)

Der Server lehnt nie ab. Er setzt einen schlechten Wert auf den nächsten gültigen oder ignoriert ihn. Das ist ein legitimes Design und freundlich zu Clients, aber ein Test sollte es festhalten, denn die Alternative (ein 400, das den gültigen Bereich erklärt) ist genauso verbreitet. Gefährlich sind die Fälle, in denen das Begrenzen einen Client-Bug verdeckt. Ein Client, der wegen eines Template-Fehlers limit=abc schickt, bekommt hier alle 20 Datensätze. Bei einer Tabelle mit zwei Millionen Zeilen bedeutet dasselbe Verhalten, dass der Server versucht, alle zurückzugeben.

Eine Seite hinter dem Ende liefert eine leere Liste mit 200. Das tun die meisten APIs, und das erwarten Clients, die so lange blättern, bis nichts mehr zurückkommt. Ein 404 für eine leere Seite wäre auch vertretbar. Ein 500 nicht.

Keine Lücken, keine Duplikate

Der wichtigste Paginierungstest ist zugleich der einfachste zu beschreiben: Geh jede Seite durch, sammle die IDs und prüf, dass du jeden Datensatz genau einmal bekommen hast. Vergleich die gesammelte Anzahl mit 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);

Lass ihn mit mehreren Seitengrößen laufen: 1, 3, 7 und eine, die größer ist als die ganze Liste. Off-by-one-Fehler in Offset-Berechnungen zeigen sich oft nur bei bestimmten Größen. Unterstützt die API sowohl page als auch offset, prüf, dass limit=5&page=3 und limit=5&offset=10 dieselben Datensätze liefern.

Dieser Test besteht nur zuverlässig, wenn niemand die Daten ändert, während er läuft. Auf einem geteilten Server wie diesem könnte das jemand tun. Das führt zur größten Schwäche von Seitennummern, die weiter unten behandelt wird.

Sortieren

Sortier nach Preis absteigend und prüf die Reihenfolge des ganzen Ergebnisses statt nur des ersten Elements:

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

Die ersten fünf kommen als 249.99, 199.99, 89.99, 89.99, 59.99 zurück. Die Produkte 3 und 8 kosten dasselbe. Gleichstände sind für die Paginierung wichtig: Löst der Server sie nicht auf feste Weise auf, kann dasselbe Produkt am Ende von Seite 1 und am Anfang von Seite 2 auftauchen oder auf keiner von beiden. Die meisten Datenbanken versprechen nichts über die Reihenfolge gleicher Zeilen, sofern die Abfrage keinen zweiten Sortierschlüssel ergänzt, meist die ID.

Sortieren nach Kategorie zeigt, wie diese API damit umgeht. Innerhalb jeder Kategorie bleiben die Produkte in ID-Reihenfolge, aufsteigend wie absteigend:

Ergebnisse (id:Kategorie)
?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 ...

Das ist stabil, und genau das willst du. Um es zu testen, sortier nach einem Feld mit vielen Duplikaten, hol die Liste zweimal (und blätter mit einem kleinen Limit hindurch) und prüf, dass sich die Reihenfolge nie ändert.

Weitere Sortierfälle, die du bei jeder API ausprobieren kannst:

  • Strings mit gemischter Groß- und Kleinschreibung. Kommt "apple" vor oder nach "Banana"?
  • Als Strings gespeicherte Zahlen, bei denen "10" vor "9" einsortiert wird.
  • Datensätze, bei denen das Sortierfeld null ist oder fehlt. Landen sie vorn, hinten oder verschwinden sie?
  • Daten in verschiedenen Zeitzonen.

Parameter, die ignoriert werden

Was passiert, wenn du einen Parameter falsch angibst, sorgt für mehr Ärger als jede der Funktionen selbst:

Ergebnisse
?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

Jeder davon liefert 200 und eine Liste, die vernünftig aussieht. Eine Sortierung nach einem Feld, das es nicht gibt, wird verworfen. Eine Reihenfolge sideways wird zu aufsteigend. Ein falsch geschriebener Filter, categry, wird ignoriert, der Client hat also nach Büchern gefragt und alle 20 Produkte bekommen. Filtern nach einem verschachtelten Feld mit Punktschreibweise wird nicht unterstützt und ebenfalls ignoriert.

Der falsch geschriebene Filter ist in der Praxis der schlimmste. Nimm eine Ansicht, die "Bestellungen von Kunde 42" zeigt und auf einem Filterparameter aufbaut, den jemand umbenannt hat. Nach der Umbenennung sieht jeder Kunde jede Bestellung, und die API liefert die ganze Zeit 200. Wenn du Filter testest, nimm immer einen Fall auf, in dem der Filter die meisten Datensätze entfernen sollte, und prüf, dass er es getan hat. Ein Test, der nur prüft, dass "jedes zurückgegebene Produkt in der Kategorie books ist", besteht auch bei einer leeren Liste, also prüf auch die Anzahl.

Filter sind bei dieser API exakt und ignorieren Groß- und Kleinschreibung: ?category=Electronics liefert die fünf Elektronikprodukte, und ?category=electron liefert eine leere Liste. Boolesche Felder funktionieren genauso:

GET/api/Products?inStock=false

Die Suche mit q ist ein Teilstring-Abgleich. Hier wird die Kodierung wichtig, denn Suchbegriffe enthalten Leerzeichen, Kaufmanns-Und, Prozentzeichen und Anführungszeichen, und all das hat in einer URL eine besondere Bedeutung:

Ergebnisse (IDs)
?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    []

Leerzeichen funktionieren als + oder als %20. Ein wörtliches % muss als %25gesendet werden, und es findet korrekt das T-Shirt aus "100% organic cotton". Eine Suche nach einem Apostroph funktioniert und liefert zwei Bücher, ein kleines Zeichen dafür, dass die Suche kein SQL aus aneinandergeklebten Strings baut (die Sicherheitslektion erklärt, warum das wichtig ist). Schickst du ein Leerzeichen unkodiert, weigert sich curl, den Request überhaupt zu senden.

Die letzten beiden Zeilen verdienen eine Anmerkung. Einen Parameter zu wiederholen, ist in einer URL erlaubt, und APIs gehen unterschiedlich damit um: Der erste Wert gewinnt, der letzte gewinnt, beide Werte werden mit ODER verknüpft, oder sie werden zu einem String verbunden. Hier liefert das Wiederholen von q oder category eine leere Liste, was nahelegt, dass die Werte zu etwas wie books,home verbunden wurden, das auf nichts passt. Wenn die Oberfläche deines Produkts je erlaubt, zwei Kategorien zu wählen, wartet hier ein Bug darauf, gefunden zu werden.

Alles kombinieren

Filter, sortier und paginier in einem Request und prüf, dass alles in der richtigen Reihenfolge angewendet wird: erst filtern, dann sortieren, dann die Seite nehmen.

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

Seite 2 enthält die Produkte 3 und 4 (89.99 und 59.99), und X-Total-Countist 5, die Zahl der Elektronikprodukte, nicht 20. Ignorierte die Gesamtzahl den Filter, würde ein Client "Seite 1 von 10" für eine Liste mit drei Seiten anzeigen. Dieser Fehler ist häufig, weil Gesamtzahlen oft von einer separaten Abfrage gezählt werden, die jemand vergessen hat anzupassen.

Warum Seitennummern versagen

Seitennummern und Offsets beschreiben eine Position in der Liste: "überspring 20, gib mir 10". Das ist in Ordnung für Daten, die sich nicht ändern. Ändern sie sich, verschieben sich die Positionen:

  1. Du holst Seite 1 (Elemente 1 bis 10) einer Liste, die nach neuesten zuerst sortiert ist.
  2. Jemand legt ein neues Element an. Es landet ganz oben, und alles rutscht eins nach unten.
  3. Du holst Seite 2 (Positionen 11 bis 20). Position 11 ist jetzt das Element, das vorher die 10 war, du siehst es also zweimal.

Ein Element zu löschen, bewirkt das Gegenteil: Alles rutscht nach oben, und ein Element wird nie angezeigt. Bei einem Newsfeed ist das ein kleines Ärgernis. Bei einem Job, der alle Rechnungen exportiert, indem er Seiten durchgeht, heißt es, dass eine Rechnung im Export fehlt und niemand es bemerkt.

Cursor-Paginierung vermeidet das, indem sie beschreibt, wo du aufgehört hast, statt wie weit du bist. Jede Antwort gibt dir ein Token für den nächsten Request:

GET/api/pagination/cursor?limit=3
Dieser Request nutzt einen Endpunkt für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Antwort
{
  "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"
}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Du gibst next_cursor als ?cursor= zurück und machst weiter, bis has_morefalse ist. Ein ungültiger Cursor bekommt ein klares 400: "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."

Keyset-Paginierung ist dieselbe Idee ohne Verpackung. Der Client schickt die letzte ID, die er gesehen hat, und der Server liefert die Datensätze danach:

GET/api/pagination/keyset?after_id=10&limit=3
Dieser Request nutzt einen Endpunkt für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Antwort
{
  "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
}

Der dritte Stil packt die Seitenlinks in einen Link-Header, wie es die API von GitHub tut. Der Body bleibt ein einfaches Array:

Terminal
$ 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
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Die Seitenzahl stimmt (100 Elemente in Seiten zu 3 ergeben 34 Seiten). Aber sieh dir die URLs an. Der Request ging an https, und jeder Link zeigt auf http. Das ist ein echter Bug, und ein häufiger: Die Anwendung läuft hinter einem Proxy, der HTTPS übernimmt, sieht selbst nur einfaches HTTP und baut Links aus dem, was sie sieht. Ein Client, der diesen Links folgt, wird entweder umgeleitet oder schickt bei strengerer Konfiguration seinen nächsten Request unverschlüsselt. Schema und Host jeder URL zu prüfen, die eine API erzeugt, ist ein billiger Test, der das erwischt.

Für jeden Paginierungsstil sind die Kerntests dieselben: bis zum Ende gehen, IDs sammeln, nach Lücken und Duplikaten suchen und prüfen, was auf der letzten Seite passiert. Bei Cursorn und Keysets leg während des Durchgehens einen Datensatz an und bestätige, dass du trotzdem jeden ursprünglichen Datensatz genau einmal siehst.

Übung

Nimm /api/Booksanmelden zum Anzeigen und notier die Ergebnisse jedes Schritts.

  1. Find über X-Total-Count heraus, wie viele Bücher es gibt, und geh die Liste mit limit=4 durch. Hast du jede ID einmal bekommen?
  2. Sortier nach genre. Hol sie zweimal und mit limit=3 über mehrere Seiten. Ist die Reihenfolge innerhalb jedes Genres stabil?
  3. Filter nach einem Genre, sortier nach price absteigend und frag nach Seite 2. Ist X-Total-Count die gefilterte Anzahl?
  4. Schreib den Filternamen falsch. Was kommt zurück?
  5. Geh /api/pagination/offsetanmelden zum Anzeigen und /api/pagination/linkanmelden zum Anzeigen bis zum Ende durch. Wo signalisieren sie die letzte Seite, und stimmen beide bei der Gesamtzahl überein?

Entscheide bei jeder Überraschung, ob es ein Bug ist, eine Lücke in der Dokumentation oder eine Designentscheidung, die du trotzdem hinterfragen würdest.

Üben und weitergehen