REST et API HTTP#

Les API REST (Representational State Transfer) dominent l’architecture des services web depuis les années 2000. Proposé par Roy Fielding dans sa thèse de doctorat en 2000, REST n’est pas un protocole mais un style architectural définissant un ensemble de contraintes qui, respectées, produisent des systèmes évolutifs, simples et interopérables.

Ce chapitre couvre les principes fondamentaux de REST, la conception d’URL, la sémantique des méthodes HTTP, les codes de statut, la pagination, le versioning, l’authentification, et la spécification OpenAPI. Il inclut un mini serveur REST avec la stdlib Python et des visualisations pédagogiques.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import pandas as pd
import json
import hashlib
import base64
import time
import urllib.request
import urllib.parse
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Les principes architecturaux de REST#

Les six contraintes de Fielding#

REST est défini par six contraintes architecturales. Respecter ces contraintes produit un système aux propriétés bien connues :

1. Client-Serveur : séparation stricte entre interface utilisateur (client) et stockage de données (serveur). Le client ne se préoccupe pas de la persistance des données ; le serveur ne se préoccupe pas de l’interface.

2. Sans état (Stateless) : chaque requête du client doit contenir toutes les informations nécessaires à sa compréhension. Le serveur ne conserve aucun contexte de session entre les requêtes. L’état de session est entièrement géré côté client (tokens, cookies).

3. Cache : les réponses doivent être qualifiées de cachables ou non-cachables. Un cache bien géré améliore les performances et réduit la charge serveur.

4. Interface uniforme : c’est la contrainte centrale de REST. Elle comprend quatre sous-contraintes : identification des ressources dans les requêtes (URI), manipulation des ressources par leurs représentations, messages auto-descriptifs, et hypermédia comme moteur d’état applicatif (HATEOAS).

5. Système en couches : le client ne sait pas s’il parle directement au serveur final ou à un intermédiaire (load balancer, cache, proxy). Cela permet la scalabilité et la sécurité.

6. Code à la demande (optionnel) : le serveur peut optionnellement envoyer du code exécutable au client (JavaScript, WebAssembly).

HATEOAS#

HATEOAS (Hypermedia As The Engine Of Application State) est la contrainte la plus souvent ignorée des API dites « REST ». Elle stipule que le client ne doit pas avoir de connaissance préalable des URIs de l’API : le serveur doit inclure dans chaque réponse les liens vers les actions disponibles.

{
  "id": 42,
  "nom": "Jean Dupont",
  "statut": "actif",
  "_links": {
    "self": {"href": "/api/v1/utilisateurs/42"},
    "modifier": {"href": "/api/v1/utilisateurs/42", "method": "PUT"},
    "supprimer": {"href": "/api/v1/utilisateurs/42", "method": "DELETE"},
    "commandes": {"href": "/api/v1/utilisateurs/42/commandes"},
    "factures": {"href": "/api/v1/utilisateurs/42/factures"}
  }
}

En pratique, HATEOAS est rarement implémenté complètement. La plupart des APIs modernes sont des APIs « REST-like » qui respectent les contraintes client-serveur, stateless, et interface uniforme, mais pas HATEOAS.

Hide code cell source

# Visualisation : les 6 contraintes REST et leurs bénéfices
fig, ax = plt.subplots(figsize=(12, 7))
ax.set_xlim(0, 12)
ax.set_ylim(0, 8)
ax.axis("off")
ax.set_title("Les 6 contraintes architecturales de REST (Fielding, 2000)", fontsize=13, fontweight="bold")

contraintes = [
    (1.5, 6.5, "Client-Serveur", "#2196F3",
     "Séparation UI / données\n→ Évolutivité indépendante"),
    (4.5, 6.5, "Sans état\n(Stateless)", "#4CAF50",
     "Pas de session serveur\n→ Scalabilité, fiabilité"),
    (7.5, 6.5, "Cache", "#FF9800",
     "Réponses cachables/non\n→ Performance réseau"),
    (1.5, 3.0, "Interface\nUniforme", "#9C27B0",
     "URI, représentation, HATEOAS\n→ Interopérabilité"),
    (4.5, 3.0, "Système\nen couches", "#F44336",
     "Proxys, LB, cache transparents\n→ Sécurité, scalabilité"),
    (7.5, 3.0, "Code à la\ndemande (opt.)", "#607D8B",
     "JS, WASM envoyé au client\n→ Extensibilité"),
]

for x, y, titre, couleur, benefice in contraintes:
    # Boîte principale
    rect = mpatches.FancyBboxPatch((x - 1.3, y - 0.6), 2.6, 1.2,
                                    boxstyle="round,pad=0.1",
                                    facecolor=couleur, edgecolor="white",
                                    alpha=0.9, linewidth=2, zorder=5)
    ax.add_patch(rect)
    ax.text(x, y, titre, ha="center", va="center", fontsize=9.5,
            color="white", fontweight="bold", zorder=6)
    # Bénéfice
    ax.text(x, y - 1.2, benefice, ha="center", va="top", fontsize=7.5,
            color="#37474F", style="italic", multialignment="center")

# Flèche centrale REST
center_x, center_y = 10.2, 4.75
cercle = plt.Circle((center_x, center_y), 1.2, color="#37474F", alpha=0.15, zorder=3)
ax.add_patch(cercle)
ax.text(center_x, center_y, "API\nREST", ha="center", va="center",
        fontsize=12, fontweight="bold", color="#37474F", zorder=4)

# Flèches vers le centre
for x, y, *_ in contraintes:
    ax.annotate("", xy=(center_x - 1.1, center_y + (y - center_y)*0.2),
                xytext=(x + 1.4 if x < 6 else x - 1.4, y),
                arrowprops=dict(arrowstyle="->", color="#9E9E9E", lw=1,
                               connectionstyle="arc3,rad=0.1"))

ax.text(6, 0.5,
        "Une API est vraiment RESTful si elle respecte toutes ces contraintes. "
        "En pratique, on parle souvent d'API HTTP ou API REST-like.",
        ha="center", fontsize=9, color="#607D8B", style="italic")

plt.tight_layout()
plt.show()
_images/b04d4b31c421f6a9f322d84f824a6c083d99683b2593a60348096b5655a8f62d.png

Conception d’URL et nommage des ressources#

Ressources et représentations#

En REST, tout est ressource : un utilisateur, une commande, un article, une image. Chaque ressource est identifiée par une URI (Uniform Resource Identifier). Une ressource peut avoir plusieurs représentations (JSON, XML, HTML) négociées via l’en-tête Accept.

Règles de nommage des URIs :

  • Utiliser des noms (pas des verbes) : /utilisateurs et non /obtenirUtilisateurs

  • Utiliser des pluriels pour les collections : /utilisateurs, /commandes

  • Utiliser des minuscules et des tirets pour les séparateurs de mots : /articles-de-blog

  • Éviter les extensions de fichiers : /utilisateurs/42 et non /utilisateurs/42.json

  • La hiérarchie reflète les relations : /utilisateurs/42/commandes (commandes d’un utilisateur)

Exemples de conception d’URL :

Ressource

URI

Méthode

Action

Collection utilisateurs

/api/v1/utilisateurs

GET

Lister

Créer un utilisateur

/api/v1/utilisateurs

POST

Créer

Utilisateur spécifique

/api/v1/utilisateurs/42

GET

Lire

Modifier un utilisateur

/api/v1/utilisateurs/42

PUT

Remplacer

Mise à jour partielle

/api/v1/utilisateurs/42

PATCH

Modifier

Supprimer

/api/v1/utilisateurs/42

DELETE

Supprimer

Commandes d’un utilisateur

/api/v1/utilisateurs/42/commandes

GET

Lister

Paramètres de requête#

Les paramètres de requête servent à :

  • Filtrage : GET /articles?categorie=tech&statut=publie

  • Tri : GET /utilisateurs?tri=nom&ordre=asc

  • Pagination : GET /commandes?page=3&limite=20

  • Recherche : GET /produits?q=ordinateur+portable

  • Sélection de champs : GET /utilisateurs?champs=id,nom,email

URIs des actions non-CRUD

Certaines opérations ne correspondent pas aux actions CRUD standards. Deux approches :

  1. Sous-ressource verbale : POST /commandes/42/annuler — acceptable quand l’action modifie l’état de la ressource

  2. Utiliser PATCH avec un champ statut : PATCH /commandes/42 avec {"statut": "annule"} — plus RESTful

Éviter absolument : GET /annulerCommande?id=42 — viole la sémantique des méthodes HTTP.

Idempotence et sécurité des méthodes HTTP#

Hide code cell source

# Tableau des propriétés des méthodes HTTP
fig, ax = plt.subplots(figsize=(13, 5))
ax.axis("off")

colonnes = ["Méthode", "Sûre\n(Safe)", "Idempotente", "Corps\nrequête", "Corps\nréponse", "Cache", "Usage typique"]
lignes = [
    ["GET", "Oui", "Oui", "Non (ignoré)", "Oui", "Oui", "Lire une ressource ou collection"],
    ["HEAD", "Oui", "Oui", "Non", "Non (headers)", "Oui", "Vérifier existence, taille"],
    ["OPTIONS", "Oui", "Oui", "Non", "Oui", "Non", "Méthodes autorisées, CORS preflight"],
    ["POST", "Non", "Non", "Oui", "Oui", "Non", "Créer une ressource"],
    ["PUT", "Non", "Oui", "Oui", "Oui", "Non", "Remplacer une ressource entière"],
    ["PATCH", "Non", "Non*", "Oui", "Oui", "Non", "Modification partielle"],
    ["DELETE", "Non", "Oui", "Optionnel", "Optionnel", "Non", "Supprimer une ressource"],
]

couleurs_lignes = []
for i, ligne in enumerate(lignes):
    row = []
    for j, cell in enumerate(ligne):
        if j == 0:
            row.append("#ECEFF1")
        elif cell in ("Oui",):
            row.append("#E8F5E9")
        elif cell in ("Non",):
            row.append("#FFEBEE")
        elif i % 2 == 0:
            row.append("#FAFAFA")
        else:
            row.append("#F5F5F5")
    couleurs_lignes.append(row)

table = ax.table(
    cellText=lignes,
    colLabels=colonnes,
    cellLoc="center",
    loc="center",
    cellColours=couleurs_lignes,
)
table.auto_set_font_size(False)
table.set_fontsize(8.5)
table.scale(1, 1.9)

for j in range(len(colonnes)):
    table[0, j].set_facecolor("#37474F")
    table[0, j].set_text_props(color="white", fontweight="bold")

ax.set_title("Propriétés des méthodes HTTP — Sécurité et idempotence", fontsize=12, fontweight="bold", pad=20)
ax.text(0.5, -0.05, "* PATCH peut être rendu idempotent avec des opérations de type JSON Patch (RFC 6902)",
        ha="center", transform=ax.transAxes, fontsize=8, color="#607D8B", style="italic")
plt.tight_layout()
plt.show()
_images/247dce4befe429ff56f98bc6ba24dc4c68a75f928c837c93df48a6dae06ac4a9.png

Idempotence vs sécurité

  • Sûre (safe) : la méthode ne modifie pas l’état du serveur. GET, HEAD, OPTIONS sont sûres.

  • Idempotente : effectuer la même opération N fois produit le même résultat qu’une seule fois. DELETE /utilisateurs/42 effectué deux fois : la deuxième fois, la ressource est déjà absente (404), mais l’état du serveur est le même.

  • POST n’est ni sûre ni idempotente : deux POST identiques créent deux ressources distinctes.

Codes de statut HTTP appropriés#

Utiliser les codes de statut HTTP corrects est essentiel pour une API RESTful. Les clients (humains ou machines) s’appuient sur ces codes pour comprendre le résultat de chaque requête.

Hide code cell source

# Visualisation des codes de statut HTTP pour les APIs REST
fig, axes = plt.subplots(1, 5, figsize=(15, 6))
fig.suptitle("Codes de statut HTTP pour APIs REST", fontsize=13, fontweight="bold")

groupes = {
    "2xx\nSuccès": {
        "color": "#4CAF50",
        "codes": [
            ("200", "OK", "Lecture réussie"),
            ("201", "Created", "Ressource créée"),
            ("202", "Accepted", "Traitement asynchrone"),
            ("204", "No Content", "Succès sans contenu\n(DELETE réussi)"),
            ("206", "Partial Content", "Réponse paginée\nou Range"),
        ]
    },
    "3xx\nRedirection": {
        "color": "#FF9800",
        "codes": [
            ("301", "Moved\nPermanently", "URI changé\ndéfinitivement"),
            ("302", "Found", "Redirection\ntemporaire"),
            ("304", "Not Modified", "Cache valide\n(ETag/Last-Modified)"),
            ("307", "Temporary\nRedirect", "Même méthode,\nURI temp."),
            ("308", "Permanent\nRedirect", "Même méthode,\nURI perm."),
        ]
    },
    "4xx\nErreur client": {
        "color": "#F44336",
        "codes": [
            ("400", "Bad Request", "Corps invalide,\nformat incorrect"),
            ("401", "Unauthorized", "Authentification\nrequise"),
            ("403", "Forbidden", "Accès refusé\n(auth OK, droits non)"),
            ("404", "Not Found", "Ressource\ninexistante"),
            ("405", "Method Not\nAllowed", "Méthode HTTP\nnon autorisée"),
            ("409", "Conflict", "Conflit état\n(doublon)"),
            ("410", "Gone", "Supprimée\ndéfinitivement"),
            ("422", "Unprocessable\nEntity", "Validation\nmétier échoue"),
            ("429", "Too Many\nRequests", "Rate limiting\ndépassé"),
        ]
    },
    "5xx\nErreur serveur": {
        "color": "#9C27B0",
        "codes": [
            ("500", "Internal\nServer Error", "Erreur générique\nserveur"),
            ("501", "Not\nImplemented", "Fonctionnalité\nnon dispo"),
            ("502", "Bad Gateway", "Erreur proxy/\nupstream"),
            ("503", "Service\nUnavailable", "Serveur\nsurchargé"),
            ("504", "Gateway\nTimeout", "Timeout upstream"),
        ]
    },
}

for ax, (titre, groupe) in zip(axes, groupes.items()):
    color = groupe["color"]
    codes = groupe["codes"]
    ax.set_xlim(0, 4)
    ax.set_ylim(-0.5, len(codes))
    ax.axis("off")
    ax.set_title(titre, fontsize=11, fontweight="bold", color=color)

    for i, (code, nom, desc) in enumerate(codes):
        y = len(codes) - 1 - i
        rect = mpatches.FancyBboxPatch((0.1, y - 0.38), 3.8, 0.76,
                                        boxstyle="round,pad=0.05",
                                        facecolor=color, edgecolor="white",
                                        alpha=0.15 + 0.05 * (i % 2),
                                        linewidth=1)
        ax.add_patch(rect)
        ax.text(0.4, y + 0.1, code, fontsize=11, fontweight="bold", color=color, va="center")
        ax.text(0.4, y - 0.2, nom, fontsize=7, color="#424242", va="center")
        ax.text(2.1, y, desc, fontsize=6.5, color="#616161", va="center",
                multialignment="left")

# 1xx
ax5 = axes[4]
ax5.set_xlim(0, 4)
ax5.set_ylim(-0.5, 5)
ax5.axis("off")
ax5.set_title("1xx\nInformatif", fontsize=11, fontweight="bold", color="#2196F3")
codes_1xx = [
    ("100", "Continue", "Le client peut\ncontinuer"),
    ("101", "Switching\nProtocols", "Upgrade WebSocket,\nHTTP/2"),
]
for i, (code, nom, desc) in enumerate(codes_1xx):
    y = 4 - i
    rect = mpatches.FancyBboxPatch((0.1, y - 0.38), 3.8, 0.76,
                                    boxstyle="round,pad=0.05",
                                    facecolor="#2196F3", edgecolor="white",
                                    alpha=0.2, linewidth=1)
    ax5.add_patch(rect)
    ax5.text(0.4, y + 0.1, code, fontsize=11, fontweight="bold", color="#2196F3", va="center")
    ax5.text(0.4, y - 0.2, nom, fontsize=7, color="#424242", va="center")
    ax5.text(2.1, y, desc, fontsize=6.5, color="#616161", va="center", multialignment="left")

plt.tight_layout()
plt.show()
_images/bc4290684358ef3eabf59dbb2d629baee7afbf9d13f10a18d6e475f8162ff839.png

Pagination#

Renvoyer l’intégralité d’une collection peut être coûteux voire impossible pour des collections de millions d’éléments. La pagination est indispensable.

Pagination offset/limit#

GET /api/v1/articles?offset=40&limit=20

Retourne les articles 41 à 60. Simple à implémenter, mais présente des problèmes avec les données dynamiques (si un élément est inséré entre deux pages, des éléments peuvent être sautés ou dupliqués).

Réponse avec métadonnées :

{
  "data": [...],
  "pagination": {
    "offset": 40,
    "limit": 20,
    "total": 1247,
    "pages": 63
  }
}

Pagination par curseur (cursor-based)#

Plus robuste pour les données dynamiques. Le curseur est un identifiant opaque pointant vers un élément spécifique :

GET /api/v1/articles?apres=eyJpZCI6NDF9&limite=20

Réponse :

{
  "data": [...],
  "pagination": {
    "curseur_suivant": "eyJpZCI6NjF9",
    "curseur_precedent": "eyJpZCI6MjF9",
    "a_suivant": true,
    "a_precedent": true
  }
}

Versioning d’API#

Les APIs évoluent. Le versioning permet d’introduire des changements incompatibles sans casser les clients existants.

Hide code cell source

# Comparaison des stratégies de versioning
fig, axes = plt.subplots(1, 3, figsize=(14, 5))
fig.suptitle("Stratégies de versioning d'API", fontsize=13, fontweight="bold")

strategies = [
    {
        "titre": "Versioning par URL",
        "exemple": "GET /api/v2/utilisateurs/42",
        "pros": ["Simple et visible", "Facile à tester\ndans un navigateur",
                 "Facile à router\npar l'infrastructure", "Très répandu"],
        "cons": ["L'URI devrait identifier\nune ressource, pas une version",
                 "Prolifération d'URLs"],
        "color": "#2196F3",
    },
    {
        "titre": "Versioning par en-tête Accept",
        "exemple": "Accept: application/vnd.example.v2+json",
        "pros": ["URIs propres", "Conforme à REST\n(négociation de contenu)",
                 "Peut coexister avec\nd'autres négociations"],
        "cons": ["Moins visible", "Plus difficile à tester\n(curl, navigateur)",
                 "Complexité serveur"],
        "color": "#4CAF50",
    },
    {
        "titre": "Versioning par paramètre",
        "exemple": "GET /api/utilisateurs/42?version=2",
        "pros": ["Simple à ajouter", "Facile à tester"],
        "cons": ["Mélange filtrage et\nversioning dans les params",
                 "Peu conventionnel",
                 "Paramètre peut être\ncaché par des caches"],
        "color": "#FF9800",
    },
]

for ax, strat in zip(axes, strategies):
    ax.set_xlim(0, 5)
    ax.set_ylim(-1, 10)
    ax.axis("off")
    ax.set_title(strat["titre"], fontsize=10.5, fontweight="bold", color=strat["color"])

    # Exemple d'URL
    rect = mpatches.FancyBboxPatch((0.2, 8.2), 4.6, 1.0,
                                    boxstyle="round,pad=0.1",
                                    facecolor=strat["color"], alpha=0.15,
                                    edgecolor=strat["color"], linewidth=1.5)
    ax.add_patch(rect)
    ax.text(2.5, 8.7, strat["exemple"], ha="center", va="center",
            fontsize=7.5, color=strat["color"], fontweight="bold",
            family="monospace")

    # Avantages
    ax.text(2.5, 7.6, "Avantages", ha="center", fontsize=9,
            fontweight="bold", color="#2E7D32")
    for i, pro in enumerate(strat["pros"]):
        ax.text(0.4, 7.0 - i * 1.1, f"✓  {pro}", fontsize=8, color="#2E7D32",
                va="top")

    # Inconvénients
    y_cons = 7.0 - len(strat["pros"]) * 1.1 - 0.6
    ax.text(2.5, y_cons + 0.3, "Inconvénients", ha="center", fontsize=9,
            fontweight="bold", color="#C62828")
    for i, con in enumerate(strat["cons"]):
        ax.text(0.4, y_cons - 0.1 - i * 1.1, f"✗  {con}", fontsize=8,
                color="#C62828", va="top")

plt.tight_layout()
plt.show()
_images/a24430fcdee7f4820ba74abddbce90da47830572d1d3f88fcf4863e3a74dbb88.png

Authentification et autorisation#

Basic Authentication#

L’authentification HTTP Basic encode identifiant:mot_de_passe en Base64 dans l’en-tête Authorization. Elle est simple mais doit toujours être utilisée sur HTTPS car le Base64 n’est pas du chiffrement.

Authorization: Basic dXNlcjpwYXNzd29yZA==

Bearer Token (JWT)#

Les JSON Web Tokens (JWT) sont le mécanisme d’authentification standard des APIs modernes. Un JWT est un token signé contenant des claims (revendications) :

Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxNTAwMDAwMH0.xxxx
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Le serveur vérifie la signature JWT sans accéder à une base de données, ce qui est stateless et scalable.

OAuth 2.0 : Authorization Code Flow#

OAuth 2.0 est le standard d’autorisation pour permettre à une application tierce d’accéder aux ressources d’un utilisateur sans que cet utilisateur partage son mot de passe.

Hide code cell source

# Diagramme flux OAuth 2.0 Authorization Code
fig, ax = plt.subplots(figsize=(13, 9))
ax.set_xlim(0, 14)
ax.set_ylim(0, 11)
ax.axis("off")
ax.set_title("Flux OAuth 2.0 — Authorization Code Flow", fontsize=13, fontweight="bold")

# Acteurs
acteurs = [
    (1.5, "Utilisateur\n(Resource Owner)", "#2196F3"),
    (5.5, "Application\n(Client)", "#4CAF50"),
    (9.5, "Serveur\nd'autorisation", "#FF9800"),
    (13, "Serveur\nde ressources", "#9C27B0"),
]

for x, label, color in acteurs:
    ax.axvline(x=x, color=color, linewidth=2, ymin=0.05, ymax=0.9, alpha=0.7)
    ax.text(x, 10.7, label, ha="center", va="center", fontsize=9,
            fontweight="bold", color=color, multialignment="center")

# Messages
messages = [
    # (y, x1, x2, label, color)
    (9.5,  5.5, 1.5, "1. Clique sur 'Connecter avec Google'", "#4CAF50"),
    (8.5,  5.5, 9.5, "2. Redirect → /authorize?client_id=...&redirect_uri=...&scope=...&state=...", "#FF9800"),
    (7.5,  9.5, 1.5, "3. Page de connexion Google", "#FF9800"),
    (6.5,  1.5, 9.5, "4. Identifiants (login/password)", "#2196F3"),
    (5.5,  9.5, 1.5, "5. Redirect → /callback?code=AUTH_CODE&state=...", "#FF9800"),
    (4.5,  1.5, 5.5, "6. Suit la redirection (code fourni)", "#2196F3"),
    (3.5,  5.5, 9.5, "7. POST /token {code, client_secret, redirect_uri}", "#4CAF50"),
    (2.5,  9.5, 5.5, "8. {access_token, refresh_token, expires_in}", "#FF9800"),
    (1.5,  5.5, 13,  "9. GET /userinfo (Authorization: Bearer ACCESS_TOKEN)", "#4CAF50"),
    (0.7,  13,  5.5, "10. {id, email, nom, ...}", "#9C27B0"),
]

for y, x1, x2, label, color in messages:
    ax.annotate("", xy=(x2, y), xytext=(x1, y),
                arrowprops=dict(arrowstyle="->", color=color, lw=1.8))
    mid = (x1 + x2) / 2
    ax.text(mid, y + 0.15, label, ha="center", fontsize=6.8, color=color,
            style="italic")

plt.tight_layout()
plt.show()
_images/7fea0313ab9145ae534c16d66ec974870237766ddddf8be110e6dc90a971acfa.png

Hide code cell source

# Simulation : vérification d'un JWT
import json
import base64
import hashlib
import hmac

def base64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

def base64url_decode(s: str) -> bytes:
    padding = 4 - len(s) % 4
    if padding != 4:
        s += "=" * padding
    return base64.urlsafe_b64decode(s)

def creer_jwt(payload: dict, secret: str) -> str:
    """Crée un JWT signé HS256."""
    header = {"alg": "HS256", "typ": "JWT"}
    header_b64 = base64url_encode(json.dumps(header, separators=(",", ":")).encode())
    payload_b64 = base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
    message = f"{header_b64}.{payload_b64}"
    signature = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).digest()
    sig_b64 = base64url_encode(signature)
    return f"{message}.{sig_b64}"

def verifier_jwt(token: str, secret: str) -> dict | None:
    """Vérifie un JWT et retourne le payload si valide."""
    try:
        header_b64, payload_b64, sig_b64 = token.split(".")
        message = f"{header_b64}.{payload_b64}"
        expected_sig = hmac.new(
            secret.encode(),
            message.encode(),
            hashlib.sha256
        ).digest()
        actual_sig = base64url_decode(sig_b64)
        if not hmac.compare_digest(expected_sig, actual_sig):
            return None
        return json.loads(base64url_decode(payload_b64))
    except Exception:
        return None

# Démonstration
secret = "ma_cle_secrete_super_longue_et_complexe"
payload = {
    "sub": "42",
    "nom": "Jean Dupont",
    "role": "admin",
    "iat": 1715000000,
    "exp": 1715086400,
}

token = creer_jwt(payload, secret)
print("JWT généré :")
print(token[:60] + "...")
print()

parties = token.split(".")
print(f"Header   : {json.loads(base64url_decode(parties[0]))}")
print(f"Payload  : {json.loads(base64url_decode(parties[1]))}")
print()

# Vérification
payload_verifie = verifier_jwt(token, secret)
print(f"Signature valide : {payload_verifie is not None}")
print(f"Utilisateur      : {payload_verifie['nom']}, rôle={payload_verifie['role']}")

# Token falsifié
token_falsifie = token[:-5] + "XXXXX"
print(f"\nToken falsifié valide : {verifier_jwt(token_falsifie, secret) is not None}")
JWT généré :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5vbSI...

Header   : {'alg': 'HS256', 'typ': 'JWT'}
Payload  : {'sub': '42', 'nom': 'Jean Dupont', 'role': 'admin', 'iat': 1715000000, 'exp': 1715086400}

Signature valide : True
Utilisateur      : Jean Dupont, rôle=admin

Token falsifié valide : False

OpenAPI et Swagger#

OpenAPI (anciennement Swagger) est le standard de description des APIs HTTP. Une spécification OpenAPI (YAML ou JSON) décrit complètement l’API : routes, méthodes, paramètres, corps de requête, réponses, schémas de données, sécurité.

Hide code cell source

# Exemple de spécification OpenAPI 3.0 (affichage pédagogique)
openapi_spec = """
openapi: "3.0.3"
info:
  title: API Utilisateurs
  description: API REST pour la gestion des utilisateurs
  version: "1.0.0"
  contact:
    name: Lôc Cosnier
    email: contact@alkimya.fr

servers:
  - url: https://api.example.com/v1
    description: Production
  - url: https://api-staging.example.com/v1
    description: Staging

paths:
  /utilisateurs:
    get:
      summary: Lister les utilisateurs
      operationId: listerUtilisateurs
      tags: [Utilisateurs]
      security:
        - bearerAuth: []
      parameters:
        - name: page
          in: query
          schema: {type: integer, default: 1}
        - name: limite
          in: query
          schema: {type: integer, default: 20, maximum: 100}
        - name: role
          in: query
          schema: {type: string, enum: [admin, user, moderateur]}
      responses:
        "200":
          description: Liste paginée des utilisateurs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginationUtilisateurs"
        "401":
          $ref: "#/components/responses/NonAuthenrifie"

    post:
      summary: Créer un utilisateur
      operationId: creerUtilisateur
      tags: [Utilisateurs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NouvelUtilisateur"
      responses:
        "201":
          description: Utilisateur créé
          headers:
            Location:
              schema: {type: string}
              description: URI du nouvel utilisateur
        "409":
          description: Email déjà utilisé

  /utilisateurs/{id}:
    get:
      summary: Obtenir un utilisateur
      operationId: obtenirUtilisateur
      tags: [Utilisateurs]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: integer}
      responses:
        "200":
          description: Utilisateur trouvé
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Utilisateur"
        "404":
          description: Utilisateur non trouvé

components:
  schemas:
    Utilisateur:
      type: object
      properties:
        id: {type: integer, example: 42}
        nom: {type: string, example: "Jean Dupont"}
        email: {type: string, format: email}
        role: {type: string, enum: [admin, user, moderateur]}
        cree_le: {type: string, format: date-time}
        _links:
          $ref: "#/components/schemas/HATEOASLinks"

    NouvelUtilisateur:
      type: object
      required: [nom, email, mot_de_passe]
      properties:
        nom: {type: string, minLength: 2, maxLength: 100}
        email: {type: string, format: email}
        mot_de_passe: {type: string, minLength: 12}
        role: {type: string, default: user}

    HATEOASLinks:
      type: object
      additionalProperties:
        type: object
        properties:
          href: {type: string}
          method: {type: string}

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
"""

print("Extrait de spécification OpenAPI 3.0 :")
print("=" * 60)
# Afficher seulement les premières lignes pour la lisibilité
for i, line in enumerate(openapi_spec.strip().split("\n")[:50]):
    print(line)
print("  ...")
print(f"\nSpécification complète : {len(openapi_spec.split(chr(10)))} lignes")
Extrait de spécification OpenAPI 3.0 :
============================================================
openapi: "3.0.3"
info:
  title: API Utilisateurs
  description: API REST pour la gestion des utilisateurs
  version: "1.0.0"
  contact:
    name: Lôc Cosnier
    email: contact@alkimya.fr

servers:
  - url: https://api.example.com/v1
    description: Production
  - url: https://api-staging.example.com/v1
    description: Staging

paths:
  /utilisateurs:
    get:
      summary: Lister les utilisateurs
      operationId: listerUtilisateurs
      tags: [Utilisateurs]
      security:
        - bearerAuth: []
      parameters:
        - name: page
          in: query
          schema: {type: integer, default: 1}
        - name: limite
          in: query
          schema: {type: integer, default: 20, maximum: 100}
        - name: role
          in: query
          schema: {type: string, enum: [admin, user, moderateur]}
      responses:
        "200":
          description: Liste paginée des utilisateurs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginationUtilisateurs"
        "401":
          $ref: "#/components/responses/NonAuthenrifie"

    post:
      summary: Créer un utilisateur
      operationId: creerUtilisateur
      tags: [Utilisateurs]
      requestBody:
        required: true
        content:
  ...

Spécification complète : 120 lignes

Mini serveur REST avec http.server#

Hide code cell source

import json
import threading
import time
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs

# Base de données en mémoire
DB = {
    "utilisateurs": {
        1: {"id": 1, "nom": "Alice Martin", "email": "alice@example.com", "role": "admin"},
        2: {"id": 2, "nom": "Bob Durand", "email": "bob@example.com", "role": "user"},
        3: {"id": 3, "nom": "Chloé Petit", "email": "chloe@example.com", "role": "user"},
    },
    "next_id": 4,
}

class RESTHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # Silence des logs

    def _envoyer_reponse(self, code: int, data, headers: dict = None):
        body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", len(body))
        if headers:
            for k, v in headers.items():
                self.send_header(k, v)
        self.end_headers()
        self.wfile.write(body)

    def _lire_corps(self) -> dict:
        longueur = int(self.headers.get("Content-Length", 0))
        if longueur > 0:
            return json.loads(self.rfile.read(longueur).decode("utf-8"))
        return {}

    def do_GET(self):
        parsed = urlparse(self.path)
        chemin = parsed.path
        params = parse_qs(parsed.query)

        if chemin == "/api/v1/utilisateurs":
            # GET /api/v1/utilisateurs — avec pagination
            limite = int(params.get("limite", [10])[0])
            page = int(params.get("page", [1])[0])
            tous = list(DB["utilisateurs"].values())
            debut = (page - 1) * limite
            fin = debut + limite
            data = {
                "data": tous[debut:fin],
                "pagination": {
                    "page": page, "limite": limite,
                    "total": len(tous),
                    "pages": max(1, -(-len(tous) // limite)),
                }
            }
            self._envoyer_reponse(200, data)

        elif chemin.startswith("/api/v1/utilisateurs/"):
            try:
                uid = int(chemin.split("/")[-1])
            except ValueError:
                self._envoyer_reponse(400, {"erreur": "ID invalide"})
                return
            if uid not in DB["utilisateurs"]:
                self._envoyer_reponse(404, {"erreur": f"Utilisateur {uid} non trouvé"})
                return
            user = DB["utilisateurs"][uid].copy()
            user["_links"] = {
                "self": {"href": f"/api/v1/utilisateurs/{uid}"},
                "modifier": {"href": f"/api/v1/utilisateurs/{uid}", "method": "PUT"},
                "supprimer": {"href": f"/api/v1/utilisateurs/{uid}", "method": "DELETE"},
            }
            self._envoyer_reponse(200, user)
        else:
            self._envoyer_reponse(404, {"erreur": "Route non trouvée"})

    def do_POST(self):
        if self.path == "/api/v1/utilisateurs":
            corps = self._lire_corps()
            champs_requis = ["nom", "email"]
            manquants = [c for c in champs_requis if c not in corps]
            if manquants:
                self._envoyer_reponse(422, {"erreur": f"Champs requis manquants : {manquants}"})
                return

            # Vérification email unique
            emails = [u["email"] for u in DB["utilisateurs"].values()]
            if corps["email"] in emails:
                self._envoyer_reponse(409, {"erreur": "Email déjà utilisé"})
                return

            uid = DB["next_id"]
            DB["next_id"] += 1
            nouvel_user = {"id": uid, "role": "user", **corps}
            DB["utilisateurs"][uid] = nouvel_user
            self._envoyer_reponse(
                201, nouvel_user,
                headers={"Location": f"/api/v1/utilisateurs/{uid}"}
            )
        else:
            self._envoyer_reponse(404, {"erreur": "Route non trouvée"})

    def do_PATCH(self):
        if self.path.startswith("/api/v1/utilisateurs/"):
            try:
                uid = int(self.path.split("/")[-1])
            except ValueError:
                self._envoyer_reponse(400, {"erreur": "ID invalide"})
                return
            if uid not in DB["utilisateurs"]:
                self._envoyer_reponse(404, {"erreur": "Utilisateur non trouvé"})
                return
            corps = self._lire_corps()
            DB["utilisateurs"][uid].update(corps)
            self._envoyer_reponse(200, DB["utilisateurs"][uid])
        else:
            self._envoyer_reponse(404, {"erreur": "Route non trouvée"})

    def do_DELETE(self):
        if self.path.startswith("/api/v1/utilisateurs/"):
            try:
                uid = int(self.path.split("/")[-1])
            except ValueError:
                self._envoyer_reponse(400, {"erreur": "ID invalide"})
                return
            if uid not in DB["utilisateurs"]:
                self._envoyer_reponse(404, {"erreur": "Utilisateur non trouvé"})
                return
            del DB["utilisateurs"][uid]
            self.send_response(204)
            self.end_headers()
        else:
            self._envoyer_reponse(404, {"erreur": "Route non trouvée"})

# Démarrage du serveur
serveur = HTTPServer(("localhost", 8888), RESTHandler)
thread = threading.Thread(target=serveur.serve_forever, daemon=True)
thread.start()
print("Serveur REST démarré sur http://localhost:8888")
time.sleep(0.1)

def appel_api(methode: str, chemin: str, corps: dict = None) -> tuple:
    """Client HTTP minimal pour tester l'API."""
    url = f"http://localhost:8888{chemin}"
    data = json.dumps(corps).encode() if corps else None
    headers = {"Content-Type": "application/json"} if corps else {}
    req = urllib.request.Request(url, data=data, headers=headers, method=methode)
    try:
        with urllib.request.urlopen(req) as resp:
            return resp.status, json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read().decode())

# Tests de l'API
print("\n--- Tests de l'API REST ---")

# GET liste
code, data = appel_api("GET", "/api/v1/utilisateurs")
print(f"\nGET /utilisateurs → {code}")
print(f"  Total : {data['pagination']['total']} utilisateurs")

# GET détail avec HATEOAS
code, data = appel_api("GET", "/api/v1/utilisateurs/1")
print(f"\nGET /utilisateurs/1 → {code}")
print(f"  {data['nom']} ({data['email']})")
print(f"  Liens HATEOAS : {list(data['_links'].keys())}")

# POST créer
code, data = appel_api("POST", "/api/v1/utilisateurs",
                        {"nom": "Damien Leclerc", "email": "damien@example.com"})
print(f"\nPOST /utilisateurs → {code} (créé id={data['id']})")

# POST doublon email
code, data = appel_api("POST", "/api/v1/utilisateurs",
                        {"nom": "Autre", "email": "alice@example.com"})
print(f"\nPOST /utilisateurs (doublon email) → {code}: {data['erreur']}")

# PATCH mise à jour partielle
code, data = appel_api("PATCH", "/api/v1/utilisateurs/2", {"role": "moderateur"})
print(f"\nPATCH /utilisateurs/2 → {code}: role={data['role']}")

# DELETE
code, _ = appel_api("DELETE", "/api/v1/utilisateurs/3"), ""
print(f"\nDELETE /utilisateurs/3 → {code[0]}")

# GET 404
code, data = appel_api("GET", "/api/v1/utilisateurs/999")
print(f"\nGET /utilisateurs/999 → {code}: {data['erreur']}")

serveur.shutdown()
Serveur REST démarré sur http://localhost:8888

--- Tests de l'API REST ---

GET /utilisateurs → 200
  Total : 3 utilisateurs

GET /utilisateurs/1 → 200
  Alice Martin (alice@example.com)
  Liens HATEOAS : ['self', 'modifier', 'supprimer']

POST /utilisateurs → 201 (créé id=4)

POST /utilisateurs (doublon email) → 409: Email déjà utilisé

PATCH /utilisateurs/2 → 200: role=moderateur
---------------------------------------------------------------------------
JSONDecodeError                           Traceback (most recent call last)
Cell In[9], line 187
    184 print(f"\nPATCH /utilisateurs/2 → {code}: role={data['role']}")
    186 # DELETE
--> 187 code, _ = appel_api("DELETE", "/api/v1/utilisateurs/3"), ""
    188 print(f"\nDELETE /utilisateurs/3 → {code[0]}")
    190 # GET 404

Cell In[9], line 154, in appel_api(methode, chemin, corps)
    152 try:
    153     with urllib.request.urlopen(req) as resp:
--> 154         return resp.status, json.loads(resp.read().decode())
    155 except urllib.error.HTTPError as e:
    156     return e.code, json.loads(e.read().decode())

File /usr/lib/python3.13/json/__init__.py:346, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
    341     s = s.decode(detect_encoding(s), 'surrogatepass')
    343 if (cls is None and object_hook is None and
    344         parse_int is None and parse_float is None and
    345         parse_constant is None and object_pairs_hook is None and not kw):
--> 346     return _default_decoder.decode(s)
    347 if cls is None:
    348     cls = JSONDecoder

File /usr/lib/python3.13/json/decoder.py:345, in JSONDecoder.decode(self, s, _w)
    340 def decode(self, s, _w=WHITESPACE.match):
    341     """Return the Python representation of ``s`` (a ``str`` instance
    342     containing a JSON document).
    343 
    344     """
--> 345     obj, end = self.raw_decode(s, idx=_w(s, 0).end())
    346     end = _w(s, end).end()
    347     if end != len(s):

File /usr/lib/python3.13/json/decoder.py:363, in JSONDecoder.raw_decode(self, s, idx)
    361     obj, end = self.scan_once(s, idx)
    362 except StopIteration as err:
--> 363     raise JSONDecodeError("Expecting value", s, err.value) from None
    364 return obj, end

JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Visualisation des bonnes pratiques REST#

Hide code cell source

# Scorecard visuel : bonnes pratiques API REST
fig, ax = plt.subplots(figsize=(12, 7))
ax.set_xlim(0, 12)
ax.set_ylim(-0.5, 12)
ax.axis("off")
ax.set_title("Checklist des bonnes pratiques API REST", fontsize=13, fontweight="bold")

bonnes_pratiques = [
    ("NOMMAGE", [
        ("Utiliser des noms de ressources au pluriel (/articles, /utilisateurs)", True),
        ("Utiliser des minuscules et tirets (/articles-de-blog)", True),
        ("Hiérarchie pour les sous-ressources (/users/42/orders)", True),
        ("Éviter les verbes dans les URIs (/getUser, /deleteArticle)", False),
    ]),
    ("MÉTHODES", [
        ("GET pour la lecture, POST pour la création", True),
        ("PUT pour le remplacement complet, PATCH pour la mise à jour partielle", True),
        ("DELETE retourne 204 No Content ou 200 OK", True),
        ("GET modifie l'état du serveur (side effects)", False),
    ]),
    ("RÉPONSES", [
        ("Utiliser les codes HTTP appropriés (201 Created, 204 No Content)", True),
        ("Inclure un corps JSON d'erreur descriptif pour 4xx/5xx", True),
        ("Paginer les collections volumineuses", True),
        ("Retourner 200 OK pour tout (même les erreurs)", False),
    ]),
    ("SÉCURITÉ", [
        ("Authentification par JWT Bearer token", True),
        ("HTTPS obligatoire pour tout le trafic", True),
        ("Validation et sanitisation de toutes les entrées", True),
        ("Exposer des IDs internes séquentiels prévisibles", False),
    ]),
]

y = 11
for categorie, pratiques in bonnes_pratiques:
    ax.text(0.3, y, categorie, fontsize=10, fontweight="bold", color="#37474F")
    y -= 0.5
    for pratique, bon in pratiques:
        couleur = "#2E7D32" if bon else "#C62828"
        signe = "✓" if bon else "✗"
        style = "normal" if bon else "italic"
        ax.text(0.6, y, f"{signe}  {pratique}", fontsize=8.5, color=couleur,
                style=style, va="top")
        y -= 0.7
    y -= 0.2

plt.tight_layout()
plt.show()

Résumé#

REST est avant tout un style architectural fondé sur des contraintes qui produisent des systèmes évolutifs et interopérables. Les APIs REST modernes sont construites autour de quelques principes simples : des URIs identifiant des ressources (noms, pas verbes), des méthodes HTTP avec leur sémantique correcte (idempotence, sécurité), des codes de statut appropriés, et une authentification par token.

La spécification OpenAPI permet de documenter, valider et générer du code client/serveur automatiquement. HATEOAS, bien que rarement implémenté en totalité, guide vers des APIs auto-descriptives où le client découvre les actions disponibles à partir des réponses du serveur.