angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Flask RESTful API validation des paramètres de la requête avec les schémas Marshmallow

Créez des schémas distincts pour les paramètres de requête path, query et body et validez-les avec une seule fonction.

30 mars 2021
Dans API, Flask
post main image

Lorsque vous construisez un API RESTful, la première chose à faire est de définir les codes d'état et les réponses aux erreurs. La RFC 7807 'Problem Details for HTTP APIs' spécifie les paramètres minimums que vous devez retourner. Si vous ne l'avez pas étudiée, je vous suggère de le faire. Bien sûr, vous voudrez souvent inclure plus de détails sur ce qui a mal tourné. Les APIs sont destinés aux développeurs et nous voulons leur permettre de comprendre facilement pourquoi un appel a échoué.

Lorsque vous construisez un API avec Flask , il est presque impossible d'ignorer un paquet de sérialisation/désérialisation comme Marshmallow. Et avec le paquet APISpec, il est facile de documenter votre API.

Lorsque vous appelez vos méthodes API , la première chose à faire est de valider les paramètres de la requête. Je vous montre ci-dessous comment j'ai fait cela avec Marshmallow.

Validation des entrées avec Marshmallow

Ecrire un API est différent de l'écriture d'une application web. Pour une application web Flask avec des formulaires, nous utilisons le package WTForms pour récupérer et valider les paramètres de la requête. Pour une API, nous utilisons des schémas. Les schémas décrivent comment communiquer avec un API et nous permettent de documenter notre API avec Swagger, par exemple.

Le paquet Marshmallow nous permet non seulement de désérialiser les paramètres d'entrée, mais il contient également un large ensemble de validateurs.

Un exemple de classe de modèle : Ville

Dans cet article, je vais utiliser la classe modèle City (SQLAlchemy).

class City(Base):
    __tablename__ = 'city'

    id = Column(Integer, primary_key=True)
    name = Column(String(100), server_default='')

Nous voulons les opérations suivantes :

  • Obtenir une liste de villes, avec pagination et filtre.
  • Obtenir une ville par son identifiant
  • Créer une ville
  • Mettre à jour une ville par son identifiant
  • Supprimer une ville par son identifiant

Nous avons donc les requêtes suivantes :

GET       /cities?page=1&per_page=10&search=an
GET       /cities/4
POST      /cities, city name in request body
PUT       /cities/5, city name in request body
DELETE   /cities/7

Et nos méthodes ressemblent à :

@blueprint_cities.route('', methods=['GET'])
def cities_list():
    ...

@blueprint_cities.route('/<int:city_id>', methods=['GET'])
def cities_get(city_id):
    ...

@blueprint_cities.route('', methods=['POST'])
def cities_create():
    ...

@blueprint_cities.route('/<int:city_id>', methods=['PUT'])
def cities_update(city_id):
    ...

@blueprint_cities.route('/<int:city_id>', methods=['DELETE'])
def cities_delete(city_id):
    ...

Flask et paramètres de requête

Il existe trois types de paramètres de requête :

  • Paramètres de chemin : city_id.
  • Paramètres de requête : page, per_page, search
  • Paramètres du corps : nom

Dans Flask , nous pouvons accéder à ces paramètres en utilisant l'objet de requête :

  • request.view_args
  • request.args
  • request.form

Traduction en schémas Marshmallow

Nous commençons par créer des classes de base, des schémas, pour les trois types de paramètres de requête :

class RequestPathParamsSchema(Schema):
    pass

class RequestQueryParamsSchema(Schema):
    pass

class RequestBodyParamsSchema(Schema):
    pass

Puis nous créons des schémas pour ces paramètres :

class CitiesRequestQueryParamsSchema(PaginationQueryParamsSchema, NameFilterQueryParamsSchema):
    pass
    
class CitiesRequestPathParamsSchema(RequestPathParamsSchema):
    city_id = fields.Int(
        description='The id of the city.',
        validate=validate.Range(min=1, max=9999),
        required=True)

class CitiesCreateRequestBodyParamsSchema(RequestBodyParamsSchema):
    name = fields.Str(
        description='The name of the city',
        validate=validate.Length(min=2, max=40),
        required=True)

class CitiesUpdateRequestBodyParamsSchema(RequestBodyParamsSchema):
    name = fields.Str(
        description='The name of the city',
        validate=validate.Length(min=2, max=40),
        required=True)

Cela ressemble beaucoup à l'utilisation de WTForms. Plus haut, j'ai référencé deux classes : PaginationQueryParamsSchema et NameFilterQueryParamsSchema. Les voici :

class PaginationQueryParamsSchema(RequestQueryParamsSchema):
    page = fields.Int(
        missing=PAGINATION_PAGE_VALUE_DEFAULT,
        description='Pagination page number, first page is 1.',
        validate=validate.Range(min=PAGINATION_PAGE_VALUE_MIN, max=PAGINATION_PAGE_VALUE_MAX),
        required=False)
    per_page = fields.Int(
        missing=PAGINATION_PER_PAGE_VALUE_DEFAULT,
        description='Pagination items per page.',
        validate=validate.Range(min=PAGINATION_PER_PAGE_VALUE_MIN, max=PAGINATION_PER_PAGE_VALUE_MAX),
        required=False)

class NameFilterQueryParamsSchema(RequestQueryParamsSchema):
    name = fields.Str(
        description='The (part of the) name to search for',
        validate=validate.Length(min=2, max=40),
        required=False)

Pour la désérialisation et la validation des objets, Marshmallow nous donne la méthode load(). Cette méthode renvoie un dictionnaire de noms de champs mappés à des valeurs. Si une erreur de validation se produit, une ValidationError est levée.

Par exemple, pour vérifier le paramètre city_id du chemin Url de la requête dans les méthodes GET, UPDATE et DELETE, nous appelons load() comme suit :

try:
    result = CitiesRequestPathParamsSchema().load(request.view_args)
except ValidationError as err:
    ...

Lorsqu'aucune erreur n'est trouvée, le résultat ressemble à :

{'city_id': 5}

Nous pouvons faire de même pour les paramètres de requête Url et les paramètres du corps de la requête.

Une seule fonction pour vérifier tous les schémas

Avec ce qui précède, nous devons encore appeler la méthode load() pour tous les schémas d'une méthode. Dans notre exemple, seule la méthode request PUT possède deux schémas, mais dans un API plus réaliste, on peut s'attendre à plus de schémas. Je cherchais un moyen de les combiner pour éviter les répétitions et réduire le code.

Comme nos schémas Cities sont hérités de nos classes de base RequestPathParamsSchema, RequestQueryParamsSchema et RequestBodyParamsSchema, nous pouvons les utiliser pour sélectionner les paramètres à transmettre à la méthode load(). La fonction Python isinstance() est utilisée à cet effet. Elle renvoie également True pour vérifier si un objet est hérité d'une classe de base.

J'ai créé une classe d'aide APIUtils avec une méthode request_schemas_load() où je peux passer un ou plusieurs schémas à valider et à charger.

class  APIUtils:
    ...

    @classmethod
    def schema_check(cls, schema=None, json_data=None, title=None):
        # load and validate
        try:
            return schema.load(data=json_data, partial=True)

        except ValidationError as err:
            raise  APIError(
                status_code=400,
                title=title,
                messages=err.messages,
                data=err.data,
                valid_data=err.valid_data,
            )

    @classmethod
    def request_schemas_load(cls, schemas):
        if not isinstance(schemas, list):
            schemas = [schemas]

        result_path, result_query, result_body = {}, {}, {}
        for schema in schemas:
            if isinstance(schema, api_spec.RequestPathParamsSchema):
                # path params
                result_path.update(cls.schema_check(
                    schema=schema,
                    json_data=request.view_args,
                    title='One or more request url path parameters did not validate'))

            if isinstance(schema, api_spec.RequestQueryParamsSchema):
                # query params
                result_query.update(cls.schema_check(
                    schema=schema, 
                    json_data=request.args,
                    title='One or more request url query parameters did not validate'))

            if isinstance(schema, api_spec.RequestBodyParamsSchema):
                # body params
                result_body.update(cls.schema_check(
                    schema=schema, 
                    json_data=request.get_json(),
                    title='One or more request body parameters did not validate'))

        return {
            'path': result_path,
            'query': result_query,
            'body': result_body,
        }

    @classmethod
    def get_by_id_or_404(cls, res, res_id, res_name, res_id_name):
        obj = app_db.session.query(res).get(res_id)
        if obj is  None:
            raise  APIError(
                status_code=404,
                title='The requested resource could not be found',
                messages={
                    res_name: [
                        'Not found',
                    ]
                },
                data={
                    res_id_name: res_id,
                },
            )
        return obj

Dans le code ci-dessus, nous itérons à travers les schémas, les chargeons et les validons un par un. Le résultat est un dictionnaire des dictionnaires de résultats de schema.load() de Marshmallow. L'hypothèse est que nous n'avons pas de paramètres différents avec le même nom dans un type de requête (path, query, body).

Title est le titre de la RFC 7807. C'est un message pour tous les types d'erreurs de paramètres de requête.

En cas d'erreur, une exception est levée. Les données des messages ValidationError sont également incluses dans la réponse
et comprennent les détails de l'erreur :

  • err.messages
  • err.data
  • err.valid_data

J'ajoute également la méthode get_by_id_or_404() pour afficher la réponse au cas où une ressource n'aurait pas été trouvée.

Code pour la méthode de mise à jour de la ville

Avec l'aide de la méthode request_schemas_load() de APIUtils, nous pouvons maintenant écrire le code de la méthode de mise à jour de la ville :

@blueprint_cities.route('/<int:city_id>', methods=['PUT'])
def cities_update(city_id):
    result =  APIUtils.request_schemas_load([
        CitiesRequestPathParamsSchema(),
        CitiesUpdateRequestBodyParamsSchema()])

    city =  APIUtils.get_by_id_or_404(City, city_id, 'City', 'city_id')

    for k, v in result['body'].items():
        setattr(city, k, v)
    app_db.session.commit()

    return jsonify({
        'data': CitiesResponseSchema().dump(city)
    }), 200

Dans l'exemple ci-dessus, j'utilise la méthode APIUtils.get_by_id_or_404() pour obtenir une seule ville par identifiant. Si la ville n'existe pas, une erreur APIError est levée. Lorsqu'il n'y a pas d'erreur, et que la ville est trouvée, je mets à jour les paramètres du corps avec les valeurs de la requête.

Quelques réponses aux erreurs API

J'utilise Curl pour montrer quelques résultats. J'ai déjà chargé la base de données avec quelques villes.

Tout d'abord, nous obtenons deux villes :

curl -i "http://127.0.0.1:5000/api/v1/cities?page=1&per_page=2"

La réponse :

HTTP/1.0 200 OK
Content-Type:  application/json
Content-Length: 247
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 15:51:39 GMT

{
  "data": [
    {
      "id": 1, 
      "name": "Beijing"
    }, 
    {
      "id": 2, 
      "name": "Berlin"
    }
  ], 
  "meta": {
    "count": 2, 
    "limit": 2, 
    "offset": 0, 
    "page": 1, 
    "per_page": 2, 
    "total": 11
  }
}

Maintenant, mettons à jour le nom de la ville Berlin en Hambourg, mais avec des erreurs.

Exemple 1 : mauvais paramètre path, city_id = 0

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg"}'  http://127.0.0.1:5000/api/cities/0

La réponse :

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 247
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:13:22 GMT

{
  "status": 400, 
  "title": "One or more request url path parameters did not validate", 
  "messages": {
    "city_id": [
      "Must be greater than or equal to 1 and less than or equal to 9999."
    ]
  }, 
  "data": {
    "city_id": 0
  }
}

Exemple 2 : mauvais paramètre body, name = H

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"H"}'  http://127.0.0.1:5000/api/v1/cities/2

La réponse :

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 205
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:15:46 GMT

{
  "status": 400, 
  "title": "One or more request body parameters did not validate", 
  "messages": {
    "name": [
      "Length must be between 2 and 40."
    ]
  }, 
  "data": {
    "name": "H"
  }
}

Exemple 3 : ajout d'un paramètre inconnu au corps : quelque chose = rien

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg", "something": "nothing"}'  http://127.0.0.1:5000/api/v1/cities/2

La réponse :

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 273
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:21:40 GMT

{
  "status": 400,
  "title": "One or more request body parameters did not validate", 
  "messages": {
    "something": [
      "Unknown field."
    ]
  }, 
  "data": {
    "name": "Hamburg", 
    "something": "nothing"
  },
  "valid_data": {
    "name": "Hamburg"
  }
}

Exemple 4 : sélectionner une ville inconnue, pour afficher 404

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg"}'  http://127.0.0.1:5000/api/v1/cities/20

La réponse :

HTTP/1.0 404 NOT FOUND
Content-Type:  application/json
Content-Length: 173
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Tue, 30 Mar 2021 16:26:38 GMT

{
  "status": 404, 
  "title": "The requested resource could not be found", 
  "messages": {
    "City": [
      "Not found"
    ]
  }, 
  "data": {
    "city_id": 20
  }
}

Utilisation du prétraitement et du post-traitement du schéma

Vous pouvez rencontrer des problèmes lorsque vous utilisez des valeurs par défaut (facultatives). Heureusement, Marshmallow est suffisamment souple pour vous permettre de faire votre propre traitement. Vous pouvez étendre votre schéma avec les méthodes de prétraitement et de post-traitement pre_load() et post_load().

Par exemple, j'ai étendu le schéma PaginationQueryParamsSchema pour gérer correctement les valeurs par défaut de la pagination en ajoutant la méthode pre_load() :

class PaginationQueryParamsSchema(RequestQueryParamsSchema):
    def pre_load_(self, data, many, **kwargs):
		# your processing here
		...
        return data

    page = fields.Int(
		...

    per_page = fields.Int(
		...

Résumé

Marshmallow facilite non seulement l'utilisation de schémas lors du vidage d'objets, mais inclut également un ensemble complet de fonctions de validation de schémas que nous pouvons utiliser pour vérifier les paramètres de requête. Dans ce billet, j'ai montré une façon de traiter les paramètres de requête d'un RESTful API. Bien sûr, il existe d'autres moyens. En utilisant l'objet de requête Flask , nous pouvons passer request.view_args pour les paramètres Url path, request.args pour les paramètres Url query et request.form pour les paramètres body, à la méthode schema.load().

Liens / crédits

marshmallow: simplified object serialization
https://marshmallow.readthedocs.io/en/stable/

Problem Details for HTTP APIs
https://tools.ietf.org/html/rfc7807

En savoir plus...

API Flask

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.