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 ?

L’API comme produit

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

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

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

Règle pratique de granularité

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 :

{
  "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.

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.

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 ?

Erreurs comme guide

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

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()
=== VALIDATION-ERROR (HTTP 422) ===
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Erreur de validation",
  "status": 422,
  "detail": "3 champs invalides dans la requête",
  "correlation_id": "req_77215fcf87b2",
  "timestamp": "2026-03-26T09:46:35.106656+00:00",
  "instance": "/api/v1/users",
  "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"
    }
  ]
}

=== NOT-FOUND (HTTP 404) ===
{
  "type": "https://api.example.com/problems/not-found",
  "title": "Ressource introuvable",
  "status": 404,
  "detail": "L'utilisateur avec l'id 'usr_xyz999' n'existe pas",
  "correlation_id": "req_c8a5795ec53b",
  "timestamp": "2026-03-26T09:46:35.106954+00:00",
  "instance": "/api/v1/users/usr_xyz999"
}

=== RATE-LIMIT-EXCEEDED (HTTP 429) ===
{
  "type": "https://api.example.com/problems/rate-limit-exceeded",
  "title": "Quota dépassé",
  "status": 429,
  "detail": "Limite de 100 requêtes/minute atteinte",
  "correlation_id": "req_7e8526a1cb55",
  "timestamp": "2026-03-26T09:46:35.107083+00:00",
  "instance": "/api/v1/search",
  "retry_after": 42,
  "limit": 100,
  "window": "1 minute"
}

=== CONFLICT (HTTP 409) ===
{
  "type": "https://api.example.com/problems/conflict",
  "title": "Conflit de ressource",
  "status": 409,
  "detail": "Un utilisateur avec l'email 'alice@example.com' existe déjà",
  "correlation_id": "req_184a6278afae",
  "timestamp": "2026-03-26T09:46:35.107198+00:00",
  "instance": "/api/v1/users",
  "conflicting_resource": "/api/v1/users/usr_abc123"
}

Visualisation des codes HTTP par famille#

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()
_images/b364c78237516c847a7b05f8c53ed61a342512679fafee66f06f98d56ec612ae.png

Radar de qualité d’API#

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")
_images/bdf24b47d3d356554004819bac06eb3c12bedd72c530193bc27d276fa3553772.png
Scores par critère :
  API mature (ex: Stripe)             score moyen: 4.9/5
  API interne typique                 score moyen: 2.4/5
  API legacy                          score moyen: 1.2/5

Idempotency-Key Store — simulation#

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}%)")
=== Simulation Idempotency-Key ===

  [MISS]     Clé 'idem_ea2aad64572345a...' — paiement créé : pay_1393bd65bb96
  [HIT]      Clé 'idem_ea2aad64572345a...' — réponse en cache retournée
  [CONFLICT] Clé 'idem_ea2aad64572345a...' — corps différent rejeté
  [MISS]     Clé 'idem_9bcecd21ccd145f...' — paiement créé : pay_edf85605404d
  Tentative 1 :   [MISS]     Clé 'idem_b991791950a44d1...' — paiement créé : pay_8cf4311e57d8
  Tentative 2 :   [HIT]      Clé 'idem_b991791950a44d1...' — réponse en cache retournée
  Tentative 3 :   [HIT]      Clé 'idem_b991791950a44d1...' — réponse en cache retournée
  Tentative 4 :   [HIT]      Clé 'idem_b991791950a44d1...' — réponse en cache retournée

=== Statistiques du store ===
  hits                 : 4
  misses               : 3
  conflicts            : 1
  expired              : 0
  store_size           : 3

  Taux de déduplication : 4/7 (57%)

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.