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.
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
Laissez un commentaire
Commentez anonymement ou connectez-vous pour commenter.
Commentaires (1)
Laissez une réponse
Répondez de manière anonyme ou connectez-vous pour répondre.
yo please change your font colors. its horrible to read the text/code.
Récent
- Graphique de séries temporelles avec Flask, Bootstrap et Chart.js
- Utiliser IPv6 avec Microk8s
- Utilisation de Ingress pour accéder à RabbitMQ sur un cluster Microk8s
- Galerie vidéo simple avec Flask, Jinja, Bootstrap et JQuery
- Planification de base des tâches avec APScheduler
- Un commutateur de base de données avec HAProxy et HAProxy Runtime API
Les plus consultés
- Utiliser PyInstaller et Cython pour créer un exécutable Python
- Réduire les temps de réponse d'un Flask SQLAlchemy site web
- Utilisation des Python's pyOpenSSL pour vérifier les certificats SSL téléchargés d'un hôte
- Connexion à un service sur un hôte Docker à partir d'un conteneur Docker
- Utiliser UUIDs au lieu de Integer Autoincrement Primary Keys avec SQLAlchemy et MariaDb
- SQLAlchemy : Utilisation de Cascade Deletes pour supprimer des objets connexes