diff --git a/README-dev.md b/README-dev.md --- a/README-dev.md +++ b/README-dev.md @@ -41,6 +41,12 @@ Used by `api` - utils Utilities used throughout swh-web-ui. +### About apidoc + +This is a 'decorator tower' that stores the data associated with the documentation upon loading +the apidoc module. The top decorator of any tower should be @apidoc.route(). Apidoc raises an +exception if this decorator is missing, and flask raises an exception if it is present but not at +the top of the tower. ## Graphics summary diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -96,18 +96,88 @@ cls.apidoc_routes[route] = docstring -class route(object): # noqa: N801 +class APIDocException(Exception): + """ + Custom exception to signal errors in the use of the APIDoc decorators + """ + + +class APIDocBase(object): + """ + The API documentation decorator base class, responsible for the + operations that link the decorator stack together: + + * manages the _inner_dec property, which represents the + decorator directly below self in the decorator tower + * contains the logic used to return appropriately if self is the last + decorator to be applied to the API function + """ + + def __init__(self): + self._inner_dec = None + + @property + def inner_dec(self): + return self._inner_dec + + @inner_dec.setter + def inner_dec(self, instance): + self._inner_dec = instance + + @property + def data(self): + raise NotImplementedError + + def process_rv(self, f, args, kwargs): + """ + From the arguments f has, determine whether or not it is the last + decorator in the stack, and return the appropriate call to f. + """ + + rv = None + if 'outer_decorator' in f.__code__.co_varnames: + rv = f(*args, **kwargs) + else: + nargs = {k: v for k, v in kwargs.items() if k != 'outer_decorator'} + if f.__code__.co_argcount > 0 and (args, nargs) == ((), {}): + rv = None # Documentation call + else: + rv = f(*args, **nargs) + return rv + + def maintain_stack(self, f, args, kwargs): + """ + From the arguments f is called with, determine whether or not the + stack link was made by @apidoc.route, and maintain the linking for + the next call to f. + """ + + if 'outer_decorator' not in kwargs: + raise APIDocException('Apidoc %s: expected an apidoc' + ' route decorator first' + % self.__class__.__name__) + kwargs['outer_decorator'].inner_dec = self + kwargs['outer_decorator'] = self + + return self.process_rv(f, args, kwargs) + + +class route(APIDocBase): # noqa: N801 """ Decorate an API method to register it in the API doc route index and create the corresponding Flask route. - Caution: decorating a method with this requires to also decorate it - __at least__ with @returns, or breaks the decorated endpoint + + This decorator is responsible for bootstrapping the linking of subsequent + decorators, as well as traversing the decorator stack to obtain the + documentation data from it. + Args: route: the 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 """ def __init__(self, route, noargs=False): + super().__init__() self.route = route self.noargs = noargs @@ -116,17 +186,77 @@ @wraps(f) def doc_func(*args, **kwargs): - return f(call_args=(args, kwargs), - doc_route=self.route, - noargs=self.noargs) + kwargs['outer_decorator'] = self + rv = self.process_rv(f, args, kwargs) + return self.compute_return(f, rv) if not self.noargs: app.add_url_rule(self.route, f.__name__, doc_func) return doc_func + def filter_api_url(self, endpoint, route_re, noargs): + doc_methods = {'GET', 'HEAD', 'OPTIONS'} + if re.match(route_re, endpoint['rule']): + if endpoint['methods'] == doc_methods and not noargs: + return False + return True + + def compute_return(self, f, rv): + # Build documentation + data = self.data + if not f.__doc__: + raise APIDocException('Apidoc %s: expected a docstring' + ' for function %s' + % (self.__class__.__name__, f.__name__)) + data['docstring'] = f.__doc__ + + route_re = re.compile('.*%s$' % data['route']) + endpoint_list = APIUrls.get_method_endpoints(f.__name__) + other_urls = [url for url in endpoint_list if + self.filter_api_url(url, route_re, data['noargs'])] + data['urls'] = other_urls + + # Build example endpoint URL + if 'args' in data: + defaults = {arg['name']: arg['default'] for arg in data['args']} + example = url_for(f.__name__, **defaults) + data['example'] = re.sub(r'(.*)\?.*', r'\1', example) + + # Prepare and send to mimetype selector if it's not a doc request + if re.match(route_re, request.url) and not data['noargs'] \ + and request.method == 'GET': + return app.response_class( + render_template('apidoc.html', **data), + content_type='text/html') + + g.doc_env = data # Store for response processing + return rv + + @property + def data(self): + data = {'route': self.route, 'noargs': self.noargs} + + doc_instance = self.inner_dec + while doc_instance: + if isinstance(doc_instance, arg): + if 'args' not in data: + data['args'] = [] + data['args'].append(doc_instance.data) + elif isinstance(doc_instance, raises): + if 'excs' not in data: + data['excs'] = [] + data['excs'].append(doc_instance.data) + elif isinstance(doc_instance, returns): + data['return'] = doc_instance.data + else: + raise APIDocException('Unknown API documentation decorator') + doc_instance = doc_instance.inner_dec + + return data + -class arg(object): # noqa: N801 +class arg(APIDocBase): # noqa: N801 """ Decorate an API method to display an argument's information on the doc page specified by @route above. @@ -138,25 +268,28 @@ argdoc: the argument's documentation string """ def __init__(self, name, default, argtype, argdoc): + super().__init__() self.doc_dict = { 'name': name, 'type': argtype.value, 'doc': argdoc, 'default': default } + self.inner_dec = None def __call__(self, f): @wraps(f) - def arg_fun(*args, **kwargs): - if 'args' in kwargs: - kwargs['args'].append(self.doc_dict) - else: - kwargs['args'] = [self.doc_dict] - return f(*args, **kwargs) + def arg_fun(*args, outer_decorator=None, **kwargs): + kwargs['outer_decorator'] = outer_decorator + return self.maintain_stack(f, args, kwargs) return arg_fun + @property + def data(self): + return self.doc_dict -class raises(object): # noqa: N801 + +class raises(APIDocBase): # noqa: N801 """ Decorate an API method to display information pertaining to an exception that can be raised by this method. @@ -165,6 +298,7 @@ doc: the exception's documentation string """ def __init__(self, exc, doc): + super().__init__() self.exc_dict = { 'exc': exc.value, 'doc': doc @@ -172,71 +306,37 @@ def __call__(self, f): @wraps(f) - def exc_fun(*args, **kwargs): - if 'excs' in kwargs: - kwargs['excs'].append(self.exc_dict) - else: - kwargs['excs'] = [self.exc_dict] - return f(*args, **kwargs) + def exc_fun(*args, outer_decorator=None, **kwargs): + kwargs['outer_decorator'] = outer_decorator + return self.maintain_stack(f, args, kwargs) return exc_fun + @property + def data(self): + return self.exc_dict + -class returns(object): # noqa: N801 +class returns(APIDocBase): # noqa: N801 """ Decorate an API method to display information about its return value. - Caution: this MUST be the last decorator in the apidoc decorator stack, - or the decorated endpoint breaks Args: rettype: the return value's type as an Enum value from apidoc.rettypes retdoc: the return value's documentation string """ def __init__(self, rettype=None, retdoc=None): + super().__init__() self.return_dict = { 'type': rettype.value, 'doc': retdoc } - def filter_api_url(self, endpoint, route_re, noargs): - doc_methods = {'GET', 'HEAD', 'OPTIONS'} - if re.match(route_re, endpoint['rule']): - if endpoint['methods'] == doc_methods and not noargs: - return False - return True - def __call__(self, f): @wraps(f) - def ret_fun(*args, **kwargs): - # Build documentation - env = { - 'docstring': f.__doc__, - 'route': kwargs['doc_route'], - 'return': self.return_dict - } - - for arg in ['args', 'excs']: - if arg in kwargs: - env[arg] = kwargs[arg] - - route_re = re.compile('.*%s$' % kwargs['doc_route']) - endpoint_list = APIUrls.get_method_endpoints(f.__name__) - other_urls = [url for url in endpoint_list if - self.filter_api_url(url, route_re, kwargs['noargs'])] - env['urls'] = other_urls - - # Build example endpoint URL - if 'args' in env: - defaults = {arg['name']: arg['default'] for arg in env['args']} - example = url_for(f.__name__, **defaults) - env['example'] = re.sub(r'(.*)\?.*', r'\1', example) - - # Prepare and send to mimetype selector if it's not a doc request - if re.match(route_re, request.url) and not kwargs['noargs'] \ - and request.method == 'GET': - return app.response_class( - render_template('apidoc.html', **env), - content_type='text/html') - - cargs, ckwargs = kwargs['call_args'] - g.doc_env = env # Store for response processing - return f(*cargs, **ckwargs) + def ret_fun(*args, outer_decorator=None, **kwargs): + kwargs['outer_decorator'] = outer_decorator + return self.maintain_stack(f, args, kwargs) return ret_fun + + @property + def data(self): + return self.return_dict diff --git a/swh/web/ui/renderers.py b/swh/web/ui/renderers.py --- a/swh/web/ui/renderers.py +++ b/swh/web/ui/renderers.py @@ -39,7 +39,7 @@ class SWHMultiResponse(Response, SWHFilterEnricher): """ A Flask Response subclass. - Override force_type to transform dict responses into callable Flask + Override force_type to transform dict/list responses into callable Flask response objects whose mimetype matches the request's Accept header: HTML template render, YAML dump or default to a JSON dump. """ @@ -62,7 +62,7 @@ rv = cls.filter_by_fields(cls, rv) acc_mime = ['application/json', 'application/yaml', 'text/html'] best_match = request.accept_mimetypes.best_match(acc_mime) - # return a template render + if wants_html(best_match): data = json.dumps(rv, sort_keys=True, indent=4, separators=(',', ': ')) @@ -72,13 +72,11 @@ rv = Response(render_template('apidoc.html', **env), content_type='text/html', **options) - # return formatted yaml elif wants_yaml(best_match): rv = Response( yaml.dump(rv), content_type='application/yaml', **options) - # return formatted json else: # jsonify is unhappy with lists in Flask 0.10.1, use json.dumps rv = Response( diff --git a/swh/web/ui/tests/test_apidoc.py b/swh/web/ui/tests/test_apidoc.py --- a/swh/web/ui/tests/test_apidoc.py +++ b/swh/web/ui/tests/test_apidoc.py @@ -4,16 +4,17 @@ # See top-level LICENSE file for more information -from unittest.mock import MagicMock, patch -from nose.tools import istest +from nose.tools import istest, nottest from swh.web.ui import apidoc -from swh.web.ui.tests import test_app +from swh.web.ui.main import app +from swh.web.ui.tests.test_app import SWHApidocTestCase -class APIDocTestCase(test_app.SWHApidocTestCase): +class APIDocTestCase(SWHApidocTestCase): def setUp(self): + self.arg_dict = { 'name': 'my_pretty_arg', 'default': 'some default value', @@ -37,310 +38,88 @@ 'doc': 'a dict with amazing properties' } - @patch('swh.web.ui.apidoc.APIUrls') - @patch('swh.web.ui.apidoc.app') - @istest - def apidoc_route(self, mock_app, mock_api_urls): - # given - decorator = apidoc.route('/some/url/for/doc/') - mock_fun = MagicMock(return_value=123) - mock_fun.__doc__ = 'Some documentation' - mock_fun.__name__ = 'some_fname' - decorated = decorator.__call__(mock_fun) - - # when - decorated('some', 'value', kws='and a kw') - - # then - mock_fun.assert_called_once_with( - call_args=(('some', 'value'), {'kws': 'and a kw'}), - doc_route='/some/url/for/doc/', - noargs=False - ) - mock_api_urls.index_add_route.assert_called_once_with( - '/some/url/for/doc/', - 'Some documentation') - mock_app.add_url_rule.assert_called_once_with( - '/some/url/for/doc/', 'some_fname', decorated) + @apidoc.route('/my/nodoc/url/') + @nottest + def apidoc_nodoc_tester(arga, argb): + return arga + argb @istest - def apidoc_arg_noprevious(self): - # given - decorator = apidoc.arg('my_pretty_arg', - default='some default value', - argtype=apidoc.argtypes.sha1, - argdoc='this arg does things') - mock_fun = MagicMock(return_value=123) - decorated = decorator.__call__(mock_fun) - self.arg_dict['type'] = self.arg_dict['type'].value - - # when - decorated(call_args=((), {}), doc_route='some/route/') - - # then - mock_fun.assert_called_once_with( - call_args=((), {}), - doc_route='some/route/', - args=[self.arg_dict] - ) + def apidoc_nodoc_failure(self): + with self.assertRaises(Exception): + self.client.get('/my/nodoc/url/') @istest - def apidoc_arg_previous(self): - # given - decorator = apidoc.arg('my_other_arg', - default='some other value', - argtype=apidoc.argtypes.sha1, - argdoc='this arg is optional') - mock_fun = MagicMock(return_value=123) - decorated = decorator.__call__(mock_fun) - - # when - decorated(call_args=((), {}), - doc_route='some/route/', - args=[self.arg_dict]) - - # then - mock_fun.assert_called_once_with( - call_args=((), {}), - doc_route='some/route/', - args=[self.arg_dict, - {'name': 'my_other_arg', - 'default': 'some other value', - 'type': apidoc.argtypes.sha1.value, - 'doc': 'this arg is optional'}]) + def apidoc_badorder_failure(self): + with self.assertRaises(AssertionError): + @app.route('/my/badorder/url//') + @apidoc.arg('foo', + default=True, + argtype=apidoc.argtypes.int, + argdoc='It\'s so fluffy!') + @apidoc.route('/my/badorder/url/') + @nottest + def apidoc_badorder_tester(foo, bar=0): + """ + Some irrelevant doc since the decorators are bad + """ + return foo + bar + + @app.route('/some///') + @apidoc.route('/some/doc/route/') + @nottest + def apidoc_route_tester(myarg, myotherarg, akw=0): + """ + Sample doc + """ + return {'result': myarg + myotherarg + akw} @istest - def apidoc_raises_noprevious(self): - # given - decorator = apidoc.raises(exc=apidoc.excs.badinput, - doc='My exception documentation') - mock_fun = MagicMock(return_value=123) - decorated = decorator.__call__(mock_fun) - self.stub_excs[0]['exc'] = self.stub_excs[0]['exc'].value - + def apidoc_route_doc(self): # when - decorated(call_args=((), {}), doc_route='some/route/') + rv = self.client.get('/some/doc/route/') # then - mock_fun.assert_called_once_with( - call_args=((), {}), - doc_route='some/route/', - excs=self.stub_excs - ) + self.assertEqual(rv.status_code, 200) + self.assert_template_used('apidoc.html') @istest - def apidoc_raises_previous(self): - # given - decorator = apidoc.raises(exc=apidoc.excs.notfound, - doc='Another documentation') - mock_fun = MagicMock(return_value=123) - decorated = decorator.__call__(mock_fun) - expected_excs = self.stub_excs + [{ - 'exc': apidoc.excs.notfound.value, - 'doc': 'Another documentation'}] - expected_excs[0]['exc'] = expected_excs[0]['exc'].value + def apidoc_route_fn(self): # when - decorated(call_args=((), {}), - doc_route='some/route/', - excs=self.stub_excs) + rv = self.client.get('/some/1/1/') # then - mock_fun.assert_called_once_with( - call_args=((), {}), - doc_route='some/route/', - excs=expected_excs) + self.assertEqual(rv.status_code, 200) + + @app.route('/some/full///') + @apidoc.route('/some/complete/doc/route/') + @apidoc.arg('myarg', + default=67, + argtype=apidoc.argtypes.int, + argdoc='my arg') + @apidoc.raises(exc=apidoc.excs.badinput, doc='Oops') + @apidoc.returns(rettype=apidoc.rettypes.dict, + retdoc='sum of args') + @nottest + def apidoc_full_stack_tester(myarg, myotherarg, akw=0): + """ + Sample doc + """ + return {'result': myarg + myotherarg + akw} - @patch('swh.web.ui.apidoc.render_template') - @patch('swh.web.ui.apidoc.url_for') - @patch('swh.web.ui.apidoc.APIUrls') - @patch('swh.web.ui.apidoc.request') @istest - def apidoc_returns_doc_call(self, - mock_request, - mock_api_urls, - mock_url_for, - mock_render): - # given - decorator = apidoc.returns(rettype=apidoc.rettypes.dict, - retdoc='a dict with amazing properties') - mock_fun = MagicMock(return_value=123) - mock_fun.__name__ = 'some_fname' - mock_fun.__doc__ = 'Some documentation' - decorated = decorator.__call__(mock_fun) - - mock_api_urls.get_method_endpoints.return_value = self.stub_rule_list - - mock_request.url = 'http://my-domain.tld/some/doc/route/' - mock_request.method = 'GET' - mock_url_for.return_value = 'http://my-domain.tld/meaningful_route/' - - expected_env = { - 'urls': [{'rule': 'some/route/with/args/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}, - {'rule': 'some/other/route/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}], - 'docstring': 'Some documentation', - 'args': self.stub_args, - 'excs': self.stub_excs, - 'route': 'some/doc/route/', - 'example': 'http://my-domain.tld/meaningful_route/', - 'return': self.stub_return - } - + def apidoc_full_stack_doc(self): # when - decorated( - docstring='Some documentation', - call_args=(('some', 'args'), {'kw': 'kwargs'}), - args=self.stub_args, - excs=self.stub_excs, - doc_route='some/doc/route/', - noargs=False - ) + rv = self.client.get('/some/complete/doc/route/') # then - self.assertEqual(mock_fun.call_args_list, []) # function not called - mock_render.assert_called_once_with( - 'apidoc.html', - **expected_env - ) + self.assertEqual(rv.status_code, 200) + self.assert_template_used('apidoc.html') - @patch('swh.web.ui.apidoc.g') - @patch('swh.web.ui.apidoc.url_for') - @patch('swh.web.ui.apidoc.APIUrls') - @patch('swh.web.ui.apidoc.request') @istest - def apidoc_returns_noargs(self, - mock_request, - mock_api_urls, - mock_url_for, - mock_g): - - # given - decorator = apidoc.returns(rettype=apidoc.rettypes.dict, - retdoc='a dict with amazing properties') - mock_fun = MagicMock(return_value=123) - mock_fun.__name__ = 'some_fname' - mock_fun.__doc__ = 'Some documentation' - decorated = decorator.__call__(mock_fun) - - mock_api_urls.get_method_endpoints.return_value = [ - {'rule': 'some/doc/route/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}] - mock_request.url = 'http://my-domain.tld/some/doc/route/' - doc_dict = { - 'urls': [ - {'rule': 'some/doc/route/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}], - 'docstring': 'Some documentation', - 'route': 'some/doc/route/', - 'return': {'type': apidoc.rettypes.dict.value, - 'doc': 'a dict with amazing properties'} - } - - # when - decorated( - call_args=((), {}), - doc_route='some/doc/route/', - noargs=True - ) - - # then - mock_fun.assert_called_once_with() - self.assertEqual(mock_g.doc_env, doc_dict) - - @patch('swh.web.ui.apidoc.g') - @patch('swh.web.ui.apidoc.url_for') - @patch('swh.web.ui.apidoc.APIUrls') - @patch('swh.web.ui.apidoc.request') - @istest - def apidoc_returns_same_fun(self, - mock_request, - mock_api_urls, - mock_url_for, - mock_g): - - # given - decorator = apidoc.returns(rettype=apidoc.rettypes.dict, - retdoc='a dict with amazing properties') - mock_fun = MagicMock(return_value=123) - mock_fun.__name__ = 'some_fname' - mock_fun.__doc__ = 'Some documentation' - decorated = decorator.__call__(mock_fun) - - mock_api_urls.get_method_endpoints.return_value = [ - {'rule': 'some/doc/route/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}, - {'rule': 'some/doc/route/', - 'methods': {'POST'}}] - mock_request.url = 'http://my-domain.tld/some/doc/route/' - mock_request.method = 'POST' - doc_dict = { - 'urls': [{'rule': 'some/doc/route/', - 'methods': {'POST'}}], - 'docstring': 'Some documentation', - 'route': 'some/doc/route/', - 'return': {'type': apidoc.rettypes.dict.value, - 'doc': 'a dict with amazing properties'} - } - - # when - decorated( - call_args=(('my', 'args'), {'kw': 'andkwargs'}), - doc_route='some/doc/route/', - noargs=False - ) - - # then - mock_fun.assert_called_once_with('my', 'args', kw='andkwargs') - self.assertEqual(mock_g.doc_env, doc_dict) - - @patch('swh.web.ui.apidoc.g') - @patch('swh.web.ui.apidoc.url_for') - @patch('swh.web.ui.apidoc.APIUrls') - @patch('swh.web.ui.apidoc.request') - @istest - def apidoc_return_endpoint_call(self, - mock_request, - mock_api_urls, - mock_url_for, - mock_g): - # given - decorator = apidoc.returns(rettype=apidoc.rettypes.dict, - retdoc='a dict with amazing properties') - mock_fun = MagicMock(return_value=123) - mock_fun.__name__ = 'some_fname' - mock_fun.__doc__ = 'Some documentation' - decorated = decorator.__call__(mock_fun) - - mock_api_urls.get_method_endpoints.return_value = self.stub_rule_list - - mock_request.url = 'http://my-domain.tld/some/arg/route/' - mock_url_for.return_value = 'http://my-domain.tld/some/arg/route' - - doc_dict = { - 'urls': [{'rule': 'some/route/with/args/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}, - {'rule': 'some/other/route/', - 'methods': {'GET', 'HEAD', 'OPTIONS'}}], - 'docstring': 'Some documentation', - 'args': self.stub_args, - 'excs': self.stub_excs, - 'route': 'some/doc/route/', - 'example': 'http://my-domain.tld/some/arg/route', - 'return': self.stub_return - } - + def apidoc_full_stack_fn(self): # when - decorated( - docstring='Some documentation', - call_args=(('some', 'args'), {'kw': 'kwargs'}), - args=self.stub_args, - excs=self.stub_excs, - noargs=False, - doc_route='some/doc/route/', - ) + rv = self.client.get('/some/full/1/1/') # then - mock_fun.assert_called_once_with('some', 'args', kw='kwargs') - self.assertEqual(mock_g.doc_env, doc_dict) + self.assertEqual(rv.status_code, 200) diff --git a/swh/web/ui/tests/test_app.py b/swh/web/ui/tests/test_app.py --- a/swh/web/ui/tests/test_app.py +++ b/swh/web/ui/tests/test_app.py @@ -59,16 +59,6 @@ return main.app.test_client(), main.app.config, storage, main.app -class SWHApidocTestCase(unittest.TestCase): - """Testing APIDoc class. - - """ - @classmethod - def setUpClass(cls): - cls.app, cls.app_config, cls.storage, _ = create_app() - cls.maxDiff = None - - class SWHApiTestCase(unittest.TestCase): """Testing API class. @@ -93,3 +83,9 @@ """ _, _, _, appToDecorate = create_app() return appToDecorate + + +class SWHApidocTestCase(SWHViewTestCase, SWHApiTestCase): + """Testing APIDoc class. + + """