diff --git a/README-dev.md b/README-dev.md
index 1608149c..8807738b 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

diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py
index 0f273773..d0c8e217 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 d617bd3d..09b00529 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 77930edc..3ce98372 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 c4c9f5f7..4a218b79 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.
-
- """