Changeset View
Changeset View
Standalone View
Standalone View
swh/web/api/apidoc.py
# Copyright (C) 2015-2019 The Software Heritage developers | # Copyright (C) 2015-2019 The Software Heritage developers | ||||
# See the AUTHORS file at the top-level directory of this distribution | # See the AUTHORS file at the top-level directory of this distribution | ||||
# License: GNU Affero General Public License version 3, or any later version | # License: GNU Affero General Public License version 3, or any later version | ||||
# See top-level LICENSE file for more information | # See top-level LICENSE file for more information | ||||
import docutils.nodes | |||||
import docutils.parsers.rst | |||||
import docutils.utils | |||||
import functools | import functools | ||||
from functools import wraps | from functools import wraps | ||||
import os | import os | ||||
import re | import re | ||||
import textwrap | import textwrap | ||||
from typing import List | |||||
import docutils.nodes | |||||
import docutils.parsers.rst | |||||
import docutils.utils | |||||
from rest_framework.decorators import api_view | from rest_framework.decorators import api_view | ||||
import sentry_sdk | import sentry_sdk | ||||
from swh.web.common.utils import parse_rst | from swh.web.common.utils import parse_rst | ||||
from swh.web.api.apiurls import APIUrls | from swh.web.api.apiurls import APIUrls | ||||
from swh.web.api.apiresponse import make_api_response, error_response | from swh.web.api.apiresponse import make_api_response, error_response | ||||
class _HTTPDomainDocVisitor(docutils.nodes.NodeVisitor): | class _HTTPDomainDocVisitor(docutils.nodes.NodeVisitor): | ||||
""" | """ | ||||
docutils visitor for walking on a parsed rst document containing sphinx | docutils visitor for walking on a parsed rst document containing sphinx | ||||
httpdomain roles. Its purpose is to extract relevant info regarding swh | httpdomain roles. Its purpose is to extract relevant info regarding swh | ||||
api endpoints (for instance url arguments) from their docstring written | api endpoints (for instance url arguments) from their docstring written | ||||
using sphinx httpdomain. | using sphinx httpdomain. | ||||
""" | """ | ||||
# httpdomain roles we want to parse (based on sphinxcontrib.httpdomain 1.6) | # httpdomain roles we want to parse (based on sphinxcontrib.httpdomain 1.6) | ||||
parameter_roles = ('param', 'parameter', 'arg', 'argument') | parameter_roles = ('param', 'parameter', 'arg', 'argument') | ||||
request_json_object_roles = ('reqjsonobj', 'reqjson', '<jsonobj', '<json') | |||||
request_json_array_roles = ('reqjsonarr', '<jsonarr') | |||||
response_json_object_roles = ('resjsonobj', 'resjson', '>jsonobj', '>json') | response_json_object_roles = ('resjsonobj', 'resjson', '>jsonobj', '>json') | ||||
response_json_array_roles = ('resjsonarr', '>jsonarr') | response_json_array_roles = ('resjsonarr', '>jsonarr') | ||||
query_parameter_roles = ('queryparameter', 'queryparam', 'qparam', 'query') | query_parameter_roles = ('queryparameter', 'queryparam', 'qparam', 'query') | ||||
request_header_roles = ('<header', 'reqheader', 'requestheader') | request_header_roles = ('<header', 'reqheader', 'requestheader') | ||||
response_header_roles = ('>header', 'resheader', 'responseheader') | response_header_roles = ('>header', 'resheader', 'responseheader') | ||||
status_code_roles = ('statuscode', 'status', 'code') | status_code_roles = ('statuscode', 'status', 'code') | ||||
def __init__(self, document, urls, data): | def __init__(self, document, urls, data): | ||||
super().__init__(document) | super().__init__(document) | ||||
self.urls = urls | self.urls = urls | ||||
self.url_idx = 0 | self.url_idx = 0 | ||||
self.data = data | self.data = data | ||||
self.args_set = set() | self.args_set = set() | ||||
self.params_set = set() | self.params_set = set() | ||||
self.inputs_set = set() | |||||
self.returns_set = set() | self.returns_set = set() | ||||
self.status_codes_set = set() | self.status_codes_set = set() | ||||
self.reqheaders_set = set() | self.reqheaders_set = set() | ||||
self.resheaders_set = set() | self.resheaders_set = set() | ||||
self.field_list_visited = False | self.field_list_visited = False | ||||
def process_paragraph(self, par): | def process_paragraph(self, par): | ||||
""" | """ | ||||
▲ Show 20 Lines • Show All 46 Lines • ▼ Show 20 Lines | def visit_field_list(self, node): | ||||
self.args_set.add(field_data[2]) | self.args_set.add(field_data[2]) | ||||
# Query Parameters | # Query Parameters | ||||
if field_data[0] in self.query_parameter_roles: | if field_data[0] in self.query_parameter_roles: | ||||
if field_data[2] not in self.params_set: | if field_data[2] not in self.params_set: | ||||
self.data['params'].append({'name': field_data[2], | self.data['params'].append({'name': field_data[2], | ||||
'type': field_data[1], | 'type': field_data[1], | ||||
'doc': text}) | 'doc': text}) | ||||
self.params_set.add(field_data[2]) | self.params_set.add(field_data[2]) | ||||
# Request data type | |||||
if (field_data[0] in self.request_json_array_roles or | |||||
field_data[0] in self.request_json_object_roles): | |||||
# array | |||||
if field_data[0] in self.request_json_array_roles: | |||||
self.data['input_type'] = 'array' | |||||
# object | |||||
else: | |||||
self.data['input_type'] = 'object' | |||||
# input object field | |||||
if field_data[2] not in self.inputs_set: | |||||
self.data['inputs'].append({'name': field_data[2], | |||||
'type': field_data[1], | |||||
'doc': text}) | |||||
self.inputs_set.add(field_data[2]) | |||||
# Response type | # Response type | ||||
if field_data[0] in self.response_json_array_roles or \ | if (field_data[0] in self.response_json_array_roles or | ||||
field_data[0] in self.response_json_object_roles: | field_data[0] in self.response_json_object_roles): | ||||
# array | # array | ||||
if field_data[0] in self.response_json_array_roles: | if field_data[0] in self.response_json_array_roles: | ||||
self.data['return_type'] = 'array' | self.data['return_type'] = 'array' | ||||
# object | # object | ||||
else: | else: | ||||
self.data['return_type'] = 'object' | self.data['return_type'] = 'object' | ||||
# returned object field | # returned object field | ||||
if field_data[2] not in self.returns_set: | if field_data[2] not in self.returns_set: | ||||
▲ Show 20 Lines • Show All 122 Lines • ▼ Show 20 Lines | |||||
class APIDocException(Exception): | class APIDocException(Exception): | ||||
""" | """ | ||||
Custom exception to signal errors in the use of the APIDoc decorators | Custom exception to signal errors in the use of the APIDoc decorators | ||||
""" | """ | ||||
def api_doc(route, noargs=False, need_params=False, tags=[], | def api_doc(route: str, noargs: bool = False, need_params: bool = False, | ||||
handle_response=False, api_version='1'): | tags: List[str] = [], handle_response: bool = False, | ||||
api_version: str = '1'): | |||||
""" | """ | ||||
Decorate an API function to register it in the API doc route index | Decorator for an API endpoint implementation used to generate a dedicated | ||||
and create the corresponding DRF route. | view displaying its HTML documentation. | ||||
The documentation will be generated from the endpoint docstring based on | |||||
sphinxcontrib-httpdomain format. | |||||
Args: | Args: | ||||
route (str): documentation page's route | route: documentation page's route | ||||
noargs (boolean): set to True if the route has no arguments, and its | noargs: set to True if the route has no arguments, and its | ||||
result should be displayed anytime its documentation | result should be displayed anytime its documentation | ||||
is requested. Default to False | is requested. Default to False | ||||
need_params (boolean): specify the route requires query parameters | need_params: specify the route requires query parameters | ||||
otherwise errors will occur. It enables to avoid displaying the | otherwise errors will occur. It enables to avoid displaying the | ||||
invalid response in its HTML documentation. Default to False. | invalid response in its HTML documentation. Default to False. | ||||
tags (list): Further information on api endpoints. Two values are | tags: Further information on api endpoints. Two values are | ||||
possibly expected: | possibly expected: | ||||
* hidden: remove the entry points from the listing | * hidden: remove the entry points from the listing | ||||
* upcoming: display the entry point but it is not followable | * upcoming: display the entry point but it is not followable | ||||
handle_response (boolean): indicate if the decorated function takes | handle_response: indicate if the decorated function takes | ||||
care of creating the HTTP response or delegates that task to the | care of creating the HTTP response or delegates that task to the | ||||
apiresponse module | apiresponse module | ||||
api_version (str): api version string | api_version: api version string | ||||
""" | """ | ||||
urlpattern = '^' + api_version + route + '$' | |||||
tags = set(tags) | tags_set = set(tags) | ||||
# @api_doc() Decorator call | # @api_doc() Decorator call | ||||
def decorator(f): | def decorator(f): | ||||
# if the route is not hidden, add it to the index | |||||
# If the route is not hidden, add it to the index | if 'hidden' not in tags_set: | ||||
if 'hidden' not in tags: | |||||
doc_data = get_doc_data(f, route, noargs) | doc_data = get_doc_data(f, route, noargs) | ||||
doc_desc = doc_data['description'] | doc_desc = doc_data['description'] | ||||
first_dot_pos = doc_desc.find('.') | first_dot_pos = doc_desc.find('.') | ||||
APIUrls.add_route(route, doc_desc[:first_dot_pos+1], | APIUrls.add_doc_route(route, doc_desc[:first_dot_pos+1], | ||||
tags=tags) | noargs=noargs, api_version=api_version, | ||||
tags=tags_set) | |||||
# If the decorated route has arguments, we create a specific | |||||
# documentation view | |||||
if not noargs: | |||||
# create a dedicated view to display endpoint HTML doc | |||||
@api_view(['GET', 'HEAD']) | @api_view(['GET', 'HEAD']) | ||||
@wraps(f) | @wraps(f) | ||||
def doc_view(request): | def doc_view(request): | ||||
doc_data = get_doc_data(f, route, noargs) | doc_data = get_doc_data(f, route, noargs) | ||||
return make_api_response(request, None, doc_data) | return make_api_response(request, None, doc_data) | ||||
view_name = 'api-%s-%s' % \ | route_name = '%s-doc' % route[1:-1].replace('/', '-') | ||||
(api_version, route[1:-1].replace('/', '-')) | urlpattern = f'^{api_version}{route}doc/$' | ||||
view_name = 'api-%s-%s' % (api_version, route_name) | |||||
APIUrls.add_url_pattern(urlpattern, doc_view, view_name) | APIUrls.add_url_pattern(urlpattern, doc_view, view_name) | ||||
@wraps(f) | @wraps(f) | ||||
def documented_view(request, **kwargs): | def documented_view(request, **kwargs): | ||||
doc_data = get_doc_data(f, route, noargs) | doc_data = get_doc_data(f, route, noargs) | ||||
try: | try: | ||||
response = f(request, **kwargs) | response = f(request, **kwargs) | ||||
except Exception as exc: | except Exception as exc: | ||||
Show All 20 Lines | def get_doc_data(f, route, noargs): | ||||
Build documentation data for the decorated api endpoint function | Build documentation data for the decorated api endpoint function | ||||
""" | """ | ||||
data = { | data = { | ||||
'description': '', | 'description': '', | ||||
'response_data': None, | 'response_data': None, | ||||
'urls': [], | 'urls': [], | ||||
'args': [], | 'args': [], | ||||
'params': [], | 'params': [], | ||||
'input_type': '', | |||||
'inputs': [], | |||||
'resheaders': [], | 'resheaders': [], | ||||
'reqheaders': [], | 'reqheaders': [], | ||||
'return_type': '', | 'return_type': '', | ||||
'returns': [], | 'returns': [], | ||||
'status_codes': [], | 'status_codes': [], | ||||
'examples': [], | 'examples': [], | ||||
'route': route, | 'route': route, | ||||
'noargs': noargs | 'noargs': noargs | ||||
} | } | ||||
if not f.__doc__: | if not f.__doc__: | ||||
raise APIDocException('apidoc: expected a docstring' | raise APIDocException('apidoc: expected a docstring' | ||||
' for function %s' | ' for function %s' | ||||
% (f.__name__,)) | % (f.__name__,)) | ||||
# use raw docstring as endpoint documentation if sphinx | # use raw docstring as endpoint documentation if sphinx | ||||
# httpdomain is not used | # httpdomain is not used | ||||
if '.. http' not in f.__doc__: | if '.. http' not in f.__doc__: | ||||
data['description'] = f.__doc__ | data['description'] = f.__doc__ | ||||
# else parse the sphinx httpdomain docstring with docutils | # else parse the sphinx httpdomain docstring with docutils | ||||
# (except when building the swh-web documentation through autodoc | # (except when building the swh-web documentation through autodoc | ||||
# sphinx extension, not needed and raise errors with sphinx >= 1.7) | # sphinx extension, not needed and raise errors with sphinx >= 1.7) | ||||
elif 'SWH_WEB_DOC_BUILD' not in os.environ: | elif 'SWH_WEB_DOC_BUILD' not in os.environ: | ||||
_parse_httpdomain_doc(f.__doc__, data) | _parse_httpdomain_doc(f.__doc__, data) | ||||
# process returned object info for nicer html display | # process input/returned object info for nicer html display | ||||
inputs_list = '' | |||||
returns_list = '' | returns_list = '' | ||||
for inp in data['inputs']: | |||||
# special case for array of non object type, for instance | |||||
# :<jsonarr string -: an array of string | |||||
if inp['name'] != '-': | |||||
inputs_list += ('\t* **%s (%s)**: %s\n' % | |||||
(inp['name'], inp['type'], inp['doc'])) | |||||
for ret in data['returns']: | for ret in data['returns']: | ||||
returns_list += '\t* **%s (%s)**: %s\n' %\ | # special case for array of non object type, for instance | ||||
(ret['name'], ret['type'], ret['doc']) | # :>jsonarr string -: an array of string | ||||
if ret['name'] != '-': | |||||
returns_list += ('\t* **%s (%s)**: %s\n' % | |||||
(ret['name'], ret['type'], ret['doc'])) | |||||
data['inputs_list'] = inputs_list | |||||
data['returns_list'] = returns_list | data['returns_list'] = returns_list | ||||
return data | return data | ||||
DOC_COMMON_HEADERS = ''' | DOC_COMMON_HEADERS = ''' | ||||
:reqheader Accept: the requested response content type, | :reqheader Accept: the requested response content type, | ||||
either ``application/json`` (default) or ``application/yaml`` | either ``application/json`` (default) or ``application/yaml`` | ||||
Show All 19 Lines |