Skip to content

2. Actions, routage, réponses

Ce chapitre étudie le chemin d'une requête, du navigateur jusqu'à l'action qui la traite, et la réponse que renvoie cette action. Les vues n'apparaîtront qu'au chapitre 4 : ici, les actions renvoient directement du texte ou du JSON.

2.1. Exemple 01 : la structure d'une application Flask

Le premier exemple est la plus petite application [Flask] possible : un seul fichier.

01-structure/
└── app.py

2.1.1. Le fichier app.py

# app.py : la plus petite application Flask possible
from flask import Flask

# l'application : un objet de la classe Flask. __name__ (ici "__main__" ou "app")
# indique à Flask où se trouve l'application, pour y chercher ses fichiers
app = Flask(__name__)


# une action (Flask dit une "vue") : la fonction appelée pour la requête GET /
@app.get("/")
def index():
    # la valeur rendue par la fonction EST la réponse envoyée au navigateur
    return "Bonjour de la part de Flask !"


# exécuté seulement quand on lance le fichier lui-même : python app.py
if __name__ == "__main__":
    # le serveur de développement : http://localhost:5000 ; Ctrl-C l'arrête.
    # debug=True : rechargement automatique après une modification, et page de
    # débogage en cas d'erreur (JAMAIS en production)
    app.run(port=5000, debug=True)

Commentons ce code :

  • ligne 2 : l'importation de la classe [Flask] du paquet [flask], installé dans l'environnement virtuel par [pip]. En [Python], un fichier [.py] est un module, et un module n'utilise que les noms qu'il a importés ou définis lui-même (en dehors des fonctions intégrées au langage, comme [print] ou [len]) ;
  • ligne 6 : l'application, un objet de la classe [Flask]. C'est elle qui reçoit les requêtes et choisit la fonction qui traite chacune. Le paramètre est le nom du module courant, que [Python] range dans la variable [__name__] : [Flask] s'en sert pour trouver le dossier de l'application, où il cherchera plus tard les vues (dossier [templates]) et les fichiers statiques (dossier [static]) ;
  • ligne 10 : [@app.get("/")] est un décorateur. Un décorateur s'écrit [@expression], juste avant la définition d'une fonction (ou d'une classe) ; [Python] appelle l'expression avec la fonction en paramètre, au moment où il exécute la définition. Ici, [app.get("/")] enregistre la fonction [index] dans l'application : elle traitera les requêtes [GET /]. C'est le routage. Une fonction ainsi associée à une route est une action (la documentation de [Flask] dit une fonction de vue, cf. introduction) ;
  • lignes 11-13 : l'action rend une chaîne de caractères. La valeur rendue par une action est la réponse envoyée au navigateur : [Flask] la place dans le corps de la réponse HTTP. Par convention, les noms de fonctions [Python] s'écrivent en minuscules, les mots séparés par des soulignés ([action01_post]) ;
  • ligne 17 : quand on exécute le fichier lui-même ([python app.py]), [Python] donne à la variable [__name__] la valeur ["__main__"] ; quand le module est importé par un autre, elle vaut le nom du module (["app"]). Ce test, très courant en [Python], réserve donc la suite au lancement direct du fichier ;
  • ligne 21 : [app.run] démarre le serveur de développement de [Werkzeug], qui attend les requêtes sur le port 5000 de la machine (5000 est d'ailleurs la valeur par défaut) jusqu'à ce qu'on l'arrête par [Ctrl-C]. [debug=True] active le mode debug : le serveur surveille les fichiers de l'application et la redémarre dès qu'on en modifie un ; une exception non traitée affiche une page de débogage (cf. exemple 03). Ce mode est fait pour le développement, et seulement pour lui : la page de débogage permet d'exécuter du code [Python] sur le serveur.

2.1.2. Exécution

Dans un terminal ouvert sur le dossier [01-structure], l'environnement virtuel des exemples étant activé, on tape [python app.py]. [Flask] affiche ses messages de démarrage :

1
2
3
4
5
6
7
8
 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 332-612-699

Les messages indiquent le nom de l'application, le mode debug, l'adresse d'écoute et le port. [127.0.0.1] est l'adresse de la machine elle-même ([localhost]) : le serveur n'accepte que les requêtes venues de cette machine, ce qui convient au développement ([app.run(host="0.0.0.0")] l'ouvrirait aux autres machines du réseau). La ligne [Restarting with stat] signale le mécanisme du rechargement automatique : le processus lancé par [python app.py] ne fait que surveiller les fichiers ; il lance l'application dans un second processus, qu'il relance à chaque modification. Le « PIN » protège la console [Python] de la page de débogage.

Il existe une seconde façon de lancer une application, la commande [flask], installée avec [Flask] dans l'environnement virtuel :

flask --app app run --debug

L'option [--app] désigne le module de l'application ([app], c'est-à-dire le fichier [app.py]), [run] démarre le serveur de développement et [--debug] active le mode debug ; [--port 5001] changerait le port. La commande trouve elle-même, dans le module, l'objet [Flask] nommé [app] (ou, nous le verrons à l'exemple 02, une fonction [create_app]). Avec elle, le test [if __name__ == "__main__"] ne sert plus. Les exemples utilisent [python app.py], plus simple à retenir ; l'étude de cas fera de même.

Demandons l'URL [http://localhost:5000/] avec un navigateur :

Image

puis avec [curl]. L'option [-i] affiche aussi les entêtes HTTP de la réponse :

curl -i http://localhost:5000/
1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.13.7
Date: Sat, 26 Sep 2026 13:22:44 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 29
Connection: close

Bonjour de la part de Flask !

La première ligne de la réponse est la version du protocole, le code de statut, 200, et son libellé, [OK]. Viennent ensuite les entêtes : le nom du serveur, la date, le type du contenu, sa longueur en octets, et [Connection: close] (le serveur de développement ferme la connexion après chaque réponse). Une ligne vide sépare les entêtes du corps de la réponse. Dans la suite du document, nous ne reproduirons plus les entêtes [Server], [Date] et [Connection], qui ne varient pas.

L'entête [Content-Type: text/html; charset=utf-8] indique que la réponse est du HTML, dont les caractères sont codés en UTF-8. C'est le choix de [Flask] pour toute chaîne rendue par une action : le navigateur interprète donc la chaîne comme du HTML (ici, sans balise, elle s'affiche telle quelle). L'exemple 03 montrera comment annoncer un autre type.

À chaque requête, la console du serveur affiche une ligne de journal : l'adresse du client, la date, la requête et le code de la réponse.

127.0.0.1 - - [26/Sep/2026 15:22:44] "GET / HTTP/1.1" 200 -

2.2. Exemple 02 : le routage

Le routage associe une URL et une méthode HTTP (GET, POST...) à une action. L'exemple 02 range ses actions dans deux fichiers :

1
2
3
4
5
6
02-routage/
├── controllers/
│   ├── __init__.py
│   ├── accueil.py
│   └── premier.py
└── app.py

Le dossier [controllers] est un paquet [Python] (cf. introduction). Chacun de ses deux modules définit un blueprint : un groupe de routes, que l'application enregistre ensuite. C'est l'équivalent [Flask] des classes contrôleurs de [Spring MVC] ou d'[ASP.NET Core MVC].

2.2.1. L'application : app.py

# app.py : l'application, construite par une "fabrique" (factory)
from flask import Flask

from controllers.accueil import accueil
from controllers.premier import premier


# la fabrique : une fonction qui construit et configure l'application
def create_app():
    app = Flask(__name__)
    # chaque blueprint (un groupe de routes) est enregistré auprès de l'application
    app.register_blueprint(accueil)
    app.register_blueprint(premier)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • lignes 4-5 : l'importation des deux blueprints, les objets [accueil] et [premier] des modules [controllers/accueil.py] et [controllers/premier.py] ;
  • lignes 9-14 : l'application n'est plus une variable du module, créée dès l'importation : elle est construite par une fonction, appelée fabrique (*factory*). C'est la forme recommandée par la documentation de [Flask], que suivront tous les exemples suivants : la configuration de l'application est regroupée en un seul endroit, et l'on peut construire plusieurs applications différemment configurées (pour des tests, par exemple). La commande [flask --app app run] la connaît : faute de variable [app], elle appelle la fonction [create_app] du module ;
  • lignes 12-13 : chaque blueprint est enregistré dans l'application : ses routes deviennent celles de l'application ;
  • ligne 18 : on construit l'application, puis on lance le serveur.

2.2.2. Le blueprint accueil : controllers/accueil.py

# controllers/accueil.py : la page d'accueil
from flask import Blueprint

# un blueprint : un ensemble de routes, enregistré ensuite dans l'application.
# "accueil" : son nom ; __name__ : le module où il est défini
accueil = Blueprint("accueil", __name__)


# GET / : une page HTML qui contient des liens vers les autres actions.
# Une chaîne rendue par une action est envoyée avec le type text/html
@accueil.get("/")
def index():
    return """
        <h2>Exemple 02 - le routage</h2>
        <ul>
          <li><a href="/premier">/premier</a></li>
          <li><a href="/premier/action01">/premier/action01</a></li>
          <li><a href="/premier/produits/17">/premier/produits/17</a></li>
          <li><a href="/premier/produits/17/avis/3">/premier/produits/17/avis/3</a></li>
          <li><a href="/premier/fichiers/images/logo.png">/premier/fichiers/images/logo.png</a></li>
          <li><a href="/premier/produits/abc">/premier/produits/abc</a></li>
          <li><a href="/inconnue">/inconnue</a></li>
        </ul>
        """
  • ligne 6 : le blueprint. Son premier paramètre est son nom, [accueil] : il servira à désigner ses actions ([accueil.index], cf. exemple 04). Le second, comme pour l'application, est le nom du module ;
  • ligne 11 : un blueprint a les mêmes décorateurs que l'application : l'action [index] traite [GET /] ;
  • lignes 13-23 : une chaîne entre trois guillemets peut s'étendre sur plusieurs lignes et contenir des guillemets. L'action rend du HTML, envoyé avec le type [text/html] (cf. exemple 01).

L'action [index] renvoie ainsi une page HTML qui contient des liens vers les actions de l'autre blueprint :

Image

2.2.3. Le blueprint premier : controllers/premier.py

# controllers/premier.py : un blueprint dont toutes les routes commencent par /premier
from flask import Blueprint

# url_prefix : préfixe commun à toutes les routes du blueprint
premier = Blueprint("premier", __name__, url_prefix="/premier")


# GET /premier : la route "" désigne le préfixe lui-même
@premier.get("")
def index():
    return "Action [index] du blueprint [premier]"


# GET /premier/action01
@premier.get("/action01")
def action01():
    return "Action [action01] du blueprint [premier], requête GET"


# POST /premier/action01 : même chemin, autre méthode HTTP, autre fonction
@premier.post("/action01")
def action01_post():
    return "Action [action01_post] du blueprint [premier], requête POST"


# GET /premier/produits/17 : <int:id> est une variable de chemin ;
# le convertisseur [int] n'accepte que des chiffres, et fournit un entier
@premier.get("/produits/<int:id>")
def produit(id):
    return f"Produit n° {id}"


# GET /premier/produits/17/avis/3 : deux variables de chemin
@premier.get("/produits/<int:id>/avis/<int:numero>")
def avis(id, numero):
    return f"Avis n° {numero} du produit n° {id}"


# GET /premier/fichiers/... : le convertisseur [path] accepte n'importe quelle
# fin de chemin, barres obliques comprises
@premier.get("/fichiers/<path:chemin>")
def fichier(chemin):
    return f"Fichier demandé : {chemin}"

Commentons ce code :

  • ligne 5 : le paramètre [url_prefix] fixe le préfixe commun à toutes les routes du blueprint : ses actions traitent des URL qui commencent par [/premier] ;
  • lignes 9-10 : la route [""] désigne le préfixe lui-même : l'action [index] traite [GET /premier]. L'URL d'une action est la concaténation du préfixe du blueprint et du chemin de sa route. Le nom de l'action ([index]) peut être le même que dans un autre blueprint : [Flask] les distingue par le nom du blueprint ([premier.index], [accueil.index]) ;
  • lignes 15-16 : l'action [action01] traite [GET /premier/action01] ;
  • lignes 21-22 : le même chemin, mais pour la méthode POST : le décorateur [post] remplace [get]. Il existe aussi [put], [delete] et [patch], et le décorateur général [route], qui accepte la liste des méthodes : [@premier.route("/action01", methods=["GET", "POST"])]. Dans un même module, deux fonctions ne peuvent pas porter le même nom (la seconde remplacerait la première) : d'où le nom [action01_post] ;
  • lignes 28-30 : [<int:id>] est une variable de chemin : ce segment de l'URL peut prendre plusieurs valeurs. [int] est un convertisseur : le segment doit être formé de chiffres, sinon la route ne correspond pas ; sa valeur est convertie en entier, puis passée au paramètre de même nom de l'action, [id]. Pour [/premier/produits/17], [id] vaut le nombre 17. C'est la liaison de données (*data binding*), que le chapitre suivant étudiera en détail. Sans convertisseur ([<id>]), la variable accepte n'importe quel texte sans barre oblique, et reste une chaîne. [Werkzeug] fournit aussi les convertisseurs [float], [uuid] et [path], et l'exemple 12 montrera comment écrire les siens ;
  • ligne 30 : une f-chaîne (le préfixe [f]) : chaque expression entre accolades est évaluée, et sa valeur insérée dans le texte ;
  • lignes 34-36 : deux variables de chemin, passées à deux paramètres de l'action ;
  • lignes 41-43 : le convertisseur [path] accepte n'importe quelle fin d'URL, y compris des [/].

2.2.4. Exécution

Voici les réponses à quelques requêtes :

curl -i http://localhost:5000/premier
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 37

Action [index] du blueprint [premier]
curl -i http://localhost:5000/premier/produits/17/avis/3
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 28

Avis n° 3 du produit n° 17

Le texte fait 26 caractères, mais la réponse 28 octets : en UTF-8, le caractère [°] occupe deux octets.

curl -i http://localhost:5000/premier/fichiers/images/logo.png
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 34

Fichier demandé : images/logo.png

Pour envoyer une requête POST, on utilise l'option [-X POST] de [curl] :

curl -i -X POST http://localhost:5000/premier/action01
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 60

Action [action01_post] du blueprint [premier], requête POST

Une méthode HTTP qu'aucune action ne traite pour ce chemin produit une erreur 405 ([Method Not Allowed]). L'entête [Allow] indique les méthodes acceptées ; [Flask] y ajoute [HEAD] (la même requête que GET, sans le corps de la réponse) et [OPTIONS] (la liste des méthodes acceptées), qu'il traite lui-même pour toute route GET :

curl -i -X PUT http://localhost:5000/premier/action01
HTTP/1.1 405 METHOD NOT ALLOWED
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, POST, GET, HEAD
Content-Length: 153

<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>

Enfin, une URL qu'aucune route ne traite produit une erreur 404. C'est aussi le cas d'un identifiant qui n'est pas un nombre entier : aucune route ne correspond à [/premier/produits/abc].

curl -i http://localhost:5000/premier/produits/abc
1
2
3
4
5
6
7
8
9
HTTP/1.1 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 207

<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>

Les réponses d'erreur de [Flask] (en fait, de [Werkzeug]) sont de courtes pages HTML, en anglais. Dans un navigateur :

Image

Le chapitre 7 montrera comment les remplacer par des pages propres à l'application. Remarquons encore que [Flask] distingue [/premier] de [/premier/] : la seconde URL produit elle aussi une erreur 404, car aucune route ne se termine par une barre oblique. À l'inverse, une route déclarée avec une barre finale ([@premier.get("/liste/")]) accepte aussi l'URL sans barre, par une redirection.

La commande [flask routes], lancée dans le dossier de l'exemple, liste toutes les routes de l'application :

flask --app app routes
Endpoint               Methods  Rule
---------------------  -------  --------------------------------------------
accueil.index          GET      /
premier.action01       GET      /premier/action01
premier.action01_post  POST     /premier/action01
premier.avis           GET      /premier/produits/<int:id>/avis/<int:numero>
premier.fichier        GET      /premier/fichiers/<path:chemin>
premier.index          GET      /premier
premier.produit        GET      /premier/produits/<int:id>
static                 GET      /static/<path:filename>

La colonne [Endpoint] donne le nom de chaque route : le nom du blueprint, un point, le nom de la fonction. La dernière route, [static], est créée par [Flask] lui-même : elle sert les fichiers du dossier [static] de l'application (cf. exemple 15).

2.3. Exemple 03 : les réponses d'une action

Ce que renvoie une action détermine la réponse HTTP. L'exemple 03 passe en revue les principaux cas. Le fichier [app.py] :

# app.py : l'application de l'exemple 03
from flask import Flask

from controllers.reponses import reponses


def create_app():
    app = Flask(__name__)
    # le JSON produit garde les caractères accentués tels quels ("créée" et non
    # "cr\u00e9\u00e9e"), et les clés dans leur ordre d'origine (et non triées)
    app.json.ensure_ascii = False
    app.json.sort_keys = False
    app.register_blueprint(reponses)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • lignes 11-12 : deux réglages du fournisseur JSON de l'application ([app.json]), l'objet qui convertit les objets [Python] en JSON. Par défaut, il écrit les caractères non ASCII sous forme d'échappements ([créée] devient [créée], ce qui est du JSON valide, mais peu lisible), et il trie les clés des dictionnaires par ordre alphabétique. Nous gardons les caractères tels quels, et les clés dans l'ordre où l'action les a écrites.

Le fichier [controllers/reponses.py] :

# controllers/reponses.py : les différentes réponses d'une action
import asyncio
import time
from dataclasses import dataclass

from flask import Blueprint, Response, abort, jsonify

reponses = Blueprint("reponses", __name__, url_prefix="/reponses")


# une chaîne, annoncée comme du texte brut : le navigateur l'affiche telle quelle.
# Response : la réponse complète, construite par l'action elle-même
@reponses.get("/texte")
def texte():
    return Response("<h1>Action [texte]</h1>", mimetype="text/plain")


# une chaîne seule : Flask l'envoie avec le type text/html ; le navigateur l'interprète
@reponses.get("/html")
def html():
    return "<h1>Action [html]</h1>"


# une dataclass : une classe de données (cf. exemple 06), déclarée ici dans le module
@dataclass
class Personne:
    nom: str
    prenom: str
    age: int


# un objet : jsonify le sérialise en JSON, avec le type [application/json]
@reponses.get("/json")
def json():
    return jsonify(Personne("Martin", "Jean", 42))


# un dictionnaire rendu tel quel : Flask le sérialise lui-même en JSON
@reponses.get("/dictionnaire")
def dictionnaire():
    return {"nom": "Martin", "prenom": "Jean", "age": 42}


# une liste : sérialisée en tableau JSON
@reponses.get("/liste")
def liste():
    return ["Mme", "Melle", "Mr"]


# un tuple (contenu, code) : le code 201 (Created) au lieu de 200 (OK)
@reponses.get("/cree")
def cree():
    return {"message": "ressource créée"}, 201


# aucune réponse : le code 204 (No Content) signifie "rien à afficher"
@reponses.get("/vide")
def vide():
    return "", 204


# une action asynchrone (async def) : Flask attend la fin de la coroutine.
# Il faut l'extra [async] de Flask : pip install "flask[async]"
@reponses.get("/tache")
async def tache():
    debut = time.perf_counter()
    await asyncio.sleep(0.5)  # une attente de 500 ms, sans bloquer la boucle d'événements
    return {"attente": f"{round((time.perf_counter() - debut) * 1000)} ms"}


# une erreur "prévue" : le tuple (contenu, 404) produit une réponse 404
@reponses.get("/introuvable")
def introuvable():
    return {"message": "Cette ressource n'existe pas"}, 404


# la même, par abort : une exception HTTP, que Flask transforme en réponse 404
@reponses.get("/abandon")
def abandon():
    abort(404, "Cette ressource n'existe pas non plus")


# une erreur "imprévue" : une exception non traitée produit une réponse 500
@reponses.get("/exception")
def exception():
    raise RuntimeError("Une erreur imprévue")

Commentons ce code :

  • lignes 13-15 : la classe [Response] représente une réponse HTTP complète. L'action la construit elle-même : le corps, et le type ([mimetype]), ici [text/plain] (du texte brut). Le navigateur n'interprétera pas les balises ;
  • lignes 19-21 : la même chaîne, rendue seule : [Flask] l'envoie avec le type [text/html], et le navigateur l'interprète ;
  • lignes 25-29 : une dataclass : une classe dont on déclare seulement les champs et leurs types ; le décorateur [@dataclass] écrit lui-même le constructeur ([Personne("Martin", "Jean", 42)]), la représentation textuelle et la comparaison des objets. L'exemple 06 y reviendra ;
  • lignes 33-35 : la fonction [jsonify] convertit son paramètre en JSON, et fabrique une réponse de type [application/json]. Le fournisseur JSON de [Flask] sait convertir les dictionnaires, les listes, les chaînes, les nombres, les booléens, [None] (qui devient [null]), mais aussi les dataclasses, les dates et les nombres [Decimal] (cf. exemple 06) ;
  • lignes 39-41 : plus simple encore : une action qui rend un dictionnaire (ou une liste, lignes 45-47) produit une réponse JSON, sans appel à [jsonify]. Une dataclass, en revanche, doit passer par [jsonify] : rendue seule, elle provoquerait une erreur (« The view function did not return a valid response ») ;
  • lignes 51-53 : une action peut rendre un tuple [(contenu, code)] : le code 201 ([Created]) remplace le code 200. Un tuple peut aussi contenir des entêtes : [(contenu, code, {"X-Exemple": "03"})] ;
  • lignes 57-59 : le code 204 ([No Content]) signifie « rien à afficher » : la réponse n'a pas de corps ;
  • lignes 64-68 : une action asynchrone, déclarée par [async def]. Le mot-clé [await] suspend la coroutine pendant l'attente ([asyncio.sleep]), sans bloquer le programme. [Flask] exécute l'action dans une boucle d'événements créée pour la requête, et attend son résultat : c'est utile pour lancer plusieurs attentes en parallèle (plusieurs appels à des services web, par exemple), mais cela ne rend pas [Flask] lui-même asynchrone. Il faut installer l'extra [[async]] de [Flask] (cf. [requirements.txt]) ;
  • lignes 72-74 : une erreur « prévue » : le tuple [(contenu, 404)] produit une réponse 404 ([Not Found]), avec un corps JSON choisi par l'action ;
  • lignes 78-80 : la même erreur, produite par la fonction [abort]. [abort] ne rend rien : elle lève une exception ([NotFound], pour le code 404), qui interrompt l'action ; [Flask] la transforme en réponse d'erreur, avec le message donné en second paramètre. C'est la façon la plus courante d'arrêter une action en erreur, même au fond d'une fonction appelée par l'action ;
  • lignes 84-86 : une erreur « imprévue » : une exception que personne ne traite produit une réponse 500 ([Internal Server Error]).

Dans un navigateur, la différence entre [text/plain] et [text/html] est visible :

Image

Image

Voici les réponses des autres actions :

curl -i http://localhost:5000/reponses/json
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 55

{
  "nom": "Martin",
  "prenom": "Jean",
  "age": 42
}

La dataclass a été convertie en objet JSON, un champ par attribut. En mode debug, le JSON est présenté sur plusieurs lignes, indenté ; sans le mode debug, il tient sur une seule ligne ([{"nom":"Martin","prenom":"Jean","age":42}]). L'action [/reponses/dictionnaire] produit exactement la même réponse.

curl -i http://localhost:5000/reponses/liste
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 31

[
  "Mme",
  "Melle",
  "Mr"
]
curl -i http://localhost:5000/reponses/cree
1
2
3
4
5
6
7
HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 37

{
  "message": "ressource créée"
}
curl -i http://localhost:5000/reponses/vide
HTTP/1.1 204 NO CONTENT
Content-Type: text/html; charset=utf-8
curl -i http://localhost:5000/reponses/tache
1
2
3
4
5
6
7
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 26

{
  "attente": "501 ms"
}
curl -i http://localhost:5000/reponses/introuvable
1
2
3
4
5
6
7
HTTP/1.1 404 NOT FOUND
Content-Type: application/json
Content-Length: 48

{
  "message": "Cette ressource n'existe pas"
}
curl -i http://localhost:5000/reponses/abandon
1
2
3
4
5
6
7
8
9
HTTP/1.1 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 128

<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>Cette ressource n&#39;existe pas non plus</p>

La réponse de [abort] est la page d'erreur standard de [Werkzeug], avec notre message (son apostrophe a été échappée en [&#39;] : un texte inséré dans du HTML ne doit pas pouvoir y introduire de balises, nous y reviendrons).

Reste l'exception imprévue. En mode debug, le navigateur affiche la page du débogueur de [Werkzeug] : le type et le message de l'exception, puis la pile d'appels (la liste des fonctions en cours d'exécution au moment de l'erreur), de la plus ancienne à la plus récente, avec pour chacune la ligne de code concernée :

Image

La dernière fonction de la pile est notre action, [exception], à la ligne 86 du fichier [reponses.py]. En cliquant sur l'une des lignes de la pile, on peut même ouvrir une console [Python] dans le contexte de cette fonction, après avoir saisi le PIN affiché au démarrage : c'est pour cela que le mode debug ne doit jamais être activé sur un serveur accessible à d'autres. La console du serveur affiche elle aussi la pile d'appels (nous n'en reproduisons que la fin) :

1
2
3
4
5
6
127.0.0.1 - - [26/Sep/2026 15:23:10] "GET /reponses/exception HTTP/1.1" 500 -
Traceback (most recent call last):
  ...
  File ".../exemples/03-reponses/controllers/reponses.py", line 86, in exception
    raise RuntimeError("Une erreur imprévue")
RuntimeError: Une erreur imprévue

Sans le mode debug ([app.run(port=5000)]), la même requête produit une courte page d'erreur 500, en anglais, qui ne révèle rien du code (« Internal Server Error. The server encountered an internal error and was unable to complete your request... ») ; la pile d'appels n'est écrite que dans la console du serveur. Le chapitre 7 montrera comment afficher une page d'erreur propre à l'application.

2.4. Exemple 04 : les redirections

Une redirection est une réponse qui demande au navigateur d'aller chercher une autre URL : un code 3xx, et l'entête [Location], qui contient la nouvelle adresse. Le navigateur envoie aussitôt une seconde requête, vers cette adresse. Le fichier [controllers/redirections.py] :

# controllers/redirections.py : les redirections
from flask import Blueprint, redirect, url_for

redirections = Blueprint("redirections", __name__)


# redirection permanente (301) : la page a définitivement changé d'adresse
@redirections.get("/ancienne")
def ancienne():
    return redirect("/nouvelle", code=301)


@redirections.get("/nouvelle")
def nouvelle():
    return "Vous êtes sur la page [nouvelle]"


# redirection temporaire (302, le code par défaut), vers une adresse calculée
# en recollant des morceaux de texte
@redirections.get("/aller/<destination>")
def aller(destination):
    return redirect("/page/" + destination)


# url_for fabrique l'adresse d'une ACTION, désignée par son nom
# ("blueprint.fonction") : le paramètre [nom] est codé correctement pour une URL
@redirections.get("/vers-modele/<destination>")
def vers_modele(destination):
    return redirect(url_for("redirections.page", nom=destination))


@redirections.get("/page/<nom>")
def page(nom):
    return f"Vous êtes sur la page [{nom}]"


# redirection vers un autre site
@redirections.get("/documentation")
def documentation():
    return redirect("https://flask.palletsprojects.com/")
  • lignes 8-10 : la fonction [redirect] fabrique une réponse de redirection vers l'adresse donnée. Le code 301 ([Moved Permanently]) signifie que la page a définitivement changé d'adresse : le navigateur (et les moteurs de recherche) peuvent retenir la nouvelle ;
  • lignes 20-22 : sans paramètre [code], [redirect] produit une redirection temporaire, de code 302 ([Found]). L'adresse est calculée en recollant des morceaux de texte, ce qui pose un problème, que nous allons voir ;
  • lignes 27-29 : la fonction [url_for] fabrique l'URL d'une action, désignée par son nom (["redirections.page"] : le blueprint, un point, la fonction), et non par son chemin. Ses paramètres nommés fournissent les variables du chemin (ici [nom]), correctement codées pour une URL. Si l'on change un jour le chemin de la route [page], les appels à [url_for] suivront d'eux-mêmes ;
  • lignes 38-40 : une redirection vers un autre site.

Les réponses, avec [curl] (qui, sans option, ne suit pas les redirections) :

curl -i http://localhost:5000/ancienne
HTTP/1.1 301 MOVED PERMANENTLY
Content-Type: text/html; charset=utf-8
Content-Length: 205
Location: /nouvelle

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/nouvelle">/nouvelle</a>. If not, click the link.

La réponse a aussi un petit corps HTML, pour un client qui ne suivrait pas les redirections. L'option [-L] de [curl] suit la redirection, comme le ferait un navigateur :

curl -L http://localhost:5000/ancienne
Vous êtes sur la page [nouvelle]

Comparons maintenant les deux redirections calculées, avec une destination qui contient un point d'interrogation, codé [%3F] dans l'URL (un [?] non codé commencerait les paramètres de l'URL) :

curl -i http://localhost:5000/aller/a%3Fb
1
2
3
4
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 205
Location: /page/a?b
curl -i http://localhost:5000/vers-modele/a%3Fb
1
2
3
4
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 209
Location: /page/a%3Fb

[Flask] a décodé la variable de chemin avant de la passer à l'action : [destination] vaut [a?b]. La première action la recolle telle quelle dans l'adresse : [/page/a?b], que le navigateur comprend comme le chemin [/page/a] suivi du paramètre [b]. La seconde passe par [url_for], qui la code à nouveau : [/page/a%3Fb]. En suivant les redirections :

curl -L http://localhost:5000/aller/a%3Fb
curl -L http://localhost:5000/vers-modele/a%3Fb
Vous êtes sur la page [a]
Vous êtes sur la page [a?b]

Seule la seconde arrive sur la bonne page. D'où la règle : une adresse qui contient une valeur variable se fabrique avec [url_for], jamais en recollant des chaînes. Dans le navigateur, on ne voit que la page finale ; la barre d'adresse affiche la nouvelle URL :

Image

Deux autres codes de redirection existent : 303 ([See Other]) et 307 ([Temporary Redirect]), que l'on obtient par [redirect(url, code=303)]. Après une requête POST, les codes 302 et 303 font envoyer par le navigateur une requête GET vers la nouvelle adresse, alors que 307 (et 308, la version permanente) lui font répéter la requête POST. L'exemple 20 utilisera la redirection après un POST.

2.5. Exemple 05 : les objets requête et réponse

Jusqu'ici, les actions ne recevaient que des variables de chemin, et rendaient une valeur que [Flask] transformait en réponse. Une action peut aussi consulter la requête en cours, et construire elle-même la réponse. Le fichier [controllers/http.py] :

# controllers/http.py : les objets requête et réponse
import time

from flask import Blueprint, Response, make_response, request

http = Blueprint("http", __name__, url_prefix="/http")


# la requête : méthode, chemin, paramètres, entêtes...
# [request] est un objet global de Flask, qui désigne la requête EN COURS
@http.get("/requete")
def requete():
    # un dictionnaire Python garde l'ordre d'insertion de ses clés
    return {
        "methode": request.method,
        "chemin": request.path,
        # query_string : des octets (bytes), que l'on décode en texte
        "parametres": request.query_string.decode(),
        "navigateur": request.headers.get("User-Agent"),
        "ip": request.remote_addr,
    }


# la réponse : make_response construit l'objet réponse à partir de ce que
# l'action rendrait ; on lui ajoute un entête avant de le rendre
@http.get("/entete")
def entete():
    reponse = make_response("Réponse avec un entête ajouté")
    reponse.headers["X-Exemple"] = "05-requete-reponse"
    return reponse


# la réponse construite "à la main" : corps, code, type
@http.get("/reponse")
def reponse():
    return Response("Réponse construite par [Response(...)]", status=200,
                    mimetype="text/plain")


# une réponse envoyée PAR MORCEAUX : le code et les entêtes partent avec le
# premier morceau, et ne peuvent plus changer ensuite
@http.get("/flux")
def flux():
    def morceaux():
        yield "Début de la réponse... "
        time.sleep(1)
        # trop tard pour annoncer une erreur : le code 200 est déjà parti
        raise RuntimeError("erreur au milieu de la réponse")

    return Response(morceaux(), mimetype="text/plain")
  • ligne 4 : l'importation de l'objet [request]. En [Flask], la requête en cours n'est pas un paramètre de l'action : c'est un objet global, [request], que l'on importe du paquet [flask]. Comment un objet global peut-il désigner la requête en cours, alors que le serveur en traite plusieurs à la fois, sur plusieurs threads (des fils d'exécution parallèles) ? [request] est un mandataire (*proxy*) : chaque fois qu'on l'utilise, il transmet l'opération à la requête du thread courant. [Flask] appelle cela le contexte de requête : pendant qu'une requête est traitée, [request] désigne cette requête-là. En dehors du traitement d'une requête, l'utiliser provoque une erreur (« Working outside of request context ») ;
  • lignes 14-20 : quelques propriétés de la requête : sa méthode HTTP, son chemin, ses paramètres bruts (la partie de l'URL qui suit le [?], sous forme d'octets, que [decode] transforme en texte), ses entêtes ([request.headers], un dictionnaire, dont la méthode [get] rend [None] pour un entête absent), l'adresse IP du client. Les paramètres, un par un, seront l'objet du chapitre suivant ;
  • lignes 26-30 : la fonction [make_response] construit l'objet réponse (un [Response]) à partir de ce que rendrait l'action (ici une chaîne) ; l'action peut alors le modifier avant de le rendre. On lui ajoute un entête [X-Exemple] (par convention, les entêtes propres à une application commencent souvent par [X-]) ;
  • lignes 34-37 : la réponse construite « à la main » : le corps, le code de statut et le type ;
  • lignes 42-50 : une réponse envoyée par morceaux (*streaming*). La fonction [morceaux] est un générateur : chaque [yield] produit un morceau du corps. Construite sur un générateur, la réponse ne connaît pas sa longueur à l'avance : le serveur envoie le code et les entêtes, puis chaque morceau dès qu'il est produit. C'est utile pour une réponse très longue, produite petit à petit. Mais une fois le premier morceau parti, le code de statut ne peut plus changer : ici, le générateur lève une exception après le premier morceau. C'est l'erreur à ne pas commettre : une réponse commencée ne peut plus devenir une réponse d'erreur.

Les réponses :

curl -i "http://localhost:5000/http/requete?nom=martin&age=42"
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 140

{
  "methode": "GET",
  "chemin": "/http/requete",
  "parametres": "nom=martin&age=42",
  "navigateur": "curl/8.5.0",
  "ip": "127.0.0.1"
}

(les guillemets autour de l'URL empêchent le terminal d'interpréter le caractère [&]). Dans un navigateur, l'entête [User-Agent] est celui du navigateur ; [Chrome] présente le JSON brut, avec une case à cocher qui l'indente :

Image

curl -i http://localhost:5000/http/entete
1
2
3
4
5
6
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 32
X-Exemple: 05-requete-reponse

Réponse avec un entête ajouté
curl -i http://localhost:5000/http/reponse
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 39

Réponse construite par [Response(...)]
curl -i http://localhost:5000/http/flux
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked

Début de la réponse...

L'entête [Transfer-Encoding: chunked] remplace [Content-Length] : le corps arrive par morceaux, chacun précédé de sa longueur. Au bout d'une seconde, la réponse s'arrête : [curl] a reçu un code 200 et un corps tronqué, sans aucun moyen de savoir qu'une erreur s'est produite. Seule la console du serveur l'indique :

1
2
3
4
5
6
Error on request:
Traceback (most recent call last):
  ...
  File ".../exemples/05-requete-reponse/controllers/http.py", line 48, in morceaux
    raise RuntimeError("erreur au milieu de la réponse")
RuntimeError: erreur au milieu de la réponse

2.6. Exemple 06 : services partagés et injection de dépendances

Une action ne devrait pas contenir elle-même la logique de l'application : elle la délègue à des services, des objets qui savent faire une chose (lire des produits, calculer un panier...). Plusieurs actions utilisent souvent le même service. Dans [Spring] ou [ASP.NET Core], un conteneur d'injection de dépendances crée les services et les fournit automatiquement à ceux qui en ont besoin. [Flask] n'a pas de conteneur : c'est la fabrique de l'application qui crée les services, et les actions les demandent à l'application. L'exemple 06 organise son code par domaine (les produits, les clients), plutôt que par nature (contrôleurs, modèles) :

06-services/
├── clients/
│   ├── __init__.py
│   └── controller.py
├── produits/
│   ├── __init__.py
│   ├── controller.py
│   ├── produit.py
│   ├── service.py
│   └── service_memoire.py
└── app.py

2.6.1. Le produit

# produits/produit.py : un produit
from dataclasses import dataclass
from decimal import Decimal


# une dataclass : Python écrit lui-même le constructeur, __repr__, __eq__...
# frozen=True : l'objet est immuable (ses champs sont fixés par le constructeur)
@dataclass(frozen=True)
class Produit:
    id: int
    nom: str
    prix: Decimal  # Decimal : un nombre décimal exact (pas d'arrondi binaire)
  • ligne 8 : une dataclass (cf. exemple 03). Avec [frozen=True], l'objet est immuable : ses champs sont fixés par le constructeur, et toute tentative de modification ([p.prix = 2]) lève une exception. Un objet immuable peut être partagé sans risque entre plusieurs requêtes ;
  • lignes 10-12 : les champs, avec leur type. Les types ne sont pas vérifiés par [Python] à l'exécution : ce sont des annotations, que lisent les outils d'analyse (celui de [Visual Studio Code]), et certaines bibliothèques ([Pydantic], au chapitre suivant). Le prix est un [Decimal], un nombre décimal exact : un [float] ne sait pas représenter exactement 0,1 ou 3,2 (en binaire, ces nombres ont une infinité de chiffres), et une somme de prix en [float] peut donner 5,500000000000001.

2.6.2. Le contrat du service : service.py

# produits/service.py : le CONTRAT du service, ce qu'il sait faire, sans dire comment
from abc import ABC, abstractmethod

from flask import current_app

from produits.produit import Produit


# une classe abstraite (ABC) : on ne peut pas en créer d'objet ; une classe
# fille doit écrire (redéfinir) toutes les méthodes marquées @abstractmethod
class ProduitsService(ABC):

    @abstractmethod
    def find_all(self) -> list[Produit]: ...

    # Produit | None : un produit, ou None s'il n'existe pas
    @abstractmethod
    def find_by_id(self, id: int) -> Produit | None: ...


# le service de l'application en cours (cf. app.py). current_app désigne
# l'application qui traite la requête en cours
def produits_service() -> ProduitsService:
    return current_app.extensions["produits_service"]
  • lignes 11-18 : le contrat du service : ce qu'il sait faire, sans dire comment. En [Python], un contrat s'écrit par une classe abstraite : une classe qui hérite de [ABC] ([Abstract Base Class]), et dont les méthodes marquées [@abstractmethod] n'ont pas de corps (les trois points [...] tiennent lieu de corps vide). On ne peut pas créer d'objet de cette classe ; une classe fille doit redéfinir toutes ses méthodes abstraites. C'est l'équivalent des interfaces de [Java] et [C#] ;
  • ligne 14 : l'annotation du type rendu par la méthode (après [->]) : une liste de produits. [self] est l'objet sur lequel la méthode est appelée (l'équivalent de [this] en [Java]), toujours écrit en premier paramètre ;
  • ligne 18 : [Produit | None] : un produit, ou [None] s'il n'existe pas ;
  • lignes 23-24 : la fonction qui fournit le service aux actions. [current_app] est, comme [request], un mandataire : il désigne l'application qui traite la requête en cours. [app.extensions] est un dictionnaire que [Flask] met à la disposition des extensions (les bibliothèques qui s'ajoutent à [Flask]) pour y ranger leurs objets ; nous l'utilisons pour nos propres services. La fonction indique aussi le type du service ([-> ProduitsService]) : [Visual Studio Code] peut alors proposer ses méthodes.

2.6.3. L'implémentation : service_memoire.py

# produits/service_memoire.py : une IMPLÉMENTATION du contrat, avec des données en mémoire
from decimal import Decimal

from produits.produit import Produit
from produits.service import ProduitsService


class ProduitsServiceMemoire(ProduitsService):

    def __init__(self):
        # un tuple : une liste non modifiable
        self._produits = (
            Produit(1, "stylo", Decimal("1.5")),
            Produit(2, "cahier", Decimal("3.2")),
            Produit(3, "gomme", Decimal("0.8")),
        )

    def find_all(self) -> list[Produit]:
        return list(self._produits)

    def find_by_id(self, id: int) -> Produit | None:
        # next(générateur, None) : le premier produit qui convient, ou None
        return next((p for p in self._produits if p.id == id), None)
  • ligne 8 : la classe hérite du contrat, et redéfinit ses deux méthodes ;
  • lignes 10-15 : [__init__] est le constructeur de la classe. Il range les produits dans l'attribut [_produits] de l'objet ; le souligné initial signale, par convention, un attribut privé, que le code extérieur à la classe ne devrait pas utiliser. Les produits sont dans un tuple (des parenthèses) : une séquence non modifiable. Le prix est construit à partir d'une chaîne ([Decimal("1.5")]) : construit à partir du [float] 1.5, il hériterait de ses imprécisions ;
  • lignes 18-19 : la méthode rend une nouvelle liste, copie du tuple : l'appelant peut la modifier sans toucher aux données du service ;
  • lignes 21-23 : [(p for p in self._produits if p.id == id)] est une expression génératrice : elle produit, un par un, les produits dont l'id est celui demandé. [next] rend le premier élément produit, ou la valeur par défaut, [None], s'il n'y en a aucun.

2.6.4. Le blueprint des produits : controller.py

# produits/controller.py : les actions de /produits
from flask import Blueprint, jsonify

from produits.service import produits_service

produits = Blueprint("produits", __name__, url_prefix="/produits")


@produits.get("")
def liste():
    # le service est obtenu auprès de l'application, à chaque requête
    return jsonify(produits_service().find_all())


@produits.get("/<int:id>")
def un_produit(id):
    produit = produits_service().find_by_id(id)
    if produit is None:
        return {"message": f"Produit n° {id} introuvable"}, 404
    return jsonify(produit)
  • ligne 4 : le blueprint ne connaît que le contrat du service (le module [service]), pas son implémentation : il ignore que les produits sont en mémoire ;
  • lignes 10-12 : l'action demande le service à l'application, puis lui délègue le travail. [jsonify] convertit la liste de dataclasses en tableau JSON ;
  • lignes 16-20 : un produit inconnu produit une réponse 404, avec un message en JSON.

2.6.5. Le blueprint des clients : controller.py

# clients/controller.py : un second blueprint, qui utilise le MÊME service
from dataclasses import dataclass
from decimal import Decimal

from flask import Blueprint, jsonify

from produits.service import produits_service

clients = Blueprint("clients", __name__, url_prefix="/clients")


# le contenu du panier, rendu en JSON
@dataclass
class Panier:
    client: str
    articles: int
    total: Decimal


@clients.get("/panier")
def panier():
    produits = produits_service().find_all()
    # la somme des prix ; start=Decimal(0) : on additionne des Decimal
    total = sum((p.prix for p in produits), start=Decimal(0))
    return jsonify(Panier("Jean Martin", len(produits), total))
  • lignes 14-17 : le contenu du panier, rendu en JSON ;
  • ligne 22 : ce blueprint utilise le même service que celui des produits : un seul objet, partagé ;
  • ligne 24 : [sum] additionne les éléments d'un itérable ; son paramètre [start] est la valeur de départ de la somme (0 par défaut). Partir de [Decimal(0)] plutôt que de l'entier 0 n'est pas indispensable (un entier et un [Decimal] s'additionnent), mais garantit un résultat [Decimal] même pour un panier vide.

2.6.6. La déclaration du service : app.py

# app.py : l'application de l'exemple 06
from flask import Flask

from clients.controller import clients
from produits.controller import produits
from produits.service_memoire import ProduitsServiceMemoire


def create_app():
    app = Flask(__name__)
    # JSON : accents conservés, clés dans leur ordre d'origine (cf. exemple 03)
    app.json.ensure_ascii = False
    app.json.sort_keys = False
    # le SERVICE : un seul objet, créé une fois, au démarrage, et partagé par
    # toutes les requêtes. On le range dans app.extensions, un dictionnaire que
    # Flask met à la disposition des extensions... et de nos services
    app.extensions["produits_service"] = ProduitsServiceMemoire()
    app.register_blueprint(produits)
    app.register_blueprint(clients)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • ligne 17 : c'est ici, et ici seulement, que l'on choisit l'implémentation du service. Le service est créé une fois, au démarrage, et le même objet sert toutes les requêtes de tous les utilisateurs : c'est un singleton (la portée par défaut des beans de [Spring]). Pour passer à une implémentation qui lit une base de données, il suffirait de changer cette ligne : les blueprints, qui ne connaissent que le contrat, ne changeraient pas. C'est tout l'intérêt de l'injection de dépendances, même écrite « à la main » : chaque partie de l'application dépend de contrats, et un seul endroit décide des implémentations.

Un service partagé par toutes les requêtes doit supporter d'être utilisé en même temps par plusieurs d'entre elles : le serveur de développement traite chaque requête sur son propre thread. Celui-ci ne pose aucun problème : il ne fait que lire des données qui ne changent jamais. L'exemple 20 montrera un service dont les données changent, et la précaution qu'il impose.

2.6.7. Exécution

curl -i http://localhost:5000/produits
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 184

[
  {
    "id": 1,
    "nom": "stylo",
    "prix": "1.5"
  },
  {
    "id": 2,
    "nom": "cahier",
    "prix": "3.2"
  },
  {
    "id": 3,
    "nom": "gomme",
    "prix": "0.8"
  }
]

Les prix sont devenus des chaînes (["1.5"]) : JSON n'a qu'un type de nombre, que la plupart des programmes lisent comme un nombre à virgule flottante ; le fournisseur JSON de [Flask] convertit donc un [Decimal] en chaîne, pour ne rien perdre de sa précision.

curl -i http://localhost:5000/produits/9
1
2
3
4
5
6
7
HTTP/1.1 404 NOT FOUND
Content-Type: application/json
Content-Length: 45

{
  "message": "Produit n° 9 introuvable"
}
curl -i http://localhost:5000/clients/panier
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 65

{
  "client": "Jean Martin",
  "articles": 3,
  "total": "5.5"
}