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
Anatomie d'une URL REST
| Bonne pratique URL | Exemple |
|---|---|
| Noms au pluriel | /users pas /user |
| Minuscules, tirets | /order-items pas /orderItems |
| Pas de verbe dans l'URL | DELETE /users/5 pas /deleteUser/5 |
| Versionner l'API | /v1/users |
| Ressources imbriquées | /users/5/orders |
HTTP & méthodes
| Méthode | URL | Action | Idempotent | Corps |
|---|---|---|---|---|
| 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
| Code | Signification | Usage typique |
|---|---|---|
| 200 OK | Succès | GET, PUT, PATCH réussis |
| 201 Created | Créé | POST réussi — retourner la ressource |
| 204 No Content | Succès sans corps | DELETE réussi |
| 301 Moved | Redirection permanente | Changement d'URL |
| 400 Bad Request | Requête invalide | JSON malformé, paramètre manquant |
| 401 Unauthorized | Non authentifié | Token absent ou expiré |
| 403 Forbidden | Accès refusé | Authentifié mais pas autorisé |
| 404 Not Found | Introuvable | Ressource inexistante |
| 409 Conflict | Conflit | Email déjà utilisé |
| 422 Unprocessable | Validation échouée | Données invalides (FastAPI) |
| 429 Too Many Requests | Rate limit | Trop d'appels |
| 500 Server Error | Erreur serveur | Exception non gérée |
| 503 Unavailable | Service indisponible | Maintenance, 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).
// 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
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)
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
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"))
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
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")
# 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
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()
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
| Ressource | GET | POST | PUT | PATCH | DELETE |
|---|---|---|---|---|---|
/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 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 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
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)
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.
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
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: "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.
# 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
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
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 OK | Succès GET/PUT/PATCH |
| 201 Created | POST réussi |
| 204 No Content | DELETE réussi |
| 400 Bad Request | Données invalides |
| 401 Unauthorized | Non authentifié |
| 403 Forbidden | Non autorisé |
| 404 Not Found | Ressource absente |
| 422 Unprocessable | Validation échouée |
| 429 Too Many Requests | Rate 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 |
| /docs | Swagger UI auto-généré |
Bonnes pratiques
| Noms pluriels | /users, /orders |
| Pas de verbe | DELETE /users/5 |
| Versioning | /v1/users |
| timeout=(3, 30) | Toujours un timeout |
| Variables env | Jamais de clé en dur |
| raise_for_status() | Vérifier toujours |