Page MenuHomeSoftware Heritage

D98.id342.diff
No OneTemporary

D98.id342.diff

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,8 +5,10 @@
import re
import yaml
+import json
-from flask import make_response, request
+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,6 +82,61 @@
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'"<a href="\1">\1</a>"', content)
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 @@
-<!DOCTYPE html>
-<html>
- <head>
- {% block head %}
-
- {% block meta %}
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
- <meta name="robots" content="NONE,NOARCHIVE" />
- {% endblock %}
-
- <title>{% block title %}Software Heritage API{% endblock %}</title>
-
- {% block style %}
- {% block bootstrap_theme %}
- <link rel="stylesheet" type="text/css" href="{{url_for('flask-api.static', filename='css/bootstrap.min.css')}}"/>
- <link rel="stylesheet" type="text/css" href="{{url_for('flask-api.static', filename='css/bootstrap-tweaks.css')}}"/>
- {% endblock %}
- <link rel="stylesheet" type="text/css" href="{{url_for('flask-api.static', filename='css/prettify.css')}}"/>
- <link rel="stylesheet" type="text/css" href="{{url_for('flask-api.static', filename='css/default.css')}}"/>
- {% endblock %}
-
- {% endblock %}
- </head>
-
- <body class="{% block bodyclass %}{% endblock %} container">
-
- <div class="wrapper">
-
- {% block navbar %}
- <div class="navbar {% block bootstrap_navbar_variant %}navbar-inverse{% endblock %}">
- <div class="navbar-inner">
- <div class="container-fluid">
- <span href="/">
- {% block branding %}<a class="navbar-brand" rel="nofollow" href='{{url_for('homepage')}}'>Software Heritage API v1</span></a>{% endblock %}
- </span>
- <ul class="nav pull-right">
- {% block userlinks %}
- <!--{ if user.is_authenticated }
- <li class="dropdown">
- <a href="#" class="dropdown-toggle" data-toggle="dropdown">
- {{ user }}
- <b class="caret"></b>
- </a>
- <ul class="dropdown-menu">
- <li>optional_logout request</li>
- </ul>
- </li>
- { else }-->
- <li><!-- optional_login request --></li>
- <!--{ endif }-->
- {% endblock %}
- </ul>
- </div>
- </div>
- </div>
- {% endblock %}
-
- <!--
- {% block breadcrumbs %}
- <ul class="breadcrumb">
- {% for breadcrumb_name, breadcrumb_url in breadcrumblist %}
- <li>
- <a href="{{ breadcrumb_url }}" {% if forloop.last %}class="active"{% endif %}>{{ breadcrumb_name }}</a> {% if not forloop.last %}<span class="divider">&rsaquo;</span>{% endif %}
- </li>
- {% endfor %}
- </ul>
- {% endblock %}
- -->
- <div style="height: 50px"></div>
-
- <!-- Content -->
- <div id="content">
-
- {% if 'GET' in allowed_methods %}
- <form id="get-form" class="pull-right">
- <fieldset>
- <div class="btn-group format-selection">
- <a class="btn btn-primary js-tooltip" href='{{ request.get_full_path }}' rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a>
-
- <button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown" title="Specify a format for the GET request">
- <span class="caret"></span>
- </button>
- <ul class="dropdown-menu">
- {% for format in available_formats %}
- <li>
- <a class="js-tooltip format-option" href='<!-- add_query_param request api_settings.URL_FORMAT_OVERRIDE format -->' rel="nofollow" title="Make a GET request on the {{ name }} resource with the format set to `{{ format }}`">{{ format }}</a>
- </li>
- {% endfor %}
- </ul>
- </div>
-
- </fieldset>
- </form>
- {% endif %}
-
- <!--{% if 'OPTIONS' in allowed_methods %}
- <form class="button-form" action="{{ request.full_path }}" method="POST" class="pull-right">
- <- csrf_token ->
- <input type="hidden" name="_method" value="OPTIONS" />
- <button class="btn btn-primary js-tooltip" title="Make an OPTIONS request on the resource">OPTIONS</button>
- </form>
- {% endif %}-->
-
- {% if 'DELETE' in allowed_methods %}
- <form class="button-form" action="{{ request.full_path }}" method="POST" class="pull-right">
- <!-- csrf_token -->
- <input type="hidden" name="_method" value="DELETE" />
- <button class="btn btn-danger js-tooltip" title="Make a DELETE request on the resource">DELETE</button>
- </form>
- {% endif %}
-
- <div class="content-main">
- <div class="page-header"><h1>{{ view_name }}</h1></div>
- {% if view_description %}
- <div style="margin-top: -10px; margin-bottom: 10px">
- {{ view_description | safe_docstring_display | safe}}
- </div>
- {% endif %}
- <div class="request-info" style="clear: both" >
- <pre><b>{{ request.method }}</b> {{ request.full_path }}</pre>
- </div>
- <div class="response-info">
- <pre><div class="meta nocode"><b>HTTP {{ status }}</b>{% autoescape off %}
-{% for key, val in headers.items() %}<b>{{ key }}:</b> <span class="lit">{{ val|e }}<!--{ val|break_long_headers|urlize_quoted_links }--></span>
-{% endfor %}
-</div>{% if content %}{{ content|urlize_api_links }}{% endif %}<!-- |urlize_quoted_links --></pre>{% endautoescape %}
- </div>
- </div>
-
-
- {% if 'POST' in allowed_methods or 'PUT' in allowed_methods or 'PATCH' in allowed_methods %}
- <div>
- <div class="well">
- <div id="generic-content-form">
- <form action="{{ request.full_path }}" method="POST" class="form-horizontal">
- <fieldset>
-<div class="control-group">
- <label for="id__content_type" class="control-label">Media type:</label>
- <div class="controls">
- <select id="id__content_type" name="_content_type">
-<option value="application/json" selected="selected">application/json</option>
-<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>
-<option value="multipart/form-data">multipart/form-data</option>
-</select>
- <span class="help-block"></span>
- </div>
- </div>
- <div class="control-group">
- <label for="id__content" class="control-label">Content:</label>
- <div class="controls">
- <textarea name="_content" cols="40" rows="10"></textarea>
- </div>
- </div>
- <div class="form-actions">
- {% if 'POST' in allowed_methods %}
- <button class="btn btn-primary" title="Make a POST request on the resource">POST</button>
- {% endif %}
- {% if 'PUT' in allowed_methods %}
- <button class="btn btn-primary js-tooltip" name="_method" value="PUT" title="Make a PUT request on the resource">PUT</button>
- {% endif %}
- {% if 'PATCH' in allowed_methods %}
- <button class="btn btn-primary js-tooltip" name="_method" value="PATCH" title="Make a PATCH request on the resource">PATCH</button>
- {% endif %}
- </div>
- </fieldset>
- </form>
- </div>
- </div>
- </div>
- {% endif %}
-
- </div>
- <!-- END content-main -->
-
- </div>
- <!-- END Content -->
-
- <div id="push"></div>
-
- </div>
-
- </div><!-- ./wrapper -->
-
- {% block footer %}
- {% endblock %}
-
- {% block script %}
- <script src="{{url_for('flask-api.static', filename='js/jquery.min.js')}}"></script>
- <script src="{{url_for('flask-api.static', filename='js/bootstrap.min.js')}}"></script>
- <script src="{{url_for('flask-api.static', filename='js/prettify-min.js')}}"></script>
- <script src="{{url_for('flask-api.static', filename='js/default.js')}}"></script>
- {% endblock %}
- </body>
-</html>
+{% extends "layout.html" %}
+{% block title %}Software Heritage API Overview{% endblock %}
+{% block content %}
+<div class="api-doc">
+ {% for route, doc in doc_routes %}
+ <div class="api-doc-route">
+ <h2> <a href="{{ route }}">{{ route }}</a> </h2>
+ {{ doc }}
+ </div>
+ </br>
+ {% endfor %}
+</div>
+{% 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 %}
+<div class="docstring">
+ <h2> Overview </h2>
+ {{ docstring | safe }}
+</div>
+{% endif %}
+{% if response_data and response_data is not none %}
+<div class="response-data">
+ <h2> Request </h2>
+ <pre><strong>{{ request.method }}</strong> {{ request.url }}</pre>
+ <h2> Result </h2>
+ <pre> {% autoescape off %} {{ response_data | urlize_api_links }} {% endautoescape %} </pre>
+</div>
+{% endif %}
+<hr/>
+<div class="doc-urls">
+ <table class="m-x-auto table">
+ <thead>
+ <tr>
+ <th>URL</th>
+ <th>Allowed Methods</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for url in urls %}
+ <tr>
+ <td>
+ {{ url['rule'] }}
+ </td>
+ <td>
+ {{ url['methods'] | sort | join(', ') }}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+<hr/>
+{% if args and args|length > 0 %}
+<div class="doc-args">
+ <h2> Args </h2>
+ <dl class="doc-argslist dl-horizontal">
+ {% for arg in args %}
+ <dt> {{ arg['name'] }}: {{ arg['type'] }} </dt>
+ <dd> {{ arg['doc'] }} </dd>
+ {% endfor %}
+ </dl>
+</div>
+{% endif %}
+{% if excs and excs|length > 0 %}
+<div class="doc-excs">
+ <h2> Raises </h2>
+ <dl class="doc-excslist dl-horizontal">
+ {% for exc in excs %}
+ <dt> {{ exc['exc'] }} </dt>
+ <dd> {{ exc['doc'] }} </dd>
+ {% endfor %}
+ </dl>
+</div>
+{% endif %}
+{% if return %}
+<div class="doc-return">
+ <h2> Returns </h2>
+ <dl class="doc-return dl-horizontal">
+ <dt>{{ return['type'] }}</dt>
+ <dd>{{ return['doc'] }}</dd>
+ </dl>
+</div>
+{% endif %}
+{% if example %}
+<div class="doc-example">
+ <h2> Example </h2>
+ <dl class="doc-example dl-horizontal">
+ <dd>
+ <a href="{{ example }}">{{ example }}</a>
+ </dd>
+ </dl>
+</div>
+{% 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 @@
</div>
</div>
<button id="cal-clear">Reset</button>
- {% for key in ['type', 'lister', 'projet', 'url'] %}
+ {% for key in ['type', 'lister', 'project', 'url'] %}
{% if origin[key] is not none %}
<div class="row">
<div class="col-md-2">{{ key }}</div>
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,6 +325,14 @@
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 = """<p>Show all revisions (~git log) starting from
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/<int:origin_id>/')
+@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,27 @@
@app.route('/api/1/search/', methods=['POST'])
@app.route('/api/1/search/<string:q>/')
+@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, or 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 +148,56 @@
return enrich_fn(res)
-@app.route('/api/1/origin/')
@app.route('/api/1/origin/<int:origin_id>/')
+@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/<int:person_id>/')
+@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/<string:sha1_git>/')
+@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 +261,31 @@
'/branch/<path:branch_name>'
'/ts/<string:ts>'
'/directory/<path:path>/')
+@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 +293,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 +320,31 @@
@app.route('/api/1/revision'
'/origin/<int:origin_id>'
'/ts/<string: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 +361,25 @@
ts)
-@app.route('/api/1/revision/')
@app.route('/api/1/revision/<string:sha1_git>/')
@app.route('/api/1/revision/<string:sha1_git>/prev/<path:context>/')
+@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 +392,53 @@
@app.route('/api/1/revision/<string:sha1_git>/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/<string:sha1_git>/directory/')
@app.route('/api/1/revision/<string:sha1_git>/directory/<path:dir_path>/')
+@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 +451,27 @@
@app.route('/api/1/revision/<string:sha1_git>/log/')
@app.route('/api/1/revision/<string:sha1_git>/prev/<path:prev_sha1s>/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 +514,6 @@
@app.route('/api/1/revision'
- '/origin/log/')
-@app.route('/api/1/revision'
'/origin/<int:origin_id>/log/')
@app.route('/api/1/revision'
'/origin/<int:origin_id>'
@@ -523,26 +525,32 @@
@app.route('/api/1/revision'
'/origin/<int:origin_id>'
'/ts/<string: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 +584,28 @@
return response
-@app.route('/api/1/directory/')
@app.route('/api/1/directory/<string:sha1_git>/')
@app.route('/api/1/directory/<string:sha1_git>/<path:path>/')
+@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 +654,23 @@
@app.route('/api/1/content/<string:q>/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 +679,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/<string:q>/')
+@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 +712,21 @@
enrich_fn=utils.enrich_content)
-@app.route('/api/1/entity/')
@app.route('/api/1/entity/<string:uuid>/')
+@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():

File Metadata

Mime Type
text/plain
Expires
Dec 20 2024, 8:56 PM (11 w, 4 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3225918

Event Timeline