Créer une API REST avec Python et Flask : guide pour développeurs intermédiaires
Ce tutoriel explique, de bout en bout, comment concevoir, développer, tester et préparer au déploiement une API REST en Python avec Flask. L’objectif est d’aller au-delà du “Hello World” : structure de projet, validation, gestion d’erreurs, pagination, authentification par jetons, tests, documentation et bonnes pratiques.
1) Prérequis et objectifs
Prérequis techniques
- Connaissances intermédiaires en Python (fonctions, exceptions, modules, environnements virtuels).
- Notions HTTP (verbes
GET/POST/PUT/PATCH/DELETE, codes de statut, en-têtes). - Connaissances de base en JSON.
- Un environnement Linux/macOS/Windows avec Python 3.10+.
Objectifs du guide
À la fin, vous saurez :
- Structurer un projet Flask pour une API REST.
- Implémenter des endpoints CRUD.
- Valider les entrées et normaliser les erreurs.
- Gérer une base de données via SQLAlchemy et les migrations via Alembic/Flask-Migrate.
- Mettre en place une authentification par jeton (JWT).
- Écrire des tests automatisés.
- Préparer une exécution en production avec un serveur WSGI.
2) Démarrage : environnement, dépendances, structure
2.1 Créer un dossier de projet et un environnement virtuel
mkdir api-flask-tutoriel
cd api-flask-tutoriel
python -m venv .venv
# Linux/macOS
source .venv/bin/activate
# Windows (PowerShell)
# .venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
2.2 Installer les dépendances
Nous allons utiliser :
Flask: micro-framework web.Flask-SQLAlchemy: intégration SQLAlchemy.Flask-Migrate: migrations (Alembic).Flask-JWT-Extended: JWT.marshmallow: sérialisation/validation.pytest: tests.python-dotenv: variables d’environnement via fichier.env(pratique en dev).
pip install Flask Flask-SQLAlchemy Flask-Migrate Flask-JWT-Extended marshmallow pytest python-dotenv
Optionnel mais utile :
pip install gunicorn
2.3 Arborescence recommandée
Créez cette structure :
mkdir -p app/{api,models,schemas,services} tests
touch app/__init__.py app/config.py
touch app/models/__init__.py app/schemas/__init__.py
touch app/api/__init__.py
touch app/api/routes.py
touch app/models/user.py app/models/todo.py
touch app/schemas/user.py app/schemas/todo.py
touch app/services/auth.py
touch wsgi.py
touch .env
Idée clé : séparer la création de l’application (factory), les routes, les modèles, les schémas et les services. Cette séparation rend le projet maintenable quand il grandit.
3) Configuration : environnements, base de données, secrets
3.1 Fichier .env
Exemple :
cat > .env << 'EOF'
FLASK_ENV=development
SECRET_KEY=dev-secret-change-me
JWT_SECRET_KEY=dev-jwt-secret-change-me
DATABASE_URL=sqlite:///dev.db
EOF
SECRET_KEY: sert notamment à signer des cookies et d’autres mécanismes internes.JWT_SECRET_KEY: clé de signature des JWT. En production, elle doit être forte et stockée de façon sécurisée.DATABASE_URL: URL SQLAlchemy. Ici SQLite pour simplifier.
3.2 Configuration Flask (app/config.py)
import os
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "change-me-too")
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///dev.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
JWT_SECRET_KEY = "test-jwt"
SECRET_KEY = "test-secret"
4) Factory d’application, extensions et gestion d’erreurs
4.1 Initialiser l’app (app/__init__.py)
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from dotenv import load_dotenv
from .config import Config
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
def create_app(config_object=Config):
load_dotenv()
app = Flask(__name__)
app.config.from_object(config_object)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
from .api.routes import api_bp
app.register_blueprint(api_bp, url_prefix="/api")
register_error_handlers(app)
return app
def register_error_handlers(app: Flask):
@app.errorhandler(404)
def not_found(_):
return jsonify({"error": "not_found", "message": "Ressource introuvable"}), 404
@app.errorhandler(405)
def method_not_allowed(_):
return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée"}), 405
@app.errorhandler(500)
def server_error(_):
return jsonify({"error": "server_error", "message": "Erreur interne"}), 500
Pourquoi une factory ?
- Permet de créer plusieurs instances configurées différemment (dev/test/prod).
- Facilite les tests (app isolée, DB en mémoire).
- Évite des effets de bord au moment de l’import.
5) Modèles de données : utilisateurs et tâches (Todo)
Nous allons créer une API de gestion de tâches associées à un utilisateur.
5.1 Modèle User (app/models/user.py)
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from .. import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
todos = db.relationship("Todo", back_populates="user", cascade="all, delete-orphan")
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
5.2 Modèle Todo (app/models/todo.py)
from datetime import datetime
from .. import db
class Todo(db.Model):
__tablename__ = "todos"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
user = db.relationship("User", back_populates="todos")
5.3 Import des modèles (app/models/__init__.py)
from .user import User
from .todo import Todo
6) Schémas Marshmallow : sérialisation et validation
Marshmallow sert à :
- Valider les entrées (
emailvalide, longueur detitle, etc.). - Sérialiser les objets SQLAlchemy en JSON.
6.1 Schéma utilisateur (app/schemas/user.py)
from marshmallow import Schema, fields, validate
class UserRegisterSchema(Schema):
email = fields.Email(required=True)
password = fields.String(required=True, validate=validate.Length(min=8, max=128))
class UserPublicSchema(Schema):
id = fields.Integer(required=True)
email = fields.Email(required=True)
created_at = fields.DateTime(required=True)
6.2 Schéma todo (app/schemas/todo.py)
from marshmallow import Schema, fields, validate
class TodoCreateSchema(Schema):
title = fields.String(required=True, validate=validate.Length(min=1, max=200))
class TodoUpdateSchema(Schema):
title = fields.String(validate=validate.Length(min=1, max=200))
done = fields.Boolean()
class TodoSchema(Schema):
id = fields.Integer(required=True)
title = fields.String(required=True)
done = fields.Boolean(required=True)
created_at = fields.DateTime(required=True)
user_id = fields.Integer(required=True)
7) Authentification : JWT (connexion et protection)
7.1 Service d’authentification (app/services/auth.py)
from flask_jwt_extended import create_access_token
from ..models.user import User
def create_token_for_user(user: User) -> str:
# L'identité stockée dans le JWT est typiquement l'id utilisateur
return create_access_token(identity=str(user.id))
7.2 Pourquoi JWT ?
- Stateless : pas de session serveur obligatoire.
- Facile à consommer côté clients (SPA, mobile).
- Permet d’ajouter des claims si nécessaire (rôles, permissions), avec prudence.
Points d’attention :
- Durée de vie du jeton, rotation, révocation (liste noire) si besoin.
- Stockage côté client : éviter les failles XSS (souvent cookie
HttpOnlyou stockage sécurisé selon contexte).
8) Routes API : CRUD, pagination, erreurs cohérentes
8.1 Blueprint et routes (app/api/routes.py)
from flask import Blueprint, jsonify, request
from marshmallow import ValidationError
from flask_jwt_extended import jwt_required, get_jwt_identity
from .. import db
from ..models.user import User
from ..models.todo import Todo
from ..schemas.user import UserRegisterSchema, UserPublicSchema
from ..schemas.todo import TodoCreateSchema, TodoUpdateSchema, TodoSchema
from ..services.auth import create_token_for_user
api_bp = Blueprint("api", __name__)
user_register_schema = UserRegisterSchema()
user_public_schema = UserPublicSchema()
todo_create_schema = TodoCreateSchema()
todo_update_schema = TodoUpdateSchema()
todo_schema = TodoSchema()
todos_schema = TodoSchema(many=True)
def json_error(code: str, message: str, status: int, details=None):
payload = {"error": code, "message": message}
if details is not None:
payload["details"] = details
return jsonify(payload), status
@api_bp.errorhandler(ValidationError)
def handle_validation_error(err: ValidationError):
return json_error("validation_error", "Entrée invalide", 422, details=err.messages)
@api_bp.get("/health")
def health():
return jsonify({"status": "ok"}), 200
@api_bp.post("/auth/register")
def register():
data = user_register_schema.load(request.get_json(silent=True) or {})
email = data["email"].lower().strip()
if User.query.filter_by(email=email).first():
return json_error("email_taken", "Cet email est déjà utilisé", 409)
user = User(email=email)
user.set_password(data["password"])
db.session.add(user)
db.session.commit()
return jsonify({"user": user_public_schema.dump(user)}), 201
@api_bp.post("/auth/login")
def login():
payload = request.get_json(silent=True) or {}
email = (payload.get("email") or "").lower().strip()
password = payload.get("password") or ""
if not email or not password:
return json_error("invalid_credentials", "Identifiants invalides", 401)
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(password):
return json_error("invalid_credentials", "Identifiants invalides", 401)
token = create_token_for_user(user)
return jsonify({"access_token": token, "token_type": "Bearer"}), 200
def get_current_user():
user_id = get_jwt_identity()
if not user_id:
return None
return User.query.get(int(user_id))
@api_bp.get("/todos")
@jwt_required()
def list_todos():
user = get_current_user()
if not user:
return json_error("unauthorized", "Non authentifié", 401)
# Pagination simple : ?page=1&per_page=20
page = request.args.get("page", default=1, type=int)
per_page = request.args.get("per_page", default=20, type=int)
per_page = max(1, min(per_page, 100))
query = Todo.query.filter_by(user_id=user.id).order_by(Todo.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return jsonify({
"items": todos_schema.dump(pagination.items),
"page": page,
"per_page": per_page,
"total": pagination.total
}), 200
@api_bp.post("/todos")
@jwt_required()
def create_todo():
user = get_current_user()
if not user:
return json_error("unauthorized", "Non authentifié", 401)
data = todo_create_schema.load(request.get_json(silent=True) or {})
todo = Todo(title=data["title"], user_id=user.id)
db.session.add(todo)
db.session.commit()
return jsonify({"todo": todo_schema.dump(todo)}), 201
@api_bp.get("/todos/<int:todo_id>")
@jwt_required()
def get_todo(todo_id: int):
user = get_current_user()
if not user:
return json_error("unauthorized", "Non authentifié", 401)
todo = Todo.query.filter_by(id=todo_id, user_id=user.id).first()
if not todo:
return json_error("not_found", "Todo introuvable", 404)
return jsonify({"todo": todo_schema.dump(todo)}), 200
@api_bp.patch("/todos/<int:todo_id>")
@jwt_required()
def update_todo(todo_id: int):
user = get_current_user()
if not user:
return json_error("unauthorized", "Non authentifié", 401)
todo = Todo.query.filter_by(id=todo_id, user_id=user.id).first()
if not todo:
return json_error("not_found", "Todo introuvable", 404)
data = todo_update_schema.load(request.get_json(silent=True) or {})
if "title" in data:
todo.title = data["title"]
if "done" in data:
todo.done = data["done"]
db.session.commit()
return jsonify({"todo": todo_schema.dump(todo)}), 200
@api_bp.delete("/todos/<int:todo_id>")
@jwt_required()
def delete_todo(todo_id: int):
user = get_current_user()
if not user:
return json_error("unauthorized", "Non authentifié", 401)
todo = Todo.query.filter_by(id=todo_id, user_id=user.id).first()
if not todo:
return json_error("not_found", "Todo introuvable", 404)
db.session.delete(todo)
db.session.commit()
return "", 204
Explications importantes
- Validation :
schema.load(...)lèveValidationErrorautomatiquement, capturée par le handler du blueprint, renvoyant422 Unprocessable Entityavec détails. - Isolation multi-utilisateur : toutes les requêtes
Todofiltrent paruser_id=user.id. Sans cela, un utilisateur pourrait accéder aux données d’un autre. - Pagination : limite
per_pageà 100 pour éviter des réponses énormes. - Codes HTTP :
201création,200lecture/mise à jour,204suppression sans corps,401non authentifié,404introuvable,409conflit (email déjà pris),422validation.
9) Point d’entrée WSGI
9.1 wsgi.py
from app import create_app
app = create_app()
Cela permet de lancer l’application via un serveur WSGI en production.
10) Migrations et base de données
10.1 Initialiser Flask-Migrate
Définissez la variable FLASK_APP et lancez les commandes :
# Linux/macOS
export FLASK_APP=wsgi.py
# Windows (PowerShell)
# $env:FLASK_APP="wsgi.py"
flask db init
flask db migrate -m "init schema"
flask db upgrade
Vous devriez voir un fichier dev.db apparaître (SQLite) et un dossier migrations/.
10.2 Pourquoi les migrations sont cruciales
En équipe, la base de données évolue. Les migrations :
- versionnent le schéma,
- permettent de rejouer l’historique sur un environnement vierge,
- réduisent les “ça marche sur ma machine”.
11) Lancer le serveur en développement
flask run --port 5000
Si flask run ne trouve pas l’app, vérifiez :
FLASK_APP=wsgi.py- l’environnement virtuel activé
- les dépendances installées
12) Tester l’API avec des commandes réelles (curl)
12.1 Vérifier la santé
curl -i http://127.0.0.1:5000/api/health
12.2 Créer un utilisateur
curl -i -X POST http://127.0.0.1:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"motdepassefort"}'
12.3 Se connecter et récupérer un jeton
TOKEN=$(curl -s -X POST http://127.0.0.1:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"motdepassefort"}' | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
echo "$TOKEN"
12.4 Créer une todo
curl -i -X POST http://127.0.0.1:5000/api/todos \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Apprendre Flask sérieusement"}'
12.5 Lister les todos (avec pagination)
curl -s http://127.0.0.1:5000/api/todos?page=1&per_page=10 \
-H "Authorization: Bearer $TOKEN" | python -m json.tool
12.6 Mettre à jour une todo
curl -i -X PATCH http://127.0.0.1:5000/api/todos/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"done": true}'
12.7 Supprimer une todo
curl -i -X DELETE http://127.0.0.1:5000/api/todos/1 \
-H "Authorization: Bearer $TOKEN"
13) Tests automatisés avec Pytest
Tester une API REST est indispensable : on valide les contrats (codes, schémas, règles métier) et on évite les régressions.
13.1 Dépendances et organisation
Nous avons déjà installé pytest. Créons des fixtures pour :
- créer une app en configuration de test,
- initialiser la base en mémoire,
- obtenir un client HTTP Flask.
13.2 Fichier tests/test_api.py
import pytest
from app import create_app, db
from app.config import TestConfig
@pytest.fixture()
def app():
app = create_app(TestConfig)
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture()
def client(app):
return app.test_client()
def register_and_login(client, email="bob@example.com", password="motdepassefort"):
r = client.post("/api/auth/register", json={"email": email, "password": password})
assert r.status_code == 201
r = client.post("/api/auth/login", json={"email": email, "password": password})
assert r.status_code == 200
token = r.get_json()["access_token"]
return token
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
assert r.get_json()["status"] == "ok"
def test_todo_crud(client):
token = register_and_login(client)
headers = {"Authorization": f"Bearer {token}"}
r = client.post("/api/todos", json={"title": "Tâche 1"}, headers=headers)
assert r.status_code == 201
todo_id = r.get_json()["todo"]["id"]
r = client.get("/api/todos", headers=headers)
assert r.status_code == 200
assert r.get_json()["total"] == 1
r = client.patch(f"/api/todos/{todo_id}", json={"done": True}, headers=headers)
assert r.status_code == 200
assert r.get_json()["todo"]["done"] is True
r = client.delete(f"/api/todos/{todo_id}", headers=headers)
assert r.status_code == 204
r = client.get("/api/todos", headers=headers)
assert r.status_code == 200
assert r.get_json()["total"] == 0
def test_validation_error(client):
token = register_and_login(client)
headers = {"Authorization": f"Bearer {token}"}
r = client.post("/api/todos", json={"title": ""}, headers=headers)
assert r.status_code == 422
body = r.get_json()
assert body["error"] == "validation_error"
13.3 Lancer les tests
pytest -q
Bonnes pratiques :
- Utiliser une base en mémoire pour la rapidité.
- Tester les erreurs (validation, 404, 401) autant que les cas “heureux”.
- Garder les tests déterministes (pas d’ordre implicite, pas d’accès réseau externe).
14) Normalisation des réponses et des erreurs
Une API consommée par plusieurs clients doit être prévisible :
- Les erreurs doivent avoir une forme stable (
error,message,details). - Les objets retournés doivent être cohérents (
{"todo": ...},{"items": [...]}). - Les codes HTTP doivent refléter l’état réel.
Si vous souhaitez aller plus loin, vous pouvez standardiser davantage :
- Ajouter un identifiant de corrélation (
request_id) dans les logs et les réponses. - Documenter les erreurs possibles par endpoint.
- Mettre en place une gestion globale des exceptions applicatives (ex.
DomainError).
15) Documentation minimale de l’API (approche pragmatique)
Sans introduire un générateur OpenAPI complet, vous pouvez déjà documenter :
- les routes,
- les paramètres,
- les exemples de requêtes/réponses.
Exemple (extrait) :
POST /api/auth/login
- Corps JSON :
email(string, requis)password(string, requis)
- Réponse 200 :
{"access_token":"...","token_type":"Bearer"}
- Réponse 401 :
{"error":"invalid_credentials","message":"Identifiants invalides"}
Si vous voulez une documentation interactive, vous pourrez ensuite intégrer une solution OpenAPI (par exemple via une extension dédiée) et générer un schéma.
16) Sécurité : points essentiels pour une API REST
16.1 Validation et sanitation
- Validez systématiquement les entrées (ce que nous faisons avec Marshmallow).
- N’utilisez jamais directement des données non validées dans des requêtes SQL brutes.
16.2 Authentification et autorisation
- Authentification : prouver “qui est l’utilisateur” (JWT).
- Autorisation : vérifier “a-t-il le droit ?” (filtrage par
user_id).
16.3 Gestion des secrets
- Ne commitez jamais
.enven production. - Utilisez un gestionnaire de secrets (variables d’environnement, coffre-fort, etc.).
- Changez
SECRET_KEYetJWT_SECRET_KEY.
16.4 Limitation de débit et protections
Pour une API exposée, ajoutez :
- limitation de débit,
- protection contre brute force sur
/auth/login, - logs structurés,
- surveillance.
17) Exécution en production (principes)
En production, évitez flask run (serveur de dev). Utilisez un serveur WSGI.
17.1 Lancer avec Gunicorn
gunicorn -w 2 -b 0.0.0.0:8000 wsgi:app
-w 2: 2 workers (à ajuster selon CPU et charge).-b: bind sur l’interface/port.wsgi:app: modulewsgi.py, variableapp.
Derrière, on place souvent un reverse proxy (ex. Nginx) pour TLS, compression, cache, etc.
18) Améliorations possibles (niveau intermédiaire → avancé)
- Tri et filtrage des todos (
?done=true,?q=...). - PUT vs PATCH :
PUTremplace entièrement la ressource,PATCHapplique un diff. Ici nous avons choisiPATCHpour les mises à jour partielles. - Refresh tokens et rotation de jetons.
- Rôles et permissions (admin, user) via claims JWT ou tables dédiées.
- Gestion des erreurs SQL (contraintes, intégrité) avec messages propres.
- OpenAPI/Swagger : génération d’un contrat formel.
- Logs structurés (JSON) et traçage.
- CORS si vous avez un client web sur un autre domaine (à activer avec prudence).
19) Récapitulatif
Vous avez mis en place :
- Une API Flask structurée avec factory, blueprints, modèles, schémas et services.
- Une base SQLite gérée par SQLAlchemy et versionnée via migrations.
- Une authentification JWT avec endpoints d’inscription et de connexion.
- Un CRUD complet pour une ressource
Todo, isolée par utilisateur. - Une validation robuste et des erreurs JSON cohérentes.
- Des tests Pytest pour sécuriser le comportement.
20) Annexes : commandes utiles
Réinstaller rapidement l’environnement
rm -rf .venv
python -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install Flask Flask-SQLAlchemy Flask-Migrate Flask-JWT-Extended marshmallow pytest python-dotenv gunicorn
Recréer la base SQLite (développement uniquement)
rm -f dev.db
flask db upgrade
Lister les routes Flask
flask routes
Si vous souhaitez, je peux proposer une variante avec une architecture “application package” plus stricte (couches repository/service), ou ajouter une documentation OpenAPI complète et une stratégie de refresh tokens.