Architecture logicielle
Conception d'API REST : ressources, sémantique HTTP, versionnement et erreurs
Intermédiaire12 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Une API REST est un contrat avec lequel vous vivrez pendant des années, et l'essentiel de son coût à long terme se décide dès la première semaine. Ce guide couvre la modélisation des ressources, la sémantique HTTP définie par la RFC 9110, la conception des représentations et des erreurs avec les problem details, la pagination et le filtrage, le cache avec les ETag, ainsi que les stratégies de versionnement qui permettent de faire évoluer une API sans casser ses consommateurs.
Ce que vous allez apprendre
- Modéliser des ressources et des URI qui reflètent le domaine plutôt que la base de données
- Appliquer correctement la sémantique HTTP des méthodes, des codes de statut et des requêtes conditionnelles
- Concevoir une pagination, un filtrage et un tri qui restent justes quand les données changent
- Renvoyer des erreurs exploitables par une machine grâce aux problem details de la RFC 9457
- Faire évoluer une API de façon compatible et choisir une stratégie de versionnement viable dans la durée
Une API HTTP est l'interface la plus durable que la plupart des systèmes exposeront jamais. Le service qui se trouve derrière sera réécrit, migré vers une autre plateforme, peut-être remplacé entièrement, pendant que l'API continuera de servir des clients écrits par des gens partis depuis longtemps. Cette asymétrie justifie d'investir un vrai effort de conception dès le départ : le code interne se refactore un mardi après-midi, mais un nom de champ publié est quasiment définitif.
Bien concevoir une API REST consiste surtout à utiliser HTTP tel qu'il a été spécifié, plutôt que d'y faire transiter un protocole maison. La RFC 9110 définit ce que signifient les méthodes, ce que signifient les codes de statut et comment fonctionnent les requêtes conditionnelles ; une API qui respecte ces définitions obtient gratuitement le cache, la sûreté des nouvelles tentatives, le bon comportement des proxys et le support des bibliothèques clientes. Une API qui les ignore doit tout réinventer, et mal.
Ressources et URI
Une ressource est un élément du domaine qui mérite d'être adressé : une commande, un client, un paiement, l'ensemble des transactions d'un compte. Ce n'est pas une table de base de données, et c'est en modélisant une URI par table que les API finissent par exposer des choix de schéma qu'on ne peut plus modifier ensuite.
Les conventions qui tiennent dans la durée :
- Des noms au pluriel pour les collections, avec l'identifiant ajouté pour un élément :
/orders,/orders/8f21c. La cohérence compte davantage que le choix lui-même. - Une imbrication uniquement quand l'enfant ne peut pas exister seul.
/orders/8f21c/linesest raisonnable, car une ligne n'a aucun sens hors de sa commande./customers/42/orderss'exprime mieux sous la forme/orders?customer=42dès lors que les commandes sont adressables en tant que telles, et imbriquer sur trois niveaux est toujours une erreur. - Pas de verbes dans les chemins. Le verbe, c'est la méthode.
/orders/8f21c/cancelest une exception pragmatique courante, à laquelle il vaut la peine de résister une fois : l'alternative est un champ d'état mis à jour avecPATCH, ou une sous-ressource/orders/8f21c/cancellationcréée avecPOST, qui vous donne en plus un endroit où enregistrer qui a annulé et pourquoi. - Kebab-case, minuscules, sans extension de fichier, et sans slash final, choisis une fois pour toutes et appliqués partout.
- Des identifiants opaques. Les entiers séquentiels trahissent les volumes et invitent à l'énumération. Des UUID ou de courts identifiants aléatoires ne coûtent rien de plus.
Le fond du sujet, c'est que les URI font partie du contrat public. Si une URI encode votre hiérarchie interne, réorganiser cette hiérarchie devient un changement cassant.
Sémantique des méthodes
La RFC 9110 attribue à chaque méthode deux propriétés qui comptent énormément en pratique : est-elle sûre (elle ne modifie pas l'état) et est-elle idempotente (la répéter produit le même effet que l'exécuter une seule fois) ? Toute l'infrastructure s'appuie dessus — les proxys peuvent réessayer les requêtes idempotentes, les navigateurs peuvent précharger les requêtes sûres —, si bien que les violer provoque des bugs que vous ne reproduirez jamais en local.
| Méthode | Sûre | Idempotente | Usage |
|---|---|---|---|
| GET | Oui | Oui | Récupérer une représentation ; ne jamais modifier l'état, ne jamais prendre de corps |
| HEAD | Oui | Oui | En-têtes uniquement — vérifier une taille ou une existence |
| POST | Non | Non | Créer une ressource subordonnée, ou soumettre une demande de traitement |
| PUT | Non | Oui | Créer ou remplacer une ressource à une URI connue |
| PATCH | Non | Non | Appliquer une modification partielle |
| DELETE | Non | Oui | Supprimer une ressource ; un second appel renvoie 404 ou 204 |
| OPTIONS | Oui | Oui | Indiquer les méthodes supportées ; utilisée par le preflight CORS |
Le fait que POST ne soit pas idempotent est le vrai problème pratique : un client qui tombe en timeout ne peut pas savoir si la commande a été créée. On le résout avec une clé d'idempotence — un identifiant généré par le client, envoyé dans un en-tête, stocké côté serveur avec le résultat et rejoué en cas de répétition. L'implémentation est détaillée dans les patterns de conception des microservices.
Pour PATCH, choisissez un media type défini plutôt que d'inventer une sémantique. application/merge-patch+json envoie un objet contenant les champs à modifier, avec null pour supprimer ; application/json-patch+json (RFC 6902) envoie une liste ordonnée d'opérations et peut cibler des positions dans un tableau. Le merge patch couvre la plupart des besoins ; JSON Patch se justifie quand les clients modifient des listes.
Codes de statut
Utilisez un petit ensemble de codes avec précision plutôt qu'un grand ensemble à peu près.
| Code | Signification en pratique |
|---|---|
| 200 OK | Succès avec un corps |
| 201 Created | Ressource créée ; ajoutez un en-tête Location qui pointe vers elle |
| 202 Accepted | Requête acceptée pour un traitement asynchrone ; renvoyez une URI de suivi |
| 204 No Content | Succès sans rien à renvoyer, typiquement pour DELETE ou PUT |
| 304 Not Modified | GET conditionnel dont le validateur correspond toujours |
| 400 Bad Request | Syntaxe incorrecte ou corps impossible à parser |
| 401 Unauthorized | Identifiants absents ou invalides — l'authentification a échoué |
| 403 Forbidden | Authentifié, mais non autorisé |
| 404 Not Found | Ressource inexistante, ou volontairement masquée à cet appelant |
| 409 Conflict | La requête entre en conflit avec l'état courant — doublon, version divergente |
| 412 Precondition Failed | Le If-Match d'une requête conditionnelle n'était pas vérifié |
| 422 Unprocessable Content | Syntaxiquement valide, sémantiquement invalide — échecs de validation |
| 429 Too Many Requests | Limite de débit atteinte ; ajoutez Retry-After |
| 500 / 502 / 503 / 504 | Erreur serveur, réponse amont invalide, service indisponible, timeout amont |
Deux distinctions sont à l'origine de l'essentiel des confusions. 401 signifie « je ne sais pas qui vous êtes » ; 403 signifie « je le sais, et vous n'avez pas le droit ». Ensuite, 400 contre 422 : réservez 400 aux corps que le parseur rejette, et utilisez 422 quand le JSON a été parsé sans erreur mais que son contenu enfreint une règle métier. Ne renvoyez jamais 200 avec un objet d'erreur dans le corps : toutes les bibliothèques clientes, tous les proxys et tous les systèmes de monitoring traitent 200 comme un succès, et vous avez rendu les échecs invisibles. Le catalogue complet se trouve dans les codes de statut HTTP.
Des erreurs sur lesquelles le client peut agir
La RFC 9457 définit application/problem+json, une petite structure standard qui évite à chaque API d'inventer sa propre enveloppe d'erreur.
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account FR76…0189 has a balance of 12.40 EUR; the transfer requires 250.00 EUR.",
"instance": "/transfers/9c1a4b7e",
"balance": "12.40",
"currency": "EUR",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
Les règles qui la rendent utile : type est une URI stable qui identifie la nature du problème, c'est sur elle que les clients aiguillent leur traitement, et elle ne doit donc jamais changer de sens ; title est un court résumé lisible qui reste constant pour un type donné ; detail décrit cette occurrence précise et peut varier ; les membres d'extension portent les données structurées dont le client a besoin. Incluez un identifiant de trace pour pouvoir rattacher un ticket de support à une trace distribuée.
Pour les échecs de validation, renvoyez tous les problèmes d'un coup plutôt que le premier seulement, afin qu'un formulaire puisse signaler tous les champs erronés en un seul aller-retour.
{
"type": "https://api.example.com/problems/validation",
"title": "Request validation failed",
"status": 422,
"errors": [
{ "field": "email", "code": "format", "message": "must be a valid email address" },
{ "field": "quantity", "code": "min", "message": "must be at least 1" }
]
}
Ne laissez jamais fuiter de stack traces, de fragments SQL ni de noms d'hôtes internes dans un detail. Journalisez-les avec l'identifiant de trace et renvoyez plutôt cet identifiant.
Collections : pagination, filtrage, tri
Tout endpoint de collection finira par contenir plus de lignes qu'une réponse ne devrait en porter : paginez donc dès le premier jour et ne renvoyez jamais de liste non bornée.
La pagination par offset (?page=3&size=50 ou ?offset=100&limit=50) est simple et permet aux clients de sauter à une page arbitraire. Ses faiblesses sont bien réelles : la base parcourt puis écarte tout ce qui précède l'offset, si bien que la page 500 est bien plus lente que la page 1, et les insertions concurrentes décalent les lignes entre deux requêtes, de sorte que des éléments sont sautés ou dupliqués.
La pagination par curseur renvoie un jeton opaque qui encode la position dans un ordre de tri stable, et que le client renvoie pour obtenir la page suivante. Les pages profondes coûtent autant que les premières, et les écritures concurrentes ne provoquent pas de sauts.
GET /orders?status=confirmed&sort=-created_at&limit=50 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [ { "id": "8f21c", "status": "confirmed", "total": "89.90" } ],
"next": "/orders?status=confirmed&sort=-created_at&limit=50&cursor=eyJjIjoiMjAyNi0wOS0wN1QwOTo0MSJ9"
}
Quel que soit votre choix, appliquez les mêmes disciplines : triez toujours sur une valeur unique (ajoutez la clé primaire pour départager, faute de quoi la pagination devient non déterministe), plafonnez limit à un maximum documenté, et utilisez un vocabulaire fixe de paramètres de requête pour les filtres plutôt que d'accepter des expressions arbitraires — une API qui accepte une chaîne de filtre est une API dont le planificateur de requêtes fait désormais partie du contrat public.
Représentations et négociation de contenu
Les clients indiquent ce qu'ils acceptent avec Accept ; les serveurs décrivent ce qu'ils ont envoyé avec Content-Type. Supportez bien un media type principal — presque toujours application/json — et n'en ajoutez d'autres que lorsqu'un vrai consommateur en a besoin.
Certaines conventions méritent d'être fixées tôt, car les changer plus tard casse tout le monde : un style de casse unique pour les noms de champs (camelCase ou snake_case, choisi une fois) ; des horodatages RFC 3339 en UTC avec un décalage explicite ; des montants sous forme de chaînes accompagnées d'un champ devise explicite, jamais en nombres à virgule flottante ; des énumérations sous forme de jetons stables en majuscules plutôt que de libellés d'affichage ; et des sens distincts, documentés, pour un champ absent et un champ à null.
N'enveloppez pas chaque réponse dans une structure générique du type {"data": …, "meta": …}. HTTP fournit déjà le statut, les en-têtes et le corps ; l'enveloppe ajoute une couche que les clients doivent déballer et duplique des informations que le protocole transporte déjà. Les métadonnées qui accompagnent légitimement une collection — liens de pagination, nombre total d'éléments — sont l'exception raisonnable.
Cache et concurrence
Les réponses à un GET peuvent porter des validateurs qui évitent aux clients et aux proxys de retransférer des données inchangées, et ces mêmes validateurs résolvent le problème des mises à jour perdues lors des écritures.
GET /orders/8f21c HTTP/1.1
If-None-Match: "v14"
HTTP/1.1 304 Not Modified
ETag: "v14"
Cache-Control: private, max-age=60
Un ETag est un validateur opaque associé à une représentation précise — un numéro de version, un hash du contenu. Un client qui en possède un envoie If-None-Match et reçoit un 304 sans corps si rien n'a changé. Pour les écritures, le client envoie If-Match: "v14" avec son PUT ou son PATCH ; si la ressource a évolué entre-temps, le serveur renvoie 412 Precondition Failed et le client peut recharger puis réessayer au lieu d'écraser silencieusement la modification de quelqu'un d'autre. C'est du contrôle de concurrence optimiste implémenté dans le protocole, et son ajout ne coûte presque rien.
Positionnez Cache-Control délibérément sur chaque réponse : private pour tout ce qui est propre à un utilisateur, no-store pour tout ce qui est sensible, et un max-age modeste pour les données de référence lues par de nombreux clients.
Versionnement et compatibilité
La version la moins chère est celle que vous n'avez jamais à livrer : épuisez donc d'abord les évolutions compatibles. Ajouter un champ optionnel, ajouter un endpoint, ajouter une valeur d'énumération que les clients sont censés tolérer d'après la documentation, assouplir une règle de validation — tout cela reste compatible si les clients ignorent les champs inconnus, ce que votre documentation doit exiger. Supprimer un champ, le renommer, changer un type, durcir une validation ou modifier un code de statut sont en revanche des changements cassants.
Quand une rupture est inévitable :
| Stratégie | Exemple | Compromis |
|---|---|---|
| Chemin de l'URI | /v2/orders | Évident, facile à router et à mettre en cache, mais la version est figée dans chaque URI côté client |
| Media type | Accept: application/vnd.example.order.v2+json | Précis et par ressource ; clients et outillage le gèrent souvent mal |
| En-tête personnalisé | X-API-Version: 2 | Garde les URI stables ; invisible dans les logs et les tests depuis un navigateur, à moins d'y regarder |
| Paramètre de requête | /orders?version=2 | Simple ; cohabite mal avec le cache |
Le versionnement par le chemin l'emporte pour la plupart des équipes grâce à sa simplicité opérationnelle, et son principal défaut — une granularité grossière — reste gérable si vous versionnez rarement. Quelle que soit la stratégie retenue, versionnez l'API entière plutôt que des endpoints isolés, maintenez la version précédente en service pendant la migration des consommateurs, publiez un en-tête Sunset et une date de dépréciation sur l'ancienne, et mesurez le trafic par version pour que le retrait soit une décision fondée sur des faits plutôt que sur l'espoir.
L'hypermédia, avec mesure
La contrainte d'hypermédia veut qu'un client découvre ce qu'il peut faire ensuite grâce aux liens présents dans la réponse plutôt qu'à des URI codées en dur. Appliquée pleinement, elle découple durablement les clients de votre structure d'URI.
À bien y regarder, très peu de clients sont écrits pour naviguer dynamiquement, et les formats hypermédia génériques ajoutent un cérémonial que la plupart des équipes finissent par abandonner. Ce qui est rentable, c'est un usage ciblé : les liens de pagination (les clients suivent réellement next), les liens vers des ressources liées qui économisent une recherche, et les liens représentant les transitions d'état disponibles, qui permettent au serveur d'indiquer à une interface qu'une commande peut être annulée mais pas remboursée, sans que le client ait à réimplémenter cette règle.
{
"id": "8f21c",
"status": "CONFIRMED",
"total": { "amount": "89.90", "currency": "EUR" },
"_links": {
"self": { "href": "/orders/8f21c" },
"lines": { "href": "/orders/8f21c/lines" },
"cancel": { "href": "/orders/8f21c/cancellation", "method": "POST" }
}
}
Décrire l'API
Un document OpenAPI est le contrat lisible par une machine : chemins, opérations, schémas, schémas de sécurité, exemples. Il génère des bibliothèques clientes, pilote des serveurs de mock, alimente les tests de contrat et nourrit le portail développeurs d'une plateforme d'API management.
Deux approches coexistent. Design first consiste à écrire le document OpenAPI avant le code, à le relire avec les consommateurs et à générer les stubs du serveur — c'est préférable pour les API publiques ou partenaires, dont le contrat se négocie. Code first génère le document à partir de l'implémentation annotée, comme le fait springdoc, ce qui le maintient exact à moindre effort et convient aux API internes. Dans les deux cas, gardez le document sous contrôle de version, examinez son diff en code review pour que les changements cassants soient visibles, et incluez des exemples réalistes — l'exemple est la partie que les développeurs lisent vraiment.
Points clés à retenir
- Concevez les URI autour des ressources du domaine, pas des tables, et considérez chaque chemin et chaque nom de champ publiés comme pratiquement définitifs.
- Respectez les propriétés de sûreté et d'idempotence définies par la RFC 9110 ; là où
POSTne peut pas être idempotent, ajoutez une clé d'idempotence. - Utilisez un ensemble réduit et précis de codes de statut, et ne signalez jamais un échec avec
200. - Renvoyez les erreurs sous forme de problem details RFC 9457, avec une URI
typestable, des extensions structurées et un identifiant de trace. - Paginez chaque collection, triez sur une clé unique et préférez les curseurs quand les données changent pendant la lecture ou que les pages deviennent profondes.
- Faites évoluer l'API de façon compatible en ajoutant plutôt qu'en modifiant ; quand une rupture est inévitable, versionnez l'API entière, annoncez une date de fin de vie et mesurez l'usage avant de retirer quoi que ce soit.
Sources et lectures complémentaires
Questions fréquentes
- Faut-il utiliser PUT ou PATCH pour les mises à jour ?
- PUT remplace la ressource entière par la représentation envoyée : omettre un champ revient donc à l'effacer. PATCH applique une modification partielle décrite par le corps de la requête. La plupart des clients réels veulent des mises à jour partielles, d'où la popularité de PATCH ; définissez alors explicitement son media type, application/merge-patch+json pour de simples mises à jour de champs ou application/json-patch+json lorsque vous avez besoin d'opérations sur des tableaux.
- La pagination par offset est-elle vraiment si mauvaise ?
- Elle fait l'affaire pour les jeux de données petits et stables, et pour les interfaces qui doivent sauter directement à la page 40. À grande échelle, elle pose deux vrais problèmes : la base doit parcourir puis écarter tout ce qui précède l'offset, si bien que les pages profondes ralentissent, et les lignes insérées ou supprimées entre deux requêtes font sauter ou répéter des éléments. Une pagination par curseur fondée sur une clé de tri stable évite les deux.
- Faut-il implémenter HATEOAS pour pouvoir parler de REST ?
- Selon la définition d'origine, oui : c'est la contrainte d'hypermédia qui permet aux clients de découvrir les transitions au lieu de coder les URI en dur. En pratique, très peu de clients naviguent dynamiquement dans les liens, et l'hypermédia complet rentabilise rarement son coût. Le compromis pragmatique consiste à fournir des liens pour les vraies transitions d'état et pour la pagination, que les clients suivent réellement, et à documenter tout le reste dans OpenAPI.
- Combien de temps faut-il maintenir une ancienne version d'API ?
- Assez longtemps pour le consommateur le plus lent qui compte pour vous : pour une API publique, cela signifie en général au moins douze mois, et pour les clients mobiles au moins aussi longtemps que la plus ancienne version de l'application encore supportée. Annoncez une date de fin de vie dès la sortie de la nouvelle version, publiez l'en-tête Sunset sur les endpoints dépréciés et mesurez l'usage par version pour prouver qui appelle encore avant de couper quoi que ce soit.