---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# 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.

```http
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.

```http
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.

```{admonition} Niveau 2 en pratique
:class: note
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.
```

```{code-cell} python3
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.

```python
# 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.

```python
# 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.

```{admonition} HATEOAS et les SPAs
:class: tip
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.
```

```{code-cell} python3
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))
```

## 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

```http
# 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.

```http
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.

```python
# 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."""
    ...
```

```{admonition} La règle des actions
:class: important
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.

```http
# 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.

```python
# 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.

```python
# 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
```

```{code-cell} python3
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.

```python
# 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())
```

```{admonition} Idempotency Key
:class: tip
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é.
```

```{code-cell} python3
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.

```http
GET /users/5?fields=id,name,email
GET /orders?fields=id,status,total
```

```python
# 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`.

```http
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.

```python
# 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)
```

```{admonition} Sécurité des sparse fieldsets
:class: warning
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.

```python
# 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.

```python
# Corps de la requête PATCH /orders
[
  {"id": 17, "status": "shipped"},
  {"id": 18, "status": "shipped"},
  {"id": 19, "status": "shipped"}
]
```

```python
@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.

```{admonition} Code 207 Multi-Status
:class: note
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.
