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

Documentación de un Flask RESTful API con OpenAPI (Swagger) utilizando APISpec

Cree sus propias funciones view class y de utilidad para reducir el código y evitar errores.

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

Cuando creas un API, quieres documentarlo y hoy en día es obvio utilizar OpenAPI para ello. Ya estoy utilizando el paquete Marshmallow . La misma gente también desarrolló el paquete APIspec, con un plugin Flask en un paquete adicional apispec-webframeworks. Para presentar la documentación de OpenAPI utilizo el paquete flask-swagger-ui y el paquete flask-jwt-extended se utiliza para proteger los endpoints.

A continuación te muestro una forma de crear tu propio API view class junto con las funciones de utilidad API view class para reducir el código y evitar errores.

Inicio rápido mediante un ejemplo

Hay mucha información en Internet sobre la construcción de un API con Flask y Swagger. Me alegré de encontrar un pequeño y bonito ejemplo para empezar, 'REST API Development with Flask', ver enlaces más abajo. Lo que sucede aquí es:

  • Configurar tu especificación básica OpenAPI
  • Documenta tus endpoints usando docstrings
  • Escanear todas las funciones de la vista y añadirlas a su especificación

Generar la documentación de OpenAPI escaneando docstrings

¿Es una buena idea utilizar docstrings para generar su documento OpenAPI ? Los docstrings están ligados a una función o método. Tengo sentimientos encontrados sobre esto. Cuando tienes muchas funciones o métodos en diferentes archivos, hacer cambios en tu docstrings puede llevar mucho tiempo.

La alternativa sería crear un documento OpenAPI independiente. En este caso, toda la información se encuentra en un solo lugar, la mayoría de las veces, en un solo documento. Primero creas las especificaciones y utilizas este documento para crear tus funciones y métodos. Con el software adecuado probablemente puedas generar grandes partes del código automáticamente. Esto también hace que sea muy fácil hacer cambios.

Aunque esta última es la forma preferida, aquí estoy utilizando el método de escaneo docstring .

Problema: docstring duplicación

La mayoría de las veces tenemos varias clases de modelos que se tratan más o menos igual. He creado una aplicación de demostración que consiste en las siguientes clases:

  • Friend, puede tener cero o una ciudad, y cero o más aficiones
  • City
  • Hobby
  • User

El OpenAPI docstrings para estas clases son más o menos lo mismo. Por ejemplo, los métodos de actualización para el City y el Hobby son

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>

y

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>

Observe que estos métodos están decorados con el @jwt_required() decorator. Supongo que tú también quieres hacer esto (en algún momento).

Para evitar la duplicación he creado un APIModelViewClass base y algunas funciones de utilidad en APIModelViewClassUtils.

Por qué no se puede utilizar Flask MethodView

He mirado en Flask MethodView ya que la documentación dice que es útil para construir APIs. Esta clase tiene métodos como get, post que son llamados dependiendo del método de solicitud. Esto significa que el método de solicitud GET llama al método get de la clase MethodView .

Para un City, mi API debe tener las siguientes funciones:

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>

Estamos utilizando el método GET tanto para una lista de ciudades como para una sola ciudad. La documentación de Flask sugiere que decidamos dentro del método get qué función debemos realizar:

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

Pero ahora ya no podemos añadir un docstring ... :-(

Una forma de salir de esto es utilizar dos clases MethodView . Una para la función de lista y las funciones de creación y otra para las funciones 'by_id'. Pero incluso con esta solución hay problemas. Parece que cuando se utiliza Blueprints, no se añaden todas las funciones por apispec, ver también 'Only single path added for Flask API MethodViews #14'.

No estoy seguro de cuál es el estado de esto en este momento, así que decidí crear mi propio 'MethodView', llamado APIModelViewClass, y mi propia clase de utilidades APIModelViewClassUtils.

Utilizando un decorator para añadir el docstrings

La opción obvia es utilizar un decorator para añadir el OpenAPI docstring a los métodos API . Una vez que están allí, pueden ser escaneados y cargados por apispec.

Muchos de mis métodos API ya están decorados con el @jwt_required() decorator, lo que significa que debemos combinar estos decorators.

He creado una clase APIModelViewClass, que de momento es sólo un stub, y una clase APIModelViewClassUtils
que contiene funciones de utilidad, de momento con tres métodos:

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

Más tarde puedo fusionar estas clases. A continuación se muestra parte del código.

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

En decorate_with_docstring() decoramos el método con un formato docstring. En endpoint_decorator() primero decoramos el método y luego comprobamos si se concede el acceso usando verify_jwt_in_request(). En endpoints_register registramos los endpoints utilizados por esta clase.

Ahora (parte de) el CityAPI tiene el siguiente aspecto:

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

Definimos los parámetros del decorator fuera de la función __init__(). Son constantes y no ocupan mucho espacio. Lo anterior se puede optimizar y hacer mejor, pero al menos es fácil de leer y mantener.

Abrir la seguridad de API

Arriba he mostrado cómo he utilizado flask_jwt_extended para proteger los endpoints. Ahora debemos decirle a OpenAPI lo que hicimos. En OpenAPI elijo proteger todos los endpoints por defecto y posteriormente eliminar esta protección en el docstrings de los métodos no protegidos.

# 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)

En el docstrings de los métodos que están protegidos no hacemos nada. En el docstrings de los métodos que no están protegidos añado:

    security: []

Probando

Probar un API documentado con OpenAPI es ... difícil. Tenemos la interfaz real y la documentada (OpenAPI). Deberíamos escribir un conjunto de pruebas al menos para la interfaz documentada. Yo no lo he hecho todavía, sino que he utilizado Pytest y el paquete de peticiones para hacer pruebas funcionales de API. He empezado a hacer algunas pruebas con postman.

Resumen

Codificar mi API usando el paquete Marshmallow fue una experiencia agradable. Enviar mensajes (de error) consistentes fue un reto porque cada paquete tiene su propio formato por defecto. Y documentar mi API con OpenAPI (Swagger) con Python fue como una pesadilla. Flask MethodView y Blueprints no funcionan como se espera con apispec, documentación poco clara en apispec-webframeworks.

Tuve que aprender cosas nuevas, como la forma de escribir las líneas de OpenAPI a mano. Estoy contento con el resultado final, y sé exactamente cómo funciona, lo que significa que es fácil de mejorar y hacer cambios.

Debe haber mejores maneras de hacer esto. He estado leyendo sobre FastAPI, este es un Python framework dedicado a construir APIs rápido. Tiene un rendimiento mucho mejor en comparación con Flask y parece que soporta muchas cosas out-of-the-box. Tal vez lo pruebe la próxima vez, o convierta este API en FastAPI. Pero también tiene 540 cuestiones abiertas en Github ... puede ser bueno, puede ser malo.

Enlaces / créditos

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/

Leer más

API Flask

Deje un comentario

Comente de forma anónima o inicie sesión para comentar.

Comentarios (1)

Deje una respuesta.

Responda de forma anónima o inicie sesión para responder.

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!