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

# Chapitre 4 — Design d'API : principes fondamentaux

La conception d'une API est un acte d'ingénierie autant que de communication. Une API est une interface publique — une fois exposée et adoptée, elle acquiert des contraintes de stabilité qui rendent chaque décision initiale difficile à défaire. Ce chapitre pose les principes qui guident les bonnes décisions de design, indépendamment du style d'API (REST, GraphQL, gRPC).

## Qu'est-ce qu'une bonne API

### Affordance

Le terme *affordance*, emprunté à la psychologie cognitive, désigne la capacité d'un objet à suggérer son propre usage. Une bonne API possède une affordance élevée : le développeur peut *deviner* comment l'utiliser correctement sans lire la documentation.

```
GET    /users          → liste les utilisateurs
GET    /users/42       → récupère l'utilisateur 42
POST   /users          → crée un utilisateur
PUT    /users/42       → remplace l'utilisateur 42
PATCH  /users/42       → met à jour partiellement l'utilisateur 42
DELETE /users/42       → supprime l'utilisateur 42
```

Cette structure est prédictible. Un développeur qui connaît un endpoint peut inférer les autres.

### Principe de moindre surprise

Une API qui respecte le principe de moindre surprise (*Principle of Least Astonishment*) se comporte conformément aux attentes de son consommateur. Les violations courantes :

- `DELETE /users/42` retourne `200` avec le corps `{"deleted": true}` au lieu de `204 No Content`
- `GET /users?status=inactive` retourne une liste vide sans `404` alors que le filtre est invalide
- Un endpoint qui modifie l'état lors d'un GET (effets de bord sur les méthodes sûres)
- Des codes HTTP incorrects : `200 OK` avec `{"error": "not found"}` dans le corps

### Contrat stable

Un contrat d'API est l'ensemble des garanties que le fournisseur offre aux consommateurs : noms des champs, types, codes de statut, sémantique des opérations. Un contrat stable signifie que les changements additifs sont autorisés (nouveaux champs optionnels) mais que les changements cassants (suppression de champ, changement de type) nécessitent une nouvelle version.

### Ergonomie pour le consommateur

L'ergonomie se mesure à la facilité d'intégration :

- Peut-on accomplir 80 % des cas d'usage sans lire la documentation ?
- Le SDK ou le client généré est-il idiomatique dans le langage cible ?
- Les erreurs sont-elles assez descriptives pour corriger le problème sans aide ?
- Le onboarding (première requête fonctionnelle) prend-il moins de 10 minutes ?

```{admonition} L'API comme produit
:class: note
Jeff Bezos a imposé en 2002 que toutes les communications inter-équipes Amazon passent par des APIs — "Toute équipe qui ne se conforme pas sera licenciée". Cette discipline a donné naissance à AWS. La leçon : concevoir une API comme si elle était consommée par un tiers externe, même pour un usage interne.
```

## API-first vs code-first

### Code-first

L'approche code-first consiste à écrire le code, puis à générer la documentation et le schéma de l'API à partir du code. C'est l'approche par défaut de la plupart des frameworks (FastAPI, Spring, Rails).

Avantages : rapidité initiale, documentation toujours synchronisée avec le code.

Inconvénients : le design est contraint par les choix d'implémentation ; la revue de contrat est difficile ; les clients ne peuvent pas commencer l'intégration avant que l'API soit implémentée.

### API-first

L'approche API-first consiste à définir le contrat de l'API (OpenAPI, Protobuf, GraphQL schema) avant d'écrire une ligne d'implémentation. Le contrat est le livrable de première classe.

Avantages :
- **Revue de contrat** : le contrat OpenAPI peut être reviewé sans comprendre le code
- **Mocking précoce** : les clients peuvent intégrer contre un mock dès la définition du contrat
- **Parallélisation** : front-end et back-end avancent simultanément
- **Gouvernance** : les changements cassants sont détectables par diff du schéma
- **Génération de code** : les clients, serveurs stub, et tests peuvent être générés

```yaml
# openapi.yaml — défini AVANT l'implémentation
openapi: "3.1.0"
info:
  title: Billing API
  version: "1.0.0"
paths:
  /invoices/{invoice_id}:
    get:
      summary: Récupère une facture
      parameters:
        - name: invoice_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Facture trouvée
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "404":
          $ref: "#/components/responses/NotFound"
```

### Contrat comme documentation vivante

Dans un workflow API-first, le fichier OpenAPI est versionné dans Git, sa modification suit le même processus de pull request que le code, et les changements cassants bloquent la CI. Des outils comme `openapi-diff` ou `breaking-changes-detector` automatisent la détection.

## Couplage et stabilité

### Loi de Postel

> "Be conservative in what you do, be liberal in what you accept from others."

La *loi de Postel* (RFC 793) appliquée aux APIs : acceptez un large éventail d'entrées valides (formats de date multiples, champs supplémentaires ignorés) mais produisez des sorties strictement conformes au contrat. L'enjeu est d'absorber les variations des clients sans les laisser se reposer sur des comportements non documentés.

### Couplage structurel

Le couplage structurel survient quand les consommateurs dépendent de détails d'implémentation non documentés — l'ordre des champs JSON, la présence d'un champ `_internal_version`, la forme exacte d'un message d'erreur. Plus le couplage est fort, plus les évolutions sont coûteuses.

Mitigations :
- Ne jamais exposer les identifiants internes de base de données directement
- Documenter explicitement les champs qui font partie du contrat stable
- Utiliser `additionalProperties: false` dans OpenAPI pour formaliser les limites du contrat

### Tolerant Reader pattern

Le *Tolerant Reader* (Martin Fowler) est un pattern côté consommateur : lire seulement les champs nécessaires, ignorer les champs inconnus, ne pas supposer l'ordre, tolérer des valeurs nulles là où on attendait un objet.

```python
# Côté consommateur — Tolerant Reader
def parse_user_response(data: dict) -> dict:
    return {
        "id": data.get("id"),            # Pas d'accès direct [key] qui lèverait KeyError
        "name": data.get("name", ""),    # Valeur par défaut
        "email": data.get("email"),
        # Ignorer tous les autres champs — pas de dépendance sur la structure complète
    }
```

## Modélisation des ressources

### Nommage

Les conventions de nommage des ressources REST établies par les APIs les plus adoptées (GitHub, Stripe, Twilio) :

- **Noms au pluriel** pour les collections : `/users`, `/invoices`, `/products`
- **Noms en minuscules avec tirets** pour les mots composés : `/shipping-addresses`, pas `shippingAddresses` ni `shipping_addresses`
- **Substantifs, pas verbes** : `/users/42` pas `/getUser/42`
- **Hiérarchie avec modération** : `/users/42/orders` est acceptable, `/users/42/orders/7/items/3/reviews` est une odeur de conception

### Granularité

Un écueil fréquent : des ressources trop fines qui obligent les clients à enchaîner de nombreuses requêtes. L'autre extrême : des ressources trop grosses qui renvoient des payloads énormes dont le client n'utilise que 5 %.

```{admonition} Règle pratique de granularité
:class: tip
Modélisez les ressources selon les cas d'usage réels. Si 80 % des clients demandent toujours l'utilisateur avec son adresse, `GET /users/42` devrait inclure l'adresse. Si seulement 10 % en ont besoin, `GET /users/42/addresses` est approprié. Observez les patterns d'accès réels après la mise en production.
```

### Ressources vs actions

Certaines opérations ne correspondent pas naturellement à un CRUD sur une ressource :

- Activation d'un compte → `POST /users/42/activate` (sous-ressource action)
- Envoi d'un email de confirmation → `POST /emails/confirmation` (ressource email)
- Recherche full-text → `GET /search?q=...` ou `POST /search` (ressource recherche)
- Transfert de fonds → `POST /transfers` avec `from_account` et `to_account` (ressource transfert)

L'astuce est de *nominaliser* l'action : au lieu d'un verbe, créer une ressource qui représente l'état souhaité ou l'événement.

### Anti-patterns courants

| Anti-pattern | Exemple | Correct |
|---|---|---|
| Verbe dans l'URL | `GET /getUserById/42` | `GET /users/42` |
| Mélange singulier/pluriel | `/user/42`, `/products` | `/users/42`, `/products` |
| Niveaux de hiérarchie excessifs | `/api/v1/users/42/orders/7/items/3` | `/order-items?order_id=7&item_id=3` |
| Format dans l'URL | `/users.json`, `/users/42.xml` | Négociation de contenu (`Accept`) |
| Actions en GET | `GET /users/42/delete` | `DELETE /users/42` |

## Gestion des erreurs

### Format cohérent — RFC 9457 Problem Details

La RFC 9457 (*Problem Details for HTTP APIs*) standardise la structure des réponses d'erreur :

```json
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Erreur de validation",
  "status": 422,
  "detail": "Le champ 'email' n'est pas une adresse valide",
  "instance": "/api/v1/users",
  "correlation_id": "req_01hx4k7m9n8p",
  "errors": [
    {
      "field": "email",
      "message": "Format email invalide",
      "rejected_value": "alice@"
    }
  ]
}
```

Champs standardisés :
- `type` : URI qui identifie le type de problème (documenté, stable)
- `title` : description courte humaine, stable pour un `type` donné
- `status` : code HTTP (redondant mais utile dans les logs)
- `detail` : description spécifique à cette occurrence
- `instance` : URI de la ressource concernée

### Codes HTTP appropriés

| Situation | Code | Note |
|-----------|------|------|
| Succès avec corps | 200 | GET, POST (rare), PUT réussis |
| Ressource créée | 201 | POST avec `Location` header |
| Succès sans corps | 204 | DELETE, PUT sans corps de retour |
| Requête invalide | 400 | Syntaxe, paramètres manquants |
| Non authentifié | 401 | Credentials absents ou invalides |
| Non autorisé | 403 | Authentifié mais sans permission |
| Non trouvé | 404 | Ressource inexistante |
| Méthode interdite | 405 | `Allow` header obligatoire |
| Conflit | 409 | Doublon, état incompatible |
| Entité non traitable | 422 | Validation sémantique échouée |
| Précondition échouée | 412 | ETag/If-Match failed |
| Trop de requêtes | 429 | Rate limiting |
| Erreur interne | 500 | Inattendu — logs côté serveur |
| Service indisponible | 503 | `Retry-After` recommandé |

### Correlation ID

Chaque requête doit recevoir un identifiant de corrélation unique, propagé dans les logs de tous les services traversés, et retourné dans la réponse d'erreur. Cela permet de reconstituer le chemin d'une requête dans une architecture distribuée.

```python
from fastapi import FastAPI, Request
import uuid

app = FastAPI()

@app.middleware("http")
async def correlation_id_middleware(request: Request, call_next):
    correlation_id = request.headers.get("X-Request-ID") or f"req_{uuid.uuid4().hex[:12]}"
    request.state.correlation_id = correlation_id
    response = await call_next(request)
    response.headers["X-Request-ID"] = correlation_id
    return response
```

## Idempotence

Une opération est *idempotente* si son exécution répétée produit le même effet que son exécution unique.

### Idempotence des méthodes HTTP

| Méthode | Sûre | Idempotente | Note |
|---------|------|-------------|------|
| GET | Oui | Oui | Aucun effet de bord |
| HEAD | Oui | Oui | Comme GET sans corps |
| OPTIONS | Oui | Oui | |
| PUT | Non | Oui | Remplace la ressource |
| DELETE | Non | Oui | Supprimer deux fois → même état final |
| POST | Non | Non | Chaque appel crée une nouvelle ressource |
| PATCH | Non | Souvent non | Dépend du contenu du patch |

DELETE est idempotent : supprimer un objet déjà supprimé doit retourner `404` (ou `204` selon la convention choisie), mais l'état du système est identique.

### Idempotency-Key pour POST

Pour les opérations POST qui ne doivent pas être rejouées (paiement, envoi d'email, transfert), le header `Idempotency-Key` permet au client de retenter une requête en sécurité :

```
POST /api/v1/payments HTTP/1.1
Idempotency-Key: pay_01hx4k7m9n8p
Content-Type: application/json

{"amount": 9900, "currency": "EUR", "customer_id": "cus_42"}
```

Le serveur stocke la clé et la réponse associée. Si la même clé est reçue dans la fenêtre de déduplication, le serveur retourne la réponse stockée sans re-exécuter l'opération.

```python
from fastapi import FastAPI, Request, HTTPException, Header
from typing import Optional
import hashlib, time, json

app = FastAPI()

# Stockage des réponses idempotentes (en production : Redis avec TTL)
_idempotency_store: dict[str, dict] = {}
IDEMPOTENCY_TTL = 86400  # 24 heures

@app.post("/api/v1/payments")
async def create_payment(
    request: Request,
    body: dict,
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key")
):
    if idempotency_key:
        cached = _idempotency_store.get(idempotency_key)
        if cached:
            if cached["request_hash"] != _hash_body(body):
                raise HTTPException(
                    status_code=422,
                    detail="Idempotency-Key réutilisé avec un corps différent"
                )
            if time.time() - cached["created_at"] < IDEMPOTENCY_TTL:
                return cached["response"]

    # Traitement réel du paiement
    payment = {"id": f"pay_{idempotency_key[:8] if idempotency_key else 'new'}",
               "status": "pending", "amount": body.get("amount")}

    if idempotency_key:
        _idempotency_store[idempotency_key] = {
            "response": payment,
            "request_hash": _hash_body(body),
            "created_at": time.time(),
        }

    return payment

def _hash_body(body: dict) -> str:
    return hashlib.sha256(
        json.dumps(body, sort_keys=True).encode()
    ).hexdigest()
```

## API as a Product

### Documentation comme livrable de première classe

La documentation n'est pas un sous-produit de l'implémentation — elle est le produit principal d'une API. Un endpoint sans documentation n'existe pas pour le consommateur.

Niveaux de documentation :
1. **Référence API** : tous les endpoints, paramètres, schémas, codes de réponse (générable depuis OpenAPI)
2. **Guides de démarrage** : quickstart en 5 minutes, premiers exemples fonctionnels
3. **Guides thématiques** : authentification, pagination, gestion des erreurs
4. **Tutoriels** : cas d'usage complets du début à la fin
5. **Changelog** : toutes les modifications, avec dates et versions

### Developer Experience (DX)

La DX mesure la qualité de l'expérience d'un développeur qui consomme l'API. Indicateurs :

- **Time to first call** : combien de temps pour exécuter la première requête authentifiée ?
- **Taux de succès au premier essai** : quelle proportion des développeurs réussit l'intégration sans demander de l'aide ?
- **Clarté des messages d'erreur** : un message d'erreur 400 contient-il assez d'information pour se corriger ?

```{admonition} Erreurs comme guide
:class: tip
Un message d'erreur de qualité contient : ce qui ne va pas, pourquoi c'est invalide, et comment le corriger. Par exemple : `"Le champ 'amount' doit être un entier en centimes (valeur reçue: 99.99 — envoyez 9999 pour 99,99 €)"` est infiniment plus utile que `"Invalid request"`.
```

### Onboarding

Un bon onboarding API inclut :
- Un environnement sandbox avec des données de test
- Des credentials de test pré-configurés (pas besoin de parler à un commercial)
- Des exemples de code dans les langages principaux (Python, JavaScript, curl)
- Des webhooks de test facilement déclenchables

## Gouvernance

### Style guide

Un style guide documente les conventions de l'organisation sur la conception d'APIs : nommage, codes HTTP, formats de date, structure des erreurs, conventions de pagination, politiques de versioning.

Éléments typiques d'un style guide :
- Format des dates : ISO 8601 (`2026-03-25T14:30:00Z`) ou timestamp Unix ?
- Convention de nommage des champs JSON : `snake_case` ou `camelCase` ?
- Pagination : cursor-based ou offset-based ?
- Versioning : `/v1/` dans l'URL ou header `API-Version` ?
- Format des IDs : entier auto-incrémenté, UUID v4, ou ULID ?

### Review process

Les APIs devraient passer par un processus de revue avant publication, distinct de la revue de code habituelle. La revue d'API porte sur :

1. **Cohérence** : l'API respecte-t-elle le style guide ?
2. **Completeness** : tous les cas d'usage prévus sont-ils couverts ?
3. **Security** : les contrôles d'accès sont-ils corrects ?
4. **Breaking changes** : y a-t-il des changements cassants par rapport à la version précédente ?
5. **Documentation** : la documentation est-elle complète ?

### Breaking changes policy

Un *breaking change* est tout changement qui peut casser un client existant :
- Suppression ou renommage d'un champ
- Changement de type d'un champ
- Changement de sémantique d'une opération
- Suppression d'un code HTTP possible

Une politique claire réduit les conflits :
- Les changements additifs (nouveau champ optionnel, nouveau endpoint) ne nécessitent pas de nouvelle version
- Les breaking changes nécessitent une incrémentation de version majeure
- La version précédente reste disponible pendant au moins 12 mois
- Les consommateurs sont notifiés par email/changelog 6 mois avant la dépréciation

---

## Cellules d'analyse et de visualisation

### Générateur Problem Details (RFC 9457)

```{code-cell} python3
import json
import uuid
import re
from datetime import datetime, timezone

class ProblemDetail:
    """
    Générateur de réponses d'erreur RFC 9457 Problem Details.
    """
    BASE_URI = "https://api.example.com/problems"

    PROBLEM_TYPES = {
        "validation-error": {
            "title": "Erreur de validation",
            "status": 422,
            "doc": "Un ou plusieurs champs de la requête sont invalides.",
        },
        "not-found": {
            "title": "Ressource introuvable",
            "status": 404,
            "doc": "La ressource demandée n'existe pas.",
        },
        "unauthorized": {
            "title": "Authentification requise",
            "status": 401,
            "doc": "Aucun credential valide fourni.",
        },
        "forbidden": {
            "title": "Accès interdit",
            "status": 403,
            "doc": "Vous n'avez pas les permissions nécessaires.",
        },
        "rate-limit-exceeded": {
            "title": "Quota dépassé",
            "status": 429,
            "doc": "Trop de requêtes dans la fenêtre temporelle.",
        },
        "conflict": {
            "title": "Conflit de ressource",
            "status": 409,
            "doc": "La requête est en conflit avec l'état actuel de la ressource.",
        },
        "payment-required": {
            "title": "Paiement requis",
            "status": 402,
            "doc": "Votre abonnement ne couvre pas cette fonctionnalité.",
        },
        "internal-error": {
            "title": "Erreur interne",
            "status": 500,
            "doc": "Une erreur inattendue s'est produite côté serveur.",
        },
    }

    @classmethod
    def build(
        cls,
        problem_type: str,
        detail: str,
        instance: str = None,
        extensions: dict = None
    ) -> dict:
        if problem_type not in cls.PROBLEM_TYPES:
            raise ValueError(f"Type inconnu : {problem_type}")

        meta = cls.PROBLEM_TYPES[problem_type]
        correlation_id = f"req_{uuid.uuid4().hex[:12]}"

        problem = {
            "type": f"{cls.BASE_URI}/{problem_type}",
            "title": meta["title"],
            "status": meta["status"],
            "detail": detail,
            "correlation_id": correlation_id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        if instance:
            problem["instance"] = instance
        if extensions:
            problem.update(extensions)

        return problem

# Démonstration avec cas d'usage réels
cas_usage = [
    {
        "type": "validation-error",
        "detail": "3 champs invalides dans la requête",
        "instance": "/api/v1/users",
        "extensions": {
            "errors": [
                {"field": "email", "message": "Format invalide", "rejected": "alice@"},
                {"field": "age", "message": "Doit être entre 0 et 150", "rejected": -1},
                {"field": "username", "message": "Caractères interdits", "rejected": "al!ce"},
            ]
        }
    },
    {
        "type": "not-found",
        "detail": "L'utilisateur avec l'id 'usr_xyz999' n'existe pas",
        "instance": "/api/v1/users/usr_xyz999",
    },
    {
        "type": "rate-limit-exceeded",
        "detail": "Limite de 100 requêtes/minute atteinte",
        "instance": "/api/v1/search",
        "extensions": {
            "retry_after": 42,
            "limit": 100,
            "window": "1 minute",
        }
    },
    {
        "type": "conflict",
        "detail": "Un utilisateur avec l'email 'alice@example.com' existe déjà",
        "instance": "/api/v1/users",
        "extensions": {
            "conflicting_resource": "/api/v1/users/usr_abc123",
        }
    },
]

for cas in cas_usage:
    problem = ProblemDetail.build(
        cas["type"],
        cas["detail"],
        cas.get("instance"),
        cas.get("extensions"),
    )
    print(f"=== {cas['type'].upper()} (HTTP {problem['status']}) ===")
    print(json.dumps(problem, indent=2, ensure_ascii=False))
    print()
```

### Visualisation des codes HTTP par famille

```{code-cell} python3
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)

codes_http = {
    "2xx — Succès": {
        "couleur": "#55A868",
        "codes": [
            ("200 OK", "Réponse standard"),
            ("201 Created", "Ressource créée"),
            ("202 Accepted", "Traitement asynchrone"),
            ("204 No Content", "Succès sans corps"),
            ("206 Partial Content", "Range request"),
            ("207 Multi-Status", "Réponses multiples"),
        ]
    },
    "3xx — Redirection": {
        "couleur": "#4C72B0",
        "codes": [
            ("301 Moved Permanently", "Redirection permanente"),
            ("304 Not Modified", "Cache valide"),
            ("307 Temporary Redirect", "Méthode préservée"),
            ("308 Permanent Redirect", "Méthode préservée, permanent"),
        ]
    },
    "4xx — Erreur client": {
        "couleur": "#DD8452",
        "codes": [
            ("400 Bad Request", "Requête malformée"),
            ("401 Unauthorized", "Auth manquante/invalide"),
            ("403 Forbidden", "Permission refusée"),
            ("404 Not Found", "Ressource absente"),
            ("405 Method Not Allowed", "Méthode interdite"),
            ("409 Conflict", "Conflit d'état"),
            ("410 Gone", "Ressource définitivement supprimée"),
            ("412 Precondition Failed", "ETag/If-Match échoué"),
            ("415 Unsupported Media Type", "Content-Type refusé"),
            ("422 Unprocessable Entity", "Validation sémantique"),
            ("429 Too Many Requests", "Rate limit dépassé"),
        ]
    },
    "5xx — Erreur serveur": {
        "couleur": "#C44E52",
        "codes": [
            ("500 Internal Server Error", "Erreur inattendue"),
            ("502 Bad Gateway", "Réponse upstream invalide"),
            ("503 Service Unavailable", "Service temporairement indisponible"),
            ("504 Gateway Timeout", "Timeout upstream"),
        ]
    },
}

fig, ax = plt.subplots(figsize=(12, 9))
ax.set_xlim(0, 12)
ax.set_ylim(0, 27)
ax.axis("off")
fig.patch.set_facecolor("#f8f9fa")
ax.set_facecolor("#f8f9fa")

y = 26.0
for famille, data in codes_http.items():
    couleur = data["couleur"]
    # En-tête de famille
    header = mpatches.FancyBboxPatch(
        (0.2, y - 0.45), 11.6, 0.75,
        boxstyle="round,pad=0.1",
        facecolor=couleur, edgecolor="none", alpha=0.9
    )
    ax.add_patch(header)
    ax.text(0.5, y + 0.0, famille, fontsize=10.5, fontweight="bold",
            color="white", va="center")
    y -= 0.8

    for code, desc in data["codes"]:
        # Barre de code
        bar = mpatches.FancyBboxPatch(
            (0.4, y - 0.32), 11.2, 0.58,
            boxstyle="round,pad=0.08",
            facecolor=couleur, edgecolor="none", alpha=0.12
        )
        ax.add_patch(bar)
        ax.text(0.7, y + 0.0, code, fontsize=9.0, fontweight="bold",
                color=couleur, va="center")
        ax.text(4.8, y + 0.0, desc, fontsize=8.8, color="#444444", va="center")
        y -= 0.62

    y -= 0.35  # Espace entre familles

ax.set_title("Codes HTTP pertinents pour les APIs — par famille",
             fontsize=13, fontweight="bold", pad=8)
plt.savefig("http_codes_api.png", dpi=120, bbox_inches="tight",
            facecolor="#f8f9fa")
plt.show()
```

### Radar de qualité d'API

```{code-cell} python3
import numpy as np
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)

criteres = [
    "Ergonomie\n(affordance, DX)",
    "Sécurité\n(auth, validation)",
    "Performance\n(cache, compression)",
    "Évolutivité\n(versioning, stabilité)",
    "Documentation\n(référence, guides)",
    "Observabilité\n(logs, métriques)",
    "Cohérence\n(style guide)",
    "Testabilité\n(sandbox, mocks)",
]

n = len(criteres)
angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()
angles += angles[:1]  # Fermer le polygone

# Profils de trois APIs fictives
profils = {
    "API mature (ex: Stripe)": {
        "scores": [5, 5, 4, 5, 5, 5, 5, 5],
        "color": "#4C72B0",
        "alpha": 0.25,
    },
    "API interne typique": {
        "scores": [3, 3, 2, 3, 2, 2, 2, 2],
        "color": "#DD8452",
        "alpha": 0.22,
    },
    "API legacy": {
        "scores": [2, 2, 1, 1, 1, 1, 1, 1],
        "color": "#C44E52",
        "alpha": 0.18,
    },
}

fig, ax = plt.subplots(figsize=(9, 9), subplot_kw={"polar": True})
fig.patch.set_facecolor("#f8f9fa")
ax.set_facecolor("#f8f9fa")

# Grilles de référence
ax.set_ylim(0, 5)
ax.set_yticks([1, 2, 3, 4, 5])
ax.set_yticklabels(["1", "2", "3", "4", "5"], fontsize=8, color="#888888")
ax.set_xticks(angles[:-1])
ax.set_xticklabels(criteres, fontsize=9.5, fontweight="bold")

for name, data in profils.items():
    values = data["scores"] + data["scores"][:1]
    ax.plot(angles, values, "o-", linewidth=2,
            color=data["color"], label=name)
    ax.fill(angles, values, alpha=data["alpha"], color=data["color"])

# Niveau minimal recommandé
min_values = [3] * n + [3]
ax.plot(angles, min_values, "--", linewidth=1.2,
        color="#888888", alpha=0.6, label="Minimum recommandé (3/5)")

ax.legend(loc="upper right", bbox_to_anchor=(1.35, 1.15),
          fontsize=9.5, framealpha=0.8)
ax.set_title("Radar de qualité d'API — 8 dimensions",
             fontsize=13, fontweight="bold", y=1.08)
ax.grid(True, alpha=0.35)

plt.savefig("api_quality_radar.png", dpi=120, bbox_inches="tight",
            facecolor="#f8f9fa")
plt.show()

print("Scores par critère :")
for profil_name, data in profils.items():
    mean = sum(data["scores"]) / len(data["scores"])
    print(f"  {profil_name:<35} score moyen: {mean:.1f}/5")
```

### Idempotency-Key Store — simulation

```{code-cell} python3
import hashlib
import json
import time
import uuid
from datetime import datetime, timezone

class IdempotencyStore:
    """
    Store pour la déduplication des opérations non-idempotentes.
    En production : Redis avec TTL natif.
    """
    TTL = 86400  # 24 heures

    def __init__(self):
        self._store: dict[str, dict] = {}
        self._stats = {"hits": 0, "misses": 0, "conflicts": 0, "expired": 0}

    def _hash_body(self, body: dict) -> str:
        return hashlib.sha256(
            json.dumps(body, sort_keys=True, ensure_ascii=False).encode()
        ).hexdigest()

    def check_and_store(self, key: str, body: dict) -> tuple[str, dict | None]:
        """
        Retourne ("hit", réponse) si la clé existe et correspond,
                 ("conflict", None) si la clé existe avec un corps différent,
                 ("miss", None) si la clé est nouvelle.
        """
        now = time.time()
        body_hash = self._hash_body(body)

        if key in self._store:
            entry = self._store[key]
            # Vérifier TTL
            if now - entry["created_at"] > self.TTL:
                del self._store[key]
                self._stats["expired"] += 1
                self._stats["misses"] += 1
                return "miss", None

            if entry["body_hash"] != body_hash:
                self._stats["conflicts"] += 1
                return "conflict", None

            self._stats["hits"] += 1
            return "hit", entry["response"]

        self._stats["misses"] += 1
        return "miss", None

    def record(self, key: str, body: dict, response: dict) -> None:
        self._store[key] = {
            "body_hash": self._hash_body(body),
            "response": response,
            "created_at": time.time(),
        }

    def cleanup(self) -> int:
        now = time.time()
        expired_keys = [k for k, v in self._store.items()
                        if now - v["created_at"] > self.TTL]
        for k in expired_keys:
            del self._store[k]
        return len(expired_keys)

    def stats(self) -> dict:
        return {**self._stats, "store_size": len(self._store)}

# Simulation d'un endpoint de paiement avec idempotence
store = IdempotencyStore()

def process_payment(key: str, body: dict) -> tuple[int, dict]:
    """Simule un endpoint POST /payments avec Idempotency-Key."""
    status, cached = store.check_and_store(key, body)

    if status == "hit":
        print(f"  [HIT]      Clé '{key[:20]}...' — réponse en cache retournée")
        return 200, {**cached, "_from_cache": True}

    if status == "conflict":
        print(f"  [CONFLICT] Clé '{key[:20]}...' — corps différent rejeté")
        return 422, {
            "type": "https://api.example.com/problems/idempotency-conflict",
            "title": "Conflit de clé d'idempotence",
            "status": 422,
            "detail": "La clé d'idempotence a déjà été utilisée avec un corps différent",
        }

    # Traitement réel (simulé)
    payment = {
        "id": f"pay_{uuid.uuid4().hex[:12]}",
        "amount": body["amount"],
        "currency": body.get("currency", "EUR"),
        "status": "pending",
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
    store.record(key, body, payment)
    print(f"  [MISS]     Clé '{key[:20]}...' — paiement créé : {payment['id']}")
    return 201, payment

print("=== Simulation Idempotency-Key ===\n")

# Cas 1 : première requête
key1 = f"idem_{uuid.uuid4().hex[:16]}"
body1 = {"amount": 9900, "currency": "EUR", "customer_id": "cus_001"}
status, resp = process_payment(key1, body1)
payment_id_1 = resp.get("id")

# Cas 2 : retry avec la même clé et le même corps → doit retourner la même réponse
status, resp = process_payment(key1, body1)
assert resp.get("id") == payment_id_1, "La réponse doit être identique au premier appel"
assert resp.get("_from_cache"), "Doit venir du cache"

# Cas 3 : même clé, corps différent → conflit
status, resp = process_payment(key1, {"amount": 5000, "currency": "EUR"})
assert status == 422, "Doit être rejeté"

# Cas 4 : nouvelle clé → nouveau paiement
key2 = f"idem_{uuid.uuid4().hex[:16]}"
status, resp = process_payment(key2, {"amount": 2500, "currency": "USD"})

# Cas 5 : plusieurs retries d'un même paiement
key3 = f"idem_{uuid.uuid4().hex[:16]}"
body3 = {"amount": 15000, "currency": "EUR", "customer_id": "cus_002"}
for attempt in range(4):
    print(f"  Tentative {attempt + 1} :", end=" ")
    status, resp = process_payment(key3, body3)

print(f"\n=== Statistiques du store ===")
stats = store.stats()
for k, v in stats.items():
    print(f"  {k:<20} : {v}")
print(f"\n  Taux de déduplication : {stats['hits']}/{stats['hits'] + stats['misses']} "
      f"({stats['hits']/(stats['hits']+stats['misses'])*100:.0f}%)")
```

---

## Résumé

Ce chapitre a posé les principes fondamentaux de la conception d'API, indépendants du style technique :

**Qualité intrinsèque** — une bonne API possède de l'affordance (son usage se devine), respecte le principe de moindre surprise, et établit un contrat stable que les consommateurs peuvent anticiper.

**API-first** — définir le contrat avant l'implémentation permet la revue de design, le mocking précoce, la parallélisation des équipes front/back, et la détection automatisée des breaking changes par diff de schéma.

**Couplage et stabilité** — la loi de Postel, le Tolerant Reader, et la distinction entre contrat public et détails d'implémentation sont les trois piliers de la stabilité à long terme.

**Modélisation des ressources** — noms pluriels, hiérarchie limitée à deux niveaux, nominalisation des actions : des ressources bien nommées réduisent la friction cognitive.

**Gestion des erreurs** — la RFC 9457 Problem Details fournit un format standardisé. Le correlation ID est indispensable dans les architectures distribuées. Chaque erreur doit être actionnable par le consommateur.

**Idempotence** — le header `Idempotency-Key` permet aux clients de retenter les opérations POST en sécurité. Son implémentation correcte (vérification du hash du corps, TTL 24h, retour de la réponse stockée) est essentielle pour les opérations financières et les envois d'emails.

**API as a product** — documentation, DX, onboarding, et changelog sont des livrables de première classe, pas des accessoires. La gouvernance (style guide, review process, breaking changes policy) structure la cohérence dans le temps et entre équipes.
