Aller au contenu principal

Backend pratique · L2 · Section 2/6

Concevoir une API REST

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#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érationRouteSuccèsErreurs documentées
ListerGET /posts200 + items[], next401
CréerPOST /posts201 + Location: /posts/:id422 validation, 401
LireGET /posts/:id200 + ETag404
Mettre à jourPATCH /posts/:id200 + nouvel ETag404, 428 sans If-Match, 412 version changée
SupprimerDELETE /posts/:id204 sans corps404
CommenterPOST /posts/:id/comments201 + Location404, 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. PUT exige un corps complet et une sémantique de remplacement idempotent.
  • Nommer des ressources, pas des actions : POST /posts et non POST /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)

1/5

#Diagrammes REST : création puis lecture

Client
API
appel async/retour activation fragments
1. POST /users

#Mise à jour conditionnelle (ETag + If‑Match)

Client
API
DB
1. GET /posts/:id
2. 200 ETag: "v1"
3. PATCH /posts/:id If-Match: "v1"
4. BEGIN + UPDATE
5. OK
6. 200 ETag: "v2"

#Idempotence clé (Idempotency‑Key)

Client
API
DB
appel async/retour activation fragments
1. POST /payments (Idempotency‑Key: K)

#Checklist REST (animée)

  • URIs stables et orientées ressources (noms pluriels, relations claires).
  • Statuts cohérents (201 avec Location sur 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)

tsts

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)

tsts

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)

sqlsql

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;
tsts

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 :

tsts

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 409 en 422 est 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)

yamlyaml

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
  1. 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.
  2. If-Match sur PATCH : sans If-Match, répondez 428 Precondition Required ; avec un ETag périmé, 412 Precondition Failed. Le client doit relire, fusionner et rejouer.
  3. Pagination : curseur {created_at, id} encodé en base64url, lien next dans l’enveloppe. Jamais d’offset sur une collection qui bouge : des lignes insérées entre deux pages créent des doublons ou des trous.

#Quiz rapide

Quelle réponse renvoyer si le client PATCH sans envoyer If‑Match sur une ressource protégée par ETag ?
Quelle réponse renvoyer si le client PATCH sans envoyer If‑Match sur une ressource protégée par ETag ?
Quel curseur garantit une pagination stable sur posts triés par created_at DESC ?
Quel curseur garantit une pagination stable sur posts triés par created_at DESC ?
Vous ajoutez un champ supplémentaire dans la réponse JSON d’un GET existant. Est-ce une rupture de contrat ?
Vous ajoutez un champ supplémentaire dans la réponse JSON d’un GET existant. Est-ce une rupture de contrat ?