diff --git a/README-dev.md b/README-dev.md index 1608149cc..8807738bc 100644 --- a/README-dev.md +++ b/README-dev.md @@ -1,53 +1,47 @@ 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 0f273773f..d0c8e2173 100644 --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -1,342 +1,242 @@ # 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 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 +class route(object): # noqa: N801 """ Decorate an API method to register it in the API doc route index and create the corresponding Flask route. - - 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. - + 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 def __call__(self, f): APIUrls.index_add_route(self.route, f.__doc__) @wraps(f) def doc_func(*args, **kwargs): - kwargs['outer_decorator'] = self - rv = self.process_rv(f, args, kwargs) - return self.compute_return(f, rv) + return f(call_args=(args, kwargs), + doc_route=self.route, + noargs=self.noargs) 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(APIDocBase): # noqa: N801 +class arg(object): # 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, outer_decorator=None, **kwargs): - kwargs['outer_decorator'] = outer_decorator - return self.maintain_stack(f, args, kwargs) + 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 arg_fun - @property - def data(self): - return self.doc_dict - -class raises(APIDocBase): # noqa: N801 +class raises(object): # 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, outer_decorator=None, **kwargs): - kwargs['outer_decorator'] = outer_decorator - return self.maintain_stack(f, args, kwargs) + 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 exc_fun - @property - def data(self): - return self.exc_dict - -class returns(APIDocBase): # noqa: N801 +class returns(object): # 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, outer_decorator=None, **kwargs): - kwargs['outer_decorator'] = outer_decorator - return self.maintain_stack(f, args, kwargs) + 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 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 d617bd3dc..09b005298 100644 --- a/swh/web/ui/renderers.py +++ b/swh/web/ui/renderers.py @@ -1,145 +1,147 @@ # 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/list responses into callable Flask + Override force_type to transform dict 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 77930edc6..3ce983726 100644 --- a/swh/web/ui/tests/test_apidoc.py +++ b/swh/web/ui/tests/test_apidoc.py @@ -1,125 +1,346 @@ # 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 nose.tools import istest, nottest +from unittest.mock import MagicMock, patch +from nose.tools import istest from swh.web.ui import apidoc -from swh.web.ui.main import app -from swh.web.ui.tests.test_app import SWHApidocTestCase +from swh.web.ui.tests import test_app -class APIDocTestCase(SWHApidocTestCase): +class APIDocTestCase(test_app.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' } - @apidoc.route('/my/nodoc/url/') - @nottest - def apidoc_nodoc_tester(arga, argb): - return arga + argb + @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) @istest - def apidoc_nodoc_failure(self): - with self.assertRaises(Exception): - self.client.get('/my/nodoc/url/') + 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] + ) @istest - 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} + 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'}]) @istest - def apidoc_route_doc(self): + 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 + # when - rv = self.client.get('/some/doc/route/') + decorated(call_args=((), {}), doc_route='some/route/') # then - self.assertEqual(rv.status_code, 200) - self.assert_template_used('apidoc.html') + mock_fun.assert_called_once_with( + call_args=((), {}), + doc_route='some/route/', + excs=self.stub_excs + ) @istest - def apidoc_route_fn(self): + 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 # when - rv = self.client.get('/some/1/1/') + decorated(call_args=((), {}), + doc_route='some/route/', + excs=self.stub_excs) # then - 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} + mock_fun.assert_called_once_with( + call_args=((), {}), + doc_route='some/route/', + excs=expected_excs) + @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_full_stack_doc(self): + 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 + } + # when - rv = self.client.get('/some/complete/doc/route/') + 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 + ) # then - self.assertEqual(rv.status_code, 200) - self.assert_template_used('apidoc.html') + self.assertEqual(mock_fun.call_args_list, []) # function not called + mock_render.assert_called_once_with( + 'apidoc.html', + **expected_env + ) + @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_full_stack_fn(self): + 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 + } + # when - rv = self.client.get('/some/full/1/1/') + 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/', + ) # then - self.assertEqual(rv.status_code, 200) + mock_fun.assert_called_once_with('some', 'args', kw='kwargs') + self.assertEqual(mock_g.doc_env, doc_dict) diff --git a/swh/web/ui/tests/test_app.py b/swh/web/ui/tests/test_app.py index c4c9f5f79..4a218b791 100644 --- a/swh/web/ui/tests/test_app.py +++ b/swh/web/ui/tests/test_app.py @@ -1,91 +1,95 @@ # 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. - - """