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

Documentation d'une Flask RESTful API avec OpenAPI (Swagger) utilisant APISpec

Créez vos propres fonctions view class et utilitaires pour réduire le code et éviter les erreurs.

22 avril 2021
Dans API, Flask
post main image
https://unsplash.com/@sincerelymedia

Lorsque vous créez une API, vous voulez la documenter et aujourd'hui il est évident d'utiliser la OpenAPI pour cela. J'utilise déjà le paquet Marshmallow . Les mêmes personnes ont également développé le paquet APIspec, avec un plugin Flask dans un paquet supplémentaire apispec-webframeworks. Pour présenter la documentation OpenAPI , j'utilise le paquet flask-swagger-ui et le paquet flask-jwt-extended est utilisé pour protéger les terminaux.

Je vous montre ci-dessous une façon de créer votre propre API view class avec les fonctions utilitaires API view class pour réduire le code et éviter les erreurs.

Démarrage rapide par l'exemple

Il y a beaucoup d'informations sur internet sur la construction d'un API avec Flask et Swagger. J'ai été heureux de trouver un petit exemple pour commencer, 'REST API Development with Flask', voir les liens ci-dessous. Ce qui se passe ici est :

  • Configurer votre spécification OpenAPI de base.
  • Documenter vos points de terminaison en utilisant docstrings
  • Analysez toutes les fonctions de vue et ajoutez-les à votre spécification.

Génération de la documentation OpenAPI en analysant docstrings

Est-ce une bonne idée d'utiliser docstrings pour générer votre document OpenAPI ? Les docstrings sont liés à une fonction ou à une méthode. J'ai des sentiments mitigés à ce sujet. Lorsque vous avez de nombreuses fonctions ou méthodes dans différents fichiers, apporter des modifications à votre docstrings peut prendre beaucoup de temps.

L'alternative serait de créer un document OpenAPI autonome. Dans ce cas, toutes les informations se trouvent au même endroit, la plupart du temps, dans un seul document. Vous créez d'abord les spécifications et utilisez ce document pour créer vos fonctions et méthodes. Avec le logiciel approprié, vous pouvez probablement générer automatiquement de grandes parties du code. Cela permet également d'apporter très facilement des modifications.

Bien que cette dernière soit la méthode préférée, j'utilise ici la méthode de numérisation docstring .

Problème : Duplication de docstring

La plupart du temps, nous avons un certain nombre de classes de modèles qui sont traitées plus ou moins de la même manière. J'ai créé une application de démonstration composée des classes suivantes :

  • Friend, peut avoir zéro ou une ville, et zéro ou plusieurs hobbies
  • City
  • Hobby
  • User

Les OpenAPI docstrings de ces classes sont plus ou moins les mêmes. Par exemple, les méthodes de mise à jour pour les City et Hobby sont :

class CitiesAPI(...):

    ...

     @jwt_required()
    def update_by_id(self, city_id):
        '''
        ---
        put:
          tags:
          -  City
          summary: Update city by id
          description: Update city by id
          parameters:
          - in: path
            schema: CitiesRequestPathParamsSchema
          requestBody:
            required: true
            content:
                 application/json:
                    schema: CitiesUpdateRequestBodyParamsSchema
          responses:
            400:
              description: One or more request parameters did not validate
            404:
              description:  City  not found
            200:
              description:  City  updated
              content:
                 application/json:
                  schema:
                    type: object
                    properties:
                      data: CitiesResponseSchema
        '''
        <code of the method>

et

class HobbiesAPI(...):

    ...

     @jwt_required()
    def update_by_id(self, hobby_id):
        """
        ---
        put:
          tags:
          -  Hobby
          summary: Update hobby by id
          description: Update hobby by id
          parameters:
          - in: path
            schema: HobbiesRequestPathParamsSchema
          requestBody:
            required: true
            content:
                 application/json:
                    schema: HobbiesUpdateRequestBodyParamsSchema
          responses:
            400:
              description: One or more request parameters did not validate
            404:
              description:  Hobby  not found
            200:
              description:  Hobby  updated
              content:
                 application/json:
                  schema:
                    type: object
                    properties:
                      data: HobbiesResponseSchema
        """
        <code of the method>

Notez que ces méthodes sont décorées avec le @jwt_required() decorator. Je suppose que vous voulez aussi faire cela (à un moment donné).

Pour éviter les doublons, j'ai créé une base APIModelViewClass et quelques fonctions utilitaires dans APIModelViewClassUtils.

Pourquoi vous ne pouvez pas utiliser Flask MethodView

J'ai examiné la classe Flask MethodView car la documentation indique qu'elle est utile pour construire APIs. Cette classe possède des méthodes comme get, post qui sont appelées en fonction de la méthode de requête. Cela signifie que la méthode de requête GET appelle la méthode get de la classe MethodView .

Pour une City, ma API doit avoir les fonctions suivantes :

list            GET    /cities
create          POST   /cities
get_by_id       GET    /cities/<int:city_id>
update_by_id    GET    /cities/<int:city_id>
delete_by_id    GET    /cities/<int:city_id>

Nous utilisons la méthode GET à la fois pour une liste de villes et pour une seule ville. La documentation de la méthode Flask suggère que nous décidions dans la méthode get quelle fonction nous devons exécuter :

    def get(self, city_id):
        if city_id is  None:
            # return a list of citys
            pass
        else:
            # expose a single city
            pass

Mais maintenant, nous ne pouvons plus ajouter une docstring ... :-(

Une solution consiste à utiliser deux classes MethodView . Une pour les fonctions list et create et une pour les fonctions 'by_id'. Mais même avec cette solution, il y a des problèmes. Il semble que lorsque vous utilisez Blueprints, toutes les fonctions ne sont pas ajoutées par apispec, voir aussi 'Only single path added for Flask API MethodViews #14'.

Je ne suis pas sûr de l'état d'avancement de ce projet pour le moment, j'ai donc décidé de créer ma propre 'MethodView', appelée APIModelViewClass, et ma propre classe d'utilitaires APIModelViewClassUtils.

Utilisation d'une decorator pour ajouter la docstrings

Le choix évident est d'utiliser un decorator pour ajouter les méthodes OpenAPI docstring aux méthodes API . Une fois qu'ils y sont, ils peuvent être scannés et chargés par apispec.

Beaucoup de mes méthodes API sont déjà décorées avec la @jwt_required() decorator, ce qui signifie que nous devons combiner ces decorators.

J'ai créé une classe APIModelViewClass, qui n'est pour le moment qu'un stub, et une classe APIModelViewClassUtils
contenant des fonctions utilitaires, contenant pour le moment trois méthodes :

  • decorate_with_docstring()
  • endpoint_decorator()
  • endpoints_register()

Plus tard, je pourrais fusionner ces classes. Voici une partie du code.

class  APIModelViewClassUtils:

    def decorate_with_docstring(fn, op, params):
        tags = params.get('tags')
        item_name_cap, item_name, item_name_plural_cap, item_name_plural = params.get('item_names')
        in_path_schema = params.get('in_path_schema')
        in_query_schema = params.get('in_query_schema')
        in_body_schema_create = params.get('in_body_schema_create')
        in_body_schema_update = params.get('in_body_schema_update')
        out_data_schema = params.get('out_data_schema')

        '''
        if op == 'list':
            fn.__doc__ = """
            ...

        elif op == 'update_by_id':
            fn.__doc__ = """
            ---
            put:
              tags:
              - {tags}
              summary: Update {item_name} by id
              description: Update {item_name} by id
              parameters:
              - in: path
                schema: {in_path_schema}
              requestBody:
                required: true
                content:
                     application/json:
                        schema: {in_body_schema_update}
              responses:
                400:
                  description: One or more request parameters did not validate
                404:
                  description: {item_name_cap} not found
                200:
                  description: {item_name_cap} updated
                  content:
                     application/json:
                      schema:
                        type: object
                        properties:
                          data: {out_data_schema}
                    """.format(
                        tags=tags,
                        item_name=item_name,
                        item_name_cap=item_name_cap,
                        in_path_schema=in_path_schema,
                        in_body_schema_update=in_body_schema_update,
                        out_data_schema=out_data_schema)
        '''

    @classmethod
    def endpoint_decorator(cls, params):
        def wrapper(fn):

            op = fn.__name__

            # decorate endpoint
            cls.decorate_with_docstring(fn, op, params)

            @wraps(fn)
            def  decorator(*args, **kwargs):

                # verify_jwt_in_request works like @jwt_required
                verify_jwt_in_request()

                return fn(*args, **kwargs)

        return wrapper

    @staticmethod
    def endpoints_register(bp, api_model_view_class, url, id_attr_name, id_type='int'):
        model_obj = api_model_view_class()
        
        '''
        # update_by_id
        if hasattr(model_obj, 'update_by_id'):
            bp.add_url_rule(url_by_id, view_func=model_obj.update_by_id, methods=['PUT'])
        '''


class  APIModelViewClass():
    pass

Dans decorate_with_docstring() nous décorons la méthode avec un format docstring. Dans endpoint_decorator() , nous décorons d'abord la méthode, puis nous vérifions si l'accès est accordé en utilisant verify_jwt_in_request(). Dans endpoints_register, nous enregistrons les points de terminaison utilisés par cette classe.

Maintenant (une partie) de la CityAPI ressemble :

class CitiesAPI(APIModelViewClass):
    params = {
        'tags': 'City',
        'item_names': ['City', 'city', 'Cities', 'cities'],
        'in_query_schema': 'CitiesRequestQueryParamsSchema',
        'in_path_schema': 'CitiesRequestPathParamsSchema',
        'in_body_schema_create': 'CitiesCreateRequestBodyParamsSchema',
        'in_body_schema_update': 'CitiesUpdateRequestBodyParamsSchema',
        'out_data_schema': 'CitiesResponseSchema',
    }

    '''

    @APIModelViewClassUtils.endpoint_decorator(params)
    def update_by_id(self, 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

Nous définissons les paramètres de la decorator en dehors de la fonction __init__(). Ce sont des constantes et ils ne prennent pas beaucoup de place. Ce qui précède peut être optimisé et mieux fait, mais au moins il est facile à lire et à maintenir.

Ouvrir la sécurité API

Ci-dessus j'ai montré comment j'ai utilisé flask_jwt_extended pour protéger les points de terminaison. Maintenant nous devons dire à OpenAPI ce que nous avons fait. J'ai choisi de protéger tous les points de terminaison par défaut dans OpenAPI et de supprimer plus tard cette protection dans docstrings des méthodes non protégées.

# create  apispec
spec =  APISpec(
    title=title,
    version=spec_version,
    openapi_version=openapi_version,
    plugins=[FlaskPlugin(),  MarshmallowPlugin()],
    **settings
)

security_scheme_bearer = {
    "type": "http",
    "description": "Enter JWT Bearer token",
    "scheme": "bearer",
    "bearerFormat": "JWT"
}

security_scheme_basic_auth = {
    "type": "http",
    "scheme": "basic"
}

spec.components.security_scheme("BearerAuth", security_scheme_bearer)
spec.components.security_scheme("BasicAuth", security_scheme_basic_auth)

Dans la docstrings des méthodes qui sont protégées, nous ne faisons rien. Dans la docstrings des méthodes qui ne sont pas protégées, j'ajoute :

    security: []

Tester

Tester une API documentée avec OpenAPI est ... difficile. Nous avons l'interface réelle et l'interface documentée (OpenAPI). Nous devrions écrire une suite de tests au moins pour l'interface documentée. Je ne l'ai pas encore fait mais j'ai plutôt utilisé Pytest et le paquet de demandes pour faire des tests fonctionnels de API. J'ai commencé à faire quelques tests avec postman.

Résumé

Le codage de ma API en utilisant le paquet Marshmallow a été une bonne expérience. L'envoi de messages (d'erreur) cohérents a été un défi car chaque paquet a son propre format par défaut. Et documenter mon API avec OpenAPI (Swagger) avec Python était un vrai cauchemar. Q4_8291_TNEMECALPER_4 MethodView et Blueprints ne fonctionnant pas comme prévu avec apispec, documentation peu claire dans apispec-webframeworks.

J'ai dû apprendre de nouvelles choses, comme comment écrire les lignes de OpenAPI à la main. Je suis satisfait du résultat final et je sais exactement comment il fonctionne, ce qui signifie qu'il est facile de l'améliorer et d'y apporter des modifications.

Il doit y avoir de meilleures façons de procéder. J'ai lu des articles sur FastAPI, c'est un Python framework dédié à la construction de APIs rapides. Ses performances sont bien meilleures que celles de Flask et il semble prendre en charge de nombreux éléments dès la sortie de la boîte. Peut-être que j'essaierai la prochaine fois, ou que je convertirai cette API en FastAPI. Mais il a aussi 540 problèmes ouverts sur Github ... cela peut être bon ou mauvais.

Liens / crédits

apispec
https://apispec.readthedocs.io/en/latest/

Authentication and Authorization (OAS3)
https://swagger.io/docs/specification/authentication/

FastAPI - The Good, the bad and the ugly
https://dev.to/fuadrafid/fastapi-the-good-the-bad-and-the-ugly-20ob

Flask-JWT-Extended’s Documentation
https://flask-jwt-extended.readthedocs.io/en/stable/

Only single path added for Flask API MethodViews #14
https://github.com/marshmallow-code/apispec-webframeworks/issues/14

Pluggable Views
https://flask.palletsprojects.com/en/1.1.x/views/

Postman | The Collaboration Platform for API Development
https://www.postman.com

REST API Development with Flask
https://www.datascienceblog.net/post/programming/flask-api-development/

En savoir plus...

API Flask

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.

avatar

Why can't a MethodView be used? I beleive there is an error in your assessment:
list GET /cities
create POST /cities
get_by_id GET /cities/<int:city_id>
update_by_id PUT /cities/<int:city_id>
delete_by_id DELETE /cities/<int:city_id>
You hadnt made use of DELETE and PUT - so docstrings can be preserved!