Skip to content

3. The Action Template

An action requires information sent by the browser: the parameters of the URL ([?name=martin&age=42]), form fields, the request body JSON, and sometimes a request header HTTP. The original course refers to all of this information as the action model. This chapter shows how an action retrieves this information, converts it to the correct type, and validates it. This process is called data binding.

[Flask] is much more minimalist here than other frameworks: it provides access to the request information in the form of character strings and stops there. Converting ["42"] to the integer 42, verifying that a name has at least two characters, and assembling the fields into an object—all of this is the responsibility of the application. We’ll first do this by hand (Example 07), then using the [Pydantic] library (Examples 08–12), which describes a model using a class in which each field specifies its type and constraints. In the next chapter, the HTML forms will be handled by another library, [WTForms], designed specifically for them; [Pydantic] remains the natural tool for the parameters of a URL and for the JSON bodies of the API.

Note on terminology: In the following chapter, the word “model” will primarily refer to the data that an action transmits to its view. The model of an action, as used in this chapter, is what it receives.

Examples 07 through 12 are run in the same way as those in the previous chapter: open a terminal to the example directory, with the virtual environment activated ([python app.py]); [Ctrl-C] stops the server. Their [app.py] files are all similar: a factory [create_app] that creates the application, configures the provider JSON (accents preserved, keys in their original order; see Example 03), and saves a blueprint. We’ll only comment on them when they introduce something new.

3.1. Example 07: The parameters of URL

The parameters for URL are the portion that follows [?]: [?name=martin&age=42]. This portion is called the query string (*query string*). Each parameter is a pair of [name=value]; the pairs are separated by [&]. The [controllers/parameters.py] file:

# controllers/parameters.py: the parameters of the URL (the "query string")
from flask import Blueprint, abort, request

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


# /parameters/action01?name=martin: request.args contains the parameters of
# the URL; get returns the value of the parameter, or None if it is missing
@parameters.get("/action01")
def action01():
    name = request.args.get("name")
    return f"name = [{name}], of type {type(name).__name__}"


# /parameters/action02?age=42: type=int CONVERTS the value (a string) into
# an integer; a missing parameter, or one that is not an integer, gives None
@parameters.get("/action02")
def action02():
    age = request.args.get("age", type=int)
    return f"age = [{age}], of type {type(age).__name__}"


# /parameters/action03?age=42: a REQUIRED parameter; its absence, or a
# value that is not an integer, stops the action with a 400 response (Bad Request)
@parameters.get("/action03")
def action03():
    text = request.args.get("age")
    if text is None:
        # abort raises an exception, which Flask turns into an error response
        abort(400, "the [age] parameter is required")
    # the conversion is up to us
    try:
        age = int(text)
    except ValueError:
        abort(400, f"[{text}] is not an integer")
    return f"age = [{age}], of type {type(age).__name__}"


# /parameters/action04?page=2: a default value if the parameter is missing
@parameters.get("/action04")
def action04():
    page = request.args.get("page", default=1, type=int)
    return f"page = [{page}]"


# /parameters/action05?q=flask: the name of the URL parameter is not necessarily
# that of the Python variable
@parameters.get("/action05")
def action05():
    search = request.args.get("q")
    return f"search = [{search}]"


# /parameters/action06?name=martin&age=42: all the parameters at once, as a dictionary
@parameters.get("/action06")
def action06():
    return request.args.to_dict()
  • line 11: [request.args] contains the parameters of URL, which have already been decoded by [Werkzeug]. This is a special dictionary, a [MultiDict], which, as we’ll see in Example 10, accepts multiple values per key. Its method [get] returns the value of a parameter, or [None] if it is absent. Unlike [request.args["name"]], which would throw an exception for a missing parameter, [get] never causes an error;
  • line 12: the action displays the value and its type: [type(x)] is the class of [x], and [__name__] is the name of that class. A value retrieved from URL is always a string ([str]);
  • line 19: the parameter [type] of [get] specifies a conversion function applied to the string: in this case, [int], the class of integers, whose call to [int("42")] returns 42. If the conversion fails ([int("abc")] raises a [ValueError]), [get] returns [None], just as it would for a missing parameter: the action cannot distinguish between the two cases, and the input error goes unnoticed;
  • Lines 26–35: a required parameter and a manual conversion that distinguishes between the two possible errors. [abort(400, message)] terminates the action with a 400 response (*Bad Request*—the request is invalid), accompanied by the message (see Example 03). The statement [try] / [except ValueError] catches the exception thrown by [int];
  • line 42: a default value, used if the parameter is missing (or if its conversion fails). This is a typical case for a page number in a list;
  • line 50: there is no requirement to name the variable [Python] the same as the parameter of URL. Search engines often use a short parameter, [q] (*query*);
  • line 57: [to_dict()] converts [MultiDict] into a standard [Python] dictionary (by retaining only the first value of each parameter), which the action returns as: [Flask] transforms it into JSON (see Example 03).

Let’s test each action with [curl]. A parameter is present, then absent:

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

name = [martin], of type str
name = [None], of type NoneType

The quotation marks around URL are necessary in a terminal: without them, the shell would interpret [&] (which runs a command in the background under [Linux]) and [?]. A value containing spaces or special characters must be encoded in the URL: a space becomes [%20], and [&] becomes [%26] (otherwise, it would separate two parameters). The browser handles this automatically; with [curl], you have to write it out. [Werkzeug] decodes the value before storing it in [request.args]:

curl "http://localhost:5000/parameters/action01?name=jean%20dupont%20%26%20sons"
name = [jean dupont & sons], of type str

The conversion using [type=int], which was successful at first but then failed:

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

The required parameter for action 03, missing and then invalid:

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

<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>the [age] parameter is required</p>
<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>[abc] is not an integer</p>

The default value and the renamed parameter:

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

Finally, all parameters at once. The parameter [name] appears twice: [to_dict()] retains only the first value.

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

The age remained a string (["42"], enclosed in quotes in JSON): no conversion was requested. In a browser (here [Chromium]), JSON appears on a single line: [Flask] only indents JSON in debug mode, and the screenshot was taken without it; the “Pretty-print” checkbox would display it indented. Incidentally, the address bar shows the encoding of accented characters in URL ([é] becomes [%C3%A9], with its two bytes in [UTF-8]):

Image

This first example illustrates the limitations of the manual approach: for each parameter, you must write the reading, conversion, and handling of missing values and errors. With ten parameters, the action’s code would be overwhelmed by these checks. It is better to describe what is expected and let a library handle the verification. That is the purpose of the following example.

3.2. Example 08: An action model described by a class

[Pydantic] is the most widely used data validation library in [Python] (it is at the core of the [FastAPI] framework). It describes the data expected by a class: each attribute has a type and, optionally, constraints. [Pydantic] is responsible for converting the received data into these types and for validating the constraints. It is installed in the example’s virtual environment (file [requirements.txt]).

3.2.1. The model: models/person.py

# models/person.py: the model of the action, a Pydantic class in which each
# field carries its type and its constraints
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class PersonModel(BaseModel):
    # the received strings are stripped of the spaces that surround them
    model_config = ConfigDict(str_strip_whitespace=True)

    # required (no default value), from 2 to 20 characters
    name: Annotated[str, Field(min_length=2, max_length=20)]
    # an integer ("42" is converted to 42), between 0 and 150
    age: Annotated[int, Field(ge=0, le=150)]
    # one of these three values only
    title: Literal["Mr", "Mme", "Melle"]
    # optional (None by default); if present, a valid email address
    email: EmailStr | None = None
  • Line 8: A [Pydantic] model is a class that inherits from [BaseModel]. Its attributes are declared at the class level, along with their types, as in a [dataclass] (Example 06);
  • Line 10: The model’s configuration. [str_strip_whitespace=True] removes the spaces surrounding the received strings: a name entered as [" martin "] becomes ["martin"] before being validated. The attribute [model_config] is not a field in the template: [Pydantic] recognizes this reserved name;
  • Line 13: the field [name]. [Annotated[str, Field(...)]] reads “the type [str], annotated by [Field(...)].” [Annotated], from the standard module [typing], allows additional information to be attached to a type—information that [Python] ignores but that the libraries read: here, the constraints of [Pydantic], a length of 2 to 20 characters ([min_length], [max_length]). The field has no default value: it is required;
  • line 15: the type [int]: [Pydantic] converts the string ["42"] to the integer 42, and rejects ["abc"]. The constraints [ge] (*greater or equal*) and [le] (*less or equal*) limit the value. There are also [gt] and [lt] for strict inequalities;
  • line 17: [Literal["Mr", "Mme", "Melle"]] is the type of the three values listed, and only those;
  • line 19: [EmailStr] is the type of an email address; [Pydantic] validates its syntax using the [email-validator] library (which is also installed by [requirements.txt]). [EmailStr | None]: an address, or [None]. The default value [= None] makes the field optional.

This template describes the expected data; it contains no validation instructions. This is known as a declarative style.

3.2.2. The action: controllers/persons.py

# controllers/persons.py: an action whose model is a Pydantic class
from flask import Blueprint, request

from models.person import PersonModel

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


# /persons/action01?name=martin&age=42&title=Mr
# model_validate: Pydantic creates the object from the dictionary of parameters
# (BINDING: conversion of the strings into the type of each field), then checks
# the constraints (VALIDATION). An invalid model raises a ValidationError,
# turned into a 400 response by app.py: the action stops there
@persons.get("/action01")
def action01():
    person = PersonModel.model_validate(request.args.to_dict())
    return {
        # model_dump: the object as a dictionary (serializable to JSON)
        "person": person.model_dump(),
        "ageType": type(person.age).__name__,
    }
  • Line 16: The [model_validate] class method creates a [PersonModel] object from a dictionary—in this case, the one containing the parameters of URL. It performs two actions, in this order: - binding: each value in the dictionary is assigned to the field of the same name, after being converted to the field’s type (["42"] → 42); - validation: the constraints for each field are checked (length, bounds, allowed values, etc.).
  • If either step fails, [model_validate] throws a [ValidationError] exception, which contains a list of all errors (not just the first one). The action does not handle it: it stops; we’ll see who takes care of it;
  • line 19: [model_dump()] returns the object as a dictionary, which [Flask] can convert to JSON;
  • line 20: the age type, to show that the conversion took place.

3.2.3. Error handling: app.py

# app.py: the application of example 08
from flask import Flask
from pydantic import ValidationError

from controllers.persons import persons


def create_app():
    app = Flask(__name__)
    # JSON: accents kept, keys in their original order (see example 03)
    app.json.ensure_ascii = False
    app.json.sort_keys = False
    app.register_blueprint(persons)

    # an invalid model: Pydantic's ValidationError exception becomes,
    # for the WHOLE application, a 400 response that details the errors
    @app.errorhandler(ValidationError)
    def invalid_model(e: ValidationError):
        errors = [{"field": ".".join(str(x) for x in err["loc"]), "message": err["msg"]}
                  for err in e.errors()]
        return {"status": 400, "errors": errors}, 400

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • Lines 17–21: The decorator [@app.errorhandler(ValidationError)] registers an exception handler: when an action throws a [ValidationError] (or an exception of a derived class), [Flask] calls this function, passing the exception as a parameter, and returns its return value as the response. Declared at the application level, the handler applies to all of its actions: every action that validates a model benefits from it, without having to write a single line of error-handling code;
  • Line 19: [e.errors()] returns the list of errors. Each error is a dictionary; We retain two pieces of information: [loc] (*location*), the affected field, in the form of a tuple ([("name",)]; see Example 10 for a longer tuple), and [msg], the message. The expression [[... for err in e.errors()]] is a list comprehension: it constructs a list by applying the expression on the left to each element;
  • line 21: a tuple [(body, code)] (see Example 03): a dictionary, converted to JSON, and the code 400.

3.2.4. Execution

A valid template:

curl -i "http://localhost:5000/persons/action01?name=martin&age=42&title=Mr"
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 116

{
  "person": {
    "name": "martin",
    "age": 42,
    "title": "Mr",
    "email": null
  },
  "ageType": "int"
}

The age has indeed become an integer (without quotation marks in JSON), and the missing address is [None] ([null] in JSON). A name surrounded by spaces ([%20]) and a present address:

curl "http://localhost:5000/persons/action01?name=%20%20martin%20&age=42&title=Mme&email=martin@example.com"
1
2
3
4
5
6
7
8
9
{
  "person": {
    "name": "martin",
    "age": 42,
    "title": "Mme",
    "email": "martin@example.com"
  },
  "ageType": "int"
}

No parameters: the three required fields are missing.

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

{
  "status": 400,
  "errors": [
    {
      "field": "name",
      "message": "Field required"
    },
    {
      "field": "age",
      "message": "Field required"
    },
    {
      "field": "title",
      "message": "Field required"
    }
  ]
}

Four invalid fields at once:

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

All errors are reported at once, allowing the user to correct everything in one go. The messages are in English: [Pydantic] is not translated. The following example replaces them with its own messages.

3.3. Example 09: Validation Performed by the Action

In Example 08, an invalid form interrupts the action, and the application handler responds. This is convenient for a API, whose clients are programs that expect a standardized error response. However, a web page often prefers to redisplay the form with the error messages: in this case, the action must decide how to handle an invalid model. The [models/person.py] model is the one from Example 08.

3.3.1. Custom messages: models/messages.py

# models/messages.py: the messages of the validation errors, in English.
# Each Pydantic error is a dictionary: its TYPE ("missing",
# "string_too_short"...), the field concerned (loc), a message in English (msg),
# and the context (ctx): the values of the constraints ({"min_length": 2}...)
MESSAGES = {
    "missing": "the field is required",
    "string_too_short": "{min_length} character(s) at least",
    "string_too_long": "{max_length} character(s) at most",
    "int_parsing": "an integer is expected",
    "greater_than_equal": "the value must be greater than or equal to {ge}",
    "less_than_equal": "the value must be less than or equal to {le}",
    "literal_error": "unknown value",
    "value_error": "invalid value",
    "too_short": "{min_length} item(s) at least",
}


# the name of the field: loc is a tuple, ("name",) or ("color", 1) for the 2nd
# item of a list
def field(error: dict) -> str:
    return ".".join(str(x) for x in error["loc"])


# our own message; an unknown error type keeps Pydantic's message
def message_en(error: dict) -> str:
    template = MESSAGES.get(error["type"])
    if template is None:
        return error["msg"]
    # format replaces {min_length}... with the values of the context
    return template.format(**error.get("ctx", {}))
  • Lines 5–14: Each error in [Pydantic] has a type and a unique identifier (["missing"] for a missing field, ["string_too_short"] for a string that is too short, etc.), independent of the English message. The [MESSAGES] dictionary associates its own message with each type. Some contain tags such as [{min_length}], [{ge}]...: the documentation for [Pydantic] provides, for each error type, the values of its context;
  • lines 20–21: the name of the field causing the error, taken from the tuple [loc]. [".".join(...)] assembles the elements of the tuple—converted to strings—separating them with periods;
  • lines 25–30: our own error message. [MESSAGES.get(...)] returns [None] for an error type not found in the dictionary; in that case, the English message from [Pydantic] is used. Otherwise, [format] replaces each occurrence of [{name}] in the message with the value of the same name from the context: ["{min_length} character(s) at least".format(min_length=2)] yields ["2 character(s) at least"]. The notation [**dictionary] passes the dictionary entries as named parameters; [error.get("ctx", {})] returns an empty dictionary for an error without context.

This technique (translating based on the error type) is the one used in the case study: messages there are identified by keys, which are translated into the user’s language.

3.3.2. The action: controllers/persons.py

# controllers/persons.py: it is the action that decides what to do with an invalid model
from flask import Blueprint, request
from markupsafe import escape
from pydantic import ValidationError

from models.messages import field, message_en
from models.person import PersonModel

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


@persons.get("/action01")
def action01():
    try:
        person = PersonModel.model_validate(request.args.to_dict())
    except ValidationError as e:
        # the errors, field by field: e.errors() gives their list
        items = "".join(f"<li>{field(error)}: {message_en(error)}</li>" for error in e.errors())
        return f"<p>The form contains errors:</p><ul>{items}</ul>"
    # escape: the entered name is inserted into HTML; its characters < > & " '
    # are replaced by &lt; &gt;... (otherwise, an input could inject HTML)
    return f"<p>Welcome {person.title} {escape(person.name)} ({person.age} years old)</p>"
  • Lines 14–19: The action itself intercepts the [ValidationError] within a [try] / [except] block. If an error occurs, it constructs a HTML list of errors ([<ul>], one [<li>] tag per error);
  • line 22: if successful, a welcome message. The name is passed through the [escape] function in the [markupsafe] library (installed with [Flask]). [escape] replaces characters that have meaning in HTML ([<], [>], [&], ["], [']) with their entities ([&lt;], [&gt;]...): they will be displayed as-is instead of being interpreted. Without this precaution, a user could enter a “name” containing HTML, or even [JavaScript], which would execute in the browser of anyone viewing the page. This is the most common security vulnerability on the web: script injection (*XSS*, *cross-site scripting*). The title and age do not require this protection: their values have been validated by the template (one of three allowed values, an integer). In the next chapter, the [Jinja2] view engine will perform this escaping automatically for any value inserted into a page.

A valid entry, followed by an entry containing four errors:

Image

Image

The raw response to the second query, and the response for a name containing HTML:

curl "http://localhost:5000/persons/action01?name=m&age=abc&title=Mx&email=xx"
curl "http://localhost:5000/persons/action01?name=%3Cb%3Emartin%3C/b%3E&age=42&title=Mr"
<p>The form contains errors:</p><ul><li>name: 2 character(s) at least</li><li>age: an integer is expected</li><li>title: unknown value</li><li>email: invalid value</li></ul>
<p>Welcome Mr &lt;b&gt;martin&lt;/b&gt; (42 years old)</p>

The name [<b>martin</b>] (encoded as [%3Cb%3E...] in URL) is displayed as-is, without being bolded: its tags have been neutralized. Note that the response this time is a 200 status code: this is because the action chose to return a normal page containing the errors.

3.4. Example 10: Multiple Parameters

The same parameter may appear multiple times in a URL: [?color=red&color=green]. This is what a browser sends for a group of checkboxes with the same name, or a multiple-select list (see the next chapter). The [controllers/arrays.py] file:

# controllers/arrays.py: a parameter that appears several times in the URL
from flask import Blueprint, request
from pydantic import ValidationError

from models.choice import ChoiceModel
from models.messages import field, message_en

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


# /arrays/action01?color=red&color=green: get returns only the FIRST value
@arrays.get("/action01")
def action01():
    return {"color": request.args.get("color")}


# /arrays/action02?color=red&color=green: getlist returns ALL the
# values, in a list (empty if the parameter is missing)
@arrays.get("/action02")
def action02():
    colors = request.args.getlist("color")
    return {"color": colors, "count": len(colors)}


# /arrays/action03?color=red: a list, validated by ChoiceModel
@arrays.get("/action03")
def action03():
    colors = request.args.getlist("color")
    try:
        ChoiceModel(color=colors)
        errors = []
    except ValidationError as e:
        errors = [f"{field(error)}: {message_en(error)}" for error in e.errors()]
    return {"colors": colors, "valid": not errors, "errors": errors}
  • line 14: [get] returns only the first value of the parameter;
  • line 21: [getlist] returns all its values in a list, which is empty if the parameter is missing. This is the distinctive feature of [MultiDict]: it retains the list of values for each key;
  • lines 27–34: the list is validated using a [Pydantic] template. This time, the model is constructed by a standard call, [ChoiceModel(color=colors)], with the fields passed as named parameters: this is equivalent to [model_validate] on a dictionary. The object created serves no purpose other than validation: it is not retained. The expression [not errors] is equivalent to [True] for an empty list.

The template [models/choice.py]:

# models/choice.py: a list, and a constraint on EACH item of the list
from typing import Annotated, Literal

from pydantic import BaseModel, Field


class ChoiceModel(BaseModel):
    # list[Literal[...]]: each item must be "red", "green" or "blue";
    # min_length=1: the list must contain at least one item
    color: Annotated[list[Literal["red", "green", "blue"]], Field(min_length=1)]
  • Line 10: The type [list[Literal["red", "green", "blue"]]] is that of a list in which each element is one of three colors. The constraint [min_length=1], applied to a list, pertains to the number of elements: there must be at least one.

The file [models/messages.py] is the one from Example 09. The results:

1
2
3
curl "http://localhost:5000/arrays/action01?color=red&color=green"
curl "http://localhost:5000/arrays/action02?color=red&color=green"
curl http://localhost:5000/arrays/action02
{
  "color": "red"
}
{
  "color": [
    "red",
    "green"
  ],
  "count": 2
}
{
  "color": [],
  "count": 0
}

Validation was successful at first, then failed due to an unknown color, and then due to an empty list:

1
2
3
curl "http://localhost:5000/arrays/action03?color=red&color=blue"
curl "http://localhost:5000/arrays/action03?color=red&color=yellow"
curl http://localhost:5000/arrays/action03
{
  "colors": [
    "red",
    "blue"
  ],
  "valid": true,
  "errors": []
}
{
  "colors": [
    "red",
    "yellow"
  ],
  "valid": false,
  "errors": [
    "color.1: unknown value"
  ]
}
{
  "colors": [],
  "valid": false,
  "errors": [
    "color: 1 item(s) at least"
  ]
}

The "unknown value" error refers to [color.1]: the error tuple [loc] is [("color", 1)]; the field and index of the problematic element in the list (the second one, with indices starting at 0).

3.5. Example 11: The parameters of a POST query

The previous examples used GET requests, whose parameters are contained in URL. A POST request carries its data in its body, in two main forms:

  • fields from a HTML form: the body has the same syntax as a query string ([name=martin&age=42]), and its type (header [Content-Type]) is [application/x-www-form-urlencoded];
  • a JSON document, sent by a program ([JavaScript] on a page, client of a API...): the type is [application/json].

The file [controllers/post.py]:

# controllers/post.py: the parameters of a POST request
from flask import Blueprint, request
from pydantic import ValidationError

from models.messages import field, message_en
from models.person import PersonModel

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


# the fields of an HTML form (application/x-www-form-urlencoded body):
# request.form, a "multi" dictionary (MultiDict), like request.args
@post.post("/action01")
def action01():
    return {
        "receivedType": request.content_type,
        # flat=False: each field -> the list of its values
        "fields": request.form.to_dict(flat=False),
    }


# a JSON body: get_json deserializes it (dictionary, list...); a body
# that is not JSON -> error 415 (unsupported type) or 400 (malformed JSON)
@post.post("/action02")
def action02():
    return {"receivedType": request.content_type, "body": request.get_json()}


# a single field of the form
@post.post("/action03")
def action03():
    name = request.form.get("name")
    return f"name = [{name}]"


# the fields of the form, bound and validated by the PersonModel class
@post.post("/action04")
def action04():
    try:
        person = PersonModel.model_validate(request.form.to_dict()).model_dump()
        errors = []
    except ValidationError as e:
        person = None
        errors = sorted(f"{field(error)}: {message_en(error)}" for error in e.errors())
    return {"person": person, "valid": not errors, "errors": errors}
  • line 13: [post.post(...)]: the first [post] is the blueprint, the second is the method that declares a route for the method HTTP POST (see example 02);
  • line 16: [request.content_type] is the [Content-Type] header of the request;
  • line 18: [request.form] contains the fields of a form sent in POST. Like [request.args], this is a [MultiDict]. [to_dict(flat=False)] actually creates a dictionary where each value is the list of values for the field;
  • line 26: [request.get_json()] deserializes a JSON body: the text JSON becomes a [Python] object (a dictionary for a JSON object, a list for a JSON array...). If the body is not declared as JSON, [get_json] causes a 415 error (*Unsupported Media Type*); if it is, but the text is malformed, a 400 error occurs;
  • line 32: a field, interpreted as a parameter of URL;
  • lines 38–45: the [PersonModel] template from Example 08, this time linked to the form fields. The code is the same as with the parameters of URL: only the dictionary source changes. [sorted] sorts the messages to ensure a consistent response.

With [curl], the [-d] (*data*) option sends a body; the request then becomes a POST request, of type [application/x-www-form-urlencoded], unless the [-H] (*header*) option specifies a different type. The option [-X POST] explicitly sets the method (it is optional here). The fields of a form, including a repeated field:

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

{
  "receivedType": "application/x-www-form-urlencoded",
  "fields": {
    "name": [
      "martin"
    ],
    "age": [
      "42"
    ],
    "hobby": [
      "tennis",
      "piano"
    ]
  }
}

A JSON body, which contains an integer and an array:

curl -H "Content-Type: application/json" -d "{\"name\": \"martin\", \"age\": 42, \"children\": [\"léa\", \"tom\"]}" http://localhost:5000/post/action02
{
  "receivedType": "application/json",
  "body": {
    "name": "martin",
    "age": 42,
    "children": [
      "léa",
      "tom"
    ]
  }
}

The quotation marks in JSON are preceded by a [\] to prevent the command string from ending; Under [Linux] and [macOS], you can also enclose JSON in apostrophes: [-d '{"name": "martin"}']. Unlike form fields, the JSON carries data types: age is already an integer. A body that is not JSON, followed by a malformed JSON:

curl -i -d "name=martin" http://localhost:5000/post/action02
curl -i -H "Content-Type: application/json" -d "{\"name\": " 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: 163

<!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 10 (char 9)</p>

A single field, then the same URL requested as GET:

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

URL exists, but not for the GET method: [Flask] returns a 405 (*Method Not Allowed*), and its header [Allow] lists the accepted methods ([OPTIONS] is automatically handled by [Flask] for any route). Finally, the template linked to the form fields, first valid then invalid:

curl -d "name=martin&age=42&title=Mr" http://localhost:5000/post/action04
curl -d "name=m&age=200" http://localhost:5000/post/action04
{
  "person": {
    "name": "martin",
    "age": 42,
    "title": "Mr",
    "email": null
  },
  "valid": true,
  "errors": []
}
{
  "person": null,
  "valid": false,
  "errors": [
    "age: the value must be less than or equal to 150",
    "name: 2 character(s) at least",
    "title: the field is required"
  ]
}

3.6. Example 12: Custom Binding and Validation

Standard types aren’t always sufficient. This example shows three ways to extend binding and validation:

  • a path converter: a date in French format, [23-09-2026], in the path of URL;
  • a decorator that provides the action with a parameter that does not come from the URL, but from a HTTP header;
  • "custom" types for [Pydantic]: a constraint (no spaces) and a conversion (a [dd/mm/yyyy] date).
1
2
3
4
5
6
7
8
12-custom-binding/
├── app.py
├── binding/
│   ├── date_fr.py
│   ├── language.py
│   └── types.py
└── controllers/
    └── examples.py

3.6.1. A path converter: binding/date_fr.py

In Example 02, we encountered the [Werkzeug] converters: [<int:id>] accepts only an integer and converts it. You can write new ones.

# binding/date_fr.py: a French date "23-09-2026" in the path of the URL
from datetime import date, datetime

from werkzeug.routing import BaseConverter, ValidationError


# a Werkzeug CONVERTER (the library on which Flask is built):
# it decides whether a piece of the path is suitable, and turns it into a Python object
class DateFrConverter(BaseConverter):
    # the regular expression of the piece of path: dd-mm-yyyy
    regex = r"\d{2}-\d{2}-\d{4}"

    # text of the path -> date object
    def to_python(self, value: str) -> date:
        try:
            # strptime checks that the date exists: "31-02-2026" is rejected
            return datetime.strptime(value, "%d-%m-%Y").date()
        except ValueError:
            # the route does not match: Flask looks for another route, then answers 404
            raise ValidationError()

    # date object -> text of the path (used by url_for)
    def to_url(self, value: date) -> str:
        return value.strftime("%d-%m-%Y")
  • line 9: a converter is a class that inherits from [BaseConverter];
  • line 11: the attribute [regex] is a regular expression that describes the format of the accepted path segment: [\d] denotes a digit, [{2}] denotes the repetition of the preceding element—in this case, two digits. The prefix [r] in the string (*raw string*) prevents [Python] from interpreting [\]. Routing only attempts to use this converter on a path segment of this form;
  • lines 14–20: [to_python] transforms the path text into a [Python] object, in this case a [date]. [datetime.strptime] (*string parse time*) parses a string according to a specific format: [%d] for the day, [%m] for the month, and [%Y] for the four-digit year; [.date()] retains the date without the time. [strptime] verifies that the date exists: [31-02-2026] is in the correct format, but raises a [ValueError]. The converter then raises the [ValidationError] exception from [Werkzeug] (not to be confused with the one from [Pydantic]): for routing, the route does not match the URL, which will therefore result in a 404 error if no other route matches it;
  • Lines 23–24: the inverse operation, used by [url_for] to construct a URL from a date. [strftime] (*string format time*) formats a date.

The converter is stored in [app.py]:

# app.py: the application of example 12
from flask import Flask

from controllers.examples import examples
from binding.date_fr import DateFrConverter


def create_app():
    app = Flask(__name__)
    # a path variable converter, under the name [datefr]: the routes
    # can now write <datefr:day>, like <int:id>
    app.url_map.converters["datefr"] = DateFrConverter
    app.register_blueprint(examples)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • Line 12: [app.url_map] is the application’s routing table; its dictionary, [converters], associates a name with each converter ([int], [path]...). We add ours to it, under the name [datefr]. The entry must precede those of the blueprints that use it.

3.6.2. A parameter provided by a decorator: binding/language.py

An action [Flask] receives the path variables as parameters, and nothing else. To provide it with additional information, we can decorate the action: the decorator replaces the action with a function that calculates the information, then calls the action by passing it that information.

# binding/language.py: a decorator that PROVIDES a parameter to the action
from functools import wraps

from flask import request


# @with_language, placed on an action, passes it the [language] parameter, read from
# the header "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8" -> "fr"
def with_language(action):
    # wraps: the produced function keeps the name of the action (Flask uses it
    # to name the route)
    @wraps(action)
    def action_with_language(*args, **kwargs):
        # request.accept_languages: the header already parsed by Werkzeug;
        # best_match: the browser's preferred language among those offered
        kwargs["language"] = request.accept_languages.best_match(["fr", "en"], default="fr")
        return action(*args, **kwargs)

    return action_with_language
  • Line 9: A decorator is a function that takes a function (in this case, the action) and returns another function (see Example 01). Writing [@with_language] above [def hello(...)] is equivalent to writing, after the definition, [hello = with_language(hello)];
  • Line 13: The function that will replace the action. [*args] receives positional parameters in a tuple, and [**kwargs] (*keyword arguments*) receives named parameters in a dictionary. This is how [Flask] passes the path variables ([id=3]): the function receives them without needing to know them;
  • line 16: a named parameter, [language], is added. [request.accept_languages] is the request header [Accept-Language], which has already been parsed by [Werkzeug]. The browser specifies the user’s languages here, in order of preference: [fr-FR,fr;q=0.9,en;q=0.8] (the coefficient [q] measures the preference, on a scale from 0 to 1). [best_match] selects, from among the languages offered by the application, the one the user prefers, or the value [default] if none are suitable;
  • line 17: the call to the original action, with the received parameters and the new one. The notations [*args] and [**kwargs] unpack the tuple and the dictionary into parameters this time;
  • line 12: the function [action_with_language] retrieves the name ([__name__]) and documentation of the action. This is essential here: [Flask] names each route after the name of its function (the [hello] of [url_for("examples.hello")]), and would refuse to register two routes with the same name, [action_with_language], if two actions used the decorator.

We’ll see this approach throughout the course: the [@login_required] and [@role_required] decorators in the chapter on authentication are built the same way.

3.6.3. "Custom" types for Pydantic: binding/types.py

# binding/types.py: "home-made" types for the Pydantic models
from datetime import date, datetime
from typing import Annotated

from pydantic import AfterValidator, BeforeValidator


# a "home-made" constraint: the string must not contain a space.
# The function receives the value ALREADY converted to str; it returns it, or raises
# a ValueError whose message becomes that of the validation error
def no_space(value: str) -> str:
    if " " in value:
        raise ValueError("the field must not contain a space")
    return value


# Annotated: the str type, completed by a check executed AFTER the
# conversion. NoSpace is then used like an ordinary type
NoSpace = Annotated[str, AfterValidator(no_space)]


# a "home-made" conversion: "23/09/2026" -> date(2026, 9, 23), executed BEFORE
# the validation of the date type
def date_fr(value):
    if isinstance(value, str):
        try:
            return datetime.strptime(value, "%d/%m/%Y").date()
        except ValueError:
            raise ValueError(f"[{value}] is not a valid dd/mm/yyyy date")
    return value


DateFr = Annotated[date, BeforeValidator(date_fr)]
  • Lines 11–14: a validation function: it takes the field’s value as input, returns it if it is valid, and raises a [ValueError] otherwise. The exception message becomes the validation error message;
  • line 19: the type [NoSpace] is the type [str], annotated by [AfterValidator(no_space)]: [Pydantic] will call the function after verifying (and, if necessary, converting) the value to [str]. This allows us to define, once, a type that can be reused in all models, just like an ordinary type;
  • Lines 24–30: a conversion function. The type ***[date] of *[Pydantic] accepts strings in the international format, ***[2026-09-23]; we want the French format, ***[23/09/2026]. The function receives the raw value: if it is a string, it converts it to [date]. Any other value (a pre-constructed [date]**, for example) is returned as-is;
  • line 33: [BeforeValidator]: the function is called before the validation of type [date], which therefore receives a pre-constructed [date].

3.6.4. The controller: controllers/examples.py

# controllers/examples.py: custom binding and validation
from datetime import date

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

from binding.language import with_language
from binding.types import DateFr, NoSpace

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


# /examples/schedule/23-09-2026: a custom path converter (see app.py)
@examples.get("/schedule/<datefr:day>")
def schedule(day: date):
    # url_for uses the to_url method of the converter: date -> "23-09-2026"
    return f"schedule for the day [{day}], of type {type(day).__name__}, " \
           f"URL: {url_for('examples.schedule', day=day)}"


# /examples/hello: a parameter that does not come from the URL, but from a header,
# provided by the @with_language decorator
@examples.get("/hello")
@with_language
def hello(language: str):
    return "Hello!" if language == "en" else "Bonjour !"


# the model of the search: two "home-made" types
class SearchModel(BaseModel):
    keyword: NoSpace | None = None
    since: DateFr | None = None


# /examples/search?keyword=flask&since=01/09/2026
@examples.get("/search")
def search():
    model = SearchModel.model_validate(request.args.to_dict())
    return f"search for [{model.keyword}] since [{model.since}]"


# --------------------------------------------------------------- errors
# errorhandler of a BLUEPRINT: turns an exception raised by one of ITS
# actions into a response (the same decorator on the application applies to all)
@examples.errorhandler(ValidationError)
def validation(e: ValidationError):
    # the message of the first error, without Pydantic's "Value error, " prefix
    message = e.errors()[0]["msg"].removeprefix("Value error, ")
    return message, 400, {"Content-Type": "text/plain; charset=utf-8"}
  • Lines 14–18: The path variable [day] uses the converter [datefr]: The action receives a [date]. [url_for] constructs the URL for this same action based on the date, using the converter’s [to_url] method. The [\] separator at the end of the line continues the instruction on the next line; two consecutive literal strings are concatenated;
  • Lines 23–25: The order of the decorators matters. They are applied from bottom to top: [@with_language] first transforms the action, then [@examples.get] saves the transformed function to the blueprint. In the reverse order, the blueprint would save the original action, and the parameter [language] would never be provided;
  • lines 30–32: a model that uses the two “custom” types as if they were ordinary types. Both fields are optional;
  • lines 45–49: an exception handler, this time declared on the blueprint: it handles only exceptions thrown by the actions of this blueprint. [removeprefix] removes the prefix ["Value error, "] from the message, which [Pydantic] adds to messages from [ValueError]. The response is a tuple of three elements: the body, the code, and a dictionary of headers (see Example 03), in this case for a plaintext response.

3.6.5. Execution

The path converter:

curl "http://localhost:5000/examples/schedule/23-09-2026"
curl -i "http://localhost:5000/examples/schedule/31-02-2026"
1
2
3
4
5
schedule for the day [2026-09-23], of type date, URL: /examples/schedule/23-09-2026
HTTP/1.1 404 NOT FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 207
...

The date is displayed by [Python] in international format (this is the result of converting a [date] to a string), but the URL constructed by [url_for] has reverted to the French format. February 31 does not exist: 404. A date in international format, [2026-09-23], does not match the pattern described by the regular expression: 404 as well.

The decorator. The [-H] option of [curl] sets the header [Accept-Language]:

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

Without a header [Accept-Language] ([curl] does not send one), this is the default language. The chapter on internationalization will revisit this issue, with the user selecting the language.

"Custom" types:

1
2
3
4
5
curl "http://localhost:5000/examples/search?keyword=flask&since=01/09/2026"
curl "http://localhost:5000/examples/search"
curl -i "http://localhost:5000/examples/search?keyword=flask%20mvc"
curl "http://localhost:5000/examples/search?since=2026-09-01"
curl "http://localhost:5000/examples/search?since=31/02/2026"
1
2
3
4
5
6
7
8
9
search for [flask] since [2026-09-01]
search for [None] since [None]
HTTP/1.1 400 BAD REQUEST
Content-Type: text/plain; charset=utf-8
Content-Length: 34

the field must not contain a space
[2026-09-01] is not a valid dd/mm/yyyy date
[31/02/2026] is not a valid dd/mm/yyyy date

3.6.6. Conclusion

Let’s summarize the methods available to an action for obtaining its template:

Source
Method
Path variables
the action’s parameters, typed by the converters ([<int:id>], [<datefr:day>])
Parameters of URL
[request.args]: [get] (along with [type] and [default]), [getlist], [to_dict]
Fields in a POST form
[request.form], a [MultiDict], and a [request.args]
Body JSON
[request.get_json()]
Headers
[request.headers], and objects that have already been analyzed, such as [request.accept_languages]
Conversion and validation
a template [Pydantic]: [model_validate], [ValidationError], [e.errors()]
Common errors
[@app.errorhandler] (entire application) or [@blueprint.errorhandler] (a blueprint)
Calculated parameter
A decorator that adds a named parameter to the action

HTML forms, on the other hand, require more than just validation: they must be displayed, re-displayed with the user’s input and any errors, and protected against forged requests. This will be the role of [WTForms], covered in the next chapter once views have been introduced.