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.
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/
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.
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!
Recientes
- Cómo ocultar las claves primarias de la base de datos UUID de su aplicación web
- Don't Repeat Yourself (DRY) con Jinja2
- SQLAlchemy, PostgreSQL, número máximo de filas por user
- Mostrar los valores en filtros dinámicos SQLAlchemy
- Transferencia de datos segura con cifrado de Public Key y pyNaCl
- rqlite: una alternativa de alta disponibilidad y dist distribuida SQLite
Más vistos
- Usando Python's pyOpenSSL para verificar los certificados SSL descargados de un host
- Usando UUIDs en lugar de Integer Autoincrement Primary Keys con SQLAlchemy y MariaDb
- Conectarse a un servicio en un host Docker desde un contenedor Docker
- Usando PyInstaller y Cython para crear un ejecutable de Python
- SQLAlchemy: Uso de Cascade Deletes para eliminar objetos relacionados
- Flask RESTful API validación de parámetros de solicitud con esquemas Marshmallow