diff --git a/swh/web/ui/apidoc.py b/swh/web/ui/apidoc.py --- a/swh/web/ui/apidoc.py +++ b/swh/web/ui/apidoc.py @@ -3,47 +3,236 @@ # 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 flask import request, render_template, url_for +from flask import g -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 - 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']) +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 + + +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'] + g.doc_env = env # Store for response processing + return f(*cargs, **ckwargs) + return ret_fun diff --git a/swh/web/ui/backend.py b/swh/web/ui/backend.py --- a/swh/web/ui/backend.py +++ b/swh/web/ui/backend.py @@ -185,7 +185,7 @@ return [] -def revision_log(sha1_git_bin, limit=100): +def revision_log(sha1_git_bin, limit): """Return information about the revision with sha1 sha1_git_bin. Args: @@ -202,7 +202,7 @@ return main.storage().revision_log([sha1_git_bin], limit) -def revision_log_by(origin_id, branch_name, ts, limit=100): +def revision_log_by(origin_id, branch_name, ts, limit): """Return information about the revision matching the timestamp ts, from origin origin_id, in branch branch_name. @@ -215,12 +215,10 @@ Information for the revision matching the criterions. """ - rev_list = main.storage().revision_log_by(origin_id, - branch_name, - ts) - if rev_list is None: - return None - return rev_list[:limit] + return main.storage().revision_log_by(origin_id, + branch_name, + ts, + limit=limit) def stat_counters(): diff --git a/swh/web/ui/converters.py b/swh/web/ui/converters.py --- a/swh/web/ui/converters.py +++ b/swh/web/ui/converters.py @@ -197,8 +197,8 @@ """Convert swh content to serializable content dictionary. """ - if content and 'ctime' in content: - del content['ctime'] + if content: + content = {k: v for k, v in content.items() if k not in ['ctime']} return from_swh(content, hashess={'sha1', 'sha1_git', 'sha256'}, bytess={}, diff --git a/swh/web/ui/main.py b/swh/web/ui/main.py --- a/swh/web/ui/main.py +++ b/swh/web/ui/main.py @@ -5,13 +5,15 @@ 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.web.ui.renderers import SWHMultiResponse from swh.storage import get_storage @@ -26,15 +28,13 @@ 'max_log_revs': ('int', 25), } - # api's definition -app = FlaskAPI(__name__) +app = Flask(__name__) +app.response_class = SWHMultiResponse 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 @@ -54,12 +54,6 @@ """ 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. diff --git a/swh/web/ui/renderers.py b/swh/web/ui/renderers.py --- a/swh/web/ui/renderers.py +++ b/swh/web/ui/renderers.py @@ -5,10 +5,16 @@ import re import yaml +import json -from flask import make_response, request +from docutils.core import publish_parts +from docutils.writers.html4css1 import Writer, HTMLTranslator + +from flask import make_response, request, Response, render_template +from flask import g from flask.ext.api import renderers, parsers from flask_api.mediatypes import MediaType + from swh.web.ui import utils @@ -80,16 +86,99 @@ return self.enrich_with_jsonp(res) +class SWHMultiResponse(Response, SWHFilterEnricher): + """ + A Flask Response subclass. + 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): + + 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): + 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') + # return formatted yaml + elif wants_yaml(best_match): + rv = Response( + yaml.dump(rv), + content_type='application/yaml') + # 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') + return rv + + @classmethod + def force_type(cls, rv, environ=None): + # Data from apidoc + if isinstance(rv, dict) or isinstance(rv, list): + rv = cls.make_response_from_mimetype(rv) + return super().force_type(rv, environ) + + 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 = [] + + # disable blockquotes to ignore indentation issue with docstrings + def visit_block_quote(self, node): + pass + + def depart_block_quote(self, node): + pass + + 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 safely decorate docstring in browsable api.""" - src = r'(Args|Raises?|Throws?|Yields?|Returns?|Examples?|Samples?):.*' - dest = r'

\1:

  ' - return re.sub(src, dest, docstring) + """ + Utility function to htmlize reST-formatted documentation in browsable + api. + """ + return publish_parts(docstring, writer=DOCSTRING_WRITER)['html_body'] def revision_id_from_url(url): diff --git a/swh/web/ui/service.py b/swh/web/ui/service.py --- a/swh/web/ui/service.py +++ b/swh/web/ui/service.py @@ -271,7 +271,7 @@ return converters.from_revision(res) -def lookup_revision_log(rev_sha1_git, limit=25): +def lookup_revision_log(rev_sha1_git, limit): """Return information about the revision with sha1 revision_sha1_git. Args: @@ -294,7 +294,7 @@ return map(converters.from_revision, revision_entries) -def lookup_revision_log_by(origin_id, branch_name, timestamp, limit=25): +def lookup_revision_log_by(origin_id, branch_name, timestamp, limit): """Return information about the revision with sha1 revision_sha1_git. Args: diff --git a/swh/web/ui/templates/api.html b/swh/web/ui/templates/api.html --- 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 --- /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

+ {% autoescape off %} {{ docstring | safe_docstring_display }} {% endautoescape %} +
+{% 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'] }}
+
{% autoescape off %} {{ arg['doc'] | safe_docstring_display }} {% endautoescape %}
+ {% endfor %} +
+
+{% endif %} +{% if excs and excs|length > 0 %} +
+

Raises

+
+ {% for exc in excs %} +
{{ exc['exc'] }}
+
{% autoescape off %} {{ exc['doc'] | safe_docstring_display }} {% endautoescape %}
+ {% endfor %} +
+
+{% endif %} +{% if return %} +
+

Returns

+
+
{{ return['type'] }}
+
{% autoescape off %} {{ return['doc'] | safe_docstring_display }} {% endautoescape %}
+
+
+{% endif %} +{% if example %} +
+

Example

+
+
+ {{ example }} +
+
+
+{% endif %} +{% endblock %} diff --git a/swh/web/ui/templates/origin.html b/swh/web/ui/templates/origin.html --- a/swh/web/ui/templates/origin.html +++ b/swh/web/ui/templates/origin.html @@ -20,7 +20,7 @@ - {% for key in ['type', 'lister', 'projet', 'url'] %} + {% for key in ['type', 'lister', 'project', 'url'] %} {% if origin[key] is not none %}
{{ key }}
diff --git a/swh/web/ui/tests/test_apidoc.py b/swh/web/ui/tests/test_apidoc.py new file mode 100644 --- /dev/null +++ b/swh/web/ui/tests/test_apidoc.py @@ -0,0 +1,296 @@ +# 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 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.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='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/' + doc_dict = { + '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'} + } + + # 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_return_endpoint_call(self, + mock_request, + mock_api_urls, + mock_url_for, + mock_g): + # 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' + + 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 + 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') + 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 --- a/swh/web/ui/tests/test_app.py +++ b/swh/web/ui/tests/test_app.py @@ -60,6 +60,16 @@ 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. diff --git a/swh/web/ui/tests/test_backend.py b/swh/web/ui/tests/test_backend.py --- a/swh/web/ui/tests/test_backend.py +++ b/swh/web/ui/tests/test_backend.py @@ -532,12 +532,12 @@ self.storage.revision_log = MagicMock(return_value=stub_revision_log) # when - actual_revision = backend.revision_log(sha1_bin) + actual_revision = backend.revision_log(sha1_bin, limit=1) # then self.assertEqual(list(actual_revision), stub_revision_log) - self.storage.revision_log.assert_called_with([sha1_bin], 100) + self.storage.revision_log.assert_called_with([sha1_bin], 1) @istest def revision_log_by(self): @@ -571,11 +571,12 @@ return_value=stub_revision_log) # when - actual_log = backend.revision_log_by(1, 'refs/heads/master', None) + actual_log = backend.revision_log_by(1, 'refs/heads/master', + None, limit=1) # then self.assertEqual(actual_log, stub_revision_log) - self.storage.revision_log.assert_called_with([sha1_bin], 100) + self.storage.revision_log.assert_called_with([sha1_bin], 1) @istest def revision_log_by_norev(self): @@ -586,11 +587,12 @@ self.storage.revision_log_by = MagicMock(return_value=None) # when - actual_log = backend.revision_log_by(1, 'refs/heads/master', None) + actual_log = backend.revision_log_by(1, 'refs/heads/master', + None, limit=1) # then self.assertEqual(actual_log, None) - self.storage.revision_log.assert_called_with([sha1_bin], 100) + self.storage.revision_log.assert_called_with([sha1_bin], 1) @istest def stat_counters(self): diff --git a/swh/web/ui/tests/test_renderers.py b/swh/web/ui/tests/test_renderers.py --- a/swh/web/ui/tests/test_renderers.py +++ b/swh/web/ui/tests/test_renderers.py @@ -7,15 +7,140 @@ import unittest import yaml +from flask import Response from flask_api.mediatypes import MediaType from nose.tools import istest -from unittest.mock import patch +from unittest.mock import patch, MagicMock from swh.web.ui import renderers class RendererTestCase(unittest.TestCase): + @patch('swh.web.ui.renderers.g') + @patch('swh.web.ui.renderers.json') + @patch('swh.web.ui.renderers.request') + @patch('swh.web.ui.renderers.render_template') + @patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields') + @istest + def swh_multi_response_mimetype_html(self, mock_filter, mock_render, + mock_request, mock_json, mock_g): + # given + data = {'data': [12, 34], + 'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'} + mock_g.get.return_value = {'my_key': 'my_display_value'} + mock_filter.return_value = data + expected_env = { + 'my_key': 'my_display_value', + 'response_data': json.dumps(data), + 'request': mock_request + } + + 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) + + # when + rv = renderers.SWHMultiResponse.make_response_from_mimetype(data) + + # then + mock_filter.assert_called_once_with(renderers.SWHMultiResponse, data) + mock_render.assert_called_with('apidoc.html', **expected_env) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.mimetype, 'text/html') + + @patch('swh.web.ui.renderers.g') + @patch('swh.web.ui.renderers.yaml') + @patch('swh.web.ui.renderers.request') + @patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields') + @istest + def swh_multi_response_mimetype_yaml(self, mock_filter, + mock_request, mock_yaml, mock_g): + # given + data = {'data': [12, 34], + 'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'} + + 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) + mock_filter.return_value = data + + # when + rv = renderers.SWHMultiResponse.make_response_from_mimetype(data) + + # then + mock_filter.assert_called_once_with(renderers.SWHMultiResponse, data) + 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'))) + + @patch('swh.web.ui.renderers.g') + @patch('swh.web.ui.renderers.json') + @patch('swh.web.ui.renderers.request') + @patch('swh.web.ui.renderers.SWHMultiResponse.filter_by_fields') + @istest + def swh_multi_response_mimetype_json(self, mock_filter, + mock_request, mock_json, mock_g): + # given + data = {'data': [12, 34], + 'id': 'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc'} + + 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) + mock_filter.return_value = data + + # when + rv = renderers.SWHMultiResponse.make_response_from_mimetype(data) + + # then + mock_filter.assert_called_once_with(renderers.SWHMultiResponse, data) + 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'))) + + @istest + def apidoc_make_response_not_list_dict(self): + # given + incoming = Response() + + # when + rv = renderers.SWHMultiResponse.make_response_from_mimetype(incoming) + + # then + self.assertEqual(rv, incoming) + @patch('swh.web.ui.renderers.request') @istest def swh_filter_renderer_do_nothing(self, mock_request): @@ -200,32 +325,32 @@ other_content) @istest + def revision_id_from_url(self): + url = ('/browse/revision/9ba4bcb645898d562498ea66a0df958ef0e7a68c/' + 'prev/9ba4bcb645898d562498ea66a0df958ef0e7aaaa/') + + expected_id = '9ba4bcb645898d562498ea66a0df958ef0e7a68c' + self.assertEqual(renderers.revision_id_from_url(url), expected_id) + + @istest def safe_docstring_display(self): # update api link with html links content with links - docstring = """

Show all revisions (~git log) starting from -sha1_git. - The first element returned is the given sha1_git.

-

Args: - sha1_git: the revision's hash

-

Returns: - Information on the revision if found.

-

Raises: - BadInputExc in case of unknown algo_hash or bad hash - NotFoundExc if the revision is not found.

-

Example: - blah

""" - expected_docstring = """

Show all revisions (~git log) starting from -sha1_git. - The first element returned is the given sha1_git.

-

Args:

   - sha1_git: the revision's hash

-

Returns:

   - Information on the revision if found.

-

Raises:

   - BadInputExc in case of unknown algo_hash or bad hash - NotFoundExc if the revision is not found.

-

Example:

   - blah

""" + docstring = """This is my list header: + + - Here is item 1, with a continuation + line right here + - Here is item 2 + + Here is something that is not part of the list""" + + expected_docstring = """

This is my list header:

+ +

Here is something that is not part of the list

+""" self.assertEquals(renderers.safe_docstring_display(docstring), expected_docstring) diff --git a/swh/web/ui/tests/test_service.py b/swh/web/ui/tests/test_service.py --- a/swh/web/ui/tests/test_service.py +++ b/swh/web/ui/tests/test_service.py @@ -1186,7 +1186,8 @@ # when actual_revision = service.lookup_revision_log( - 'abcdbe353ed3480476f032475e7c233eff7371d5') + 'abcdbe353ed3480476f032475e7c233eff7371d5', + limit=25) # then self.assertEqual(list(actual_revision), [self.SAMPLE_REVISION]) diff --git a/swh/web/ui/tests/views/test_browse.py b/swh/web/ui/tests/views/test_browse.py --- a/swh/web/ui/tests/views/test_browse.py +++ b/swh/web/ui/tests/views/test_browse.py @@ -20,6 +20,26 @@ class SearchView(test_app.SWHViewTestCase): render_template = False + @patch('swh.web.ui.apidoc.APIUrls') + @istest + def browse_api_doc(self, mock_api_urls): + # given + endpoints = { + '/a/doc/endpoint/': 'relevant documentation', + '/some/other/endpoint/': 'more docstrings'} + mock_api_urls.apidoc_routes = endpoints + + # when + rv = self.client.get('/api/1/doc/') + + # then + self.assertEquals(rv.status_code, 200) + self.assertIsNotNone( + self.get_context_variable('doc_routes'), + sorted(endpoints.items()) + ) + self.assert_template_used('api.html') + @istest def search_default(self): # when diff --git a/swh/web/ui/views/api.py b/swh/web/ui/views/api.py --- a/swh/web/ui/views/api.py +++ b/swh/web/ui/views/api.py @@ -5,32 +5,36 @@ from types import GeneratorType -from flask import request, url_for, Response, redirect +from flask import request, url_for -from swh.web.ui import service, utils +from swh.web.ui import service, utils, apidoc as doc from swh.web.ui.exc import NotFoundExc from swh.web.ui.main import app @app.route('/api/1/stat/counters/') +@doc.route('/api/1/stat/counters/', noargs=True) +@doc.returns(rettype=doc.rettypes.dict, + retdoc="A dictionary of SWH's most important statistics") def api_stats(): """Return statistics on SWH storage. - Returns: - SWH storage's statistics. - """ return service.stat_counters() @app.route('/api/1/stat/visits//') +@doc.route('/api/1/stat/visits/') +@doc.arg('origin_id', + default=1, + argtype=doc.argtypes.int, + argdoc='The requested SWH origin identifier') +@doc.returns(rettype=doc.rettypes.list, + retdoc="""All instances of visits of the origin pointed by + origin_id as POSIX time since epoch""") def api_origin_visits(origin_id): - """Return visit dates for the given revision. - - Returns: - A list of SWH visit occurrence timestamps, sorted from oldest to - newest. - + """Return a list of visit dates as POSIX timestamps for the + given revision. """ date_gen = (item['date'] for item in service.stat_origin_visits(origin_id)) return sorted(date_gen) @@ -38,22 +42,31 @@ @app.route('/api/1/search/', methods=['POST']) @app.route('/api/1/search//') +@doc.route('/api/1/search/') +@doc.arg('q', + default='sha1:adc83b19e793491b1c6ea0fd8b46cd9f32e592fc', + argtype=doc.argtypes.algo_and_hash, + argdoc="""An algo_hash:hash string, where algo_hash is one of sha1, + sha1_git or sha256 and hash is the hash to search for in SWH""") +@doc.raises(exc=doc.excs.badinput, + doc='Raised if q is not well formed') +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""A dict with keys: + + - search_res: a list of dicts corresponding to queried content + with key 'found' to True if found, 'False' if not + - search_stats: a dict containing number of files searched and + percentage of files found + """) def api_search(q=None): """Search a content per hash. - Args: - q is of the form algo_hash:hash with algo_hash in - (sha1, sha1_git, sha256). - - Returns: - Dictionary with 'found' key and the associated result. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - - Example: - GET /api/1/search/sha1:bd819b5b28fcde3bf114d16a44ac46250da94ee5/ + This may take the form of: + - a GET request with a single checksum + - a POST request with many hashes, with the request body containing + identifiers (typically filenames) as keys and corresponding hashes as + values. """ response = {'search_res': None, @@ -139,71 +152,56 @@ return enrich_fn(res) -@app.route('/api/1/origin/') @app.route('/api/1/origin//') +@doc.route('/api/1/origin/') +@doc.arg('origin_id', + default=1, + argtype=doc.argtypes.int, + argdoc="The origin's SWH origin_id.") +@doc.raises(exc=doc.excs.notfound, + doc='Raised if origin_id does not correspond to an origin in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc='The metadata of the origin identified by origin_id') def api_origin(origin_id): """Return information about origin with id origin_id. - - - Args: - origin_id: the origin's identifier. - - Returns: - Information on the origin if found. - - Raises: - NotFoundExc if the origin is not found. - - Example: - GET /api/1/origin/1/ - """ return _api_lookup( origin_id, lookup_fn=service.lookup_origin, error_msg_if_not_found='Origin with id %s not found.' % origin_id) -@app.route('/api/1/person/') @app.route('/api/1/person//') +@doc.route('/api/1/person/') +@doc.arg('person_id', + default=1, + argtype=doc.argtypes.int, + argdoc="The person's SWH identifier") +@doc.raises(exc=doc.excs.notfound, + doc='Raised if person_id does not correspond to an origin in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc='The metadata of the person identified by person_id') def api_person(person_id): """Return information about person with identifier person_id. - - Args: - person_id: the person's identifier. - - Returns: - Information on the person if found. - - Raises: - NotFoundExc if the person is not found. - - Example: - GET /api/1/person/1/ - """ return _api_lookup( person_id, lookup_fn=service.lookup_person, error_msg_if_not_found='Person with id %s not found.' % person_id) -@app.route('/api/1/release/') @app.route('/api/1/release//') +@doc.route('/api/1/release/') +@doc.arg('sha1_git', + default='8b137891791fe96927ad78e64b0aad7bded08bdc', + argtype=doc.argtypes.sha1_git, + argdoc="The release's sha1_git identifier") +@doc.raises(exc=doc.excs.badinput, + doc='Raised if the argument is not a sha1') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if sha1_git does not correspond to a release in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc='The metadata of the release identified by sha1_git') def api_release(sha1_git): """Return information about release with id sha1_git. - - Args: - sha1_git: the release's hash. - - Returns: - Information on the release if found. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the release is not found. - - Example: - GET /api/1/release/b307094f00c3641b0c9da808d894f3a325371414 - """ error_msg = 'Release with sha1_git %s not found.' % sha1_git return _api_lookup( @@ -267,6 +265,31 @@ '/branch/' '/ts/' '/directory//') +@doc.route('/api/1/revision/origin/directory/') +@doc.arg('origin_id', + default=1, + argtype=doc.argtypes.int, + argdoc="The revision's origin's SWH identifier") +@doc.arg('branch_name', + default='refs/heads/master', + argtype=doc.argtypes.path, + argdoc="""The optional branch for the given origin (default + to master""") +@doc.arg('ts', + default='2000-01-17T11:23:54+00:00', + argtype=doc.argtypes.ts, + argdoc="""Optional timestamp (default to the nearest time + crawl of timestamp)""") +@doc.arg('path', + default='.', + argtype=doc.argtypes.path, + argdoc='The path to the directory or file to display') +@doc.raises(exc=doc.excs.notfound, + doc="""Raised if a revision matching the passed criteria was + not found""") +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata of the revision corresponding to the + passed criteria""") def api_directory_through_revision_origin(origin_id, branch_name="refs/heads/master", ts=None, @@ -274,24 +297,6 @@ with_data=False): """Display directory or content information through a revision identified by origin/branch/timestamp. - - Args: - origin_id: origin's identifier (default to 1). - branch_name: the optional branch for the given origin (default - to master). - timestamp: optional timestamp (default to the nearest time - crawl of timestamp). - path: Path to directory or file to display. - with_data: indicate to retrieve the content's raw data if path resolves - to a content. - - Returns: - Information on the directory or content pointed to by such revision. - - Raises: - NotFoundExc if the revision is not found or the path pointed to - is not found. - """ if ts: ts = utils.parse_timestamp(ts) @@ -319,28 +324,31 @@ @app.route('/api/1/revision' '/origin/' '/ts//') +@doc.route('/api/1/revision/origin/') +@doc.arg('origin_id', + default=1, + argtype=doc.argtypes.int, + argdoc="The queried revision's origin identifier in SWH") +@doc.arg('branch_name', + default='refs/heads/master', + argtype=doc.argtypes.path, + argdoc="""The optional branch for the given origin (default + to master)""") +@doc.arg('ts', + default='2000-01-17T11:23:54+00:00', + argtype=doc.argtypes.ts, + argdoc="The time at which the queried revision should be constrained") +@doc.raises(exc=doc.excs.notfound, + doc="""Raised if a revision matching given criteria was not found + in SWH""") +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata of the revision identified by the given + criteria""") def api_revision_with_origin(origin_id, branch_name="refs/heads/master", ts=None): - """Instead of having to specify a (root) revision by SHA1_GIT, users - might want to specify a place and a time. In SWH a "place" is an - origin; a "time" is a timestamp at which some place has been - observed by SWH crawlers. - - Args: - origin_id: origin's identifier (default to 1). - branch_name: the optional branch for the given origin (default - to master). - timestamp: optional timestamp (default to the nearest time - crawl of timestamp). - - Returns: - Information on the revision if found. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the revision is not found. - + """Display revision information through its identification by + origin/branch/timestamp. """ if ts: ts = utils.parse_timestamp(ts) @@ -357,24 +365,25 @@ ts) -@app.route('/api/1/revision/') @app.route('/api/1/revision//') @app.route('/api/1/revision//prev//') +@doc.route('/api/1/revision/') +@doc.arg('sha1_git', + default='ec72c666fb345ea5f21359b7bc063710ce558e39', + argtype=doc.argtypes.sha1_git, + argdoc="The revision's sha1_git identifier") +@doc.arg('context', + default='6adc4a22f20bbf3bbc754f1ec8c82be5dfb5c71a', + argtype=doc.argtypes.path, + argdoc='The navigation breadcrumbs -- use at your own risk') +@doc.raises(exc=doc.excs.badinput, + doc='Raised if sha1_git is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a revision matching sha1_git was not found in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc='The metadata of the revision identified by sha1_git') def api_revision(sha1_git, context=None): """Return information about revision with id sha1_git. - - Args: - sha1_git: the revision's hash. - - Returns: - Information on the revision if found. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the revision is not found. - - Example: - GET /api/1/revision/baf18f9fc50a0b6fef50460a76c33b2ddc57486e """ def _enrich_revision(revision, context=context): return utils.enrich_revision(revision, context) @@ -387,58 +396,53 @@ @app.route('/api/1/revision//raw/') +@doc.route('/api/1/revision/raw/') +@doc.arg('sha1_git', + default='ec72c666fb345ea5f21359b7bc063710ce558e39', + argtype=doc.argtypes.sha1_git, + argdoc="The queried revision's sha1_git identifier") +@doc.raises(exc=doc.excs.badinput, + doc='Raised if sha1_git is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a revision matching sha1_git was not found in SWH') +@doc.returns(rettype=doc.argtypes.octet_stream, + retdoc="""The message of the revision identified by sha1_git + as a downloadable octet stream""") def api_revision_raw_message(sha1_git): - """Return the raw data of the revision's message - - Args: - sha1_git: the revision's hash - - Returns: - The raw revision message, possibly in an illegible - format for humans, decoded in utf-8 by default. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the revision is not found or the revision has no - message - - Example: - GET /api/1/revision/baf18f9fc50a0b6fef50460a76c33b2ddc57486e/raw/ - + """Return the raw data of the message of revision identified by sha1_git """ raw = service.lookup_revision_message(sha1_git) - return Response(raw['message'], - headers={'Content-disposition': 'attachment;' - 'filename=rev_%s_raw' % sha1_git}, - mimetype='application/octet-stream') + return app.response_class(raw['message'], + headers={'Content-disposition': 'attachment;' + 'filename=rev_%s_raw' % sha1_git}, + mimetype='application/octet-stream') @app.route('/api/1/revision//directory/') @app.route('/api/1/revision//directory//') +@doc.route('/api/1/revision/directory/') +@doc.arg('sha1_git', + default='ec72c666fb345ea5f21359b7bc063710ce558e39', + argtype=doc.argtypes.sha1_git, + argdoc="The revision's sha1_git identifier.") +@doc.arg('dir_path', + default='.', + argtype=doc.argtypes.path, + argdoc='The path from the top level directory') +@doc.raises(exc=doc.excs.badinput, + doc='Raised if sha1_git is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc="""Raised if a revision matching sha1_git was not found in SWH + , or if the path specified does not exist""") +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata of the directory pointed by revision id + sha1-git and dir_path""") def api_revision_directory(sha1_git, dir_path=None, with_data=False): """Return information on directory pointed by revision with sha1_git. If dir_path is not provided, display top level directory. Otherwise, display the directory pointed by dir_path (if it exists). - - Args: - sha1_git: revision's hash. - dir_path: optional directory pointed to by that revision. - with_data: indicate to retrieve the content's raw data if path resolves - to a content - - Returns: - Information on the directory pointed to by that revision. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc either if the revision is not found or the path referenced - does not exist - - Example: - GET /api/1/revision/baf18f9fc50a0b6fef50460a76c33b2ddc57486e/directory/ - """ return _revision_directory_by( { @@ -451,23 +455,27 @@ @app.route('/api/1/revision//log/') @app.route('/api/1/revision//prev//log/') +@doc.route('/api/1/revision/log/') +@doc.arg('sha1_git', + default='ec72c666fb345ea5f21359b7bc063710ce558e39', + argtype=doc.argtypes.sha1_git, + argdoc='The sha1_git of the revision queried') +@doc.arg('prev_sha1s', + default='6adc4a22f20bbf3bbc754f1ec8c82be5dfb5c71a', + argtype=doc.argtypes.path, + argdoc='The navigation breadcrumbs -- use at your own risk!') +@doc.raises(exc=doc.excs.badinput, + doc='Raised if sha1_git or prev_sha1s is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a revision matching sha1_git was not found in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The log data starting at the revision identified by + sha1_git, completed with the navigation breadcrumbs, + if any""") def api_revision_log(sha1_git, prev_sha1s=None): """Show all revisions (~git log) starting from sha1_git. - The first element returned is the given sha1_git. - - Args: - sha1_git: the revision's hash. - prev_sha1s: the navigation breadcrumb - limit: optional query parameter to limit the revisions log - (default to 100). - - Returns: - Information on the revision if found, complemented with the revision's - children if we have navigation breadcrumbs for them. - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the revision is not found. + The first element returned is the given sha1_git, or the first + breadcrumb, if any. """ limit = app.config['conf']['max_log_revs'] @@ -510,8 +518,6 @@ @app.route('/api/1/revision' - '/origin/log/') -@app.route('/api/1/revision' '/origin//log/') @app.route('/api/1/revision' '/origin/' @@ -523,26 +529,32 @@ @app.route('/api/1/revision' '/origin/' '/ts//log/') +@doc.route('/api/1/revision/origin/log/') +@doc.arg('origin_id', + default=1, + argtype=doc.argtypes.int, + argdoc="The revision's SWH origin identifier") +@doc.arg('branch_name', + default='refs/heads/master', + argtype=doc.argtypes.path, + argdoc="The revision's branch name within the origin specified") +@doc.arg('ts', + default='2000-01-17T11:23:54+00:00', + argtype=doc.argtypes.ts, + argdoc="""A time or timestamp string to parse""") +@doc.raises(exc=doc.excs.notfound, + doc="""Raised if a revision matching the given criteria was not + found in SWH""") +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata of the revision log starting at the revision + matching the given criteria.""") def api_revision_log_by(origin_id, branch_name='refs/heads/master', ts=None): """Show all revisions (~git log) starting from the revision - described by its origin_id, optional branch name and timestamp. - The first element returned is the described revision. - - Args: - origin_id: the revision's origin. - branch_name: the branch of the revision (optional, defaults to - master - ts: the requested timeframe near which the revision was created. - limit: optional query parameter to limit the revisions log - (default to 100). - - Returns: - Information on the revision log if found. + described by its origin_id, optional branch name and timestamp. + The first element returned is the described revision. - Raises: - NotFoundExc if the revision is not found. """ limit = app.config['conf']['max_log_revs'] response = {'revisions': None, 'next_revs_url': None} @@ -576,26 +588,28 @@ return response -@app.route('/api/1/directory/') @app.route('/api/1/directory//') @app.route('/api/1/directory///') +@doc.route('/api/1/directory/') +@doc.arg('sha1_git', + default='adc83b19e793491b1c6ea0fd8b46cd9f32e592fc', + argtype=doc.argtypes.sha1_git, + argdoc="The queried directory's corresponding sha1_git hash") +@doc.arg('path', + default='.', + argtype=doc.argtypes.path, + argdoc="A path relative to the queried directory's top level") +@doc.raises(exc=doc.excs.badinput, + doc='Raised if sha1_git is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a directory matching sha1_git was not found in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata and contents of the release identified by + sha1_git""") def api_directory(sha1_git, path=None): """Return information about release with id sha1_git. - Args: - sha1_git: Directory's sha1_git. If path exists: starting directory for - relative navigation. - path: The path to the queried directory - - Raises: - BadInputExc in case of unknown algo_hash or bad hash. - NotFoundExc if the content is not found. - - Example: - GET /api/1/directory/8d7dc91d18546a91564606c3e3695a5ab568d179 - GET /api/1/directory/8d7dc91d18546a91564606c3e3695a5ab568d179/path/dir/ - """ if path: error_msg_path = ('Entry with path %s relative to directory ' @@ -644,21 +658,23 @@ @app.route('/api/1/content//raw/') +@doc.route('/api/1/content/raw/') +@doc.arg('q', + default='adc83b19e793491b1c6ea0fd8b46cd9f32e592fc', + argtype=doc.argtypes.algo_and_hash, + argdoc="""An algo_hash:hash string, where algo_hash is one of sha1, + sha1_git or sha256 and hash is the hash to search for in SWH. Defaults + to sha1 in the case of a missing algo_hash + """) +@doc.raises(exc=doc.excs.badinput, + doc='Raised if q is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a content matching q was not found in SWH') +@doc.returns(rettype='octet stream', + retdoc='The raw content data as an octet stream') def api_content_raw(q): """Return content's raw data if content is found. - Args: - q is of the form (algo_hash:)hash with algo_hash in - (sha1, sha1_git, sha256). - When algo_hash is not provided, 'hash' is considered sha1. - - Returns: - Content's raw data in application/octet-stream. - - Raises: - - BadInputExc in case of unknown algo_hash or bad hash - - NotFoundExc if the content is not found. - """ def generate(content): yield content['data'] @@ -667,30 +683,31 @@ if not content: raise NotFoundExc('Content with %s not found.' % q) - return Response(generate(content), mimetype='application/octet-stream') + return app.response_class(generate(content), + headers={'Content-disposition': 'attachment;' + 'filename=content_%s_raw' % q}, + mimetype='application/octet-stream') -@app.route('/api/1/content/') @app.route('/api/1/content//') +@doc.route('/api/1/content/') +@doc.arg('q', + default='adc83b19e793491b1c6ea0fd8b46cd9f32e592fc', + argtype=doc.argtypes.algo_and_hash, + argdoc="""An algo_hash:hash string, where algo_hash is one of sha1, + sha1_git or sha256 and hash is the hash to search for in SWH. Defaults + to sha1 in the case of a missing algo_hash + """) +@doc.raises(exc=doc.excs.badinput, + doc='Raised if q is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if a content matching q was not found in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc="""The metadata of the content identified by q. If content + decoding was successful, it also returns the data""") def api_content_metadata(q): """Return content information if content is found. - Args: - q is of the form (algo_hash:)hash with algo_hash in - (sha1, sha1_git, sha256). - When algo_hash is not provided, 'hash' is considered sha1. - - Returns: - Content's information. - - Raises: - - BadInputExc in case of unknown algo_hash or bad hash. - - NotFoundExc if the content is not found. - - Example: - GET /api/1/content/sha256:e2c76e40866bb6b28916387bdfc8649beceb - 523015738ec6d4d540c7fe65232b - """ return _api_lookup( q, @@ -699,27 +716,21 @@ enrich_fn=utils.enrich_content) -@app.route('/api/1/entity/') @app.route('/api/1/entity//') +@doc.route('/api/1/entity/') +@doc.arg('uuid', + default='5f4d4c51-498a-4e28-88b3-b3e4e8396cba', + argtype=doc.argtypes.uuid, + argdoc="The entity's uuid identifier") +@doc.raises(exc=doc.excs.badinput, + doc='Raised if uuid is not well formed') +@doc.raises(exc=doc.excs.notfound, + doc='Raised if an entity matching uuid was not found in SWH') +@doc.returns(rettype=doc.rettypes.dict, + retdoc='The metadata of the entity identified by uuid') def api_entity_by_uuid(uuid): """Return content information if content is found. - Args: - q is of the form (algo_hash:)hash with algo_hash in - (sha1, sha1_git, sha256). - When algo_hash is not provided, 'hash' is considered sha1. - - Returns: - Content's information. - - Raises: - - BadInputExc in case of unknown algo_hash or bad hash. - - NotFoundExc if the content is not found. - - Example: - - GET /api/1/entity/5f4d4c51-498a-4e28-88b3-b3e4e8396cba/ - - GET /api/1/entity/7c33636b-8f11-4bda-89d9-ba8b76a42cec/ - """ return _api_lookup( uuid, diff --git a/swh/web/ui/views/browse.py b/swh/web/ui/views/browse.py --- a/swh/web/ui/views/browse.py +++ b/swh/web/ui/views/browse.py @@ -10,7 +10,7 @@ from flask.ext.api.renderers import HTMLRenderer from swh.core.hashutil import ALGORITHMS -from .. import service, utils +from .. import service, utils, apidoc from ..exc import BadInputExc, NotFoundExc from ..main import app from . import api @@ -18,6 +18,19 @@ hash_filter_keys = ALGORITHMS +@app.route('/api/1/doc/') +@set_renderers(HTMLRenderer) +def api_doc(): + """Render the API's documentation. + """ + routes = apidoc.APIUrls.get_app_endpoints() + # Return a list of routes with consistent ordering + env = { + 'doc_routes': sorted(routes.items()) + } + return render_template('api.html', **env) + + @app.route('/search/', methods=['GET', 'POST']) @set_renderers(HTMLRenderer) def search():