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 %}
-
- {% 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 %}
-
- {% 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 %}
+
+
+ {% 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 %}
+
+
+
+
+
+ URL |
+ Allowed Methods |
+
+
+
+ {% for url in urls %}
+
+
+ {{ url['rule'] }}
+ |
+
+ {{ url['methods'] | sort | join(', ') }}
+ |
+
+ {% endfor %}
+
+
+
+
+{% 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 %}
+
+{% 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