Réseaux & Web

API REST
Python

Concevoir et consommer des API HTTP — requests, Flask, FastAPI et OpenAPI.

REST — principes

REST (Representational State Transfer) est un style d'architecture pour les API web. Une API REST expose des ressources via des URLs et utilise les méthodes HTTP pour décrire les opérations.

6 contraintes REST

1
Client-Serveur — séparation des responsabilités. Le client gère l'UI, le serveur les données.
2
Sans état (Stateless) — chaque requête contient toutes les informations nécessaires. Le serveur ne garde pas de session.
3
Cacheable — les réponses indiquent si elles peuvent être mises en cache.
4
Interface uniforme — URLs pour les ressources, méthodes HTTP pour les actions, JSON pour les données.
5
Système en couches — proxies, load balancers transparents pour le client.

Anatomie d'une URL REST

https:// api.exemple.com /v1/utilisateurs/42/commandes ?page=2&limit=20
Protocole
Hôte
Chemin de ressource (version / collection / id / sous-ressource)
Query string (filtres, pagination)
Bonne pratique URLExemple
Noms au pluriel/users pas /user
Minuscules, tirets/order-items pas /orderItems
Pas de verbe dans l'URLDELETE /users/5 pas /deleteUser/5
Versionner l'API/v1/users
Ressources imbriquées/users/5/orders

HTTP & méthodes

MéthodeURLActionIdempotentCorps
GET /users ou /users/5 Lire une collection ou une ressource Non
POST /users Créer une nouvelle ressource JSON
PUT /users/5 Remplacer entièrement une ressource JSON complet
PATCH /users/5 Modifier partiellement une ressource JSON partiel
DELETE /users/5 Supprimer une ressource Non
ℹ️

Idempotent : appeler la méthode plusieurs fois produit le même résultat que l'appeler une seule fois. DELETE /users/5 appelé 3 fois = l'utilisateur est supprimé (pas d'erreur à répéter). POST /users appelé 3 fois = 3 utilisateurs créés.

Codes de statut HTTP

CodeSignificationUsage typique
200 OKSuccèsGET, PUT, PATCH réussis
201 CreatedCrééPOST réussi — retourner la ressource
204 No ContentSuccès sans corpsDELETE réussi
301 MovedRedirection permanenteChangement d'URL
400 Bad RequestRequête invalideJSON malformé, paramètre manquant
401 UnauthorizedNon authentifiéToken absent ou expiré
403 ForbiddenAccès refuséAuthentifié mais pas autorisé
404 Not FoundIntrouvableRessource inexistante
409 ConflictConflitEmail déjà utilisé
422 UnprocessableValidation échouéeDonnées invalides (FastAPI)
429 Too Many RequestsRate limitTrop d'appels
500 Server ErrorErreur serveurException non gérée
503 UnavailableService indisponibleMaintenance, surcharge
💡

401 vs 403 : 401 = "je ne sais pas qui tu es" (manque d'authentification), 403 = "je sais qui tu es, mais tu n'as pas le droit" (manque d'autorisation).

Structure d'une réponse d'erreur
// Corps JSON d'une erreur — convention courante
{
  "error": {
    "code":    "USER_NOT_FOUND",
    "message": "Aucun utilisateur avec l'id 42",
    "details": [],
    "request_id": "req_abc123"
  }
}
⚠️

Ne jamais retourner 200 OK avec un corps {"success": false} — le code HTTP est le statut. Utiliser le bon code et un corps JSON décrivant l'erreur.

requests — bases

GET, POST, PUT, DELETE
import requests

BASE = "https://jsonplaceholder.typicode.com"

# GET — lire une ressource
resp = requests.get(f"{BASE}/users/1")
print(resp.status_code)     # 200
print(resp.headers["Content-Type"])
user = resp.json()          # dict Python
print(user["name"])

# GET avec paramètres
resp = requests.get(f"{BASE}/posts", params={
    "userId": 1,
    "_limit": 5,
})
# URL générée : /posts?userId=1&_limit=5

# POST — créer
nouveau = {"title": "Mon titre", "body": "...", "userId": 1}
resp = requests.post(f"{BASE}/posts", json=nouveau)
print(resp.status_code)     # 201
cree = resp.json()

# PATCH — mise à jour partielle
resp = requests.patch(f"{BASE}/posts/1", json={"title": "Nouveau titre"})

# DELETE
resp = requests.delete(f"{BASE}/posts/1")
print(resp.status_code)     # 200 (simulé par jsonplaceholder)
Réponse — attributs utiles
resp = requests.get("https://api.example.com/data")

# Statut
resp.status_code          # 200, 404, 500…
resp.ok                   # True si 200-299
resp.raise_for_status()   # lève HTTPError si 4xx/5xx

# Corps de réponse
resp.text                 # str brute
resp.json()               # dict / liste Python
resp.content              # bytes (images, PDF…)

# Métadonnées
resp.headers              # dict des en-têtes
resp.url                  # URL finale (après redirections)
resp.elapsed              # timedelta de la durée
resp.encoding             # "utf-8"
💡

Toujours utiliser json=data (pas data=json.dumps(data)) — requests s'occupe du Content-Type: application/json automatiquement.

Authentification

Bearer Token & API Key
import requests

TOKEN = "mon_token_secret"

# Bearer Token (OAuth2, JWT) — en-tête Authorization
headers = {"Authorization": f"Bearer {TOKEN}"}
resp = requests.get("https://api.example.com/me", headers=headers)

# API Key — selon l'API : header, query param, ou les deux
# En header (plus sécurisé — n'apparaît pas dans les logs)
resp = requests.get(url, headers={"X-API-Key": "ma_cle"})

# En query param (moins sécurisé)
resp = requests.get(url, params={"api_key": "ma_cle"})

# Basic Auth (utilisateur:mot_de_passe encodés en base64)
resp = requests.get(url, auth=("utilisateur", "motdepasse"))
OAuth2 — flux simplifié avec requests-oauthlib
import os
from requests_oauthlib import OAuth2Session

CLIENT_ID     = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
REDIRECT_URI  = "https://monapp.com/callback"
SCOPES        = ["read:user"]

# Étape 1 : générer l'URL d'autorisation
oauth = OAuth2Session(CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPES)
auth_url, state = oauth.authorization_url("https://provider.com/oauth/authorize")
print(f"Aller sur : {auth_url}")

# Étape 2 : échanger le code contre un token
code = input("Code reçu : ")
token = oauth.fetch_token(
    "https://provider.com/oauth/token",
    code=code,
    client_secret=CLIENT_SECRET,
)

# Étape 3 : appels authentifiés
resp = oauth.get("https://api.provider.com/user")
⚠️

Ne jamais coder les tokens et clés API en dur dans le code source. Toujours utiliser des variables d'environnement ou un fichier .env (avec python-dotenv) et ajouter .env au .gitignore.

Gestion des erreurs

Hiérarchie des exceptions requests
import requests

def appel_api(url: str) -> dict:
    try:
        resp = requests.get(url, timeout=10)
        resp.raise_for_status()  # lève si 4xx/5xx
        return resp.json()

    except requests.exceptions.HTTPError as e:
        # 4xx ou 5xx — réponse reçue mais erreur
        code = e.response.status_code
        if code == 401:
            raise RuntimeError("Token invalide") from e
        elif code == 404:
            raise ValueError(f"Ressource introuvable : {url}") from e
        elif code == 429:
            raise RuntimeError("Rate limit atteint") from e
        raise

    except requests.exceptions.ConnectionError:
        raise RuntimeError("Impossible de joindre l'API")

    except requests.exceptions.Timeout:
        raise RuntimeError("L'API n'a pas répondu à temps")

    except requests.exceptions.JSONDecodeError:
        raise ValueError("Réponse non-JSON inattendue")
Hiérarchie des exceptions
# IOError
#  └── requests.exceptions.RequestException
#       ├── ConnectionError
#       │    ├── ProxyError
#       │    └── SSLError
#       ├── HTTPError         ← raise_for_status()
#       ├── URLRequired
#       ├── TooManyRedirects
#       └── Timeout
#            ├── ConnectTimeout
#            └── ReadTimeout

# Attraper toutes les erreurs requests en une fois
try:
    resp = requests.get(url)
except requests.exceptions.RequestException as e:
    print(f"Erreur réseau : {e}")
ℹ️

raise_for_status() ne fait rien si le statut est 2xx. Pour les erreurs 4xx/5xx, accéder à e.response.status_code et e.response.json() pour lire le corps d'erreur.

Session & timeout

Session — réutiliser la connexion TCP
import requests

# Session : connexion TCP réutilisée, headers partagés
with requests.Session() as session:
    # Headers communs à toutes les requêtes
    session.headers.update({
        "Authorization": f"Bearer {TOKEN}",
        "Accept":        "application/json",
        "User-Agent":    "MonApp/1.0",
    })

    # Timeout par défaut pour toutes les requêtes
    session.request = lambda method, url, **kw: \
        super(requests.Session, session).request(
            method, url, timeout=10, **kw)

    # Appels successifs — connexion TCP réutilisée
    users  = session.get(f"{BASE}/users").json()
    orders = session.get(f"{BASE}/orders").json()
Timeout, retry & backoff
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# Toujours spécifier un timeout (connect, read)
resp = requests.get(url, timeout=(3.05, 30))
# 3.05s pour établir la connexion
# 30s pour recevoir la réponse

# Retry automatique avec backoff exponentiel
retry = Retry(
    total=3,                    # 3 tentatives max
    backoff_factor=1,            # délais : 1s, 2s, 4s
    status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry)
session = requests.Session()
session.mount("https://", adapter)
session.mount("http://",  adapter)

resp = session.get(url, timeout=10)
# Réessaie automatiquement sur 429/5xx

Bonnes pratiques REST

RessourceGETPOSTPUTPATCHDELETE
/users Liste des users Créer un user Supprimer tous
/users/5 User 5 Remplacer user 5 Modifier user 5 Supprimer user 5
/users/5/orders Commandes du user 5 Créer une commande
Pagination & filtrage
# Pagination par offset
GET /users?page=2&limit=20

# Pagination par curseur (plus efficace sur grandes tables)
GET /users?cursor=eyJpZCI6MTAwfQ&limit=20

# Filtrage et tri
GET /products?category=electronics&price_max=500&sort=price&order=asc

# Réponse paginée — inclure les métadonnées
{
  "data":  [...],
  "meta": {
    "total":   1543,
    "page":    2,
    "limit":   20,
    "pages":   78
  },
  "links": {
    "prev": "/users?page=1&limit=20",
    "next": "/users?page=3&limit=20"
  }
}
Versioning & content negotiation
# Versioning dans l'URL (le plus courant)
GET /v1/users
GET /v2/users

# Versioning dans le header Accept
GET /users
Accept: application/vnd.api+json;version=2

# HATEOAS — liens dans la réponse
{
  "id":   5,
  "name": "Alice",
  "_links": {
    "self":   {"href": "/users/5"},
    "orders": {"href": "/users/5/orders"},
    "delete": {"href": "/users/5", "method": "DELETE"}
  }
}

Flask — API REST simple

API CRUD avec Flask
from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# Données en mémoire (simuler une BDD)
utilisateurs = {
    1: {"id": 1, "nom": "Alice", "email": "alice@heh.be"},
    2: {"id": 2, "nom": "Bob",   "email": "bob@heh.be"},
}

@app.get("/v1/users")
def lister_users():
    return jsonify(list(utilisateurs.values()))

@app.get("/v1/users/<int:user_id>")
def obtenir_user(user_id: int):
    user = utilisateurs.get(user_id)
    if not user:
        abort(404, description=f"User {user_id} introuvable")
    return jsonify(user)

@app.post("/v1/users")
def creer_user():
    data = request.get_json()
    if not data or "nom" not in data:
        abort(400, description="Champ 'nom' requis")
    nouvel_id = max(utilisateurs) + 1
    user = {"id": nouvel_id, **data}
    utilisateurs[nouvel_id] = user
    return jsonify(user), 201

@app.delete("/v1/users/<int:user_id>")
def supprimer_user(user_id: int):
    if user_id not in utilisateurs:
        abort(404)
    del utilisateurs[user_id]
    return "", 204

if __name__ == "__main__":
    app.run(debug=True)
Gestionnaire d'erreurs global
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException

app = Flask(__name__)

@app.errorhandler(HTTPException)
def gerer_erreur_http(e):
    """Toutes les erreurs HTTP → JSON uniforme."""
    return jsonify({
        "error": {
            "code":    e.name.upper().replace(" ", "_"),
            "message": e.description,
            "status":  e.code,
        }
    }), e.code

@app.errorhandler(Exception)
def gerer_erreur_inattendue(e):
    """Erreurs non gérées → 500."""
    return jsonify({
        "error": {"code": "INTERNAL_ERROR", "message": "Erreur serveur"}
    }), 500
ℹ️

Flask est idéal pour les API simples ou les prototypes. Pour une API de production avec validation automatique et documentation OpenAPI générée, préférer FastAPI.

FastAPI — API moderne

FastAPI génère automatiquement la documentation OpenAPI, valide les données via Pydantic, et est asynchrone nativement. C'est le standard actuel pour les nouvelles APIs Python.

FastAPI — CRUD complet
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, EmailStr

app = FastAPI(title="API Utilisateurs", version="1.0.0")

# Schémas Pydantic — validation automatique
class UserCreate(BaseModel):
    nom:   str
    email: EmailStr

class User(UserCreate):
    id: int

db: dict[int, User] = {}
compteur = 0

@app.get("/users", response_model=list[User])
async def lister():
    return list(db.values())

@app.get("/users/{user_id}", response_model=User)
async def obtenir(user_id: int):
    if user_id not in db:
        raise HTTPException(status.HTTP_404_NOT_FOUND,
                            detail=f"User {user_id} introuvable")
    return db[user_id]

@app.post("/users", response_model=User, status_code=201)
async def creer(data: UserCreate):
    global compteur
    compteur += 1
    user = User(id=compteur, **data.model_dump())
    db[compteur] = user
    return user

# Lancer : uvicorn main:app --reload
# Docs : http://localhost:8000/docs
FastAPI — fonctionnalités avancées
from fastapi import FastAPI, Depends, Query, Path
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

app = FastAPI()
security = HTTPBearer()

# Dépendance — vérifier le token
async def verifier_token(creds: HTTPAuthorizationCredentials = Depends(security)):
    if creds.credentials != "mon_token":
        raise HTTPException(401, "Token invalide")
    return creds.credentials

# Query params avec validation
@app.get("/users")
async def lister(
    page:  int = Query(1, ge=1),             # ≥ 1
    limit: int = Query(20, ge=1, le=100),    # 1–100
    _token: str = Depends(verifier_token),
):
    return {"page": page, "limit": limit}

# Path param avec validation
@app.get("/users/{user_id}")
async def obtenir(user_id: int = Path(ge=1)):
    return {"id": user_id}
💡

FastAPI génère automatiquement /docs (Swagger UI) et /redoc à partir des annotations de type et des modèles Pydantic — aucune configuration supplémentaire.

OpenAPI / Swagger

openapi.yaml — structure minimale
openapi: "3.1.0"
info:
  title: API Utilisateurs
  version: "1.0.0"
  description: Gestion des utilisateurs

servers:
  - url: https://api.example.com/v1

paths:
  /users:
    get:
      summary: Lister les utilisateurs
      parameters:
        - name: page
          in: query
          schema: {type: integer, default: 1}
      responses:
        "200":
          description: Liste des utilisateurs
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"

components:
  schemas:
    User:
      type: object
      required: [id, nom, email]
      properties:
        id:    {type: integer}
        nom:   {type: string, example: "Alice"}
        email: {type: string, format: email}
ℹ️

Contract-first vs Code-first : écrire le openapi.yaml avant le code (contract-first) permet de valider l'API avec les consommateurs dès le début. FastAPI fait l'inverse : il génère le spec OpenAPI depuis le code.

Générer un client Python depuis le spec
# Installer le générateur
pip install openapi-python-client

# Générer le client depuis l'URL du spec
openapi-python-client generate \
    --url http://localhost:8000/openapi.json

# Client généré → utilisation typée
from mon_api_client import Client
from mon_api_client.api.users import get_users

client = Client(base_url="https://api.example.com")
users = get_users.sync(client=client)

Tester une API

pytest + TestClient FastAPI
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_creer_user():
    resp = client.post("/users", json={
        "nom": "Alice",
        "email": "alice@heh.be",
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["nom"] == "Alice"
    assert "id" in data

def test_user_introuvable():
    resp = client.get("/users/999")
    assert resp.status_code == 404
    assert "detail" in resp.json()

def test_email_invalide():
    resp = client.post("/users", json={
        "nom": "Bob",
        "email": "pas_un_email",
    })
    assert resp.status_code == 422  # validation Pydantic
Mocker les appels HTTP avec responses
import responses
import requests

@responses.activate
def test_appel_api_externe():
    # Simuler une réponse sans faire de vraie requête
    responses.add(
        responses.GET,
        "https://api.externe.com/users/1",
        json={"id": 1, "nom": "Alice"},
        status=200,
    )
    responses.add(
        responses.GET,
        "https://api.externe.com/users/99",
        status=404,
    )

    # Le test n'effectue pas de vraie requête HTTP
    r1 = requests.get("https://api.externe.com/users/1")
    assert r1.json()["nom"] == "Alice"

    r2 = requests.get("https://api.externe.com/users/99")
    assert r2.status_code == 404

Cheat sheet

requests

requests.get(url, params={})GET + query string
requests.post(url, json={})POST + corps JSON
requests.patch(url, json={})Mise à jour partielle
requests.delete(url)Supprimer
resp.raise_for_status()Lever si 4xx/5xx
resp.json()Corps → dict
requests.Session()Connexion réutilisée

Codes de statut

200 OKSuccès GET/PUT/PATCH
201 CreatedPOST réussi
204 No ContentDELETE réussi
400 Bad RequestDonnées invalides
401 UnauthorizedNon authentifié
403 ForbiddenNon autorisé
404 Not FoundRessource absente
422 UnprocessableValidation échouée
429 Too Many RequestsRate limit

FastAPI

@app.get("/route")Route GET
@app.post("/route")Route POST
class M(BaseModel)Schéma Pydantic
HTTPException(404)Lever une erreur HTTP
Depends(fn)Injection de dépendance
Query(ge=1, le=100)Validation query param
/docsSwagger UI auto-généré

Bonnes pratiques

Noms pluriels/users, /orders
Pas de verbeDELETE /users/5
Versioning/v1/users
timeout=(3, 30)Toujours un timeout
Variables envJamais de clé en dur
raise_for_status()Vérifier toujours