Backend pratique · L2 · Section 2/6
Concevoir une API REST
Progression
#Concevoir une API REST
Ressources, représentations, verbes HTTP. Alignez URIs et actions : GET /users/:id, POST /users, PATCH /users/:id. Documentez avec une spec vivante (OpenAPI), versionnez avec parcimonie et restez tolérant côté lecture, strict côté écriture.
Avant de continuer, vous devez savoir exposer un endpoint HTTP simple (chapitre HTTP et serveurs) et lire du JSON. À la fin de ce chapitre, vous saurez définir le contrat complet d’une collection : statuts, en-têtes, pagination, concurrence et idempotence.
Une bonne API décrit clairement son contrat : formats attendus, champs obligatoires et erreurs standardisées. Les codes de statut ne sont pas décoratifs : 201 Created signale une création réussie avec un Location vers la ressource, 204 No Content confirme une action sans corps de réponse, 409 Conflict exprime un conflit métier explicite.
#Le contrat d’une collection
Un mini-blog se dessine en quelques lignes. Le tableau ci-dessous fixe le contrat de posts et comments : chaque ligne est un engagement sur le statut, les en-têtes et le corps.
| Opération | Route | Succès | Erreurs documentées |
|---|---|---|---|
| Lister | GET /posts | 200 + items[], next | 401 |
| Créer | POST /posts | 201 + Location: /posts/:id | 422 validation, 401 |
| Lire | GET /posts/:id | 200 + ETag | 404 |
| Mettre à jour | PATCH /posts/:id | 200 + nouvel ETag | 404, 428 sans If-Match, 412 version changée |
| Supprimer | DELETE /posts/:id | 204 sans corps | 404 |
| Commenter | POST /posts/:id/comments | 201 + Location | 404, 422 |
Trois règles se dégagent :
- PATCH (modification partielle) vaut mieux que PUT (remplacement complet) quand le client n’envoie que les champs changés.
PUTexige un corps complet et une sémantique de remplacement idempotent. - Nommer des ressources, pas des actions :
POST /postset nonPOST /createPost. L’URI désigne la chose, le verbe HTTP désigne l’action. - Réponses de collection enveloppées :
{ items, next }permet d’ajouter des métadonnées (total, curseur) sans casser les clients existants.
Les écritures concurrentes exigent une protection pour éviter les mises à jour perdues. Répondez avec un ETag sur lecture et exigez If-Match sur écriture ; rejetez avec 412 Precondition Failed si la version a changé. Cette stratégie garde le serveur simple tout en donnant au client la main sur la résolution de conflit.
La pagination et le filtrage doivent être prévisibles. Préférez des curseurs stables à des offsets fragiles quand les collections bougent, et exposez des liens de navigation (next, prev) dans la réponse. Les champs triables et filtrables se déclarent pour éviter les surprises et les surcharges côté serveur.
Mini-exercice : dessinez les endpoints d’un mini-blog (posts, comments), précisez les statuts et les erreurs de chaque route, puis ajoutez des préconditions If-Match sur les mises à jour d’articles pour éviter les écrasements inattendus.
#Flow d’écriture robuste (idempotent)
#Diagrammes REST : création puis lecture
#Mise à jour conditionnelle (ETag + If‑Match)
#Idempotence clé (Idempotency‑Key)
#Checklist REST (animée)
- URIs stables et orientées ressources (noms pluriels, relations claires).
- Statuts cohérents (201 avec
Locationsur création, 409/422 explicites). - Préconditions pour la concurrence (ETag + If‑Match → 412 si conflit).
- Pagination par curseur, filtres/tri documentés, liens de navigation.
- Erreurs JSON stables (
code,message,correlationId).
#Exemples de code pratiques
#ETag + If‑Match (Express)
1import type { Request, Response } from 'express'2import crypto from 'node:crypto'3 4// Exemple: calcul d’ETag déterministe à partir des champs pertinents5function computeEtag(obj: any) {6 const json = JSON.stringify(obj)7 return `"${crypto.createHash('sha1').update(json).digest('base64').slice(0, 16)}"`8}9 10app.get('/posts/:id', async (req: Request, res: Response) => {11 const post = await db.posts.findById(Number(req.params.id))12 if (!post) return res.status(404).end()13 const etag = computeEtag({ id: post.id, updated_at: post.updated_at })14 res.setHeader('ETag', etag)#Idempotency‑Key (création)
1// Store minimal (en prod: table idem_keys avec TTL + résultat)2const idemStore = new Map<string, any>()3 4app.post('/payments', async (req, res) => {5 const key = req.get('Idempotency-Key')6 if (!key) return res.status(400).json({ type: 'about:blank', title: 'Missing Idempotency-Key', status: 400 })7 8 const cached = idemStore.get(key)9 if (cached) return res.status(201).json(cached)10 11 // ... traitement transactionnel: INSERT payment ...12 const payment = await createPayment(req.body)13 const result = { id: payment.id, status: 'created' }14 idemStore.set(key, result)Solution
Stockage durable recommandé
Utilisez une table idem_keys(key, result_hash, created_at) indexée par created_at pour la purge. Stockez soit le résultat entier, soit un hash + pointeur vers la ressource.
#Pagination par curseur stable (SQL + API)
1-- Suppose une clé composite (created_at DESC, id DESC) pour l’ordre2-- Récupération après un curseur (created_at_c, id_c)3SELECT id, title, created_at4FROM posts5WHERE (created_at, id) < (TIMESTAMPTZ :created_at_c, BIGINT :id_c)6ORDER BY created_at DESC, id DESC7LIMIT 20;1// Encodage d’un curseur compact {created_at, id} en base642function encodeCursor(c: { created_at: string; id: number }) {3 return Buffer.from(JSON.stringify(c)).toString('base64url')4}5function decodeCursor(s?: string | null) {6 if (!s) return null7 try { return JSON.parse(Buffer.from(s, 'base64url').toString('utf8')) } catch { return null }8}9 10app.get('/posts', async (req, res) => {11 const cursor = decodeCursor(req.query.cursor as string | undefined)12 const rows = await db.posts.list({ cursor, limit: 20 })13 const next = rows.length === 20 ? encodeCursor({ created_at: rows[19].created_at, id: rows[19].id }) : null14 res.json({ items: rows, next })#Filtrage et tri : une liste blanche, pas un passe-droits
Ne construisez jamais une clause SQL à partir des query params bruts. Déclarez explicitement ce que le client peut filtrer et trier :
1const FILTERABLE = { status: ['draft', 'published'], author_id: /^\d+$/ } as const2const SORTABLE = ['created_at', 'title'] as const3 4function buildClause(query: Record<string, any>) {5 const where: string[] = []6 const params: any[] = []7 if (query.status && FILTERABLE.status.includes(query.status)) {8 params.push(query.status)9 where.push(`status = $${params.length}`)10 }11 if (query.author_id && FILTERABLE.author_id.test(query.author_id)) {12 params.push(Number(query.author_id))13 where.push(`author_id = $${params.length}`)14 }Tout champ hors liste blanche est ignoré, pas rejeté : la lecture reste tolérante. L’écriture, elle, reste stricte (422 sur champ inconnu).
#Versionner sans casser les clients
Le versionnage s’applique quand un changement de contrat est rupture (breaking) : champ retiré, sémantique modifiée, statut changé. Sinon, préférez l’ajout compatible :
- Ajouter un champ à une réponse est non-rupture : les clients l’ignorent.
- Ajouter un champ requis à une requête est rupture : versionnez.
- Changer
409en422est rupture : les clients branchent leurs messages sur le statut.
Concrètement : préfixez les URIs (/v1/posts, /v2/posts) quand l’API est publique, ou négociez via l’en-tête Accept: application/vnd.myapi.v2+json pour éviter la duplication de routes. Documentez la date d’obsolescence de chaque version dans la spec.
#OpenAPI 3.1 (extrait)
1openapi: 3.1.02info: { title: Mini API, version: 1.0.0 }3paths:4 /users:5 post:6 summary: Créer un utilisateur7 requestBody:8 required: true9 content:10 application/json:11 schema:12 type: object13 required: [email, password]14 properties:La spec est vivante : générez-la depuis le code ou validez le code depuis elle, mais jamais les deux à la main. Un test de contrat qui rejoue les appels et compare les réponses à la spec attrape les dérives silencieuses.
Correction guidée de l’exercice
- Endpoints du mini-blog :
GET/POST /posts,GET/PATCH/DELETE /posts/:id,POST /posts/:id/comments,GET /posts/:id/comments. Chaque route documente ses statuts : 200/201/204 en succès, 401/404/422/412 en erreur. - If-Match sur PATCH : sans
If-Match, répondez428 Precondition Required; avec un ETag périmé,412 Precondition Failed. Le client doit relire, fusionner et rejouer. - Pagination : curseur
{created_at, id}encodé en base64url, liennextdans l’enveloppe. Jamais d’offsetsur une collection qui bouge : des lignes insérées entre deux pages créent des doublons ou des trous.