diff --git a/README-dev.md b/README-dev.md index 8807738bc..1608149cc 100644 --- a/README-dev.md +++ b/README-dev.md @@ -1,47 +1,53 @@ README-dev ========== # modules' description ## Main swh.web.ui.main: Start the server or the dev server ## Layers Folder swh/web/ui/: - api main api endpoints definition (api) - views main ui endpoints (web app) - service Orchestration layer used by api/view module. In charge of communication with `backend` to retrieve information and conversion for the upper layer. - backend Lower layer in charge of communication with swh storage. Used by `service` module. In short: 1. views -depends-> api -depends-> service -depends-> backend ----asks----> swh-storage 2. views <- api <- service <- backend <----rets---- swh-storage ## Utilities Folder swh/web/ui/: - apidoc Browsable api functions. - exc Exception definitions. - errorhandler Exception (defined in `exc`) handlers. Use at route definition time (`api`, `views`). - renderers Rendering utilities (html, json, yaml, data...). Use at route definition time (`api`, `views`). - converters conversion layer to transform swh data to serializable data. Used by `service` to convert data before transmitting to `api` or `views`. - query Utilities to parse data from http endpoints. Used by `service` - upload Utility module to deal with upload of data to webapp or api. 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 ![Summary dependencies](./docs/dependencies.png) diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py index d0c8e2173..0f273773f 100644 --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -1,242 +1,342 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import re from functools import wraps from enum import Enum from flask import request, render_template, url_for from flask import g from swh.web.ui.main import app class argtypes(Enum): # noqa: N801 """Class for centralizing argument type descriptions """ ts = 'timestamp' int = 'integer' str = 'string' path = 'path' sha1 = 'sha1' uuid = 'uuid' sha1_git = 'sha1_git' algo_and_hash = 'algo_hash:hash' class rettypes(Enum): # noqa: N801 """Class for centralizing return type descriptions """ octet_stream = 'octet stream' list = 'list' dict = 'dict' class excs(Enum): # noqa: N801 """Class for centralizing exception type descriptions """ badinput = 'BadInputExc' notfound = 'NotFoundExc' class APIUrls(object): """ Class to manage API documentation URLs. * Indexes all routes documented using apidoc's decorators. * Tracks endpoint/request processing method relationships for use in generating related urls in API documentation Relies on the load_controllers logic in main.py for initialization. """ apidoc_routes = {} method_endpoints = {} @classmethod def get_app_endpoints(cls): return cls.apidoc_routes @classmethod def get_method_endpoints(cls, fname): if len(cls.method_endpoints) == 0: cls.method_endpoints = cls.group_routes_by_method() return cls.method_endpoints[fname] @classmethod def group_routes_by_method(cls): """ Group URL endpoints according to their processing method. Returns: A dict where keys are the processing method names, and values are the routes that are bound to the key method. """ endpoints = {} for rule in app.url_map.iter_rules(): rule_dict = {'rule': rule.rule, 'methods': rule.methods} if rule.endpoint not in endpoints: endpoints[rule.endpoint] = [rule_dict] else: endpoints[rule.endpoint].append(rule_dict) return endpoints @classmethod def index_add_route(cls, route, docstring): """ Add a route to the self-documenting API reference """ if route not in cls.apidoc_routes: 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 def __call__(self, f): 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) + 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. Args: name: the argument's name. MUST match the method argument's name to create the example request URL. default: the argument's default value argtype: the argument's type as an Enum value from apidoc.argtypes 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. Args: exc: the exception name as an Enum value from apidoc.excs doc: the exception's documentation string """ def __init__(self, exc, doc): + super().__init__() self.exc_dict = { 'exc': exc.value, 'doc': doc } 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 index 056aaf14f..3a7a77c1a 100644 --- a/swh/web/ui/renderers.py +++ b/swh/web/ui/renderers.py @@ -1,146 +1,144 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import re import yaml import json from docutils.core import publish_parts from docutils.writers.html4css1 import Writer, HTMLTranslator from inspect import cleandoc from flask import request, Response, render_template from flask import g from swh.web.ui import utils class SWHFilterEnricher(): """Global filter on fields. """ def filter_by_fields(self, data): """Extract a request parameter 'fields' if it exists to permit the filtering on the data dict's keys. If such field is not provided, returns the data as is. """ fields = request.args.get('fields') if fields: fields = set(fields.split(',')) data = utils.filter_field_keys(data, fields) return data 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. """ @classmethod def make_response_from_mimetype(cls, rv, options={}): if not (isinstance(rv, list) or isinstance(rv, dict)): return rv def wants_html(best_match): return best_match == 'text/html' and \ request.accept_mimetypes[best_match] > \ request.accept_mimetypes['application/json'] def wants_yaml(best_match): return best_match == 'application/yaml' and \ request.accept_mimetypes[best_match] > \ request.accept_mimetypes['application/json'] 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=(',', ': ')) env = g.get('doc_env', {}) env['response_data'] = data env['request'] = request 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( json.dumps(rv), content_type='application/json', **options) return rv @classmethod def force_type(cls, rv, environ=None): if isinstance(rv, dict) or isinstance(rv, list): rv = cls.make_response_from_mimetype(rv) return super().force_type(rv, environ) def error_response(error_code, error): """Private function to create a custom error response. """ error_opts = {'status': error_code} error_data = {'error': str(error)} return SWHMultiResponse.make_response_from_mimetype(error_data, options=error_opts) def urlize_api_links(content): """Utility function for decorating api links in browsable api.""" return re.sub(r'"(/api/.*|/browse/.*)"', r'"\1"', content) class NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] def visit_bullet_list(self, node): self.context.append((self.compact_simple, self.compact_p)) self.compact_p = None self.compact_simple = self.is_compactable(node) self.body.append(self.starttag(node, 'ul', CLASS='docstring')) DOCSTRING_WRITER = Writer() DOCSTRING_WRITER.translator_class = NoHeaderHTMLTranslator def safe_docstring_display(docstring): """ Utility function to htmlize reST-formatted documentation in browsable api. """ docstring = cleandoc(docstring) return publish_parts(docstring, writer=DOCSTRING_WRITER)['html_body'] def revision_id_from_url(url): """Utility function to obtain a revision's ID from its browsing URL.""" return re.sub(r'/browse/revision/([0-9a-f]{40}|[0-9a-f]{64})/.*', r'\1', url) diff --git a/swh/web/ui/tests/test_apidoc.py b/swh/web/ui/tests/test_apidoc.py index 3ce983726..77930edc6 100644 --- a/swh/web/ui/tests/test_apidoc.py +++ b/swh/web/ui/tests/test_apidoc.py @@ -1,346 +1,125 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # 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', 'type': apidoc.argtypes.sha1, 'doc': 'this arg does things' } self.stub_excs = [{'exc': apidoc.excs.badinput, 'doc': 'My exception documentation'}] self.stub_args = [{'name': 'stub_arg', 'default': 'some_default'}] self.stub_rule_list = [ {'rule': 'some/route/with/args/', 'methods': {'GET', 'HEAD', 'OPTIONS'}}, {'rule': 'some/doc/route/', 'methods': {'GET', 'HEAD', 'OPTIONS'}}, {'rule': 'some/other/route/', 'methods': {'GET', 'HEAD', 'OPTIONS'}} ] self.stub_return = { 'type': apidoc.rettypes.dict.value, '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 index 4a218b791..c4c9f5f79 100644 --- a/swh/web/ui/tests/test_app.py +++ b/swh/web/ui/tests/test_app.py @@ -1,95 +1,91 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information # Functions defined here are NOT DESIGNED FOR PRODUCTION import unittest from swh.storage.api.client import RemoteStorage as Storage from swh.web.ui import main from flask.ext.testing import TestCase # Because the Storage's __init__ function does side effect at startup... class RemoteStorageAdapter(Storage): def __init__(self, base_url): self.base_url = base_url def _init_mock_storage(base_url='https://somewhere.org:4321'): """Instanciate a remote storage whose goal is to be mocked in a test context. NOT FOR PRODUCTION Returns: An instance of swh.storage.api.client.RemoteStorage destined to be mocked (it does not do any rest call) """ return RemoteStorageAdapter(base_url) # destined to be used as mock def create_app(base_url='https://somewhere.org:4321'): """Function to initiate a flask app with storage designed to be mocked. Returns: Tuple: - app test client (for testing api, client decorator from flask) - application's full configuration - the storage instance to stub and mock - the main app without any decoration NOT FOR PRODUCTION """ storage = _init_mock_storage(base_url) # inject the mock data conf = {'storage': storage, 'max_log_revs': 25} main.app.config.update({'conf': conf}) if not main.app.config['TESTING']: # HACK: install controllers only once! main.app.config['TESTING'] = True main.load_controllers() 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. """ @classmethod def setUpClass(cls): cls.app, cls.app_config, cls.storage, _ = create_app() cls.maxDiff = None class SWHViewTestCase(TestCase): """Testing view class. cf. http://pythonhosted.org/Flask-Testing/ """ # This inhibits template rendering # render_templates = False def create_app(self): """Initialize a Flask-Testing application instance to test view without template rendering """ _, _, _, appToDecorate = create_app() return appToDecorate + + +class SWHApidocTestCase(SWHViewTestCase, SWHApiTestCase): + """Testing APIDoc class. + + """