Chapitre 623 minIntermédiaire
Concevoir une API REST : pagination, filtres, versionnement, erreurs
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- rédiger le tableau des opérations d'une API à partir d'un besoin ;
- paginer une collection par décalage ou par curseur ;
- versionner une API sans casser ses clients ;
- renvoyer des erreurs au format de la RFC 9457 ;
- protéger les créations et les modifications concurrentes.
5 exercices corrigés1 schéma
Concevoir une API REST, c'est fixer son contrat avant d'écrire le code : les ressources et leurs URI, les opérations permises à chaque rôle, la forme des requêtes et des réponses, les erreurs, et les règles communes comme la pagination ou le versionnement. Ce chapitre donne une méthode en six étapes appliquée à AppliFrais, puis les bonnes pratiques que tout développeur d'API doit connaître : pagination, filtres et tri, versionnement, erreurs au format de la RFC 9457, clé d'idempotence, verrouillage optimiste et traitements longs.
Une méthode en six étapes
- Lister les acteurs et leurs besoins. Qui appelle l'API, pour faire quoi ? Le front des visiteurs, l'écran des comptables, l'application mobile, un partenaire ?
- En déduire les ressources. Les noms qui reviennent dans les besoins : fiche, frais, visiteur, justificatif, validation.
- Écrire le tableau des opérations : méthode, URI, rôle autorisé, code de succès. C'est le cœur du contrat.
- Définir les représentations : ce que le client envoie et ce qu'il reçoit, champ par champ, avec les règles de validation.
- Lister les erreurs de chaque opération : quelles règles de gestion peuvent échouer, avec quel code.
- Fixer les règles transverses : authentification, pagination, format des dates, versionnement, limites de débit.
Le résultat s'écrit dans un document OpenAPI (chapitre 7) : il sert de référence au développeur du front, au développeur de l'API et au testeur, qui peuvent travailler en parallèle.
Le tableau des opérations d'AppliFrais
Les règles de gestion : un visiteur ne voit et ne modifie que ses propres fiches ; il saisit ses frais tant que la fiche est en cours (CR), puis la clôture (CL) ; un comptable consulte toutes les fiches clôturées, les valide (VA) en fixant le montant validé, puis les met en paiement (MP).
| Opération | Méthode et URI | Rôle | Succès | Erreurs métier |
|---|---|---|---|---|
| Lister mes fiches | GET /v1/fiches?mois=&etat= | visiteur | 200 | — |
| Lister les fiches à valider | GET /v1/fiches?etat=CL&visiteur= | comptable | 200 | — |
| Créer la fiche du mois | POST /v1/fiches | visiteur | 201 | 409 si elle existe déjà ; 422 si le mois est invalide |
| Lire une fiche | GET /v1/fiches/{id} | propriétaire ou comptable | 200 | 404 si inexistante ou fiche d'un autre visiteur |
| Saisir les frais forfaitisés | PUT /v1/fiches/{id}/lignes-forfait | propriétaire | 200 | 409 si la fiche n'est plus en cours ; 422 si une quantité est négative |
| Ajouter un frais hors forfait | POST /v1/fiches/{id}/frais-hors-forfait | propriétaire | 201 | 409 si la fiche n'est plus en cours ; 422 |
| Supprimer un frais hors forfait | DELETE /v1/frais-hors-forfait/{id} | propriétaire | 204 | 409 si la fiche n'est plus en cours |
| Clôturer une fiche | PATCH /v1/fiches/{id} avec {"etat":"CL"} | propriétaire | 200 | 409 si la fiche n'est pas à l'état CR |
| Valider une fiche | POST /v1/fiches/{id}/validations | comptable | 201 | 409 si la fiche n'est pas à l'état CL ; 422 si le montant est incohérent |
| Mettre en paiement | PATCH /v1/fiches/{id} avec {"etat":"MP"} | comptable | 200 | 409 si la fiche n'est pas à l'état VA |
Pour toutes les opérations, les erreurs communes s'ajoutent : 401 sans jeton valide, 403 si le rôle ne permet pas l'opération, 400 si la requête est illisible, 429 au-delà de la limite de débit.
Ce que l'on envoie, ce que l'on reçoit
La représentation reçue par le client n'est pas la ligne de la base :
- elle ne contient jamais de champ interne ou sensible (hash de mot de passe, notes internes, identifiants techniques sans intérêt pour le client). Sélectionne explicitement les champs, ou passe par un objet de réponse dédié (un DTO, Data Transfer Object) ;
- dans une liste, on renvoie un résumé de chaque élément (identifiant, mois, état, montant) ; le détail (lignes de frais, justificatifs) s'obtient avec
GETsur l'élément. Une liste de 50 fiches avec toutes leurs lignes serait lourde et lente ; - les champs calculés utiles au client (le
totald'une fiche) peuvent figurer dans la représentation, mais le client ne les envoie jamais : le serveur les recalcule.
La représentation envoyée par le client est plus restreinte encore. Pour créer un frais hors forfait, le client envoie la date, le libellé et le montant ; il n'envoie ni l'id (généré par la base), ni la fiche de rattachement (elle est dans l'URI), ni l'état de remboursement (décidé par le comptable). Tout champ inattendu est refusé ou ignoré : c'est la liste blanche vue avec JSON Schema.
La pagination
Une collection peut contenir des milliers d'éléments : on ne la renvoie jamais en entier. La pagination découpe la liste en pages. Deux techniques dominent.
La pagination par décalage (offset). Le client demande une page et une taille ; le serveur saute les premiers éléments.
GET /v1/fiches?etat=CL&page=3&limite=20 HTTP/1.1
{
"donnees": [ "… 7 fiches …" ],
"pagination": { "page": 3, "limite": 20, "total": 47, "pages": 3 }
}
Avec 47 fiches et 20 par page, il y a 3 pages (47 ÷ 20 = 2,35, arrondi au-dessus) ; la page 3 saute (3 − 1) × 20 = 40 fiches et contient les 7 dernières. Côté SQL, c'est un LIMIT 20 OFFSET 40 (pagination en SQL). Simple, et le client peut sauter directement à une page. Deux défauts : sur une grande table, OFFSET 100000 oblige la base à lire puis jeter cent mille lignes ; et si une fiche est ajoutée pendant que le client parcourt les pages, un élément est vu deux fois ou sauté.
La pagination par curseur (cursor ou keyset). Le serveur renvoie, avec chaque page, un curseur opaque qui désigne le dernier élément lu ; le client le renvoie pour obtenir la suite.
GET /v1/fiches?etat=CL&limite=20&apres=eyJpZCI6MTE4MH0 HTTP/1.1
{
"donnees": [ "… 20 fiches …" ],
"pagination": { "suivant": "eyJpZCI6MTIwMH0", "limite": 20 }
}
Le curseur eyJpZCI6MTE4MH0 est simplement {"id":1180} encodé en base64url : le serveur fait WHERE id > 1180 ORDER BY id LIMIT 20, qui utilise l'index de la clé primaire quelle que soit la profondeur. Pas de doublon ni de trou si des fiches sont ajoutées ; en revanche, impossible de sauter à la page 37. C'est la technique des fils d'actualité et des API à gros volumes.
Par décalage (page, limite) | Par curseur (apres, limite) | |
|---|---|---|
| Aller à une page précise | Oui | Non |
| Performance sur une grande table | Se dégrade avec la profondeur | Constante (index) |
| Ajouts pendant le parcours | Doublons ou éléments sautés | Aucun problème |
| Nombre total d'éléments | Facile (COUNT) | Rarement fourni |
| Usage typique | Écrans d'administration, tableaux paginés | Défilement infini, synchronisation, exports |
Dans tous les cas, le serveur plafonne la taille de page : limite vaut 20 par défaut et 100 au maximum, quelle que soit la valeur demandée. Sans plafond, ?limite=1000000 vide la table en une requête et sature le serveur (API4 de l'OWASP, chapitre 10).
Filtrer, trier, choisir les champs
Ces options passent toutes par la chaîne de requête. Il n'existe pas de norme unique ; voici des conventions répandues, à appliquer de façon cohérente dans toute l'API.
| Besoin | Convention | Exemple |
|---|---|---|
| Filtre d'égalité | Le nom du champ | ?etat=CL&mois=2026-10 |
| Plusieurs valeurs | Valeurs séparées par des virgules | ?etat=CL,VA |
| Intervalle | Suffixes Min et Max | ?montantMin=100&montantMax=500 |
| Recherche plein texte | q | ?q=lyon |
| Tri | tri, un - pour l'ordre décroissant | ?tri=-mois,visiteur |
| Champs renvoyés | champs | ?champs=id,mois,etat |
Deux règles de sécurité : n'accepte comme critère de tri ou de filtre que des champs prévus (une liste blanche), et ne construis jamais la requête SQL en y collant le paramètre. Un ORDER BY ne se paramètre pas avec une requête préparée : on vérifie que le nom du champ figure dans la liste autorisée avant de l'utiliser (requêtes préparées).
Versionner une API
Une API publiée a des clients que tu ne contrôles pas : des applications mobiles installées sur des téléphones, le logiciel d'un partenaire. Un changement incompatible (breaking change) les casse du jour au lendemain. Le versionnement permet de faire évoluer l'API sans casser les clients existants.
| Changement | Compatible ? |
|---|---|
| Ajouter une nouvelle route | Oui |
| Ajouter un champ dans une réponse | Oui (si les clients ignorent les champs inconnus, ce qu'ils doivent faire) |
| Ajouter un paramètre facultatif | Oui |
| Renommer ou supprimer un champ d'une réponse | Non |
Changer le type d'un champ ("id": 12 devient "id": "12") | Non |
| Rendre obligatoire un champ qui était facultatif | Non |
| Supprimer une route, ou changer le sens d'un code de statut | Non |
Ajouter une valeur possible à une énumération (etat peut valoir RB) | À risque : un client qui traite tous les cas plante sur la valeur inconnue |
Trois façons d'indiquer la version :
| Stratégie | Exemple | Avantages | Inconvénients |
|---|---|---|---|
| Dans l'URI | /v1/fiches, /v2/fiches | Visible, simple à tester et à router | L'URI d'une même ressource change entre versions |
| Dans un en-tête | Accept: application/vnd.gsb.v2+json ou un en-tête dédié | URI stables | Invisible dans le navigateur, plus difficile à tester |
| Dans la chaîne de requête | /fiches?version=2 | Simple | Se mélange avec les filtres ; mauvais pour le cache |
La version dans l'URI, avec seulement le numéro majeur (v1, v2), est la plus répandue et la plus simple : c'est celle d'AppliFrais. On ne change de version majeure que pour un changement incompatible ; tous les changements compatibles s'ajoutent dans la version courante.
Quand une version doit disparaître, on prévient les clients avant de l'arrêter. Deux en-têtes normalisés le permettent :
HTTP/1.1 200 OK
Deprecation: @1798761600
Sunset: Tue, 01 Jun 2027 00:00:00 GMT
Link: <https://api.gsb.example/docs/migration-v2>; rel="deprecation"
Deprecation (RFC 9745, 2025) donne la date à partir de laquelle la ressource est dépréciée (ici un horodatage Unix, le 1er janvier 2027 ; une date future annonce une dépréciation à venir) ; Sunset (RFC 8594) donne la date à laquelle elle cessera de répondre ; le lien pointe vers le guide de migration. Après la date de fin, la route répond 410 Gone. L'erreur classique, sanctionnée par l'OWASP (API9, gestion d'inventaire défaillante), est de laisser tourner une vieille version oubliée, qui n'a pas reçu les correctifs de sécurité de la nouvelle.
Des erreurs normalisées : la RFC 9457
Le code de statut dit quel type d'erreur ; le corps doit dire précisément quoi, pour le développeur du client et parfois pour l'utilisateur. Plutôt que d'inventer un format par API, la RFC 9457 (Problem Details for HTTP APIs, juillet 2023, qui remplace la RFC 7807) définit un format standard, avec le type de contenu application/problem+json. Voici la réponse réelle de l'API de démonstration à une clôture impossible :
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.gsb.example/problemes/transition-interdite",
"title": "Changement d'état interdit",
"status": 409,
"detail": "Une fiche à l'état CR ne peut pas passer à l'état VA.",
"instance": "/v1/fiches/12",
"etatActuel": "CR"
}
| Membre | Rôle |
|---|---|
type | Une URI qui identifie le type de problème ; c'est ce que le code client teste. Elle peut pointer vers une page de documentation. Par défaut : about:blank |
title | Un résumé lisible du type de problème, le même pour toutes ses occurrences |
status | Le code de statut HTTP, répété dans le corps |
detail | Une explication propre à cette occurrence |
instance | Une URI qui identifie cette occurrence (souvent la ressource concernée) |
| extensions | Tout membre supplémentaire utile : ici etatActuel |
Pour une erreur de validation, une extension liste les champs en faute, ce qui permet au front d'afficher chaque message à côté du bon champ :
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.gsb.example/problemes/validation",
"title": "Données invalides",
"status": 422,
"detail": "Un ou plusieurs champs sont invalides.",
"erreurs": [
{ "champ": "mois", "message": "Format attendu : AAAA-MM" }
]
}
Rendre un POST rejouable : la clé d'idempotence
Le visiteur valide l'ajout d'un frais depuis son téléphone dans un train ; la requête part, le réseau coupe avant la réponse. L'application ne sait pas si le frais a été créé. Si elle renvoie le POST, elle risque un doublon ; si elle ne le renvoie pas, le frais est peut-être perdu.
La solution : le client génère un identifiant unique (un UUID) pour chaque opération et l'envoie dans l'en-tête Idempotency-Key. Il réutilise la même clé s'il renvoie la même opération.
POST /v1/fiches/12/frais-hors-forfait HTTP/1.1
Content-Type: application/json
Idempotency-Key: aed7abd4-0a0d-4845-8989-a1cd1792344e
{"date":"2026-10-14","libelle":"Repas client à Lyon","montant":23.50}
Le serveur enregistre la clé avec la réponse qu'il a produite. S'il reçoit à nouveau la même clé, il ne refait rien et renvoie la réponse enregistrée. Ce mécanisme a été popularisé par les API de paiement (un double débit est inacceptable) ; l'en-tête Idempotency-Key est en cours de normalisation à l'IETF. Les clés sont conservées un temps limité (24 heures, par exemple).
Éviter les mises à jour perdues : ETag et If-Match
Deux comptables ouvrent la même fiche à 10 h 00. La première corrige le montant validé et enregistre à 10 h 02 ; le second, qui a toujours l'ancienne version à l'écran, enregistre à 10 h 03 et écrase sa correction sans le savoir. C'est la mise à jour perdue.
Le verrouillage optimiste l'empêche avec l'ETag vu au chapitre 2 :
- Chaque
GETrenvoie l'ETagde la version lue. - Chaque modification renvoie cet
ETagdans l'en-têteIf-Match. - Le serveur compare : si la ressource a changé entre-temps, son
ETagest différent, et il refuse avec412 Precondition Failed.
PATCH /v1/fiches/12 HTTP/1.1
If-Match: "1434c386cc94c088"
Content-Type: application/json
{"etat":"CL"}
Le second comptable reçoit 412, recharge la fiche, voit la correction de sa collègue et décide en connaissance de cause. Une API peut même exiger If-Match sur toutes les modifications, et répondre 428 Precondition Required s'il manque. Côté base, l'ETag peut venir d'une colonne version incrémentée à chaque modification, ou d'une empreinte de la ligne.
Les traitements longs : 202 Accepted
Générer l'export PDF de toutes les fiches de l'année prend une minute : on ne fait pas attendre la connexion HTTP aussi longtemps. L'API accepte la demande et répond immédiatement :
POST /v1/exports HTTP/1.1
Content-Type: application/json
{"format":"pdf","annee":2026}
HTTP/1.1 202 Accepted
Location: /v1/exports/9f1e
{"id":"9f1e","statut":"en-cours"}
Le client interroge GET /v1/exports/9f1e de temps en temps : "statut": "en-cours", puis "statut": "termine" avec l'adresse du fichier. Ou bien l'API le prévient par un webhook quand c'est prêt (chapitre 12).
La liste de contrôle du concepteur
Avant de coder, relis ton contrat avec ces questions :
- Les URI sont des noms au pluriel, en minuscules avec des tirets, sans verbe ni extension.
- Chaque opération a sa méthode, son code de succès, ses erreurs, et le rôle autorisé.
- Les méthodes sûres (
GET) ne modifient rien. - Les réponses ne contiennent aucun champ sensible ou interne ; les entrées sont validées par liste blanche.
- Les collections sont paginées, avec une taille de page plafonnée.
- Les dates sont en ISO 8601, les montants dans un type exact, les identifiants longs en chaînes.
- Les erreurs suivent le format
application/problem+json, sans détail technique. - La version est dans l'URI ; un plan existe pour retirer les anciennes versions.
- Les opérations sensibles au doublon acceptent une clé d'idempotence ; les modifications concurrentes sont protégées par
If-Match. - Tout passe en HTTPS, chaque requête est authentifiée, chaque accès à un objet vérifie le propriétaire.
Exercices
Exercice 1 sur 5 Concevoir les routes d'un cas Réussi À revoir
Une association sportive veut une API pour gérer les inscriptions à ses séances. Un adhérent consulte le planning des séances de la semaine, s'inscrit à une séance s'il reste des places, et annule son inscription jusqu'à deux heures avant le début. Un coach crée des séances (date, heure, salle, nombre de places), consulte la liste des inscrits de ses séances et marque les présences.
Rédige le tableau des opérations : méthode et URI, rôle, code de succès et erreurs métier.
Voir la réponseMasquer la réponse de l'exercice 1
Une proposition (d'autres choix sont défendables s'ils sont cohérents) :
| Opération | Méthode et URI | Rôle | Succès | Erreurs métier |
|---|---|---|---|---|
| Planning de la semaine | GET /v1/seances?du=2026-11-02&au=2026-11-08 | adhérent, coach | 200 | 400 si les dates sont mal formées |
| Détail d'une séance (avec places restantes) | GET /v1/seances/{id} | adhérent, coach | 200 | 404 |
| Créer une séance | POST /v1/seances | coach | 201 + Location | 422 si la date est passée ou le nombre de places ≤ 0 ; 409 si la salle est déjà prise |
| S'inscrire | POST /v1/seances/{id}/inscriptions | adhérent | 201 | 409 si la séance est complète ou si l'adhérent est déjà inscrit |
| Annuler son inscription | DELETE /v1/inscriptions/{id} | adhérent propriétaire | 204 | 409 à moins de deux heures du début ; 404 si l'inscription est celle d'un autre |
| Liste des inscrits | GET /v1/seances/{id}/inscriptions | coach de la séance | 200 | 403 si ce n'est pas sa séance |
| Marquer une présence | PATCH /v1/inscriptions/{id} avec {"present":true} | coach de la séance | 200 | 403 ; 409 si la séance n'a pas encore commencé |
Pour toutes les routes : 401 sans jeton, 403 si le rôle ne convient pas, 429 au-delà de la limite. L'adhérent est identifié par son jeton : il n'envoie jamais son propre identifiant dans le corps.
Tu avais trouvé ?
Exercice 2 sur 5 Calculer une pagination Réussi À revoir
La collection /v1/fiches?etat=VA contient 1 250 fiches. Le client demande limite=50.
- Combien y a-t-il de pages ?
- Combien de fiches la base saute-t-elle pour la page 13 ? Écris la clause SQL correspondante.
- Une fiche validée de plus est ajoutée. Combien de pages maintenant, et combien de fiches sur la dernière ?
- Le client envoie
limite=5000. Que doit faire le serveur ?
Voir la réponseMasquer la réponse de l'exercice 2
- 1 250 ÷ 50 = 25 pages exactement.
- (13 − 1) × 50 = 600 fiches sautées :
LIMIT 50 OFFSET 600. - 1 251 ÷ 50 = 25,02, arrondi au-dessus : 26 pages ; la dernière contient 1 251 − 25 × 50 = 1 fiche.
- Plafonner : utiliser la limite maximale (100, par exemple) et l'indiquer dans la réponse (
"limite": 100), ou refuser avec400en expliquant le maximum. Il ne doit jamais renvoyer 5 000 fiches.
Tu avais trouvé ?
Exercice 3 sur 5 Rédiger une erreur RFC 9457 Réussi À revoir
Un visiteur tente d'ajouter un frais hors forfait de 23,50 € à la fiche 11, qui est validée. Écris la réponse complète de l'API (ligne de statut, en-tête Content-Type, corps) au format de la RFC 9457.
Voir la réponseMasquer la réponse de l'exercice 3
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.gsb.example/problemes/fiche-non-modifiable",
"title": "Fiche non modifiable",
"status": 409,
"detail": "La fiche 11 est à l'état VA : on ne peut plus y ajouter de frais.",
"instance": "/v1/fiches/11",
"etatActuel": "VA"
}
Le code est 409 : la requête est bien formée et les données valides, mais l'état de la fiche interdit l'opération. Le type identifie le problème de façon stable (le client peut le tester), le detail explique cette occurrence. Si la fiche 11 appartenait à un autre visiteur, la réponse serait 404, sans rien révéler de son état.
Tu avais trouvé ?
Exercice 4 sur 5 Changement compatible ou incompatible ? Réussi À revoir
L'équipe prépare une évolution de l'API. Pour chaque changement, dis s'il est compatible avec les clients existants, et s'il impose une version v2.
- Ajouter le champ
totaldans la réponse deGET /v1/fiches/{id}. - Renommer
montantValideenmontantRembourse. - Ajouter un filtre facultatif
?region=surGET /v1/fiches. - Rendre obligatoire le champ
justificatifà la création d'un frais hors forfait. - Passer les identifiants de nombres à chaînes.
- Ajouter la route
GET /v1/statistiques.
Voir la réponseMasquer la réponse de l'exercice 4
- Compatible : un client bien écrit ignore les champs qu'il ne connaît pas.
- Incompatible : les clients qui lisent
montantValidene trouvent plus rien.v2, ou bien renvoyer les deux champs pendant une période de transition et déprécier l'ancien. - Compatible : les anciens appels, sans le filtre, fonctionnent comme avant.
- Incompatible : les clients qui n'envoient pas de justificatif reçoivent désormais
422.v2, ou une règle appliquée seulement aux nouveaux clients. - Incompatible : un client qui compare
fiche.id === 12ou fait des calculs sur l'identifiant casse.v2. - Compatible : une nouvelle route ne gêne personne.
Tu avais trouvé ?
Exercice 5 sur 5 Offset ou curseur ? Réussi À revoir
Pour chaque écran, choisis la pagination par décalage ou par curseur, et justifie.
- L'écran du comptable affiche les fiches clôturées dans un tableau, avec des boutons « page 1, 2, 3… » et le nombre total.
- L'application mobile synchronise chaque nuit toutes les fiches modifiées depuis la veille, plusieurs dizaines de milliers de lignes.
- Le fil des notifications d'un visiteur, en défilement infini.
Voir la réponseMasquer la réponse de l'exercice 5
- Décalage : il faut sauter à une page précise et afficher le total ; les volumes d'un écran de comptable restent raisonnables.
- Curseur : gros volume, parcours complet, et des fiches peuvent être modifiées pendant la synchronisation. Le curseur évite les doublons et les trous, et garde des performances constantes.
- Curseur : on ne fait que « charger la suite », et de nouvelles notifications arrivent en permanence en tête de liste ; avec un décalage, elles décaleraient les pages et provoqueraient des doublons.
Tu avais trouvé ?