Conception REST avancée#
Le livre Réseaux et protocoles de cette collection présente les bases de REST : méthodes HTTP, codes de statut, sérialisation JSON. Ce chapitre part de là pour traiter les questions de conception qui se posent dès qu’une API dépasse le stade du CRUD trivial : comment modéliser la maturité d’une API, comment exposer les transitions d’état à travers les liens hypermédias, comment distinguer ressources et actions, et comment garantir les propriétés d’idempotence que les clients doivent pouvoir exploiter.
Richardson Maturity Model#
Leonard Richardson a proposé en 2008 une grille de lecture de la maturité REST en quatre niveaux. Ce modèle est devenu un outil de diagnostic courant pour évaluer une API existante ou planifier une refonte.
Level 0 — Plain Old XML (ou JSON)#
L’API utilise HTTP comme tunnel de transport. Un seul endpoint reçoit toutes les requêtes ; la sémantique de l’opération est entièrement dans le corps.
POST /api
Content-Type: application/json
{"action": "getUser", "id": 42}
C’est le schéma des anciens services SOAP et de nombreuses APIs RPC maison. HTTP n’est qu’un emballage ; les méthodes, les codes de statut et les URLs sont ignorés comme vecteurs de sens.
Level 1 — Resources#
L’API introduit des URLs distinctes par ressource. Plusieurs endpoints coexistent, mais une seule méthode HTTP (généralement POST) est utilisée pour tout.
POST /users → créer ou récupérer selon le payload
POST /users/42 → modifier ou supprimer selon le payload
POST /orders/17 → annuler, payer, ou lire selon le payload
Le progrès est réel : les URLs deviennent adressables, les logs sont plus lisibles, le routage est possible. Mais les clients doivent toujours inspecter le corps pour connaître l’intention.
Level 2 — HTTP Verbs#
L’API exploite les méthodes HTTP pour exprimer les intentions. GET lit, POST crée, PUT remplace, PATCH modifie partiellement, DELETE supprime. Les codes de statut sont utilisés correctement : 200, 201, 204, 400, 404, 409, 422.
C’est le niveau que la plupart des APIs modernes atteignent. Les frameworks comme FastAPI, Django REST Framework ou Spring Boot facilitent ce niveau par défaut.
Level 3 — HATEOAS#
Les réponses contiennent des liens vers les transitions d’état disponibles. Le client n’a pas besoin de connaître les URLs à l’avance : il découvre les actions possibles dans chaque réponse. C’est la contrainte hypermedia qui distingue REST de RPC-over-HTTP selon Fielding.
Niveau 2 en pratique
La grande majorité des APIs de production se situent au level 2. Le level 3 est recommandé pour les APIs publiques stables où la découvrabilité est une contrainte forte, mais il ajoute de la complexité de génération et de maintenance des liens.
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(11, 7))
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.axis("off")
levels = [
(0, "#d9534f", "Level 0\nPOX / RPC tunnel",
"Un seul endpoint\nPOST /api\n{\"action\": \"getUser\"}"),
(1, "#f0ad4e", "Level 1\nRessources",
"URLs distinctes\nGET /users/42\nPOST /orders"),
(2, "#5bc0de", "Level 2\nHTTP Verbs",
"Méthodes sémantiques\nGET, POST, PUT, DELETE\nCodes 200/201/404/422"),
(3, "#5cb85c", "Level 3\nHATEOAS",
"Liens hypermedia\n\"_links\": {\"cancel\": ...}\nDécouverte dynamique"),
]
bar_height = 1.4
for i, (lvl, color, title, desc) in enumerate(levels):
y = i * 1.9 + 0.3
width = 3.5 + i * 0.8
rect = mpatches.FancyBboxPatch(
(0.3, y), width, bar_height,
boxstyle="round,pad=0.1",
linewidth=1.5,
edgecolor="white",
facecolor=color,
alpha=0.85,
)
ax.add_patch(rect)
ax.text(0.7, y + bar_height / 2, title, va="center", ha="left",
fontsize=10, fontweight="bold", color="white")
ax.text(width + 0.7, y + bar_height / 2, desc, va="center", ha="left",
fontsize=8.5, color="#333333", linespacing=1.5)
ax.annotate("", xy=(0.9, 7.6), xytext=(0.9, 0.3),
arrowprops=dict(arrowstyle="->", color="#555555", lw=1.5))
ax.text(0.3, 7.7, "Maturité", fontsize=9, color="#555555", style="italic")
ax.set_title("Richardson Maturity Model — niveaux REST", fontsize=13, pad=14)
plt.show()
HATEOAS#
HATEOAS (Hypermedia As The Engine Of Application State) est la contrainte qui caractérise REST au sens strict de Fielding. Elle impose que les clients naviguent l’API exclusivement à travers les liens fournis dans les réponses, sans URL câblée en dur.
Principe fondamental#
Un client HATEOAS démarre sur un point d’entrée unique (souvent / ou /api), lit les liens disponibles, et progresse en suivant ces liens. Il ne construit jamais d’URL par concaténation. Si l’API déplace une ressource de /v1/orders à /v2/commandes, les clients qui suivent les liens s’y adaptent automatiquement.
HAL — Hypertext Application Language#
HAL est le format hypermédia le plus répandu. Il définit deux propriétés de métadonnées : _links pour les liens et _embedded pour les ressources incluses.
# Réponse HAL pour une commande
{
"id": 17,
"status": "pending",
"total": 89.90,
"currency": "EUR",
"_links": {
"self": {"href": "/orders/17"},
"customer": {"href": "/customers/5"},
"cancel": {"href": "/orders/17/cancel", "title": "Annuler"},
"pay": {"href": "/orders/17/payment", "title": "Payer"},
"items": {"href": "/orders/17/items", "title": "Articles"}
},
"_embedded": {
"items": [
{
"id": 101,
"name": "Clavier mécanique",
"quantity": 1,
"unit_price": 89.90,
"_links": {"self": {"href": "/products/101"}}
}
]
}
}
JSON:API#
JSON:API est une spécification plus complète qui standardise non seulement les liens mais aussi la pagination, les relations, les erreurs et les sparse fieldsets. Elle est plus opiniâtrée que HAL.
# Réponse JSON:API
{
"data": {
"type": "orders",
"id": "17",
"attributes": {"status": "pending", "total": 89.90},
"relationships": {
"customer": {
"data": {"type": "customers", "id": "5"},
"links": {"related": "/orders/17/customer"}
}
},
"links": {"self": "/orders/17"}
},
"included": [
{
"type": "customers",
"id": "5",
"attributes": {"name": "Alice Dupont", "email": "alice@example.com"}
}
]
}
Avantages et complexités#
L’avantage principal est le découplage : les clients ne codent pas en dur les URLs, ce qui permet de refactorer la structure de l’API sans casser les intégrations. La documentation vivante émerge naturellement des réponses.
Les complexités sont réelles : générer les liens contextuels correctement (un lien cancel ne doit apparaître que si la commande est annulable), maintenir la cohérence des liens après refactoring, et augmentation de la taille des réponses.
HATEOAS et les SPAs
Les applications frontend modernes (React, Vue) construisent souvent leurs URLs en dur dans le code client. HATEOAS est plus pertinent pour les intégrations machine-à-machine de long terme, les APIs publiques avec des clients tiers, ou les workflows complexes à étapes où les transitions varient selon l’état.
from dataclasses import dataclass, field
from typing import Dict, Optional, List
import json
@dataclass
class HalLink:
href: str
title: Optional[str] = None
method: Optional[str] = None
templated: bool = False
def to_dict(self) -> dict:
d: dict = {"href": self.href}
if self.title:
d["title"] = self.title
if self.method:
d["method"] = self.method
if self.templated:
d["templated"] = True
return d
@dataclass
class HalResource:
data: dict
links: Dict[str, HalLink] = field(default_factory=dict)
embedded: Dict[str, List["HalResource"]] = field(default_factory=dict)
def add_link(self, rel: str, href: str, **kwargs) -> "HalResource":
self.links[rel] = HalLink(href=href, **kwargs)
return self
def embed(self, rel: str, resources: List["HalResource"]) -> "HalResource":
self.embedded[rel] = resources
return self
def to_dict(self) -> dict:
result = dict(self.data)
if self.links:
result["_links"] = {k: v.to_dict() for k, v in self.links.items()}
if self.embedded:
result["_embedded"] = {
k: [r.to_dict() for r in v]
for k, v in self.embedded.items()
}
return result
# Exemple d'utilisation
order = HalResource(
data={"id": 17, "status": "pending", "total": 89.90}
)
order.add_link("self", "/orders/17")
order.add_link("cancel", "/orders/17/cancel", title="Annuler", method="POST")
order.add_link("pay", "/orders/17/payment", title="Payer", method="POST")
order.add_link("customer", "/customers/5")
item = HalResource(
data={"id": 101, "name": "Clavier mécanique", "quantity": 1, "unit_price": 89.90}
)
item.add_link("self", "/products/101")
order.embed("items", [item])
print(json.dumps(order.to_dict(), indent=2, ensure_ascii=False))
{
"id": 17,
"status": "pending",
"total": 89.9,
"_links": {
"self": {
"href": "/orders/17"
},
"cancel": {
"href": "/orders/17/cancel",
"title": "Annuler",
"method": "POST"
},
"pay": {
"href": "/orders/17/payment",
"title": "Payer",
"method": "POST"
},
"customer": {
"href": "/customers/5"
}
},
"_embedded": {
"items": [
{
"id": 101,
"name": "Clavier mécanique",
"quantity": 1,
"unit_price": 89.9,
"_links": {
"self": {
"href": "/products/101"
}
}
}
]
}
}
Ressources vs actions#
REST repose sur la manipulation de ressources identifiées par des URLs. Une règle fondamentale : les URLs sont des noms, pas des verbes.
Nommage en noms#
# Mauvais — verbes dans les URLs
POST /createUser
GET /getOrderById/17
POST /cancelOrder/17
POST /sendInvoice/17
# Correct — noms, actions via méthodes HTTP
POST /users
GET /orders/17
DELETE /orders/17
POST /invoices/17/dispatch
Sous-ressources#
Les sous-ressources modélisent les relations de composition ou de collection imbriquée.
GET /orders/17/items # articles d'une commande
POST /orders/17/items # ajouter un article
DELETE /orders/17/items/101 # supprimer un article
GET /users/5/addresses # adresses d'un utilisateur
Une sous-ressource implique que la ressource enfant n’existe que dans le contexte de la ressource parente. Si les articles de commande n’existent pas indépendamment d’une commande, /orders/{id}/items est correct.
Quand les actions sont acceptables#
Certaines opérations ne correspondent pas naturellement à CRUD. Les actions verbales sont acceptables dans deux cas : les transitions d’état explicites et les opérations sans effets de bord GET-inaccessibles.
# FastAPI — transitions d'état comme sous-ressources d'action
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
router = APIRouter(prefix="/orders", tags=["orders"])
class Order(BaseModel):
id: int
status: str
total: float
@router.post("/{order_id}/cancel", status_code=status.HTTP_200_OK)
async def cancel_order(order_id: int) -> Order:
"""Annule une commande en attente. Idempotent si déjà annulée."""
order = await get_order_or_404(order_id)
if order.status == "cancelled":
return order
if order.status not in ("pending", "confirmed"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Impossible d'annuler une commande au statut '{order.status}'",
)
return await transition_order_status(order_id, "cancelled")
@router.post("/{order_id}/payment", status_code=status.HTTP_201_CREATED)
async def initiate_payment(order_id: int, payment_data: dict) -> dict:
"""Initie le paiement d'une commande. Retourne l'URL de redirection."""
...
La règle des actions
Utilisez /resources/{id}/action uniquement pour des transitions d’état métier qui ne se modélisent pas comme une simple mise à jour d’un champ. PATCH /orders/17 {"status": "cancelled"} peut suffire pour des cas simples, mais une action dédiée permet de valider les préconditions, de logger distinctement et d’exposer des liens HATEOAS contextuels.
Relations entre ressources#
La modélisation des relations est l’un des sujets de conception les plus discutés en REST.
Sous-ressources vs ressources indépendantes#
Une sous-ressource est appropriée quand la ressource enfant n’a pas d’identité propre en dehors du parent. Une ressource indépendante est appropriée quand la ressource est partagée entre plusieurs parents ou accédée directement.
# Commentaires d'un article — dépendants, sous-ressource adaptée
GET /articles/12/comments
POST /articles/12/comments
# Tags — partagés entre articles, ressource indépendante + association
GET /tags
POST /articles/12/tags
DELETE /articles/12/tags/python
Embedding vs linking#
L’embedding (inclusion dans la réponse) et le linking (référence par URL) répondent à des besoins différents.
# Linking — la réponse référence le client par URL
{
"id": 17,
"status": "pending",
"customer_id": 5,
"_links": {"customer": {"href": "/customers/5"}}
}
# Embedding — la réponse inclut les données du client
{
"id": 17,
"status": "pending",
"customer": {
"id": 5,
"name": "Alice Dupont",
"email": "alice@example.com"
}
}
L’embedding réduit les allers-retours mais augmente la taille des réponses et crée un risque de données désynchronisées. Le linking est plus strict mais nécessite plusieurs requêtes.
Pattern ?include=#
Le pattern ?include= permet au client de choisir quelles relations inclure.
# FastAPI — include parameter
from fastapi import APIRouter, Query
from typing import Optional
router = APIRouter()
@router.get("/orders/{order_id}")
async def get_order(
order_id: int,
include: Optional[str] = Query(None, description="Relations à inclure: customer,items")
) -> dict:
order = await fetch_order(order_id)
result = order.to_dict()
includes = set(include.split(",")) if include else set()
if "customer" in includes:
result["customer"] = await fetch_customer(order.customer_id)
if "items" in includes:
result["items"] = await fetch_items(order_id)
return result
import networkx as nx
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
G = nx.DiGraph()
nodes = {
"Customer": {"color": "#4c72b0", "size": 2200},
"Order": {"color": "#dd8452", "size": 2200},
"Product": {"color": "#55a868", "size": 2200},
"Cart": {"color": "#c44e52", "size": 1800},
"Address": {"color": "#8172b2", "size": 1800},
"Review": {"color": "#937860", "size": 1600},
"Category": {"color": "#da8bc3", "size": 1600},
}
edges = [
("Customer", "Order", "place"),
("Customer", "Cart", "owns"),
("Customer", "Address", "has"),
("Order", "Product", "contains"),
("Cart", "Product", "includes"),
("Product", "Review", "has"),
("Product", "Category","belongs to"),
("Order", "Address", "ships to"),
]
G.add_nodes_from(nodes.keys())
G.add_edges_from([(u, v) for u, v, _ in edges])
pos = {
"Customer": (0, 2),
"Order": (2, 3),
"Cart": (2, 1),
"Address": (-1.5, 0.5),
"Product": (4, 2),
"Review": (5.5, 3.5),
"Category": (5.5, 0.5),
}
fig, ax = plt.subplots(figsize=(11, 7))
ax.set_title("Domaine e-commerce — relations entre ressources REST", fontsize=13, pad=14)
nx.draw_networkx_nodes(
G, pos, ax=ax,
node_color=[nodes[n]["color"] for n in G.nodes()],
node_size=[nodes[n]["size"] for n in G.nodes()],
alpha=0.9,
)
nx.draw_networkx_labels(G, pos, ax=ax, font_size=9, font_color="white", font_weight="bold")
nx.draw_networkx_edges(
G, pos, ax=ax,
edge_color="#888888",
arrows=True,
arrowsize=18,
connectionstyle="arc3,rad=0.08",
width=1.5,
)
edge_labels = {(u, v): lbl for u, v, lbl in edges}
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, ax=ax, font_size=7.5)
ax.axis("off")
plt.show()
Idempotence et safe methods#
Les propriétés d’idempotence et de sécurité sont des garanties comportementales que les clients peuvent exploiter pour implémenter des stratégies de retry, de cache et de tolérance aux pannes.
Définitions#
Une méthode est safe (sûre) si elle ne modifie pas l’état du serveur. Un client peut appeler une méthode safe autant de fois qu’il veut sans effet secondaire.
Une méthode est idempotente si l’appeler une fois ou N fois produit le même état final sur le serveur. Attention : l’idempotence concerne l’état, pas la réponse (le premier DELETE retourne 200, les suivants retournent 404, mais l’état final est identique : ressource supprimée).
Méthode |
Safe |
Idempotente |
Usage principal |
|---|---|---|---|
GET |
oui |
oui |
Lecture d’une ressource |
HEAD |
oui |
oui |
Métadonnées sans corps |
OPTIONS |
oui |
oui |
Capacités de l’endpoint (CORS) |
PUT |
non |
oui |
Remplacement complet d’une ressource |
DELETE |
non |
oui |
Suppression |
POST |
non |
non |
Création, actions non idempotentes |
PATCH |
non |
non* |
Modification partielle |
*PATCH peut être rendu idempotent selon la sémantique du patch (patch absolu vs patch relatif).
Implications pratiques#
L’idempotence permet aux clients d’implémenter un retry automatique sans risque de duplication. Si un PUT échoue avec un timeout, le client peut retenter sans craindre de créer deux ressources.
# FastAPI — PUT idempotent avec upsert
@router.put("/users/{user_id}", status_code=status.HTTP_200_OK)
async def replace_user(user_id: int, user_data: UserCreate) -> User:
"""Remplace complètement l'utilisateur. Crée si inexistant (upsert)."""
existing = await db.users.get(user_id)
if existing is None:
return await db.users.create(id=user_id, **user_data.model_dump())
return await db.users.replace(user_id, **user_data.model_dump())
# POST non idempotent — double envoi = double création
@router.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(user_data: UserCreate) -> User:
"""Crée un nouvel utilisateur. Un double envoi crée deux utilisateurs."""
return await db.users.create(**user_data.model_dump())
Idempotency Key
Pour rendre un POST idempotent (paiement, envoi d’e-mail), utiliser un header Idempotency-Key fourni par le client. Le serveur conserve la réponse pendant une fenêtre de temps et la retourne telle quelle pour les requêtes avec la même clé.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
methods = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE", "POST", "PATCH"]
safe_vals = [1, 1, 1, 0, 0, 0, 0]
idempotent_vals = [1, 1, 1, 1, 1, 0, 0]
x = range(len(methods))
width = 0.38
fig, ax = plt.subplots(figsize=(10, 5))
bars1 = ax.bar(
[i - width / 2 for i in x], safe_vals, width,
label="Safe", color="#5cb85c", alpha=0.85
)
bars2 = ax.bar(
[i + width / 2 for i in x], idempotent_vals, width,
label="Idempotente", color="#5bc0de", alpha=0.85
)
for bar, val in zip(list(bars1) + list(bars2),
safe_vals + idempotent_vals):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 0.03,
"oui" if val else "non",
ha="center", va="bottom", fontsize=8.5,
color="#2d6a4f" if val else "#c0392b",
fontweight="bold",
)
ax.set_xticks(list(x))
ax.set_xticklabels(methods, fontsize=10)
ax.set_yticks([0, 1])
ax.set_yticklabels(["Non", "Oui"])
ax.set_ylim(0, 1.3)
ax.set_title("Propriétés Safe et Idempotente des méthodes HTTP", fontsize=13, pad=14)
ax.legend(loc="upper right")
plt.show()
Réponses partielles#
Dans les APIs à large audience, une ressource peut contenir des dizaines de champs. Les clients n’en ont souvent besoin que d’une partie. Les réponses partielles permettent de réduire la taille des payloads et la charge sur la base de données.
Paramètre fields#
Le pattern le plus courant est un paramètre fields dans la query string.
GET /users/5?fields=id,name,email
GET /orders?fields=id,status,total
# FastAPI — sparse fieldsets via query parameter
from fastapi import APIRouter, Query
from typing import Optional
router = APIRouter()
@router.get("/users/{user_id}")
async def get_user(
user_id: int,
fields: Optional[str] = Query(None, description="Champs à retourner, ex: id,name,email")
) -> dict:
user = await db.users.get(user_id)
if user is None:
raise HTTPException(status_code=404)
user_dict = user.model_dump()
if fields:
allowed = set(fields.split(","))
valid = {k for k in allowed if k in user_dict}
if len(valid) != len(allowed):
invalid = allowed - valid
raise HTTPException(
status_code=400,
detail=f"Champs inconnus : {', '.join(invalid)}"
)
user_dict = {k: v for k, v in user_dict.items() if k in valid}
return user_dict
Sparse fieldsets JSON:API#
JSON:API standardise les sparse fieldsets avec la syntaxe fields[resource]=field1,field2.
GET /articles?fields[articles]=title,body&fields[authors]=name
Impact performance#
Les réponses partielles ne réduisent pas automatiquement les requêtes SQL : il faut propager les champs demandés jusqu’à la couche de persistance pour sélectionner uniquement les colonnes nécessaires.
# Projection SQL depuis les champs demandés
async def fetch_user_projected(user_id: int, fields: set[str]) -> dict:
safe_fields = fields & {"id", "name", "email", "created_at", "role"}
columns = ", ".join(safe_fields) if safe_fields else "*"
row = await db.execute(f"SELECT {columns} FROM users WHERE id = $1", user_id)
return dict(row)
Sécurité des sparse fieldsets
Ne jamais interpoler les noms de champs fournis par le client directement dans une requête SQL. Toujours valider contre une liste blanche de colonnes autorisées avant de construire la projection.
Opérations en lot (bulk)#
Les opérations en lot permettent de traiter plusieurs ressources en une seule requête HTTP, réduisant les allers-retours réseau et le overhead de connexion.
POST /batch#
Le pattern le plus simple : un endpoint dédié reçoit un tableau d’opérations.
# FastAPI — endpoint bulk générique
from fastapi import APIRouter, status
from pydantic import BaseModel
from typing import Literal, Any
router = APIRouter()
class BulkOperation(BaseModel):
method: Literal["POST", "PUT", "PATCH", "DELETE"]
path: str
body: dict | None = None
class BulkResult(BaseModel):
path: str
status: int
body: Any
@router.post("/batch", response_model=list[BulkResult])
async def batch_operations(operations: list[BulkOperation]) -> list[BulkResult]:
"""
Exécute plusieurs opérations en une requête.
Chaque opération est traitée indépendamment ; un échec partiel
ne stoppe pas les opérations suivantes.
"""
results = []
for op in operations:
try:
result = await dispatch_operation(op.method, op.path, op.body)
results.append(BulkResult(path=op.path, status=200, body=result))
except NotFoundError:
results.append(BulkResult(path=op.path, status=404, body={"error": "Not found"}))
except ValidationError as e:
results.append(BulkResult(path=op.path, status=422, body={"error": str(e)}))
return results
PATCH sur une collection#
Pour les mises à jour homogènes sur plusieurs ressources, PATCH sur la collection est une alternative élégante.
# Corps de la requête PATCH /orders
[
{"id": 17, "status": "shipped"},
{"id": 18, "status": "shipped"},
{"id": 19, "status": "shipped"}
]
@router.patch("/orders", status_code=status.HTTP_207_MULTI_STATUS)
async def bulk_update_orders(updates: list[OrderPatch]) -> list[BulkResult]:
results = []
async with db.transaction():
for update in updates:
try:
order = await db.orders.patch(update.id, update.model_dump(exclude_unset=True))
results.append(BulkResult(path=f"/orders/{update.id}", status=200, body=order))
except NotFoundError:
results.append(BulkResult(path=f"/orders/{update.id}", status=404, body=None))
return results
Transactions et atomicité#
La question transactionnelle est critique. Trois comportements sont possibles :
Tout ou rien : toute erreur rollback l’ensemble. Sémantique forte, erreur descriptive sur le premier échec.
Best-effort : chaque opération est indépendante, les erreurs sont rapportées par opération. Code de retour 207 Multi-Status.
Hybride : validation de toutes les opérations avant exécution, puis exécution transactionnelle.
Code 207 Multi-Status
Le code HTTP 207 Multi-Status (défini dans WebDAV, RFC 4918) est approprié pour les opérations en lot qui peuvent avoir des succès et des échecs partiels. Chaque sous-réponse inclut son propre code de statut.
Résumé#
Ce chapitre a présenté les dimensions avancées de la conception REST.
Le Richardson Maturity Model offre une grille de lecture à quatre niveaux : level 0 (tunnel HTTP), level 1 (ressources distinctes), level 2 (verbes HTTP et codes de statut), level 3 (HATEOAS). La plupart des APIs de production visent le level 2 ; le level 3 apporte une découvrabilité précieuse pour les APIs publiques complexes.
HATEOAS matérialise la contrainte hypermedia de REST. HAL et JSON:API sont les formats les plus courants. Les liens contextuels permettent au client de découvrir les actions disponibles sans URL câblée en dur, au prix d’une complexité de génération accrue.
La distinction ressources vs actions structure le nommage des URIs : les noms pour les ressources, les méthodes HTTP pour les opérations standard, les sous-ressources d’action (/orders/{id}/cancel) pour les transitions d’état métier.
Les propriétés safe et idempotente des méthodes HTTP sont des garanties contractuelles que les clients exploitent pour les retries et le cache. GET, HEAD, OPTIONS sont safe et idempotentes ; PUT et DELETE sont idempotentes ; POST et PATCH ne le sont pas par défaut.
Les réponses partielles (?fields=) et les opérations en lot (POST /batch, PATCH sur collection) sont des optimisations de performance que les APIs à forte charge doivent proposer, avec une attention particulière à la validation des entrées et à la sémantique transactionnelle.