"""API specification using OpenAPI"""
import json
import flask
from flask import current_app
import apispec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask_rest_api.exceptions import OpenAPIVersionNotSpecified
from .plugins import FlaskPlugin
from .field_converters import uploadfield2properties
def _add_leading_slash(string):
"""Add leading slash to a string if there is None"""
return string if string.startswith('/') else '/' + string
DEFAULT_REQUEST_BODY_CONTENT_TYPE = 'application/json'
DEFAULT_RESPONSE_CONTENT_TYPE = 'application/json'
class DocBlueprintMixin:
"""Extend Api to serve the spec in a dedicated blueprint."""
def _register_doc_blueprint(self):
"""Register a blueprint in the application to expose the spec
Doc Blueprint contains routes to
- json spec file
- spec UI (ReDoc, Swagger UI).
"""
api_url = self._app.config.get('OPENAPI_URL_PREFIX', None)
if api_url is not None:
blueprint = flask.Blueprint(
'api-docs',
__name__,
url_prefix=_add_leading_slash(api_url),
template_folder='./templates',
)
# Serve json spec at 'url_prefix/openapi.json' by default
json_path = self._app.config.get(
'OPENAPI_JSON_PATH', 'openapi.json')
blueprint.add_url_rule(
_add_leading_slash(json_path),
endpoint='openapi_json',
view_func=self._openapi_json)
self._register_redoc_rule(blueprint)
self._register_swagger_ui_rule(blueprint)
self._app.register_blueprint(blueprint)
def _register_redoc_rule(self, blueprint):
"""Register ReDoc rule
The ReDoc script URL can be specified as OPENAPI_REDOC_URL.
Otherwise, a CDN script is used based on the ReDoc version. The
version can - and should - be specified as OPENAPI_REDOC_VERSION,
otherwise, 'latest' is used.
When using 1.x branch (i.e. when OPENAPI_REDOC_VERSION is "latest" or
begins with "v1"), GitHub CDN is used.
When using 2.x branch (i.e. when OPENAPI_REDOC_VERSION is "next" or
begins with "2" or "v2"), unpkg nmp CDN is used.
OPENAPI_REDOC_VERSION is ignored when OPENAPI_REDOC_URL is passed.
"""
redoc_path = self._app.config.get('OPENAPI_REDOC_PATH')
if redoc_path is not None:
redoc_url = self._app.config.get('OPENAPI_REDOC_URL')
if redoc_url is None:
# TODO: default to 'next' when ReDoc 2.0.0 is released.
redoc_version = self._app.config.get(
'OPENAPI_REDOC_VERSION', 'latest')
# latest or v1.x -> Redoc GitHub CDN
if redoc_version == 'latest' or redoc_version.startswith('v1'):
redoc_url = (
'https://rebilly.github.io/ReDoc/releases/'
'{}/redoc.min.js'.format(redoc_version))
# next or 2.x -> unpkg npm CDN
else:
redoc_url = (
'https://cdn.jsdelivr.net/npm/redoc@'
'{}/bundles/redoc.standalone.js'.format(redoc_version))
self._redoc_url = redoc_url
blueprint.add_url_rule(
_add_leading_slash(redoc_path),
endpoint='openapi_redoc',
view_func=self._openapi_redoc)
def _register_swagger_ui_rule(self, blueprint):
"""Register Swagger UI rule
The Swagger UI scripts base URL can be specified as
OPENAPI_SWAGGER_UI_URL.
Otherwise, cdnjs is used. In this case, the Swagger UI version must be
specified as OPENAPI_SWAGGER_UI_VERSION. Versions older than 3.x branch
are not supported.
OPENAPI_SWAGGER_UI_VERSION is ignored when OPENAPI_SWAGGER_UI_URL is
passed.
OPENAPI_SWAGGER_UI_SUPPORTED_SUBMIT_METHODS specifes the methods for
which the 'Try it out!' feature is enabled.
"""
swagger_ui_path = self._app.config.get('OPENAPI_SWAGGER_UI_PATH')
if swagger_ui_path is not None:
swagger_ui_url = self._app.config.get('OPENAPI_SWAGGER_UI_URL')
if swagger_ui_url is None:
swagger_ui_version = self._app.config.get(
'OPENAPI_SWAGGER_UI_VERSION')
if swagger_ui_version is not None:
swagger_ui_url = (
'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/'
'{}/'.format(swagger_ui_version))
if swagger_ui_url is not None:
self._swagger_ui_url = swagger_ui_url
self._swagger_ui_supported_submit_methods = (
self._app.config.get(
'OPENAPI_SWAGGER_UI_SUPPORTED_SUBMIT_METHODS',
['get', 'put', 'post', 'delete', 'options',
'head', 'patch', 'trace'])
)
blueprint.add_url_rule(
_add_leading_slash(swagger_ui_path),
endpoint='openapi_swagger_ui',
view_func=self._openapi_swagger_ui)
def _openapi_json(self):
"""Serve JSON spec file"""
# We don't use Flask.jsonify here as it would sort the keys
# alphabetically while we want to preserve the order.
return current_app.response_class(
json.dumps(self.spec.to_dict(), indent=2),
mimetype='application/json')
def _openapi_redoc(self):
"""Expose OpenAPI spec with ReDoc"""
return flask.render_template(
'redoc.html', title=self._app.name, redoc_url=self._redoc_url)
def _openapi_swagger_ui(self):
"""Expose OpenAPI spec with Swagger UI"""
return flask.render_template(
'swagger_ui.html', title=self._app.name,
swagger_ui_url=self._swagger_ui_url,
swagger_ui_supported_submit_methods=(
self._swagger_ui_supported_submit_methods)
)
class APISpecMixin(DocBlueprintMixin):
"""Add APISpec related features to Api class"""
def _init_spec(
self, *, flask_plugin=None, marshmallow_plugin=None,
extra_plugins=None, openapi_version=None, **options
):
# Plugins
self.flask_plugin = flask_plugin or FlaskPlugin()
self.ma_plugin = marshmallow_plugin or MarshmallowPlugin()
plugins = [self.flask_plugin, self.ma_plugin]
plugins.extend(extra_plugins or ())
# APISpec options
openapi_version = self._app.config.get(
'OPENAPI_VERSION', openapi_version)
if openapi_version is None:
raise OpenAPIVersionNotSpecified(
'The OpenAPI version must be specified, either as '
'"OPENAPI_VERSION" app parameter or as '
'"openapi_version" spec kwarg.')
openapi_major_version = int(openapi_version.split('.')[0])
if openapi_major_version < 3:
base_path = self._app.config.get('APPLICATION_ROOT')
options.setdefault('basePath', base_path)
options.setdefault(
'produces', [DEFAULT_RESPONSE_CONTENT_TYPE, ])
options.setdefault(
'consumes', [DEFAULT_REQUEST_BODY_CONTENT_TYPE, ])
options.update(self._app.config.get('API_SPEC_OPTIONS', {}))
# Instantiate spec
self.spec = apispec.APISpec(
self._app.name,
self._app.config.get('API_VERSION', '1'),
openapi_version=openapi_version,
plugins=plugins,
**options,
)
# Register custom fields in spec
for args in self._fields:
self._register_field(*args)
# Register custom converters in spec
for args in self._converters:
self._register_converter(*args)
# Register Upload field properties function
self.ma_plugin.converter.add_attribute_function(uploadfield2properties)
def register_converter(self, converter, conv_type, conv_format=None):
"""Register custom path parameter converter
:param BaseConverter converter: Converter
Subclass of werkzeug's BaseConverter
:param str conv_type: Parameter type
:param str conv_format: Parameter format (optional)
Example: ::
# Register MongoDB's ObjectId converter in Flask application
app.url_map.converters['objectid'] = ObjectIdConverter
# Register converter in Api
api.register_converter(ObjectIdConverter, 'string', 'ObjectID')
@blp.route('/pets/{objectid:pet_id}')
...
api.register_blueprint(blp)
Once the converter is registered, all paths using it will have
corresponding path parameter documented with the right type and format.
Should be called before registering paths with
:meth:`Blueprint.route <Blueprint.route>`.
"""
self._converters.append((converter, conv_type, conv_format))
# Register converter in spec if app is already initialized
if self.spec is not None:
self._register_converter(converter, conv_type, conv_format)
def _register_converter(self, converter, conv_type, conv_format=None):
self.flask_plugin.register_converter(converter, conv_type, conv_format)
def register_field(self, field, *args):
"""Register custom Marshmallow field
Registering the Field class allows the Schema parser to set the proper
type and format when documenting parameters from Schema fields.
:param Field field: Marshmallow Field class
``*args`` can be:
- a pair of the form ``(type, format)`` to map to
- a core marshmallow field type (then that type's mapping is used)
Examples: ::
# Map to ('string', 'ObjectId') passing type and format
api.register_field(ObjectId, 'string', 'ObjectId')
# Map to ('string') passing type
api.register_field(CustomString, 'string', None)
# Map to ('integer, 'int32') passing a code marshmallow field
api.register_field(CustomInteger, ma.fields.Integer)
Should be called before registering schemas with
:meth:`schema <Api.schema>`.
"""
self._fields.append((field, *args))
# Register field in spec if app is already initialized
if self.spec is not None:
self._register_field(field, *args)
def _register_field(self, field, *args):
self.ma_plugin.map_to_openapi_type(*args)(field)