← Retour aux tutoriels

Créer une API REST avec Python et Flask : guide pour développeurs intermédiaires

api restpythonflaskbackendjsonvalidationauthentificationgestion des erreurspaginationbonnes pratiques

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

Objectifs du guide

À la fin, vous saurez :


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 :

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

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 ?


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 à :

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 ?

Points d’attention :


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


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 :


11) Lancer le serveur en développement

flask run --port 5000

Si flask run ne trouve pas l’app, vérifiez :


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 :

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 :


14) Normalisation des réponses et des erreurs

Une API consommée par plusieurs clients doit être prévisible :

Si vous souhaitez aller plus loin, vous pouvez standardiser davantage :


15) Documentation minimale de l’API (approche pragmatique)

Sans introduire un générateur OpenAPI complet, vous pouvez déjà documenter :

Exemple (extrait) :

POST /api/auth/login

{"access_token":"...","token_type":"Bearer"}
{"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

16.2 Authentification et autorisation

16.3 Gestion des secrets

16.4 Limitation de débit et protections

Pour une API exposée, ajoutez :


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

Derrière, on place souvent un reverse proxy (ex. Nginx) pour TLS, compression, cache, etc.


18) Améliorations possibles (niveau intermédiaire → avancé)

  1. Tri et filtrage des todos (?done=true, ?q=...).
  2. PUT vs PATCH : PUT remplace entièrement la ressource, PATCH applique un diff. Ici nous avons choisi PATCH pour les mises à jour partielles.
  3. Refresh tokens et rotation de jetons.
  4. Rôles et permissions (admin, user) via claims JWT ou tables dédiées.
  5. Gestion des erreurs SQL (contraintes, intégrité) avec messages propres.
  6. OpenAPI/Swagger : génération d’un contrat formel.
  7. Logs structurés (JSON) et traçage.
  8. CORS si vous avez un client web sur un autre domaine (à activer avec prudence).

19) Récapitulatif

Vous avez mis en place :


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.