Skip to content

3. Le modèle d'une action

Une action a besoin des informations envoyées par le navigateur : les paramètres de l'URL ([?nom=martin&age=42]), les champs d'un formulaire, le corps JSON d'une requête, parfois un entête HTTP. Le cours d'origine appelle modèle de l'action l'ensemble de ces informations. Ce chapitre montre comment une action [Flask] les obtient, les convertit dans le bon type et les valide. Ce travail s'appelle la liaison de données (*data binding*).

[Flask] est ici beaucoup plus sobre que d'autres frameworks : il donne accès aux informations de la requête, sous forme de chaînes de caractères, et s'arrête là. Convertir ["42"] en l'entier 42, vérifier qu'un nom a au moins deux caractères, rassembler les champs dans un objet : tout cela est à la charge de l'application. Nous le ferons d'abord à la main (exemple 07), puis avec la bibliothèque [Pydantic] (exemples 08 à 12), qui décrit un modèle par une classe dont chaque champ porte son type et ses contraintes. Au chapitre suivant, les formulaires HTML seront confiés à une autre bibliothèque, [WTForms], faite pour eux ; [Pydantic] reste l'outil naturel pour les paramètres d'une URL et pour les corps JSON des API.

Attention au vocabulaire : le mot « modèle » désignera surtout, au chapitre suivant, les données qu'une action transmet à sa vue. Le modèle d'une action, au sens de ce chapitre, est ce qu'elle reçoit.

Les exemples 07 à 12 se lancent comme ceux du chapitre précédent : un terminal ouvert sur le dossier de l'exemple, l'environnement virtuel activé, [python app.py] ; [Ctrl-C] arrête le serveur. Leurs fichiers [app.py] se ressemblent tous : une fabrique [create_app] qui crée l'application, règle le fournisseur JSON (accents conservés, clés dans leur ordre, cf. exemple 03) et enregistre un blueprint. Nous ne les commenterons que lorsqu'ils apportent quelque chose de nouveau.

3.1. Exemple 07 : les paramètres de l'URL

Les paramètres de l'URL sont la partie qui suit le [?] : [?nom=martin&age=42]. Cette partie s'appelle la chaîne de requête (*query string*). Chaque paramètre est un couple [nom=valeur] ; les couples sont séparés par des [&]. Le fichier [controllers/parametres.py] :

# controllers/parametres.py : les paramètres de l'URL (la "query string")
from flask import Blueprint, abort, request

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


# /parametres/action01?nom=martin : request.args contient les paramètres de
# l'URL ; get rend la valeur du paramètre, ou None s'il est absent
@parametres.get("/action01")
def action01():
    nom = request.args.get("nom")
    return f"nom = [{nom}], de type {type(nom).__name__}"


# /parametres/action02?age=42 : type=int CONVERTIT la valeur (une chaîne) en
# entier ; un paramètre absent, ou qui n'est pas un entier, donne None
@parametres.get("/action02")
def action02():
    age = request.args.get("age", type=int)
    return f"age = [{age}], de type {type(age).__name__}"


# /parametres/action03?age=42 : un paramètre OBLIGATOIRE ; son absence, ou une
# valeur qui n'est pas un entier, arrête l'action par une réponse 400 (Bad Request)
@parametres.get("/action03")
def action03():
    texte = request.args.get("age")
    if texte is None:
        # abort lève une exception, que Flask transforme en réponse d'erreur
        abort(400, "le paramètre [age] est obligatoire")
    # la conversion est à notre charge
    try:
        age = int(texte)
    except ValueError:
        abort(400, f"[{texte}] n'est pas un nombre entier")
    return f"age = [{age}], de type {type(age).__name__}"


# /parametres/action04?page=2 : une valeur par défaut si le paramètre manque
@parametres.get("/action04")
def action04():
    page = request.args.get("page", default=1, type=int)
    return f"page = [{page}]"


# /parametres/action05?q=flask : le nom du paramètre de l'URL n'est pas forcément
# celui de la variable Python
@parametres.get("/action05")
def action05():
    recherche = request.args.get("q")
    return f"recherche = [{recherche}]"


# /parametres/action06?nom=martin&age=42 : tous les paramètres d'un coup, en dictionnaire
@parametres.get("/action06")
def action06():
    return request.args.to_dict()
  • ligne 11 : [request.args] contient les paramètres de l'URL, déjà décodés par [Werkzeug]. C'est un dictionnaire particulier, un [MultiDict], dont nous verrons à l'exemple 10 qu'il accepte plusieurs valeurs par clé. Sa méthode [get] rend la valeur d'un paramètre, ou [None] s'il est absent. Contrairement à [request.args["nom"]], qui lèverait une exception pour un paramètre absent, [get] ne provoque jamais d'erreur ;
  • ligne 12 : l'action affiche la valeur, et son type : [type(x)] est la classe de [x], [__name__] le nom de cette classe. Une valeur tirée de l'URL est toujours une chaîne ([str]) ;
  • ligne 19 : le paramètre [type] de [get] indique une fonction de conversion, appliquée à la chaîne : ici [int], la classe des entiers, dont l'appel [int("42")] rend 42. Si la conversion échoue ([int("abc")] lève une [ValueError]), [get] rend [None], comme pour un paramètre absent : l'action ne peut pas distinguer les deux cas, et l'erreur de saisie passe inaperçue ;
  • lignes 26-35 : un paramètre obligatoire, et une conversion faite à la main, qui distingue les deux erreurs possibles. [abort(400, message)] arrête l'action par une réponse 400 (*Bad Request*, la requête est incorrecte), accompagnée du message (cf. exemple 03). L'instruction [try] / [except ValueError] intercepte l'exception levée par [int] ;
  • ligne 42 : une valeur par défaut, utilisée si le paramètre manque (ou si sa conversion échoue). C'est le cas typique du numéro de page d'une liste ;
  • ligne 50 : rien n'oblige à nommer la variable [Python] comme le paramètre de l'URL. Les moteurs de recherche utilisent souvent un paramètre court, [q] (*query*) ;
  • ligne 57 : [to_dict()] convertit le [MultiDict] en dictionnaire [Python] ordinaire (en ne gardant que la première valeur de chaque paramètre), que l'action rend : [Flask] le transforme en JSON (cf. exemple 03).

Testons chaque action avec [curl]. Un paramètre présent, puis absent :

curl -i "http://localhost:5000/parametres/action01?nom=martin"
curl http://localhost:5000/parametres/action01
1
2
3
4
5
6
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 27

nom = [martin], de type str
nom = [None], de type NoneType

Les guillemets autour de l'URL sont nécessaires dans un terminal : sans eux, le shell interpréterait le [&] (qui lance une commande en arrière-plan sous [Linux]) et le [?]. Une valeur contenant des espaces ou des caractères spéciaux doit être encodée dans l'URL : l'espace devient [%20], le [&] devient [%26] (sinon, il séparerait deux paramètres). Le navigateur le fait tout seul ; avec [curl], il faut l'écrire. [Werkzeug] décode la valeur avant de la ranger dans [request.args] :

curl "http://localhost:5000/parametres/action01?nom=jean%20dupont%20%26%20fils"
nom = [jean dupont & fils], de type str

La conversion par [type=int], réussie puis ratée :

curl "http://localhost:5000/parametres/action02?age=42"
curl "http://localhost:5000/parametres/action02?age=abc"
age = [42], de type int
age = [None], de type NoneType

Le paramètre obligatoire de l'action 03, absent puis invalide :

curl -i http://localhost:5000/parametres/action03
curl "http://localhost:5000/parametres/action03?age=abc"
HTTP/1.1 400 BAD REQUEST
Content-Type: text/html; charset=utf-8
Content-Length: 126

<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>le paramètre [age] est obligatoire</p>
<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>[abc] n&#39;est pas un nombre entier</p>

La valeur par défaut, et le paramètre renommé :

1
2
3
curl http://localhost:5000/parametres/action04
curl "http://localhost:5000/parametres/action04?page=3"
curl "http://localhost:5000/parametres/action05?q=flask"
1
2
3
page = [1]
page = [3]
recherche = [flask]

Enfin, tous les paramètres d'un coup. Le paramètre [nom] apparaît deux fois : [to_dict()] n'en garde que la première valeur.

curl "http://localhost:5000/parametres/action06?nom=martin&age=42&nom=durand"
1
2
3
4
{
  "nom": "martin",
  "age": "42"
}

L'âge est resté une chaîne (["42"], entre guillemets dans le JSON) : aucune conversion n'a été demandée. Dans un navigateur (ici [Chromium]), le JSON apparaît sur une seule ligne : [Flask] n'indente le JSON qu'en mode debug, et la capture a été faite sans ; la case « Pretty-print » le présenterait indenté. La barre d'adresse montre au passage l'encodage des caractères accentués dans l'URL ([é] devient [%C3%A9], ses deux octets en [UTF-8]) :

Image

Ce premier exemple montre les limites de l'approche manuelle : pour chaque paramètre, il faut écrire la lecture, la conversion, le traitement de l'absence et de l'erreur. Avec dix paramètres, le code de l'action serait noyé sous ces vérifications. Il vaut mieux décrire ce qu'on attend, et laisser une bibliothèque le vérifier. C'est l'objet de l'exemple suivant.

3.2. Exemple 08 : un modèle d'action décrit par une classe

[Pydantic] est la bibliothèque de validation de données la plus utilisée en [Python] (elle est au cœur du framework [FastAPI]). On y décrit les données attendues par une classe : chaque attribut porte un type, et éventuellement des contraintes. [Pydantic] se charge de convertir les données reçues dans ces types, et de vérifier les contraintes. Elle est installée dans l'environnement virtuel des exemples (fichier [requirements.txt]).

3.2.1. Le modèle : models/personne.py

# models/personne.py : le modèle de l'action, une classe Pydantic dont chaque
# champ porte son type et ses contraintes
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class PersonneModele(BaseModel):
    # les chaînes reçues sont débarrassées des espaces qui les entourent
    model_config = ConfigDict(str_strip_whitespace=True)

    # obligatoire (pas de valeur par défaut), de 2 à 20 caractères
    nom: Annotated[str, Field(min_length=2, max_length=20)]
    # un entier ("42" est converti en 42), compris entre 0 et 150
    age: Annotated[int, Field(ge=0, le=150)]
    # l'une de ces trois valeurs seulement
    titre: Literal["Mr", "Mme", "Melle"]
    # facultatif (None par défaut) ; s'il est présent, une adresse électronique valide
    email: EmailStr | None = None
  • ligne 8 : un modèle [Pydantic] est une classe qui hérite de [BaseModel]. Ses attributs sont déclarés au niveau de la classe, avec leur type, comme dans une [dataclass] (exemple 06) ;
  • ligne 10 : la configuration du modèle. [str_strip_whitespace=True] retire les espaces qui entourent les chaînes reçues : un nom saisi [" martin "] devient ["martin"] avant d'être validé. L'attribut [model_config] n'est pas un champ du modèle : [Pydantic] reconnaît ce nom réservé ;
  • ligne 13 : le champ [nom]. [Annotated[str, Field(...)]] se lit « le type [str], annoté par [Field(...)] ». [Annotated], du module standard [typing], permet d'attacher à un type des informations supplémentaires, que [Python] ignore mais que les bibliothèques lisent : ici, les contraintes de [Pydantic], une longueur de 2 à 20 caractères ([min_length], [max_length]). Le champ n'a pas de valeur par défaut : il est obligatoire ;
  • ligne 15 : le type [int] : [Pydantic] convertit la chaîne ["42"] en l'entier 42, et refuse ["abc"]. Les contraintes [ge] (*greater or equal*, supérieur ou égal) et [le] (*less or equal*) bornent la valeur. Il existe aussi [gt] et [lt] pour les inégalités strictes ;
  • ligne 17 : [Literal["Mr", "Mme", "Melle"]] est le type des trois valeurs citées, et d'elles seules ;
  • ligne 19 : [EmailStr] est le type d'une adresse électronique ; [Pydantic] en vérifie la syntaxe grâce à la bibliothèque [email-validator] (installée elle aussi par [requirements.txt]). [EmailStr | None] : une adresse, ou [None]. La valeur par défaut [= None] rend le champ facultatif.

Ce modèle décrit les données attendues ; il ne contient aucune instruction de vérification. C'est ce qu'on appelle un style déclaratif.

3.2.2. L'action : controllers/personnes.py

# controllers/personnes.py : une action dont le modèle est une classe Pydantic
from flask import Blueprint, request

from models.personne import PersonneModele

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


# /personnes/action01?nom=martin&age=42&titre=Mr
# model_validate : Pydantic crée l'objet à partir du dictionnaire des paramètres
# (LIAISON : conversion des chaînes dans le type de chaque champ), puis vérifie
# les contraintes (VALIDATION). Un modèle invalide lève une ValidationError,
# transformée en réponse 400 par app.py : l'action s'arrête là
@personnes.get("/action01")
def action01():
    personne = PersonneModele.model_validate(request.args.to_dict())
    return {
        # model_dump : l'objet sous forme de dictionnaire (sérialisable en JSON)
        "personne": personne.model_dump(),
        "typeAge": type(personne.age).__name__,
    }
  • ligne 16 : la méthode de classe [model_validate] crée un objet [PersonneModele] à partir d'un dictionnaire, ici celui des paramètres de l'URL. Elle fait deux choses, dans cet ordre : - la liaison : chaque valeur du dictionnaire est affectée au champ de même nom, après conversion dans le type du champ (["42"] → 42) ; - la validation : les contraintes de chaque champ sont vérifiées (longueur, bornes, valeurs permises...).
  • Si l'une ou l'autre échoue, [model_validate] lève une exception [ValidationError], qui contient la liste de toutes les erreurs (et pas seulement la première). L'action ne la traite pas : elle s'arrête ; nous allons voir qui s'en charge ;
  • ligne 19 : [model_dump()] rend l'objet sous forme de dictionnaire, que [Flask] sait convertir en JSON ;
  • ligne 20 : le type de l'âge, pour montrer que la conversion a eu lieu.

3.2.3. Le traitement de l'erreur : app.py

# app.py : l'application de l'exemple 08
from flask import Flask
from pydantic import ValidationError

from controllers.personnes import personnes


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
    app.register_blueprint(personnes)

    # un modèle invalide : l'exception ValidationError de Pydantic devient,
    # pour TOUTE l'application, une réponse 400 qui détaille les erreurs
    @app.errorhandler(ValidationError)
    def modele_invalide(e: ValidationError):
        erreurs = [{"champ": ".".join(str(x) for x in err["loc"]), "message": err["msg"]}
                   for err in e.errors()]
        return {"statut": 400, "erreurs": erreurs}, 400

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • lignes 17-21 : le décorateur [@app.errorhandler(ValidationError)] enregistre un gestionnaire d'exceptions : quand une action lève une [ValidationError] (ou une exception d'une classe dérivée), [Flask] appelle cette fonction, avec l'exception en paramètre, et envoie sa valeur de retour comme réponse. Déclaré sur l'application, le gestionnaire vaut pour toutes ses actions : chaque action qui valide un modèle en profite, sans écrire une ligne de traitement d'erreur ;
  • ligne 19 : [e.errors()] rend la liste des erreurs. Chacune est un dictionnaire ; on en garde deux informations : [loc] (*location*), le champ concerné, sous forme de tuple ([("nom",)], cf. exemple 10 pour un tuple plus long), et [msg], le message. L'expression [[... for err in e.errors()]] est une liste en compréhension : elle construit une liste en appliquant l'expression de gauche à chaque élément ;
  • ligne 21 : un tuple [(corps, code)] (cf. exemple 03) : un dictionnaire, converti en JSON, et le code 400.

3.2.4. Exécution

Un modèle valide :

curl -i "http://localhost:5000/personnes/action01?nom=martin&age=42&titre=Mr"
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 117

{
  "personne": {
    "nom": "martin",
    "age": 42,
    "titre": "Mr",
    "email": null
  },
  "typeAge": "int"
}

L'âge est bien devenu un entier (sans guillemets dans le JSON), et l'adresse absente vaut [None] ([null] en JSON). Un nom entouré d'espaces ([%20]) et une adresse présente :

curl "http://localhost:5000/personnes/action01?nom=%20%20martin%20&age=42&titre=Mme&email=martin@exemple.fr"
1
2
3
4
5
6
7
8
9
{
  "personne": {
    "nom": "martin",
    "age": 42,
    "titre": "Mme",
    "email": "martin@exemple.fr"
  },
  "typeAge": "int"
}

Aucun paramètre : les trois champs obligatoires manquent.

curl -i http://localhost:5000/personnes/action01
HTTP/1.1 400 BAD REQUEST
Content-Type: application/json
Content-Length: 248

{
  "statut": 400,
  "erreurs": [
    {
      "champ": "nom",
      "message": "Field required"
    },
    {
      "champ": "age",
      "message": "Field required"
    },
    {
      "champ": "titre",
      "message": "Field required"
    }
  ]
}

Quatre champs invalides à la fois :

curl "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Mlle&email=xx"
{
  "statut": 400,
  "erreurs": [
    {
      "champ": "nom",
      "message": "String should have at least 2 characters"
    },
    {
      "champ": "age",
      "message": "Input should be a valid integer, unable to parse string as an integer"
    },
    {
      "champ": "titre",
      "message": "Input should be 'Mr', 'Mme' or 'Melle'"
    },
    {
      "champ": "email",
      "message": "value is not a valid email address: An email address must have an @-sign."
    }
  ]
}

Toutes les erreurs sont signalées en une fois, ce qui permet à l'utilisateur de tout corriger d'un coup. Les messages sont en anglais : [Pydantic] n'est pas traduite. L'exemple suivant les remplace par des messages français.

3.3. Exemple 09 : la validation faite par l'action

Dans l'exemple 08, un modèle invalide interrompt l'action, et c'est le gestionnaire de l'application qui répond. C'est commode pour une API, dont les clients sont des programmes qui attendent une réponse d'erreur normalisée. Mais une page web préfère souvent réafficher le formulaire avec les messages d'erreur : c'est alors l'action qui doit décider de ce qu'elle fait d'un modèle invalide. Le modèle [models/personne.py] est celui de l'exemple 08.

3.3.1. Les messages en français : models/messages.py

# models/messages.py : les messages des erreurs de validation, en français.
# Chaque erreur de Pydantic est un dictionnaire : son TYPE ("missing",
# "string_too_short"...), le champ concerné (loc), un message en anglais (msg),
# et le contexte (ctx) : les valeurs des contraintes ({"min_length": 2}...)
MESSAGES = {
    "missing": "le champ est obligatoire",
    "string_too_short": "{min_length} caractère(s) au moins",
    "string_too_long": "{max_length} caractère(s) au plus",
    "int_parsing": "un nombre entier est attendu",
    "greater_than_equal": "la valeur doit être supérieure ou égale à {ge}",
    "less_than_equal": "la valeur doit être inférieure ou égale à {le}",
    "literal_error": "valeur inconnue",
    "value_error": "valeur invalide",
    "too_short": "{min_length} élément(s) au moins",
}


# le nom du champ : loc est un tuple, ("nom",) ou ("couleur", 1) pour le 2e
# élément d'une liste
def champ(erreur: dict) -> str:
    return ".".join(str(x) for x in erreur["loc"])


# le message en français ; un type d'erreur inconnu garde le message de Pydantic
def message_fr(erreur: dict) -> str:
    modele = MESSAGES.get(erreur["type"])
    if modele is None:
        return erreur["msg"]
    # format remplace {min_length}... par les valeurs du contexte
    return modele.format(**erreur.get("ctx", {}))
  • lignes 5-14 : chaque erreur de [Pydantic] a un type, un identifiant stable (["missing"] pour un champ absent, ["string_too_short"] pour une chaîne trop courte...), indépendant du message anglais. Le dictionnaire [MESSAGES] associe à chaque type un message français. Certains contiennent des marques [{min_length}], [{ge}]... : la documentation de [Pydantic] donne, pour chaque type d'erreur, les valeurs de son contexte ;
  • lignes 20-21 : le nom du champ en erreur, tiré du tuple [loc]. [".".join(...)] assemble les éléments du tuple, convertis en chaînes, en les séparant par des points ;
  • lignes 25-30 : le message français d'une erreur. [MESSAGES.get(...)] rend [None] pour un type d'erreur absent du dictionnaire : on garde alors le message anglais de [Pydantic]. Sinon, [format] remplace chaque marque [{nom}] du message par la valeur de même nom du contexte : ["{min_length} caractère(s) au moins".format(min_length=2)] donne ["2 caractère(s) au moins"]. La notation [**dictionnaire] passe les couples du dictionnaire comme paramètres nommés ; [erreur.get("ctx", {})] rend un dictionnaire vide pour une erreur sans contexte.

Cette technique (traduire à partir du type de l'erreur) est celle qu'on retrouvera dans l'étude de cas : les messages y sont désignés par des clés, traduites dans la langue de l'utilisateur.

3.3.2. L'action : controllers/personnes.py

# controllers/personnes.py : c'est l'action qui décide de ce qu'elle fait d'un modèle invalide
from flask import Blueprint, request
from markupsafe import escape
from pydantic import ValidationError

from models.messages import champ, message_fr
from models.personne import PersonneModele

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


@personnes.get("/action01")
def action01():
    try:
        personne = PersonneModele.model_validate(request.args.to_dict())
    except ValidationError as e:
        # les erreurs, champ par champ : e.errors() en donne la liste
        items = "".join(f"<li>{champ(erreur)} : {message_fr(erreur)}</li>" for erreur in e.errors())
        return f"<p>Le formulaire contient des erreurs :</p><ul>{items}</ul>"
    # escape : le nom saisi est inséré dans du HTML ; ses caractères < > & " '
    # sont remplacés par &lt; &gt;... (sinon, une saisie pourrait injecter du HTML)
    return f"<p>Bienvenue {personne.titre} {escape(personne.nom)} ({personne.age} ans)</p>"
  • lignes 14-19 : l'action intercepte elle-même la [ValidationError], dans un bloc [try] / [except]. En cas d'erreur, elle construit une liste HTML des erreurs ([<ul>], une balise [<li>] par erreur) ;
  • ligne 22 : en cas de succès, un message de bienvenue. Le nom est passé par la fonction [escape] de la bibliothèque [markupsafe] (installée avec [Flask]). [escape] remplace les caractères qui ont un sens en HTML ([<], [>], [&], ["], [']) par leurs entités ([&lt;], [&gt;]...) : ils s'afficheront tels quels au lieu d'être interprétés. Sans cette précaution, un utilisateur pourrait saisir un « nom » contenant du HTML, voire du [JavaScript], qui s'exécuterait dans le navigateur de quiconque afficherait la page. C'est la faille de sécurité la plus répandue du web, l'injection de script (*XSS*, *cross-site scripting*). Le titre et l'âge n'ont pas besoin de cette protection : leurs valeurs ont été vérifiées par le modèle (l'une des trois valeurs permises, un entier). Au chapitre suivant, le moteur de vues [Jinja2] fera cet échappement automatiquement, pour toute valeur insérée dans une page.

Une saisie valide, puis une saisie comportant quatre erreurs :

Image

Image

La réponse brute de la seconde requête, et celle d'un nom qui contient du HTML :

curl "http://localhost:5000/personnes/action01?nom=m&age=abc&titre=Mx&email=xx"
curl "http://localhost:5000/personnes/action01?nom=%3Cb%3Emartin%3C/b%3E&age=42&titre=Mr"
<p>Le formulaire contient des erreurs :</p><ul><li>nom : 2 caractère(s) au moins</li><li>age : un nombre entier est attendu</li><li>titre : valeur inconnue</li><li>email : valeur invalide</li></ul>
<p>Bienvenue Mr &lt;b&gt;martin&lt;/b&gt; (42 ans)</p>

Le nom [<b>martin</b>] (encodé [%3Cb%3E...] dans l'URL) est affiché tel quel, sans être mis en gras : ses balises ont été neutralisées. Remarquons que la réponse est cette fois un code 200 : c'est l'action qui a choisi de rendre une page normale contenant les erreurs.

3.4. Exemple 10 : les paramètres multiples

Un même paramètre peut apparaître plusieurs fois dans une URL : [?couleur=rouge&couleur=vert]. C'est ce qu'envoie un navigateur pour un groupe de cases à cocher de même nom, ou une liste à sélection multiple (chapitre suivant). Le fichier [controllers/tableaux.py] :

# controllers/tableaux.py : un paramètre qui apparaît plusieurs fois dans l'URL
from flask import Blueprint, request
from pydantic import ValidationError

from models.choix import ChoixModele
from models.messages import champ, message_fr

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


# /tableaux/action01?couleur=rouge&couleur=vert : get ne rend que la PREMIÈRE valeur
@tableaux.get("/action01")
def action01():
    return {"couleur": request.args.get("couleur")}


# /tableaux/action02?couleur=rouge&couleur=vert : getlist rend TOUTES les
# valeurs, dans une liste (vide si le paramètre est absent)
@tableaux.get("/action02")
def action02():
    couleurs = request.args.getlist("couleur")
    return {"couleur": couleurs, "nombre": len(couleurs)}


# /tableaux/action03?couleur=rouge : une liste, validée par ChoixModele
@tableaux.get("/action03")
def action03():
    couleurs = request.args.getlist("couleur")
    try:
        ChoixModele(couleur=couleurs)
        erreurs = []
    except ValidationError as e:
        erreurs = [f"{champ(erreur)} : {message_fr(erreur)}" for erreur in e.errors()]
    return {"couleurs": couleurs, "valide": not erreurs, "erreurs": erreurs}
  • ligne 14 : [get] ne rend que la première valeur du paramètre ;
  • ligne 21 : [getlist] rend toutes ses valeurs, dans une liste, vide si le paramètre est absent. C'est la particularité du [MultiDict] : il garde, pour chaque clé, la liste de ses valeurs ;
  • lignes 27-34 : la liste est validée par un modèle [Pydantic]. Cette fois, le modèle est construit par un appel ordinaire, [ChoixModele(couleur=couleurs)], les champs étant passés comme paramètres nommés : c'est équivalent à [model_validate] sur un dictionnaire. L'objet créé ne sert à rien d'autre qu'à valider : on ne le conserve pas. L'expression [not erreurs] vaut [True] pour une liste vide.

Le modèle [models/choix.py] :

# models/choix.py : une liste, et une contrainte sur CHAQUE élément de la liste
from typing import Annotated, Literal

from pydantic import BaseModel, Field


class ChoixModele(BaseModel):
    # list[Literal[...]] : chaque élément doit être "rouge", "vert" ou "bleu" ;
    # min_length=1 : la liste doit contenir au moins un élément
    couleur: Annotated[list[Literal["rouge", "vert", "bleu"]], Field(min_length=1)]
  • ligne 10 : le type [list[Literal["rouge", "vert", "bleu"]]] est celui d'une liste dont chaque élément est l'une des trois couleurs. La contrainte [min_length=1], appliquée à une liste, porte sur son nombre d'éléments : il en faut au moins un.

Le fichier [models/messages.py] est celui de l'exemple 09. Les résultats :

1
2
3
curl "http://localhost:5000/tableaux/action01?couleur=rouge&couleur=vert"
curl "http://localhost:5000/tableaux/action02?couleur=rouge&couleur=vert"
curl http://localhost:5000/tableaux/action02
{
  "couleur": "rouge"
}
{
  "couleur": [
    "rouge",
    "vert"
  ],
  "nombre": 2
}
{
  "couleur": [],
  "nombre": 0
}

La validation, réussie, puis ratée pour une couleur inconnue, puis pour une liste vide :

1
2
3
curl "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=bleu"
curl "http://localhost:5000/tableaux/action03?couleur=rouge&couleur=jaune"
curl http://localhost:5000/tableaux/action03
{
  "couleurs": [
    "rouge",
    "bleu"
  ],
  "valide": true,
  "erreurs": []
}
{
  "couleurs": [
    "rouge",
    "jaune"
  ],
  "valide": false,
  "erreurs": [
    "couleur.1 : valeur inconnue"
  ]
}
{
  "couleurs": [],
  "valide": false,
  "erreurs": [
    "couleur : 1 élément(s) au moins"
  ]
}

L'erreur sur la couleur inconnue désigne [couleur.1] : le tuple [loc] de l'erreur est [("couleur", 1)], le champ et l'indice de l'élément fautif dans la liste (le deuxième, les indices commençant à 0).

3.5. Exemple 11 : les paramètres d'une requête POST

Les exemples précédents utilisaient des requêtes GET, dont les paramètres sont dans l'URL. Une requête POST transporte ses données dans son corps, sous deux formes principales :

  • les champs d'un formulaire HTML : le corps a la même syntaxe qu'une chaîne de requête ([nom=martin&age=42]), et son type (entête [Content-Type]) est [application/x-www-form-urlencoded] ;
  • un document JSON, envoyé par un programme ([JavaScript] dans une page, client d'une API...) : le type est [application/json].

Le fichier [controllers/post.py] :

# controllers/post.py : les paramètres d'une requête POST
from flask import Blueprint, request
from pydantic import ValidationError

from models.messages import champ, message_fr
from models.personne import PersonneModele

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


# les champs d'un formulaire HTML (corps application/x-www-form-urlencoded) :
# request.form, un dictionnaire "multiple" (MultiDict), comme request.args
@post.post("/action01")
def action01():
    return {
        "typeRecu": request.content_type,
        # flat=False : chaque champ -> la liste de ses valeurs
        "champs": request.form.to_dict(flat=False),
    }


# un corps JSON : get_json le désérialise (dictionnaire, liste...) ; un corps
# qui n'est pas du JSON -> erreur 415 (type non pris en charge) ou 400 (JSON mal formé)
@post.post("/action02")
def action02():
    return {"typeRecu": request.content_type, "corps": request.get_json()}


# un seul champ du formulaire
@post.post("/action03")
def action03():
    nom = request.form.get("nom")
    return f"nom = [{nom}]"


# les champs du formulaire, liés et validés par la classe PersonneModele
@post.post("/action04")
def action04():
    try:
        personne = PersonneModele.model_validate(request.form.to_dict()).model_dump()
        erreurs = []
    except ValidationError as e:
        personne = None
        erreurs = sorted(f"{champ(erreur)} : {message_fr(erreur)}" for erreur in e.errors())
    return {"personne": personne, "valide": not erreurs, "erreurs": erreurs}
  • ligne 13 : [post.post(...)] : le premier [post] est le blueprint, le second la méthode qui déclare une route pour la méthode HTTP POST (cf. exemple 02) ;
  • ligne 16 : [request.content_type] est l'entête [Content-Type] de la requête ;
  • ligne 18 : [request.form] contient les champs d'un formulaire envoyé en POST. C'est, comme [request.args], un [MultiDict]. [to_dict(flat=False)] en fait un dictionnaire dont chaque valeur est la liste des valeurs du champ ;
  • ligne 26 : [request.get_json()] désérialise un corps JSON : le texte JSON devient un objet [Python] (un dictionnaire pour un objet JSON, une liste pour un tableau JSON...). Si le corps n'est pas déclaré comme du JSON, [get_json] provoque une erreur 415 (*Unsupported Media Type*) ; s'il l'est, mais que le texte est mal formé, une erreur 400 ;
  • ligne 32 : un champ, lu comme un paramètre de l'URL ;
  • lignes 38-45 : le modèle [PersonneModele] de l'exemple 08, lié cette fois aux champs du formulaire. Le code est le même qu'avec les paramètres de l'URL : seule la source du dictionnaire change. [sorted] trie les messages, pour une réponse stable.

Avec [curl], l'option [-d] (*data*) envoie un corps ; la requête devient alors une requête POST, de type [application/x-www-form-urlencoded], sauf si l'option [-H] (*header*) déclare un autre type. L'option [-X POST] fixe explicitement la méthode (elle est facultative ici). Les champs d'un formulaire, dont un champ répété :

curl -i -X POST -d "nom=martin&age=42&loisir=tennis&loisir=piano" http://localhost:5000/post/action01
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 191

{
  "typeRecu": "application/x-www-form-urlencoded",
  "champs": {
    "nom": [
      "martin"
    ],
    "age": [
      "42"
    ],
    "loisir": [
      "tennis",
      "piano"
    ]
  }
}

Un corps JSON, qui contient un entier et un tableau :

curl -H "Content-Type: application/json" -d "{\"nom\": \"martin\", \"age\": 42, \"enfants\": [\"léa\", \"tom\"]}" http://localhost:5000/post/action02
{
  "typeRecu": "application/json",
  "corps": {
    "nom": "martin",
    "age": 42,
    "enfants": [
      "léa",
      "tom"
    ]
  }
}

Les guillemets du JSON sont précédés d'un [\] pour ne pas terminer la chaîne de la commande ; sous [Linux] et [macOS], on peut aussi entourer le JSON d'apostrophes : [-d '{"nom": "martin"}']. Contrairement aux champs d'un formulaire, le JSON transporte des types : l'âge est déjà un entier. Un corps qui n'est pas du JSON, puis un JSON mal formé :

curl -i -d "nom=martin" http://localhost:5000/post/action02
curl -i -H "Content-Type: application/json" -d "{\"nom\": " http://localhost:5000/post/action02
HTTP/1.1 415 UNSUPPORTED MEDIA TYPE
Content-Type: text/html; charset=utf-8
Content-Length: 215

<!doctype html>
<html lang=en>
<title>415 Unsupported Media Type</title>
<h1>Unsupported Media Type</h1>
<p>Did not attempt to load JSON data because the request Content-Type was not &#39;application/json&#39;.</p>
HTTP/1.1 400 BAD REQUEST
Content-Type: text/html; charset=utf-8
Content-Length: 162

<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>Failed to decode JSON object: Expecting value: line 1 column 9 (char 8)</p>

Un champ unique, puis la même URL demandée en GET :

curl -d "nom=jean%20dupont" http://localhost:5000/post/action03
curl -i "http://localhost:5000/post/action03?nom=martin"
nom = [jean dupont]
HTTP/1.1 405 METHOD NOT ALLOWED
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, POST
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>

L'URL existe, mais pas pour la méthode GET : [Flask] répond 405 (*Method Not Allowed*), et son entête [Allow] indique les méthodes acceptées ([OPTIONS] est traitée automatiquement par [Flask] pour toute route). Enfin, le modèle lié aux champs du formulaire, valide puis invalide :

curl -d "nom=martin&age=42&titre=Mr" http://localhost:5000/post/action04
curl -d "nom=m&age=200" http://localhost:5000/post/action04
{
  "personne": {
    "nom": "martin",
    "age": 42,
    "titre": "Mr",
    "email": null
  },
  "valide": true,
  "erreurs": []
}
{
  "personne": null,
  "valide": false,
  "erreurs": [
    "age : la valeur doit être inférieure ou égale à 150",
    "nom : 2 caractère(s) au moins",
    "titre : le champ est obligatoire"
  ]
}

3.6. Exemple 12 : liaison et validation personnalisées

Les types standard ne suffisent pas toujours. Cet exemple montre trois façons d'étendre la liaison et la validation :

  • un convertisseur de chemin : une date au format français, [23-09-2026], dans le chemin de l'URL ;
  • un décorateur qui fournit à l'action un paramètre qui ne vient pas de l'URL, mais d'un entête HTTP ;
  • des types « maison » pour [Pydantic] : une contrainte (pas d'espace) et une conversion (une date [jj/mm/aaaa]).
1
2
3
4
5
6
7
8
12-liaison-perso/
├── app.py
├── controllers/
│   └── exemples.py
└── liaison/
    ├── date_fr.py
    ├── langue.py
    └── types.py

3.6.1. Un convertisseur de chemin : liaison/date_fr.py

Nous avons rencontré à l'exemple 02 les convertisseurs de [Werkzeug] : [<int:id>] n'accepte qu'un entier, et le convertit. On peut en écrire de nouveaux.

# liaison/date_fr.py : une date française "23-09-2026" dans le chemin de l'URL
from datetime import date, datetime

from werkzeug.routing import BaseConverter, ValidationError


# un CONVERTISSEUR de Werkzeug (la bibliothèque sur laquelle Flask est bâti) :
# il décide si un morceau du chemin convient, et le transforme en objet Python
class DateFrConverter(BaseConverter):
    # l'expression régulière du morceau de chemin : jj-mm-aaaa
    regex = r"\d{2}-\d{2}-\d{4}"

    # texte du chemin -> objet date
    def to_python(self, valeur: str) -> date:
        try:
            # strptime vérifie que la date existe : "31-02-2026" est refusée
            return datetime.strptime(valeur, "%d-%m-%Y").date()
        except ValueError:
            # la route ne correspond pas : Flask cherche une autre route, puis répond 404
            raise ValidationError()

    # objet date -> texte du chemin (utilisé par url_for)
    def to_url(self, valeur: date) -> str:
        return valeur.strftime("%d-%m-%Y")
  • ligne 9 : un convertisseur est une classe qui hérite de [BaseConverter] ;
  • ligne 11 : l'attribut [regex] est une expression régulière, qui décrit la forme du morceau de chemin accepté : [\d] désigne un chiffre, [{2}] la répétition de ce qui précède, ici deux chiffres. Le préfixe [r] de la chaîne (*raw string*) empêche [Python] d'interpréter les [\]. Le routage n'essaie ce convertisseur que sur un morceau de chemin de cette forme ;
  • lignes 14-20 : [to_python] transforme le texte du chemin en objet [Python], ici une [date]. [datetime.strptime] (*string parse time*) analyse une chaîne selon un format : [%d] le jour, [%m] le mois, [%Y] l'année sur quatre chiffres ; [.date()] garde la date sans l'heure. [strptime] vérifie que la date existe : [31-02-2026] a la bonne forme, mais lève une [ValueError]. Le convertisseur lève alors la [ValidationError] de [Werkzeug] (à ne pas confondre avec celle de [Pydantic]) : pour le routage, la route ne correspond pas à l'URL, qui aboutira donc à une erreur 404 si aucune autre route ne lui correspond ;
  • lignes 23-24 : l'opération inverse, utilisée par [url_for] pour construire une URL à partir d'une date. [strftime] (*string format time*) met une date en forme.

Le convertisseur est enregistré dans [app.py] :

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

from controllers.exemples import exemples
from liaison.date_fr import DateFrConverter


def create_app():
    app = Flask(__name__)
    # un convertisseur de variable de chemin, sous le nom [datefr] : les routes
    # peuvent désormais écrire <datefr:jour>, comme <int:id>
    app.url_map.converters["datefr"] = DateFrConverter
    app.register_blueprint(exemples)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • ligne 12 : [app.url_map] est la table de routage de l'application ; son dictionnaire [converters] associe un nom à chaque convertisseur ([int], [path]...). On y ajoute le nôtre, sous le nom [datefr]. L'enregistrement doit précéder celui des blueprints qui l'utilisent.

3.6.2. Un paramètre fourni par un décorateur : liaison/langue.py

Une action [Flask] reçoit en paramètres les variables du chemin, et rien d'autre. Pour lui fournir une autre information, on peut décorer l'action : le décorateur remplace l'action par une fonction qui calcule l'information, puis appelle l'action en la lui passant.

# liaison/langue.py : un décorateur qui FOURNIT un paramètre à l'action
from functools import wraps

from flask import request


# @avec_langue, placé sur une action, lui passe le paramètre [langue], lu dans
# l'entête "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8" -> "fr"
def avec_langue(action):
    # wraps : la fonction produite garde le nom de l'action (Flask s'en sert
    # pour nommer la route)
    @wraps(action)
    def action_avec_langue(*args, **kwargs):
        # request.accept_languages : l'entête déjà analysé par Werkzeug ;
        # best_match : la langue préférée du navigateur parmi celles proposées
        kwargs["langue"] = request.accept_languages.best_match(["fr", "en"], default="fr")
        return action(*args, **kwargs)

    return action_avec_langue
  • ligne 9 : un décorateur est une fonction qui reçoit une fonction (ici l'action) et en rend une autre (cf. exemple 01). Écrire [@avec_langue] au-dessus de [def bonjour(...)] revient à écrire, après la définition, [bonjour = avec_langue(bonjour)] ;
  • ligne 13 : la fonction qui remplacera l'action. [*args] reçoit, dans un tuple, les paramètres positionnels, [**kwargs] (*keyword arguments*), dans un dictionnaire, les paramètres nommés. C'est ainsi que [Flask] passe les variables du chemin ([id=3]) : la fonction les reçoit sans avoir à les connaître ;
  • ligne 16 : on ajoute un paramètre nommé [langue]. [request.accept_languages] est l'entête [Accept-Language] de la requête, déjà analysé par [Werkzeug]. Le navigateur y indique les langues de son utilisateur, par ordre de préférence : [fr-FR,fr;q=0.9,en;q=0.8] (le coefficient [q] mesure la préférence, de 0 à 1). [best_match] choisit, parmi les langues proposées par l'application, celle que l'utilisateur préfère, ou la valeur [default] si aucune ne convient ;
  • ligne 17 : l'appel de l'action d'origine, avec les paramètres reçus et le nouveau. Les notations [*args] et [**kwargs] déplient cette fois le tuple et le dictionnaire en paramètres ;
  • ligne 12 : la fonction [action_avec_langue] prend le nom ([__name__]) et la documentation de l'action. C'est indispensable ici : [Flask] nomme chaque route d'après le nom de sa fonction (le [bonjour] de [url_for("exemples.bonjour")]), et refuserait d'enregistrer deux routes portant le même nom, [action_avec_langue], si deux actions utilisaient le décorateur.

Nous retrouverons ce procédé tout au long du cours : les décorateurs [@connexion_requise] et [@role_requis] du chapitre sur l'authentification sont bâtis de la même façon.

3.6.3. Des types « maison » pour Pydantic : liaison/types.py

# liaison/types.py : des types "maison" pour les modèles Pydantic
from datetime import date, datetime
from typing import Annotated

from pydantic import AfterValidator, BeforeValidator


# une contrainte "maison" : la chaîne ne doit pas contenir d'espace.
# La fonction reçoit la valeur DÉJÀ convertie en str ; elle la rend, ou lève
# une ValueError dont le message devient celui de l'erreur de validation
def sans_espace(valeur: str) -> str:
    if " " in valeur:
        raise ValueError("le champ ne doit pas contenir d'espace")
    return valeur


# Annotated : le type str, complété par une vérification exécutée APRÈS la
# conversion. SansEspace s'utilise ensuite comme un type ordinaire
SansEspace = Annotated[str, AfterValidator(sans_espace)]


# une conversion "maison" : "23/09/2026" -> date(2026, 9, 23), exécutée AVANT
# la validation du type date
def date_fr(valeur):
    if isinstance(valeur, str):
        try:
            return datetime.strptime(valeur, "%d/%m/%Y").date()
        except ValueError:
            raise ValueError(f"[{valeur}] n'est pas une date jj/mm/aaaa valide")
    return valeur


DateFr = Annotated[date, BeforeValidator(date_fr)]
  • lignes 11-14 : une fonction de validation : elle reçoit la valeur du champ, la rend si elle convient, lève une [ValueError] sinon. Le message de l'exception devient celui de l'erreur de validation ;
  • ligne 19 : le type [SansEspace] est le type [str], annoté par [AfterValidator(sans_espace)] : [Pydantic] appellera la fonction après avoir vérifié (et au besoin converti) la valeur en [str]. On définit ainsi, une fois, un type réutilisable dans tous les modèles, comme un type ordinaire ;
  • lignes 24-30 : une fonction de conversion. Le type [date] de [Pydantic] accepte les chaînes au format international, [2026-09-23] ; nous voulons le format français [23/09/2026]. La fonction reçoit la valeur brute : si c'est une chaîne, elle la convertit en [date]. Toute autre valeur (une [date] déjà construite, par exemple) est rendue telle quelle ;
  • ligne 33 : [BeforeValidator] : la fonction est appelée avant la validation du type [date], qui reçoit donc une [date] déjà construite.

3.6.4. Le contrôleur : controllers/exemples.py

# controllers/exemples.py : liaison et validation personnalisées
from datetime import date

from flask import Blueprint, request, url_for
from pydantic import BaseModel, ValidationError

from liaison.langue import avec_langue
from liaison.types import DateFr, SansEspace

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


# /exemples/agenda/23-09-2026 : un convertisseur de chemin personnalisé (cf. app.py)
@exemples.get("/agenda/<datefr:jour>")
def agenda(jour: date):
    # url_for utilise la méthode to_url du convertisseur : date -> "23-09-2026"
    return f"agenda du jour [{jour}], de type {type(jour).__name__}, " \
           f"URL : {url_for('exemples.agenda', jour=jour)}"


# /exemples/bonjour : un paramètre qui ne vient pas de l'URL, mais d'un entête,
# fourni par le décorateur @avec_langue
@exemples.get("/bonjour")
@avec_langue
def bonjour(langue: str):
    return "Hello!" if langue == "en" else "Bonjour !"


# le modèle de la recherche : deux types "maison"
class RechercheModele(BaseModel):
    motCle: SansEspace | None = None
    depuis: DateFr | None = None


# /exemples/recherche?motCle=flask&depuis=01/09/2026
@exemples.get("/recherche")
def recherche():
    modele = RechercheModele.model_validate(request.args.to_dict())
    return f"recherche de [{modele.motCle}] depuis le [{modele.depuis}]"


# --------------------------------------------------------------- erreurs
# errorhandler d'un BLUEPRINT : transforme une exception levée par l'une de SES
# actions en réponse (le même décorateur sur l'application vaut pour toutes)
@exemples.errorhandler(ValidationError)
def validation(e: ValidationError):
    # le message de la première erreur, sans le préfixe "Value error, " de Pydantic
    message = e.errors()[0]["msg"].removeprefix("Value error, ")
    return message, 400, {"Content-Type": "text/plain; charset=utf-8"}
  • lignes 14-18 : la variable de chemin [jour] utilise le convertisseur [datefr] : l'action reçoit une [date]. [url_for] construit l'URL de cette même action à partir de la date, grâce à la méthode [to_url] du convertisseur. La barre [\] en fin de ligne continue l'instruction sur la ligne suivante ; deux chaînes littérales qui se suivent sont concaténées ;
  • lignes 23-25 : l'ordre des décorateurs compte. Ils s'appliquent de bas en haut : [@avec_langue] transforme d'abord l'action, puis [@exemples.get] enregistre la fonction transformée dans le blueprint. Dans l'ordre inverse, le blueprint enregistrerait l'action d'origine, et le paramètre [langue] ne serait jamais fourni ;
  • lignes 30-32 : un modèle qui utilise les deux types « maison », comme des types ordinaires. Les deux champs sont facultatifs ;
  • lignes 45-49 : un gestionnaire d'exceptions, déclaré cette fois sur le blueprint : il ne traite que les exceptions levées par les actions de ce blueprint. [removeprefix] retire du message le préfixe ["Value error, "] que [Pydantic] ajoute aux messages des [ValueError]. La réponse est un tuple de trois éléments : le corps, le code et un dictionnaire d'entêtes (cf. exemple 03), ici pour une réponse en texte brut.

3.6.5. Exécution

Le convertisseur de chemin :

curl "http://localhost:5000/exemples/agenda/23-09-2026"
curl -i "http://localhost:5000/exemples/agenda/31-02-2026"
1
2
3
4
5
agenda du jour [2026-09-23], de type date, URL : /exemples/agenda/23-09-2026
HTTP/1.1 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 207
...

La date est affichée par [Python] au format international (c'est ce que produit la conversion d'une [date] en chaîne), mais l'URL construite par [url_for] a retrouvé le format français. Le 31 février n'existe pas : 404. Une date au format international, [2026-09-23], n'a pas la forme décrite par l'expression régulière : 404 également.

Le décorateur. L'option [-H] de [curl] fixe l'entête [Accept-Language] :

1
2
3
curl -H "Accept-Language: en-US,en;q=0.9,fr;q=0.8" http://localhost:5000/exemples/bonjour
curl -H "Accept-Language: fr-FR,fr;q=0.9" http://localhost:5000/exemples/bonjour
curl http://localhost:5000/exemples/bonjour
1
2
3
Hello!
Bonjour !
Bonjour !

Sans entête [Accept-Language] ([curl] n'en envoie pas), c'est la langue par défaut. Le chapitre sur l'internationalisation reprendra cette question, avec un choix de langue fait par l'utilisateur.

Les types « maison » :

1
2
3
4
5
curl "http://localhost:5000/exemples/recherche?motCle=flask&depuis=01/09/2026"
curl "http://localhost:5000/exemples/recherche"
curl -i "http://localhost:5000/exemples/recherche?motCle=flask%20mvc"
curl "http://localhost:5000/exemples/recherche?depuis=2026-09-01"
curl "http://localhost:5000/exemples/recherche?depuis=31/02/2026"
1
2
3
4
5
6
7
8
9
recherche de [flask] depuis le [2026-09-01]
recherche de [None] depuis le [None]
HTTP/1.1 400 BAD REQUEST
Content-Type: text/plain; charset=utf-8
Content-Length: 38

le champ ne doit pas contenir d'espace
[2026-09-01] n'est pas une date jj/mm/aaaa valide
[31/02/2026] n'est pas une date jj/mm/aaaa valide

3.6.6. Conclusion

Récapitulons les moyens dont dispose une action pour obtenir son modèle :

Source
Moyen
Variables du chemin
les paramètres de l'action, typés par les convertisseurs ([<int:id>], [<datefr:jour>])
Paramètres de l'URL
[request.args] : [get] (avec [type] et [default]), [getlist], [to_dict]
Champs d'un formulaire POST
[request.form], un [MultiDict] comme [request.args]
Corps JSON
[request.get_json()]
Entêtes
[request.headers], et des objets déjà analysés comme [request.accept_languages]
Conversion et validation
un modèle [Pydantic] : [model_validate], [ValidationError], [e.errors()]
Erreurs communes
[@app.errorhandler] (toute l'application) ou [@blueprint.errorhandler] (un blueprint)
Paramètre calculé
un décorateur qui ajoute un paramètre nommé à l'action

Les formulaires HTML, eux, ont besoin de plus que la validation : il faut les afficher, les réafficher avec la saisie de l'utilisateur et ses erreurs, les protéger contre les requêtes forgées. Ce sera le rôle de [WTForms], au chapitre suivant, une fois les vues introduites.