diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py index 895999ad2..2c15838d5 100644 --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -1,49 +1,276 @@ # 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 functools import wraps + +from flask import request, render_template, url_for -import os -from swh.web.ui import utils, main from swh.web.ui.main import app -def _create_url_doc_endpoints(rules): - def split_path(path, acc): - rpath = os.path.dirname(path) - if rpath == '/': - yield from acc +class argtypes(object): + """Class for centralizing argument type descriptions + + """ + + ts = 'timestamp' + int = 'integer' + path = 'path' + sha1 = 'sha1' + uuid = 'uuid' + sha1_git = 'sha1_git' + octet_stream = 'octet stream' + algo_and_hash = 'algo_hash:hash' + + +class rettypes(object): + """Class for centralizing return type descriptions + + """ + list = 'list' + dict = 'dict' + + +class excs(object): + """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): + """ + Decorate an API method to register it in the API doc route index + and create the corresponding Flask route. + Caution: decorating a method with this requires to also decorate it + __at least__ with @returns, or breaks the decorated endpoint + Args: + route: the documentation page's route + noargs: set to True if the route has no arguments, and its result + should be displayed anytime its documentation is requested + """ + def __init__(self, route, noargs=False): + 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) + + if not self.noargs: + app.add_url_rule(self.route, f.__name__, doc_func) + + return doc_func + + +class arg(object): + """ + 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 (map, dict, list, tuple...) + argdoc: the argument's documentation string + """ + def __init__(self, name, default, argtype, argdoc): + self.doc_dict = { + 'name': name, + 'type': argtype, + 'doc': argdoc, + 'default': default + } + + def __call__(self, f): + @wraps(f) + def arg_fun(*args, **kwargs): + if 'args' in kwargs: + kwargs['args'].append(self.doc_dict) + else: + kwargs['args'] = [self.doc_dict] + return f(*args, **kwargs) + return arg_fun + + +class raises(object): + """ + Decorate an API method to display information pertaining to an exception + that can be raised by this method. + Args: + exc: the exception name + doc: the exception's documentation string + """ + def __init__(self, exc, doc): + self.exc_dict = { + 'exc': exc, + '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) + return exc_fun + + +def make_response_from_mimetype(rv, env): + + 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'] + + if isinstance(rv, dict) or isinstance(rv, list): + 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['response_data'] = data + env['request'] = request + rv = app.response_class(render_template('apidoc.html', **env), + content_type='text/html') + # return formatted yaml + elif wants_yaml(best_match): + rv = app.response_class( + yaml.dump(rv), + content_type='application/yaml') + # return formatted json else: - acc.append(rpath+'/') - yield from split_path(rpath, acc) - - url_doc_endpoints = set() - for rule in rules: - url_rule = rule['rule'] - url_doc_endpoints.add(url_rule) - if '<' in url_rule or '>' in url_rule: - continue - acc = [] - for rpath in split_path(url_rule, acc): - if rpath in url_doc_endpoints: - continue - yield rpath - url_doc_endpoints.add(rpath) - - -def install_browsable_api_endpoints(): - """Install browsable endpoints. - - """ - url_doc_endpoints = _create_url_doc_endpoints(main.rules()) - for url_doc in url_doc_endpoints: - endpoint_name = 'doc_api_' + url_doc.strip('/').replace('/', '_') - - def view_func(url_doc=url_doc): - return utils.filter_endpoints(main.rules(), - url_doc) - app.add_url_rule(rule=url_doc, - endpoint=endpoint_name, - view_func=view_func, - methods=['GET']) + # jsonify is unhappy with lists in Flask 0.10.1, use json.dumps + rv = app.response_class( + json.dumps(rv), + content_type='application/json') + return rv + + +class returns(object): + """ + 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 (map, dict, list, tuple...) + retdoc: the return value's documentation string + """ + def __init__(self, rettype=None, retdoc=None): + self.return_dict = { + 'type': rettype, + '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']} + env['example'] = url_for(f.__name__, **defaults) + + # Prepare and send to mimetype selector if it's not a doc request + if re.match(route_re, request.url) and not kwargs['noargs']: + return app.response_class( + render_template('apidoc.html', **env), + content_type='text/html') + + cargs, ckwargs = kwargs['call_args'] + rv = f(*cargs, **ckwargs) + return make_response_from_mimetype(rv, env) + return ret_fun diff --git a/swh/web/ui/main.py b/swh/web/ui/main.py index beb64b066..00c5bac45 100644 --- a/swh/web/ui/main.py +++ b/swh/web/ui/main.py @@ -1,147 +1,139 @@ # 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 logging import os +import json -from flask.ext.api import FlaskAPI +from flask import Flask from swh.core import config from swh.web.ui.renderers import RENDERERS, urlize_api_links from swh.web.ui.renderers import safe_docstring_display from swh.web.ui.renderers import revision_id_from_url from swh.storage import get_storage DEFAULT_CONFIG = { 'storage_args': ('list[str]', ['http://localhost:5000/']), 'storage_class': ('str', 'remote_storage'), 'log_dir': ('string', '/tmp/swh/log'), 'debug': ('bool', None), 'host': ('string', '127.0.0.1'), 'port': ('int', 6543), 'secret_key': ('string', 'development key'), 'max_log_revs': ('int', 25), } - # api's definition -app = FlaskAPI(__name__) +app = Flask(__name__) app.jinja_env.filters['urlize_api_links'] = urlize_api_links app.jinja_env.filters['safe_docstring_display'] = safe_docstring_display app.jinja_env.filters['revision_id_from_url'] = revision_id_from_url -AUTODOC_ENDPOINT_INSTALLED = False - def read_config(config_file): """Read the configuration file `config_file`, update the app with parameters (secret_key, conf) and return the parsed configuration as a dict""" conf = config.read(config_file, DEFAULT_CONFIG) config.prepare_folders(conf, 'log_dir') conf['storage'] = get_storage(conf['storage_class'], conf['storage_args']) return conf def load_controllers(): """Load the controllers for the application. """ from swh.web.ui import views, apidoc # flake8: noqa - # side-effects here (install autodoc endpoints so do it only once!) - global AUTODOC_ENDPOINT_INSTALLED - if not AUTODOC_ENDPOINT_INSTALLED: - apidoc.install_browsable_api_endpoints() - AUTODOC_ENDPOINT_INSTALLED = True - def rules(): """Returns rules from the application in dictionary form. Beware, must be called after swh.web.ui.main.load_controllers funcall. Returns: Generator of application's rules. """ for rule in app.url_map._rules: yield {'rule': rule.rule, 'methods': rule.methods, 'endpoint': rule.endpoint} def storage(): """Return the current application's storage. """ return app.config['conf']['storage'] def run_from_webserver(environ, start_response): """Run the WSGI app from the webserver, loading the configuration. Note: This function is called on a per-request basis so beware the side effects here! """ load_controllers() config_path = '/etc/softwareheritage/webapp/webapp.ini' conf = read_config(config_path) app.secret_key = conf['secret_key'] app.config['conf'] = conf app.config['DEFAULT_RENDERERS'] = RENDERERS logging.basicConfig(filename=os.path.join(conf['log_dir'], 'web-ui.log'), level=logging.INFO) return app(environ, start_response) def run_debug_from(config_path, verbose=False): """Run the api's server in dev mode. Note: This is called only once (contrast with the production mode in run_from_webserver function) Args: conf is a dictionary of keywords: - 'db_url' the db url's access (through psycopg2 format) - 'content_storage_dir' revisions/directories/contents storage on disk - 'host' to override the default 127.0.0.1 to open or not the server to the world - 'port' to override the default of 5000 (from the underlying layer: flask) - 'debug' activate the verbose logs - 'secret_key' the flask secret key Returns: Never """ load_controllers() conf = read_config(config_path) app.secret_key = conf['secret_key'] app.config['conf'] = conf app.config['DEFAULT_RENDERERS'] = RENDERERS host = conf.get('host', '127.0.0.1') port = conf.get('port') debug = conf.get('debug') log_file = os.path.join(conf['log_dir'], 'web-ui.log') logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, handlers=[logging.FileHandler(log_file), logging.StreamHandler()]) app.run(host=host, port=port, debug=debug) diff --git a/swh/web/ui/templates/api.html b/swh/web/ui/templates/api.html index 629e6a749..fff40c41b 100644 --- a/swh/web/ui/templates/api.html +++ b/swh/web/ui/templates/api.html @@ -1,194 +1,13 @@ - - - - {% block head %} - - {% block meta %} - - - {% endblock %} - - {% block title %}Software Heritage API{% endblock %} - - {% block style %} - {% block bootstrap_theme %} - - - {% endblock %} - - - {% endblock %} - - {% endblock %} - - - - -
- - {% block navbar %} - - {% endblock %} - - -
- - -
- - {% if 'GET' in allowed_methods %} -
-
-
- GET - - - -
- -
-
- {% endif %} - - - - {% if 'DELETE' in allowed_methods %} -
- - - -
- {% endif %} - -
- - {% if view_description %} -
- {{ view_description | safe_docstring_display | safe}} -
- {% endif %} -
-
{{ request.method }} {{ request.full_path }}
-
-
-
HTTP {{ status }}{% autoescape off %} -{% for key, val in headers.items() %}{{ key }}: {{ val|e }} -{% endfor %} -
{% if content %}{{ content|urlize_api_links }}{% endif %}
{% endautoescape %} -
-
- - - {% if 'POST' in allowed_methods or 'PUT' in allowed_methods or 'PATCH' in allowed_methods %} -
-
-
-
-
-
- -
- - -
-
-
- -
- -
-
-
- {% if 'POST' in allowed_methods %} - - {% endif %} - {% if 'PUT' in allowed_methods %} - - {% endif %} - {% if 'PATCH' in allowed_methods %} - - {% endif %} -
-
-
-
-
-
- {% endif %} - -
- - -
- - -
- - - - - - {% block footer %} - {% endblock %} - - {% block script %} - - - - - {% endblock %} - - +{% extends "layout.html" %} +{% block title %}Software Heritage API Overview{% endblock %} +{% block content %} +
+ {% for route, doc in doc_routes %} +
+

{{ route }}

+ {{ doc }} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/swh/web/ui/templates/apidoc.html b/swh/web/ui/templates/apidoc.html new file mode 100644 index 000000000..2c8c5cca0 --- /dev/null +++ b/swh/web/ui/templates/apidoc.html @@ -0,0 +1,84 @@ +{% extends "layout.html" %} +{% block title %}Software Heritage API{% endblock %} +{% block content %} + +{% if docstring %} +
+

Overview

+ {{ docstring | safe }} +
+{% endif %} +{% if response_data and response_data is not none %} +
+

Request

+
{{ request.method }} {{ request.url }}
+

Result

+
 {% autoescape off %} {{ response_data | urlize_api_links }} {% endautoescape %} 
+
+{% endif %} +
+
+ + + + + + + + + {% for url in urls %} + + + + + {% endfor %} + +
URLAllowed Methods
+ {{ url['rule'] }} + + {{ url['methods'] | sort | join(', ') }} +
+
+
+{% if args and args|length > 0 %} +
+

Args

+
+ {% for arg in args %} +
{{ arg['name'] }}: {{ arg['type'] }}
+
{{ arg['doc'] }}
+ {% endfor %} +
+
+{% endif %} +{% if excs and excs|length > 0 %} +
+

Raises

+
+ {% for exc in excs %} +
{{ exc['exc'] }}
+
{{ exc['doc'] }}
+ {% endfor %} +
+
+{% endif %} +{% if return %} +
+

Returns

+
+
{{ return['type'] }}
+
{{ return['doc'] }}
+
+
+{% endif %} +{% if example %} +
+

Example

+
+
+ {{ example }} +
+
+
+{% endif %} +{% endblock %} diff --git a/swh/web/ui/tests/test_apidoc.py b/swh/web/ui/tests/test_apidoc.py new file mode 100644 index 000000000..d7268003e --- /dev/null +++ b/swh/web/ui/tests/test_apidoc.py @@ -0,0 +1,430 @@ +# 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 json +import yaml + +from unittest.mock import MagicMock, patch +from nose.tools import istest + +from flask import Response + +from swh.web.ui import apidoc +from swh.web.ui.tests import test_app + + +class APIDocTestCase(test_app.SWHApidocTestCase): + + def setUp(self): + self.arg_dict = { + 'name': 'my_pretty_arg', + 'default': 'some default value', + 'type': 'str', + 'doc': 'this arg does things' + } + self.stub_excs = [{'exc': 'catastrophic_exception', + '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': 'some_return_type', + '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) + + @istest + def apidoc_arg_noprevious(self): + # given + decorator = apidoc.arg('my_pretty_arg', + default='some default value', + argtype='str', + argdoc='this arg does things') + mock_fun = MagicMock(return_value=123) + decorated = decorator.__call__(mock_fun) + + # 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_arg_previous(self): + # given + decorator = apidoc.arg('my_other_arg', + default='some other value', + argtype='str', + 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': 'str', + 'doc': 'this arg is optional'}]) + + @istest + def apidoc_raises_noprevious(self): + # given + decorator = apidoc.raises(exc='catastrophic_exception', + doc='My exception documentation') + mock_fun = MagicMock(return_value=123) + decorated = decorator.__call__(mock_fun) + + # when + decorated(call_args=((), {}), doc_route='some/route/') + + # then + mock_fun.assert_called_once_with( + call_args=((), {}), + doc_route='some/route/', + excs=self.stub_excs + ) + + @istest + def apidoc_raises_previous(self): + # given + decorator = apidoc.raises(exc='cataclysmic_exception', + doc='Another documentation') + mock_fun = MagicMock(return_value=123) + decorated = decorator.__call__(mock_fun) + expected_excs = self.stub_excs + [{ + 'exc': 'cataclysmic_exception', + 'doc': 'Another documentation'}] + + # when + decorated(call_args=((), {}), + doc_route='some/route/', + excs=self.stub_excs) + + # then + 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_returns_doc_call(self, + mock_request, + mock_api_urls, + mock_url_for, + mock_render): + # given + decorator = apidoc.returns(rettype='some_return_type', + 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_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 + 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(mock_fun.call_args_list, []) # function not called + mock_render.assert_called_once_with( + 'apidoc.html', + **expected_env + ) + + @patch('swh.web.ui.apidoc.make_response_from_mimetype') + @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_make_resp): + + # given + decorator = apidoc.returns(rettype='some_return_type', + 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/' + + # when + decorated( + call_args=((), {}), + doc_route='some/doc/route/', + noargs=True + ) + + # then + mock_fun.assert_called_once_with() + mock_make_resp.assert_called_once_with( + 123, + { + 'urls': [ + {'rule': 'some/doc/route/', + 'methods': {'GET', 'HEAD', 'OPTIONS'}}], + 'docstring': 'Some documentation', + 'route': 'some/doc/route/', + 'return': {'type': 'some_return_type', + 'doc': 'a dict with amazing properties'} + } + ) + + @patch('swh.web.ui.apidoc.make_response_from_mimetype') + @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_resp): + # given + decorator = apidoc.returns(rettype='some_return_type', + 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' + + # 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/', + ) + + # then + mock_fun.assert_called_once_with('some', 'args', kw='kwargs') + mock_resp.assert_called_with( + 123, + { + '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 + } + ) + + @patch('swh.web.ui.apidoc.json') + @patch('swh.web.ui.apidoc.request') + @patch('swh.web.ui.apidoc.render_template') + @istest + def apidoc_make_response_html(self, + mock_render, + mock_request, + mock_json): + # given + data = {'data': [12, 34], + 'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'} + env = {'my_key': 'my_display_value'} + + def mock_mimetypes(key): + mimetypes = { + 'text/html': 10, + 'application/json': 0.1, + 'application/yaml': 0.1 + } + return mimetypes[key] + accept_mimetypes = MagicMock() + accept_mimetypes.__getitem__.side_effect = mock_mimetypes + accept_mimetypes.best_match = MagicMock( + return_value='text/html') + mock_request.accept_mimetypes = accept_mimetypes + + mock_json.dumps.return_value = json.dumps(data) + + expected_env = { + 'my_key': 'my_display_value', + 'response_data': json.dumps(data), + 'request': mock_request + } + + # when + rv = apidoc.make_response_from_mimetype(data, env) + + # then + self.assertEqual(mock_request.accept_mimetypes['text/html'], 10) + mock_render.assert_called_with( + 'apidoc.html', + **expected_env + ) + self.assertEqual(rv.mimetype, 'text/html') + + @patch('swh.web.ui.apidoc.json') + @patch('swh.web.ui.apidoc.request') + @istest + def apidoc_make_response_json(self, + mock_request, + mock_json): + # given + data = {'data': [12, 34], + 'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'} + env = {'my_key': 'my_display_value'} + + def mock_mimetypes(key): + mimetypes = { + 'application/json': 10, + 'text/html': 0.1, + 'application/yaml': 0.1 + } + return mimetypes[key] + accept_mimetypes = MagicMock() + accept_mimetypes.__getitem__.side_effect = mock_mimetypes + accept_mimetypes.best_match = MagicMock( + return_value='application/json') + mock_request.accept_mimetypes = accept_mimetypes + mock_json.dumps.return_value = json.dumps(data) + + # when + rv = apidoc.make_response_from_mimetype(data, env) + + # then + mock_json.dumps.assert_called_once_with(data) + + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, 'application/json') + self.assertEqual(data, json.loads(rv.data.decode('utf-8'))) + + @patch('swh.web.ui.apidoc.yaml') + @patch('swh.web.ui.apidoc.request') + @istest + def apidoc_make_response_yaml(self, + mock_request, + mock_yaml): + # given + data = ['adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'] + env = {'my_key': 'my_display_value'} + + def mock_mimetypes(key): + mimetypes = { + 'application/yaml': 10, + 'application/json': 0.1, + 'text/html': 0.1 + } + return mimetypes[key] + accept_mimetypes = MagicMock() + accept_mimetypes.__getitem__.side_effect = mock_mimetypes + accept_mimetypes.best_match = MagicMock( + return_value='application/yaml') + mock_request.accept_mimetypes = accept_mimetypes + mock_yaml.dump.return_value = yaml.dump(data) + + # when + rv = apidoc.make_response_from_mimetype(data, env) + + # then + mock_yaml.dump.assert_called_once_with(data) + + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, 'application/yaml') + self.assertEqual(data, yaml.load(rv.data.decode('utf-8'))) + + @istest + def apidoc_make_response_not_list_dict(self): + # given + incoming = Response() + + # when + rv = apidoc.make_response_from_mimetype(incoming, {}) + + # then + self.assertEqual(rv, incoming) diff --git a/swh/web/ui/tests/test_app.py b/swh/web/ui/tests/test_app.py index 140f357d6..2c96e82c7 100644 --- a/swh/web/ui/tests/test_app.py +++ b/swh/web/ui/tests/test_app.py @@ -1,86 +1,96 @@ # 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 renderers, 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}) main.app.config['DEFAULT_RENDERERS'] = renderers.RENDERERS 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