Filtrage, tri et pagination
Les bugs des points d’accès de liste que personne ne remarque avant la page 3.
Un point d’accès de liste a l’air simple : on demande des produits, on reçoit des produits. Puis la liste grandit au-delà de ce qui tient dans une réponse, les utilisateurs la veulent triée par prix, filtrée par catégorie et cherchable par nom, et chacune de ces fonctionnalités est un petit bout de code avec ses propres cas limites. Les bugs de liste sont sournois parce que la réponse a toujours l’air plausible. Une page avec les dix mauvais produits reste une page de dix produits.
Cette leçon utilise /api/Products, qui compte 20 produits dans ses données de départ, et les points d’accès de démonstration sous /api/paginationdocs. Les mêmes paramètres fonctionnent sur toutes les listes de cette API, et la référence de l’API les décrit.
| Paramètre | Ce qu’il fait ici |
|---|---|
limit | Taille de page, de 1 à 100. Sans lui, vous recevez tout. |
page | Numéro de page à partir de 1, utilisé avec limit. |
offset | Saute ce nombre d’enregistrements, comme alternative à page. |
sort, order | Trie sur n’importe quel champ, asc (par défaut) ou desc. |
q | Recherche insensible à la casse dans des champs texte comme title et description. |
n’importe quel nom de champ | Filtre exact, insensible à la casse, comme ?category=books. |
La pagination
Demandez trois produits par page et regardez les en-têtes en plus du corps :
/api/Products?limit=3&page=1Vingt produits par pages de trois, cela fait sept pages, la dernière n’en contenant que deux. La dernière page est le premier endroit où chercher des bugs, car c’est le seul cas où la page n’est pas pleine :
$ 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",...}]Correct : sept pages, et la dernière contient les produits 19 et 20. Un bug classique ici est de calculer le nombre de pages par division entière (20 / 3 = 6), ce qui cache les deux derniers produits à quiconque parcourt les pages.
Les valeurs hors plage
Envoyez ensuite des valeurs que la documentation dit invalides, ou qu’elle ne mentionne pas du tout. Voici ce qu’en fait l’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)Le serveur ne refuse jamais. Il ramène une mauvaise valeur à la valeur valide la plus proche, ou l’ignore. C’est une conception légitime, et accommodante pour les clients, mais un test doit la consigner, car l’alternative (un 400 qui explique la plage valide) est tout aussi courante. Les cas dangereux sont ceux où ce rabattement masque un bug du client. Un client qui envoie limit=abc à cause d’une erreur de gabarit reçoit ici les 20 enregistrements. Sur une table de deux millions de lignes, le même comportement signifie que le serveur essaie de toutes les renvoyer.
Une page au-delà de la fin renvoie une liste vide avec un 200. C’est ce que font la plupart des API, et ce qu’attendent les clients qui bouclent jusqu’à ne plus rien recevoir. Un 404 pour une page vide serait aussi défendable. Un 500, non.
Ni trou, ni doublon
Le test de pagination le plus important est aussi le plus simple à décrire : parcourez toutes les pages, récupérez les ID, et vérifiez que vous avez reçu chaque enregistrement exactement une fois. Comparez le nombre obtenu avec X-Total-Count.
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);Lancez-le avec plusieurs tailles de page : 1, 3, 7, et une plus grande que toute la liste. Les erreurs de décalage d’un dans les calculs d’offset n’apparaissent souvent qu’avec certaines tailles. Si l’API accepte à la fois page et offset, vérifiez que limit=5&page=3 et limit=5&offset=10 renvoient les mêmes enregistrements.
Ce test ne passe de façon fiable que si personne ne modifie les données pendant son exécution. Sur un serveur partagé comme celui-ci, quelqu’un pourrait le faire. Cela mène à la principale faiblesse des numéros de page, traitée plus bas.
Le tri
Triez par prix, décroissant, et vérifiez l’ordre de tout le résultat plutôt que le seul premier élément :
/api/Products?sort=price&order=desc&limit=5Les cinq premiers reviennent en 249.99, 199.99, 89.99, 89.99, 59.99. Les produits 3 et 8 coûtent le même prix. Les égalités comptent pour la pagination : si le serveur ne les départage pas de façon fixe, le même produit peut apparaître en bas de la page 1 et en haut de la page 2, ou sur aucune des deux. La plupart des bases de données ne promettent rien sur l’ordre des lignes égales, sauf si la requête ajoute une seconde clé de tri, généralement l’ID.
Trier par catégorie montre comment cette API s’en sort. Au sein de chaque catégorie, les produits restent dans l’ordre des ID, en ordre croissant comme décroissant :
?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 ...C’est stable, et c’est ce que vous voulez. Pour le tester, triez sur un champ avec beaucoup de doublons, récupérez la liste deux fois (et parcourez-la page par page avec une petite limite), et vérifiez que l’ordre ne change jamais.
D’autres cas de tri à essayer sur n’importe quelle API :
- Des chaînes à la casse mélangée. "apple" passe-t-il avant ou après "Banana" ?
- Des nombres stockés comme chaînes, où "10" passe avant "9".
- Des enregistrements dont le champ de tri est null ou absent. Vont-ils en premier, en dernier, ou disparaissent-ils ?
- Des dates dans différents fuseaux horaires.
Les paramètres ignorés
Ce qui se passe quand on se trompe de paramètre cause plus d’ennuis que n’importe laquelle des fonctionnalités elles-mêmes :
?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 productsChacune de ces requêtes renvoie 200 et une liste qui a l’air raisonnable. Un tri sur un champ inexistant est abandonné. Un ordre sideways devient croissant. Un filtre mal orthographié, categry, est ignoré : le client a demandé des livres et reçu les 20 produits. Le filtrage sur un champ imbriqué en notation à points n’est pas pris en charge, et c’est ignoré aussi.
En pratique, le filtre mal orthographié est le pire de tous. Prenez un écran qui affiche "les commandes du client 42", construit sur un paramètre de filtre que quelqu’un a renommé. Après le renommage, chaque client voit toutes les commandes, et l’API renvoie 200 tout du long. Quand vous testez des filtres, prévoyez toujours un cas où le filtre devrait écarter la plupart des enregistrements, et vérifiez qu’il l’a fait. Un test qui vérifie seulement que "chaque produit renvoyé est dans la catégorie books" passe aussi avec une liste vide : vérifiez donc aussi le nombre.
Filtres et recherche
Sur cette API, les filtres sont exacts et insensibles à la casse : ?category=Electronics renvoie les cinq produits électroniques, et ?category=electron renvoie une liste vide. Les champs booléens fonctionnent de la même façon :
/api/Products?inStock=falseLa recherche avec q cherche une sous-chaîne. C’est là que l’encodage commence à compter, car les termes de recherche contiennent des espaces, des esperluettes, des signes pour cent et des guillemets, qui ont tous un sens particulier dans une URL :
?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 []Les espaces fonctionnent écrites + ou %20. Un % littéral doit être envoyé sous la forme %25, et il trouve correctement le T-shirt en "100% organic cotton". Une recherche d’apostrophe fonctionne et renvoie deux livres, un petit signe que la recherche ne construit pas de SQL en collant des chaînes (la leçon sur la sécurité explique pourquoi c’est important). Si vous envoyez une espace sans l’encoder, curl refuse carrément d’envoyer la requête.
Les deux dernières lignes méritent une remarque. Répéter un paramètre est permis dans une URL, et les API le traitent de différentes façons : la première valeur l’emporte, la dernière l’emporte, les deux valeurs sont combinées par un OU, ou elles sont jointes en une seule chaîne. Ici, répéter q ou category renvoie une liste vide, ce qui laisse penser que les valeurs ont été jointes en quelque chose comme books,home qui ne correspond à rien. Si l’interface de votre produit permet un jour de choisir deux catégories, un bug attend d’être trouvé ici.
Tout combiner
Filtrez, triez et paginez dans une même requête, et vérifiez que tout s’applique dans le bon ordre : d’abord le filtre, puis le tri, puis la page.
/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2La page 2 contient les produits 3 et 4 (89.99 et 59.99), et X-Total-Countvaut 5, le nombre de produits électroniques, et non 20. Si le total ignorait le filtre, un client afficherait "page 1 sur 10" pour une liste de trois pages. Cette erreur est fréquente, car les totaux sont souvent comptés par une requête séparée que quelqu’un a oublié de mettre à jour.
Pourquoi les numéros de page cassent
Les numéros de page et les offsets décrivent une position dans la liste : "saute 20, donne-m’en 10". Cela convient pour des données qui ne changent pas. Quand elles changent, les positions bougent :
- Vous récupérez la page 1 (éléments 1 à 10) d’une liste triée du plus récent au plus ancien.
- Quelqu’un crée un nouvel élément. Il arrive en tête, et tout descend d’un cran.
- Vous récupérez la page 2 (positions 11 à 20). La position 11 est maintenant l’élément qui était en 10e place : vous le voyez deux fois.
Supprimer un élément fait l’inverse : tout remonte d’un cran et un élément n’est jamais affiché. Pour un fil d’actualité, c’est une petite gêne. Pour une tâche qui exporte toutes les factures en parcourant les pages, cela veut dire qu’une facture manque à l’export et que personne ne s’en aperçoit.
La pagination par curseur évite cela en décrivant où vous vous êtes arrêté plutôt que jusqu’où vous êtes allé. Chaque réponse vous donne un jeton pour la requête suivante :
/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"
}Vous renvoyez next_cursor sous la forme ?cursor= et continuez jusqu’à ce que has_moresoit false. Un curseur invalide reçoit un 400 clair : "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."
La pagination keyset, c’est la même idée sans l’emballage. Le client envoie le dernier ID qu’il a vu, et le serveur renvoie les enregistrements qui suivent :
/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
}Le troisième style place les liens de page dans un en-tête Link, comme le fait l’API de GitHub. Le corps reste un simple tableau :
$ 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: 100Le nombre de pages est juste (100 éléments par pages de 3, cela fait 34 pages). Mais regardez les URL. La requête est partie en https, et tous les liens pointent vers http. C’est un vrai bug, et un bug courant : l’application tourne derrière un proxy qui gère le HTTPS, ne voit elle-même que du HTTP simple, et construit les liens à partir de ce qu’elle voit. Un client qui suit ces liens est soit redirigé, soit, avec une configuration plus stricte, envoie sa requête suivante sans chiffrement. Vérifier le schéma et l’hôte de chaque URL générée par une API est un test peu coûteux qui l’attrape.
Pour chaque style de pagination, les tests de base sont les mêmes : aller jusqu’au bout, récupérer les ID, chercher les trous et les doublons, et vérifier ce qui se passe sur la dernière page. Pour les curseurs et les keysets, ajoutez un enregistrement pendant le parcours et confirmez que vous voyez toujours chaque enregistrement d’origine exactement une fois.
Exercice
Prenez /api/Booksconnectez-vous pour voir et notez les résultats de chaque étape.
- Trouvez combien il y a de livres grâce à
X-Total-Count, puis parcourez la liste aveclimit=4. Avez-vous reçu chaque ID une fois ? - Triez par
genre. Récupérez la liste deux fois, et aveclimit=3à travers les pages. L’ordre au sein de chaque genre est-il stable ? - Filtrez sur un genre, triez par
pricedécroissant et demandez la page 2.X-Total-Countest-il le nombre filtré ? - Orthographiez mal le nom du filtre. Qu’est-ce qui revient ?
- Parcourez
/api/pagination/offsetconnectez-vous pour voir et/api/pagination/linkconnectez-vous pour voir jusqu’au bout. Où signalent-ils la dernière page, et sont-ils d’accord sur le total ?
Pour chaque surprise, décidez s’il s’agit d’un bug, d’un manque dans la documentation ou d’un choix de conception que vous questionneriez quand même.