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

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.
+
+ """