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,62 @@ cls.apidoc_routes[route] = docstring -class route(object): # noqa: N801 +class APIDocBase(object): + """ + The API documentation decorator base class. + Provides general functionality for the decorator stack's manageement. + """ + + 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): + rv = None + try: + rv = f(*args, **kwargs) + except TypeError as last_dec_err: # Raised if last decorator in stack + self.inner_dec = None + nargs = {k: v for k, v in kwargs.items() if k != 'outer_decorator'} + try: + rv = f(*args, **nargs) + except TypeError as no_args: # Raised if no args given - doc route + pass + return rv + + def maintain_stack(self, f, args, kwargs): + if 'outer_decorator' not in kwargs: + raise Exception('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 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 @@ -115,18 +159,75 @@ APIUrls.index_add_route(self.route, f.__doc__) @wraps(f) - def doc_func(*args, **kwargs): - return f(call_args=(args, kwargs), - doc_route=self.route, - noargs=self.noargs) + def doc_func(*args, outer_decorator=None, **kwargs): + kwargs['outer_decorator'] = self + rv = self.process_rv(f, args, kwargs) + return self.compute_return(f, rv) if not self.noargs: + print('add rule %s' % f.__name__) 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 + 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 Exception('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 +239,27 @@ 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) + 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 +268,7 @@ doc: the exception's documentation string """ def __init__(self, exc, doc): + super().__init__() self.exc_dict = { 'exc': exc.value, 'doc': doc @@ -173,70 +277,34 @@ 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) + 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) + 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(