diff --git a/swh/web/api/apidoc.py b/swh/web/api/apidoc.py --- a/swh/web/api/apidoc.py +++ b/swh/web/api/apidoc.py @@ -3,14 +3,17 @@ # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information -import docutils.nodes -import docutils.parsers.rst -import docutils.utils + import functools from functools import wraps import os import re import textwrap +from typing import List + +import docutils.nodes +import docutils.parsers.rst +import docutils.utils from rest_framework.decorators import api_view import sentry_sdk @@ -253,63 +256,79 @@ """ -def api_doc(route, noargs=False, need_params=False, tags=[], - handle_response=False, api_version='1'): +def api_doc(route: str, noargs: bool = False, need_params: bool = False, + tags: List[str] = [], handle_response: bool = False, + api_version: str = '1', post_only: bool = False): """ - Decorate an API function to register it in the API doc route index - and create the corresponding DRF route. + Decorator for an API endpoint implementation used to generate a dedicated + view displaying its HTML documentation. + + The documentation will be generated from the endpoint docstring based on + sphinxcontrib-httpdomain format. Args: - route (str): documentation page's route - noargs (boolean): set to True if the route has no arguments, and its + route: documentation page's route + noargs: set to True if the route has no arguments, and its result should be displayed anytime its documentation 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 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: * hidden: remove the entry points from the listing * 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 apiresponse module - api_version (str): api version string + api_version: api version string + post_only: if the decorated route only accepts POST requests, set this + parameter to True in order to generate a dedicated doc view + accepting GET requests """ - urlpattern = '^' + api_version + route + '$' - tags = set(tags) + + tags_set = set(tags) # @api_doc() Decorator call def decorator(f): - # If the route is not hidden, add it to the index - if 'hidden' not in tags: - doc_data = get_doc_data(f, route, noargs) + if 'hidden' not in tags_set: + doc_data = get_doc_data(f, route) doc_desc = doc_data['description'] first_dot_pos = doc_desc.find('.') APIUrls.add_route(route, doc_desc[:first_dot_pos+1], - tags=tags) + api_version=api_version, + doc_route=post_only, tags=tags_set) - # If the decorated route has arguments, we create a specific - # documentation view - if not noargs: + # If the decorated route has arguments or if the route does not accept + # GET requests, we create a specific documentation view + if not noargs or post_only: @api_view(['GET', 'HEAD']) @wraps(f) def doc_view(request): - doc_data = get_doc_data(f, route, noargs) + doc_data = get_doc_data(f, route) return make_api_response(request, None, doc_data) - view_name = 'api-%s-%s' % \ - (api_version, route[1:-1].replace('/', '-')) + route_name = route[1:-1].replace('/', '-') + urlpattern = '^' + api_version + route + # if the decorated route does not accept GET requests, we need to + # register the doc view with a different url and name + if post_only: + route_name += '-doc' + urlpattern += 'doc/$' + else: + urlpattern += '$' + + view_name = 'api-%s-%s' % (api_version, route_name) APIUrls.add_url_pattern(urlpattern, doc_view, view_name) @wraps(f) def documented_view(request, **kwargs): - doc_data = get_doc_data(f, route, noargs) + doc_data = get_doc_data(f, route) try: response = f(request, **kwargs) @@ -332,7 +351,7 @@ @functools.lru_cache(maxsize=32) -def get_doc_data(f, route, noargs): +def get_doc_data(f, route): """ Build documentation data for the decorated api endpoint function """ @@ -349,7 +368,6 @@ 'status_codes': [], 'examples': [], 'route': route, - 'noargs': noargs } if not f.__doc__: diff --git a/swh/web/api/apiurls.py b/swh/web/api/apiurls.py --- a/swh/web/api/apiurls.py +++ b/swh/web/api/apiurls.py @@ -30,13 +30,18 @@ return cls._apidoc_routes @classmethod - def add_route(cls, route, docstring, **kwargs): + def add_route(cls, route, docstring, api_version='1', + doc_route=False, **kwargs): """ Add a route to the self-documenting API reference """ - route_view_name = 'api-1-%s' % route[1:-1].replace('/', '-') + route_name = route[1:-1].replace('/', '-') + if doc_route: + route_name += '-doc' + route_view_name = 'api-%s-%s' % (api_version, route_name) if route not in cls._apidoc_routes: d = {'docstring': docstring, + 'route': '/api/%s%s' % (api_version, route), 'route_view_name': route_view_name} for k, v in kwargs.items(): d[k] = v diff --git a/swh/web/templates/api/endpoints.html b/swh/web/templates/api/endpoints.html --- a/swh/web/templates/api/endpoints.html +++ b/swh/web/templates/api/endpoints.html @@ -53,7 +53,7 @@ {% else %} - {% url doc.route_view_name %} + {{ doc.route }} {% endif %} diff --git a/swh/web/tests/api/test_apidoc.py b/swh/web/tests/api/test_apidoc.py --- a/swh/web/tests/api/test_apidoc.py +++ b/swh/web/tests/api/test_apidoc.py @@ -12,6 +12,7 @@ from swh.web.api.apidoc import api_doc, _parse_httpdomain_doc from swh.web.api.apiurls import api_route from swh.web.common.exc import BadInputExc, ForbiddenExc, NotFoundExc +from swh.web.common.utils import reverse from swh.web.tests.django_asserts import assert_template_used @@ -84,7 +85,7 @@ @api_route(r'/some/(?P[0-9]+)/(?P[0-9]+)/', - 'some-doc-route') + 'api-1-some-doc-route') @api_doc('/some/doc/route/') def apidoc_route(request, myarg, myotherarg, akw=0): """ @@ -92,23 +93,23 @@ """ return {'result': int(myarg) + int(myotherarg) + akw} -# remove deprecation warnings related to docutils -@pytest.mark.filterwarnings( - 'ignore:.*U.*mode is deprecated:DeprecationWarning') + def test_apidoc_route_doc(client): - rv = client.get('/api/1/some/doc/route/', HTTP_ACCEPT='text/html') + url = reverse('api-1-some-doc-route') + rv = client.get(url, HTTP_ACCEPT='text/html') assert rv.status_code == 200, rv.content assert_template_used(rv, 'api/apidoc.html') def test_apidoc_route_fn(api_client): - rv = api_client.get('/api/1/some/1/1/') - + url = reverse('api-1-some-doc-route', + url_args={'myarg': 1, 'myotherarg': 1}) + rv = api_client.get(url) assert rv.status_code == 200, rv.data -@api_route(r'/test/error/(?P.+)/', 'test-error') +@api_route(r'/test/error/(?P.+)/', 'api-1-test-error') @api_doc('/test/error/') def apidoc_test_error_route(request, exc_name): """ @@ -121,13 +122,15 @@ def test_apidoc_error(api_client): for exc, code in exception_http_code.items(): - rv = api_client.get('/api/1/test/error/%s/' % exc.__name__) + url = reverse('api-1-test-error', + url_args={'exc_name': exc.__name__}) + rv = api_client.get(url) assert rv.status_code == code, rv.data @api_route(r'/some/full/(?P[0-9]+)/(?P[0-9]+)/', - 'some-complete-doc-route') + 'api-1-some-complete-doc-route') @api_doc('/some/complete/doc/route/') def apidoc_full_stack(request, myarg, myotherarg, akw=0): """ @@ -136,21 +139,40 @@ return {'result': int(myarg) + int(myotherarg) + akw} -# remove deprecation warnings related to docutils -@pytest.mark.filterwarnings( - 'ignore:.*U.*mode is deprecated:DeprecationWarning') def test_apidoc_full_stack_doc(client): - rv = client.get('/api/1/some/complete/doc/route/', HTTP_ACCEPT='text/html') + url = reverse('api-1-some-complete-doc-route') + rv = client.get(url, HTTP_ACCEPT='text/html') assert rv.status_code == 200, rv.content assert_template_used(rv, 'api/apidoc.html') def test_apidoc_full_stack_fn(api_client): - rv = api_client.get('/api/1/some/full/1/1/') + url = reverse('api-1-some-complete-doc-route', + url_args={'myarg': 1, 'myotherarg': 1}) + rv = api_client.get(url) assert rv.status_code == 200, rv.data +@api_route(r'/test/post/only/', 'api-1-test-post-only', + methods=['POST']) +@api_doc('/test/post/only/', post_only=True) +def apidoc_test_post_only(request, exc_name): + """ + Sample doc + """ + return {'result': 'some data'} + + +def test_apidoc_noargs_post_only(client): + # a dedicated view accepting GET requests should have + # been created to display the HTML documentation + url = reverse('api-1-test-post-only-doc') + rv = client.get(url, HTTP_ACCEPT='text/html') + assert rv.status_code == 200, rv.content + assert_template_used(rv, 'api/apidoc.html') + + def test_api_doc_parse_httpdomain(): doc_data = { 'description': '',