Cours de Python / Créer une API JSON Flask et valider les entrées

Une petite API Flask associe des requêtes HTTP à des fonctions Python qui renvoient du JSON. Avant de créer une dépense, contrôlez la forme de l’objet, ses champs et leurs types ; renvoyez ensuite un code HTTP qui distingue une création réussie d’une entrée refusée.

Délimiter l’exercice local et installer Flask

Ce serveur est un exercice sur votre propre ordinateur. Il conserve les dépenses dans une liste en mémoire : elles disparaissent à l’arrêt du processus et ne sont pas partagées entre plusieurs processus indépendants. Le cours sur les objets et fichiers JSON aide à distinguer un format d’échange d’un mécanisme de stockage durable.

Créez un dossier d’exercice et sélectionnez l’environnement virtuel du projet. Installez Flask avec le même interpréteur que celui utilisé pour lancer les fichiers :

python -m pip install flask

La prise en main officielle de Flask présente les routes et le serveur de développement. Enregistrez le programme sous app.py, et non flask.py, afin de ne pas masquer le paquet que vous importez.

Après enregistrement du fichier ci-dessous, python app.py démarre le serveur sur 127.0.0.1:5000. Cette adresse n’écoute que sur votre machine et le débogueur est désactivé. Si le port 5000 est occupé, changez le numéro dans le programme et dans les adresses du client. N’exposez pas ce serveur de développement comme un service public ; une publication réelle demande notamment un serveur adapté, un stockage, une authentification, des autorisations et une gestion de la concurrence.

Enregistrer l’application API complète

Enregistrez ce contenu en UTF-8 dans app.py :

from flask import Flask, jsonify, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 4096
expenses = []

@app.errorhandler(413)
def too_large(error):
    return jsonify(error="request body too large"), 413

@app.get("/api/expenses")
def list_expenses():
    return jsonify(expenses)

@app.post("/api/expenses")
def add_expense():
    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        return jsonify(error="expected JSON object"), 400
    name, amount = data.get("name"), data.get("amount")
    if not isinstance(name, str) or not name.strip() or type(amount) is not int or amount < 0:
        return jsonify(error="invalid name or amount"), 400
    row = {"id": len(expenses) + 1, "name": name.strip(), "amount": amount}
    expenses.append(row)
    return jsonify(row), 201

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000, debug=False)

GET /api/expenses renvoie la liste avec le statut 200. Sur POST /api/expenses, une création réussie renvoie l’objet créé et le statut 201. Les erreurs de forme ou de valeur prévues par la fonction renvoient un objet JSON contenant error et le statut 400. Les noms d’erreurs du code restent en anglais ; ils sont des valeurs de la réponse, distinctes des explications de ce cours.

request.get_json(silent=True) retourne une valeur qui n’est pas un dictionnaire si la requête ne fournit pas l’objet JSON attendu, notamment en cas de JSON mal formé. La fonction impose alors la réponse 400 définie ici. Une liste JSON valide, comme [1,2], reste invalide pour cette API : une syntaxe JSON correcte ne garantit pas le bon contrat d’entrée.

Le nom doit être une chaîne non vide après contrôle des espaces ; ses espaces extérieurs sont retirés avant la sauvegarde. Le montant doit être un entier positif ou nul. Le test type(amount) is int exclut les booléens : en Python, isinstance(True, int) serait vrai, alors que true n’est pas un montant acceptable. Cette version ne fixe pas de plafond métier aux montants ; ajoutez-en un si les règles du domaine ou le stockage l’exigent.

MAX_CONTENT_LENGTH=4096 limite la taille du corps reçu en octets, pas seulement le nombre de caractères d’un nom. Le gestionnaire dédié renvoie le statut 413 avec une erreur JSON si cette limite est dépassée. Cette borne ne limite ni le nombre total de requêtes, ni la croissance cumulée de la liste. La référence de MAX_CONTENT_LENGTH précise son application.

Vérifier les routes avec le client de test

Enregistrez ce fichier complet sous le nom test_client.py, à côté de app.py :

from app import app, expenses
expenses.clear()
with app.test_client() as client:
    created = client.post("/api/expenses", json={"name":"lunch", "amount":8000})
    assert created.status_code == 201
    for payload in [{"name":"x","amount":True}, {"name":"x","amount":-1}, {"name":" ","amount":1}, [1,2]]:
        response = client.post("/api/expenses", json=payload)
        assert response.status_code == 400 and response.is_json
    assert client.post("/api/expenses", data="{", content_type="application/json").status_code == 400
    too_large = client.post("/api/expenses", data="x"*5000, content_type="application/json")
    assert too_large.status_code == 413 and too_large.is_json
    rows = client.get("/api/expenses")
    assert rows.status_code == 200 and len(rows.get_json()) == 1
    print(created.status_code, rows.get_json()[0]["amount"])
print("validation and 413 checks passed")

Dans le terminal de ce dossier, avec l’environnement choisi, lancez :

python test_client.py

Exécutez cette vérification sans -O : ce mode retirerait les expressions assert et rendrait les contrôles incomplets. Le résultat normal comporte deux lignes : 201 8000, puis validation and 413 checks passed.

Le test crée une dépense de 8 000, refuse les montants booléen et négatif, le nom vide, la liste JSON et le JSON mal formé, puis vérifie la réponse 413 et la présence d’une seule dépense. Il contrôle à la fois le statut et, pour plusieurs erreurs, le format JSON. Après un refus, la liste ne doit pas avoir grandi.

Il n’est pas nécessaire de démarrer python app.py dans un autre terminal : le client de test appelle directement le traitement des requêtes Flask dans son processus. expenses.clear() réinitialise la liste de ce processus de test, pas celle d’un serveur démarré séparément. La documentation des tests Flask décrit cette méthode ; elle ne vérifie pas à elle seule les sockets, un proxy inverse ou une configuration de déploiement.

Contrôler le cycle de vie et la portée des garanties

Pour l’essai manuel du serveur, ouvrez http://127.0.0.1:5000/api/expenses après son démarrage : la liste est initialement vide dans ce nouveau processus. Une visite ordinaire dans le navigateur effectue un GET et ne crée pas de dépense. Arrêtez le serveur avec Ctrl+C après les essais ; un redémarrage initialise une nouvelle liste.

Chaque POST accepté ajoute un enregistrement, même si son nom et son montant répètent une requête précédente. L’identifiant len(expenses)+1 illustre ce cas contrôlé dans un processus ; il ne remplace pas une politique d’identifiants stables et de synchronisation pour un service concurrent. Pour conserver les données, reliez l’API à un stockage et à des transactions, en reprenant les principes du cours SQLite.

Le contrat JSON décrit ici couvre les routes et les erreurs traitées dans cet exemple. Une URL inconnue ou une méthode non autorisée peut encore produire une page d’erreur HTML par défaut. Un serveur frontal ou un proxy peut aussi rejeter une requête avant Flask. La documentation de gestion des erreurs Flask explique les gestionnaires et le choix explicite des statuts.

Lors d’une extension, vérifiez les réponses et l’état conservé ensemble : une erreur de validation doit laisser les données intactes, une création doit être identifiable, et une limite de taille doit être observée dans l’environnement réellement utilisé. Définissez aussi les règles qui dépassent cette démonstration, plutôt que de considérer une liste en mémoire et quelques contrôles de type comme un service complet.