Documenting a Flask RESTful API with OpenAPI (Swagger) using APISpec
Create your own view class and utility functions to reduce code and avoid errors.
When you create an API, you want to document it and today it is obvious to use OpenAPI for this. I am already using the Marshmallow package. The same people also developed the package APIspec, with a Flask plugin in an additional package apispec-webframeworks. To present the OpenAPI documentation I use the package flask-swagger-ui and the package flask-jwt-extended is used to protect the endpoints.
Below I show you a way to create your own API view class together with API view class utility functions to reduce code and avoid errors.
Quick start by example
There is very much information on the internet about building an API with Flask and Swagger. I was happy to find a nice small example to get started, 'REST API Development with Flask', see links below. What happens here is:
- Setup your basic OpenAPI spec
- Document your endpoints using docstrings
- Scan all view functions and add them to your spec
Generating OpenAPI documentation by scanning docstrings
Is it a good idea to use docstrings to generate your OpenAPI document? Docstrings are tied to a function or method. I have mixed feelings about this. When you have many functions or methods in different files, making changes to your docstrings can be very time consuming.
The alternative would be to create a standalone OpenAPI document. Here all information is in one place, most of the time, in a single document. You first create the specifications and use this document to create your functions and methods. With the proper software you probably can generate big parts of the code automatically. This also makes it very easy to make changes.
Although the latter is the preferred way, I am using the docstring scanning method here.
Problem: docstring duplication
Most of the time we have a number of model classes which are treated more or less the same. I created a demo application consisting of the following classes:
- Friend, can have zero or one city, and zero or more hobbies
- City
- Hobby
- User
The OpenAPI docstrings for these classes are more or less the same. For example, the update methods for the City and Hobby are:
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>
and
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>
Note that these methods are decorated with the @jwt_required() decorator. I assume you also want to do this (at some time).
To avoid duplication I created a base APIModelViewClass and some utility functions in APIModelViewClassUtils.
Why you cannot use Flask MethodView
I looked into Flask MethodView as the documentation states is is useful when building APIs. This class has methods like get, post that are called depending on the request method. This means that the GET request method calls the get method of the MethodView class.
For a City, my API must have the following functions:
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>
We are using the GET method both for a list of cities and a single city. The Flask documentation suggests we decide inside the get method which function we must perform:
def get(self, city_id):
if city_id is None:
# return a list of citys
pass
else:
# expose a single city
pass
But now we cannot add a docstring anymore ... :-(
A way out of this is to use two MethodView classes. One for the list function and create functions and one for the 'by_id' functions. But even with this solution there are problems. It appears that when you use Blueprints, not all functions are added by apispec, see also 'Only single path added for Flask API MethodViews #14'.
I am not sure what the status is of this at the moment, so I decided to create my own 'MethodView', called APIModelViewClass, and my own utilities class APIModelViewClassUtils.
Using a decorator to add the docstrings
The obvious choice is to use a decorator to add the OpenAPI docstring to the API methods. Once they are there, they can be scanned and loaded by apispec.
Many of my API methods are already decorated with the @jwt_required() decorator, meaning that we must combine these decorators.
I created a class APIModelViewClass, that is at the moment just a stub, and a class APIModelViewClassUtils
containing utility functions, at the moment containing three methods:
- decorate_with_docstring()
- endpoint_decorator()
- endpoints_register()
Later I may merge these classes. Below is part of the 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
In decorate_with_docstring() we decorate the method with a formatted docstring. In endpoint_decorator() we first decorate the method and then check if access is granted using verify_jwt_in_request(). In endpoints_register we register the endpoints used by this class.
Now (part of) the CityAPI looks like:
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
We define the parameters for the decorator outside the __init__() function. They are constants and do not take up much space. The above can be optimized and done better but at least it is easy to read and maintain.
Open API security
Above I showed how I used flask_jwt_extended to protect endpoints. Now we must tell OpenAPI what we did. I choose to protect all endpoints by default in OpenAPI and later remove this protection in the docstrings of unprotected methods.
# 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)
In the docstrings of methods that are protected we do nothing. In the docstrings of methods that are not protected I add:
security: []
Testing
Testing an API documented with OpenAPI is ... difficult. We have the actual interface and the documented (OpenAPI) interface. We should write a test suite at least for the documented interface. I did not do this yet but instead used Pytest and the requests package to do functional testing of the API. I started doing some tests with postman.
Summary
Coding my API using the Marshmallow package was a nice experience. Sending out consistent (error) messages was a challenge because every package has its own default format. And documenting my API with OpenAPI (Swagger) with Python was like a nightmare. Flask MethodView and Blueprints not working as expected with apispec, unclear documentation in apispec-webframeworks.
I had to learn new things, like how to write OpenAPI lines by hand. I am happy with the final result, and know exactly how it works, meaning it is easy to improve and make changes.
There must be better ways to do this. I have been reading about FastAPI, this is a Python framework dedicated to building fast APIs. It has much better performance compared to Flask and appears to support many things out-of-the-box. Maybe I will try this the next time, or convert this API to FastAPI. But it also has 540 Open issues on Github ... can be good, can be bad.
Links / credits
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/
Leave a comment
Comment anonymously or log in to comment.
Comments (1)
Leave a reply
Reply anonymously or log in to reply.
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!
Recent
- Hiding database UUID primary keys of your web application
- Don't Repeat Yourself (DRY) with Jinja2
- SQLAlchemy, PostgreSQL, maximum number of rows per user
- Show the values in SQLAlchemy dynamic filters
- Secure data transfer with Public Key encryption and pyNaCl
- rqlite: a high-availability and distributed SQLite alternative
Most viewed
- Using Python's pyOpenSSL to verify SSL certificates downloaded from a host
- Using UUIDs instead of Integer Autoincrement Primary Keys with SQLAlchemy and MariaDb
- Connect to a service on a Docker host from a Docker container
- Using PyInstaller and Cython to create a Python executable
- SQLAlchemy: Using Cascade Deletes to delete related objects
- Flask RESTful API request parameter validation with Marshmallow schemas