diff --git a/r2/r2/controllers/api_docs.py b/r2/r2/controllers/api_docs.py index 77ec8d52f..4216d1004 100644 --- a/r2/r2/controllers/api_docs.py +++ b/r2/r2/controllers/api_docs.py @@ -1,70 +1,142 @@ import re from collections import defaultdict +from itertools import chain +import inspect from pylons.i18n import _ from reddit_base import RedditController +from r2.lib.utils import Storage from r2.lib.pages import BoringPage, ApiHelp +# API sections displayed in the documentation page. +# Each section can have a title and a markdown-formatted description. +section_info = { + 'account': { + 'title': _('account'), + }, + 'flair': { + 'title': _('flair'), + }, + 'links_and_comments': { + 'title': _('links & comments'), + }, + 'messages': { + 'title': _('private messages'), + }, + 'moderation': { + 'title': _('moderation'), + }, + 'misc': { + 'title': _('misc'), + }, + 'listings': { + 'title': _('listings'), + }, + 'search': { + 'title': _('search'), + }, + 'subreddits': { + 'title': _('subreddits'), + }, + 'users': { + 'title': _('users'), + } +} + +api_section = Storage((k, k) for k in section_info) + +def api_doc(section, **kwargs): + """ + Add documentation annotations to the decorated function. + + See ApidocsController.docs_from_controller for a list of annotation fields. + """ + def add_metadata(api_function): + doc = api_function._api_doc = getattr(api_function, '_api_doc', {}) + if 'extends' in kwargs: + kwargs['extends'] = kwargs['extends']._api_doc + doc.update(kwargs) + doc['section'] = section + return api_function + return add_metadata + class ApidocsController(RedditController): @staticmethod def docs_from_controller(controller, url_prefix='/api'): """ - Examines a controller for documentation. A dictionary of URLs is - returned. For each URL, a dictionary of HTTP methods (GET, POST, etc.) - is contained. For each URL/method pair, a dictionary containing the - following items is available: + Examines a controller for documentation. A dictionary index of + sections containing dictionaries of URLs is returned. For each URL, a + dictionary of HTTP methods (GET, POST, etc.) is contained. For each + URL/method pair, a dictionary containing the following items is + available: - - `__doc__`: Markdown-formatted docstring. - - `oauth2_scopes`: List of OAuth2 scopes - - *more to come...* + - `doc`: Markdown-formatted docstring. + - `uri`: Manually-specified URI to list the API method as + - `uri_variants`: Alternate URIs to access the API method from + - `extensions`: URI extensions the API method supports + - `parameters`: Dictionary of possible parameter names and descriptions. + - `extends`: API method from which to inherit documentation """ - api_methods = defaultdict(dict) + api_docs = defaultdict(lambda: defaultdict(dict)) for name, func in controller.__dict__.iteritems(): - i = name.find('_') - if i > 0: - method = name[:i] - action = name[i+1:] - else: + method, sep, action = name.partition('_') + if not action: continue - if func.__doc__ and method in ('GET', 'POST'): - docs = { - '__doc__': re.sub(r'\n +', '\n', func.__doc__).strip(), - } + api_doc = getattr(func, '_api_doc', None) + if api_doc and 'section' in api_doc and method in ('GET', 'POST'): + docs = {} + docs['doc'] = inspect.getdoc(func) - if hasattr(func, 'oauth2_perms'): - scopes = func.oauth2_perms.get('allowed_scopes') - if scopes: - docs['oauth2_scopes'] = scopes + if 'extends' in api_doc: + docs.update(api_doc['extends']) + # parameters are handled separately. + docs['parameters'] = {} + docs.update(api_doc) - # TODO: in the future, it would be cool to introspect the - # validators in order to generate a list of request - # parameters. Some decorators also give a hint as to response - # type (JSON, etc.) which could be included as well. + uri = docs.get('uri') or '/'.join((url_prefix, action)) + if 'extensions' in docs: + # if only one extension was specified, add it to the URI. + if len(docs['extensions']) == 1: + uri += '.' + docs['extensions'][0] + del docs['extensions'] + docs['uri'] = uri - api_methods['/'.join((url_prefix, action))][method] = docs + # add every variant to the index -- the templates will filter + # out variants in the long-form documentation + for variant in chain([uri], docs.get('uri_variants', [])): + api_docs[docs['section']][variant][method] = docs - return api_methods + return api_docs def GET_docs(self): + # controllers to gather docs from. from r2.controllers.api import ApiController, ApiminimalController - from r2.controllers.apiv1 import APIv1Controller - from r2.controllers.oauth2 import OAuth2FrontendController, OAuth2AccessController, scope_info + from r2.controllers.front import FrontController + from r2.controllers import listingcontroller - api_methods = defaultdict(dict) - for controller, url_prefix in ((ApiController, '/api'), - (ApiminimalController, '/api'), - (OAuth2FrontendController, '/api/v1'), - (OAuth2AccessController, '/api/v1'), - (APIv1Controller, '/api/v1')): - for url, methods in self.docs_from_controller(controller, url_prefix).iteritems(): - api_methods[url].update(methods) + api_controllers = [ + (ApiController, '/api'), + (ApiminimalController, '/api'), + (FrontController, '') + ] + for name, value in vars(listingcontroller).iteritems(): + if name.endswith('Controller'): + api_controllers.append((value, '')) + + # merge documentation info together. + api_docs = defaultdict(dict) + for controller, url_prefix in api_controllers: + for section, contents in self.docs_from_controller(controller, url_prefix).iteritems(): + api_docs[section].update(contents) return BoringPage( _('api documentation'), content=ApiHelp( - api_methods=api_methods, - oauth2_scopes=scope_info, - ) + api_docs=api_docs + ), + css_class="api-help", + show_sidebar=False, + show_firsttext=False ).render() diff --git a/r2/r2/controllers/reddit_base.py b/r2/r2/controllers/reddit_base.py index 709a13bbc..4da039e05 100644 --- a/r2/r2/controllers/reddit_base.py +++ b/r2/r2/controllers/reddit_base.py @@ -464,6 +464,7 @@ def paginated_listing(default_page_size=25, max_page_size=100, backend='sql'): count=VCount('count'), target=VTarget("target"), show=VLength('show', 3)) + @utils.wraps_api(fn) def new_fn(self, before, **env): if c.render_style == "htmllite": c.link_target = env.get("target") @@ -532,6 +533,7 @@ def require_https(): def prevent_framing_and_css(allow_cname_frame=False): def wrap(f): + @utils.wraps_api(f) def no_funny_business(*args, **kwargs): c.allow_styles = False if not (allow_cname_frame and c.cname and not c.authorized_cname): diff --git a/r2/r2/controllers/validator/validator.py b/r2/r2/controllers/validator/validator.py index 185a9c4e7..f19a4792c 100644 --- a/r2/r2/controllers/validator/validator.py +++ b/r2/r2/controllers/validator/validator.py @@ -44,6 +44,7 @@ from datetime import datetime, timedelta from curses.ascii import isprint import re, inspect import pycountry +from itertools import chain def visible_promo(article): is_promo = getattr(article, "promoted", None) is not None @@ -86,6 +87,12 @@ class Validator(object): c.errors.add(error, msg_params = msg_params, field = field) + def param_docs(self): + param_info = {} + for param in filter(None, tup(self.param)): + param_info[param] = None + return param_info + def __call__(self, url): a = [] if self.param: @@ -129,8 +136,17 @@ def _make_validated_kw(fn, simple_vals, param_vals, env): kw[var] = validator(env) return kw +def set_api_docs(fn, simple_vals, param_vals): + doc = fn._api_doc = getattr(fn, '_api_doc', {}) + param_info = doc.get('parameters', {}) + for validator in chain(simple_vals, param_vals.itervalues()): + param_info.update(validator.param_docs()) + doc['parameters'] = param_info + doc['lineno'] = fn.func_code.co_firstlineno + def validate(*simple_vals, **param_vals): def val(fn): + @utils.wraps_api(fn) def newfn(self, *a, **env): try: kw = _make_validated_kw(fn, simple_vals, param_vals, env) @@ -140,8 +156,7 @@ def validate(*simple_vals, **param_vals): except VerifiedUserRequiredException: return self.intermediate_redirect('/verify') - newfn.__name__ = fn.__name__ - newfn.__doc__ = fn.__doc__ + set_api_docs(newfn, simple_vals, param_vals) return newfn return val @@ -157,6 +172,7 @@ def api_validate(response_type=None): def wrap(response_function): def _api_validate(*simple_vals, **param_vals): def val(fn): + @utils.wraps_api(fn) def newfn(self, *a, **env): renderstyle = request.params.get("renderstyle") if renderstyle: @@ -187,8 +203,7 @@ def api_validate(response_type=None): responder.send_failure(errors.VERIFIED_USER_REQUIRED) return self.api_wrapper(responder.make_response()) - newfn.__name__ = fn.__name__ - newfn.__doc__ = fn.__doc__ + set_api_docs(newfn, simple_vals, param_vals) return newfn return val return _api_validate diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 880596a3f..f1f12b64f 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -3787,7 +3787,7 @@ class UserIPHistory(Templated): super(UserIPHistory, self).__init__() class ApiHelp(Templated): - def __init__(self, api_methods, oauth2_scopes, *a, **kw): - self.api_methods = api_methods - self.oauth2_scopes = oauth2_scopes + api_source_url = "https://github.com/reddit/reddit/blob/master/r2/r2/controllers/api.py" + def __init__(self, api_docs, *a, **kw): + self.api_docs = api_docs super(ApiHelp, self).__init__(*a, **kw) diff --git a/r2/r2/lib/utils/utils.py b/r2/r2/lib/utils/utils.py index 4866fb40f..7a4d7cb3b 100644 --- a/r2/r2/lib/utils/utils.py +++ b/r2/r2/lib/utils/utils.py @@ -32,6 +32,7 @@ from BeautifulSoup import BeautifulSoup from time import sleep from datetime import datetime, timedelta +from functools import wraps, partial, WRAPPER_ASSIGNMENTS from pylons import g from pylons.i18n import ungettext, _ from r2.lib.filters import _force_unicode @@ -1384,3 +1385,8 @@ def constant_time_compare(actual, expected): result |= ord(actual[i]) ^ ord(expected[i % expected_len]) return result == 0 +def wraps_api(f): + # work around issue where wraps() requires attributes to exist + if not hasattr(f, '_api_doc'): + f._api_doc = {} + return wraps(f, assigned=WRAPPER_ASSIGNMENTS+('_api_doc',)) diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index c8ef49069..e6866c176 100644 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -5140,3 +5140,179 @@ tr.gold-accent + tr > td { margin-bottom: .5em; display: inline-block; } + +.content.api-help { + font-size: 1.25em; + margin: 0 auto; + max-width: 950px; +} + +.api-help .contents { + padding: 0 20px; + margin-left: 24em; + margin-top: 20px; +} + +.api-help .contents .section { + margin-bottom: 2em; +} + +.api-help .sidebar { + float: left; + margin-left: 10px; +} + +.api-help .sidebar .head { + position: relative; + background: url(../xray-snoo-head.png) top center no-repeat; + height: 188px; + margin-bottom: -78px; + z-index: 2; +} + +.api-help .sidebar .feet { + position: relative; + background: url(../xray-snoo-feet.png) top center no-repeat; + height: 75px; + margin-top: -42px; + z-index: 2; +} + +.api-help .toc { + background: #181818 url(../xray-snoo-body.png) center repeat-y; + border: 5px solid #959595; + border-radius: 8px; + padding: 15px 2em 0 2em; + width: 18em; +} + +.api-help .contents .introduction { + position: relative; + border: 2px solid #ccc; + border-radius: 12px; + padding: 14px; + margin-bottom: -1em; +} + +.api-help .introduction:before, +.api-help .introduction:after { + position: absolute; + display: block; + content: ''; + border: 15px solid; + border-style: solid solid outset; /* mitigates firefox drawing a thicker arrow */ +} + +.api-help .introduction:before { + border-color: transparent #ccc transparent transparent; + left: -31px; + top: 58px; +} + +.api-help .introduction:after { + border-color: transparent white transparent transparent; + left: -28px; + top: 58px; +} + +.api-help .toc ul { + position: relative; + margin-top: .5em; + margin-bottom: 1.5em; + z-index: 10; +} + +.api-help .toc > ul > li > strong { + color: #aaa; +} + +.api-help .toc a.section { + color: #888; + font-weight: bold; +} + +.api-help .toc a { + color: #8EB0D2; +} + +.api-help .toc a:hover, .api-help .endpoint a:hover { + text-decoration: underline; +} + +.api-help em.placeholder { + font-style: italic; + font-weight: normal; +} + +.api-help .toc em.placeholder { + color: #8EB0D2; +} + +.api-help .endpoint em.placeholder { + color: #369; +} + +.api-help .endpoint, .api-help .section .description { + margin-bottom: 1.5em; +} + +.api-help .methods h2 { + color: black; + font-size: 1.45em; + text-align: middle; + margin-top: 1.5em; + margin-bottom: 1em; + border-bottom: 1px solid #aaa; +} + +.api-help .endpoint .info { + padding-left: 1em; + border-left: 1px solid #ddd; +} + +.api-help .endpoint h3, .api-help .endpoint .uri-variants { + color: #369; + margin-bottom: .5em; +} + +.api-help .endpoint .uri-variants { + opacity: .85; + font-weight: bold; + margin-top: -.5em; + margin-left: 3em; +} + +.api-help .endpoint .method, .api-help .endpoint .extensions { + font-weight: normal; + color: gray; +} + +.api-help .endpoint .extensions { + margin-left: .5em; +} + +.api-help .endpoint .links { + float: right; +} + +.api-help .endpoint .links a { + margin-left: .85em; + opacity: .45; +} + +.api-help .endpoint:hover .links a { + opacity: 1; +} + +.api-help .parameters { + background: #f0f0f0; + border-collapse: separate; + border-radius: 3px; + padding: 5px 10px; + width: 100%; +} + +.api-help .parameters .name { + font-family: 'Courier New', monospace; + width: 50%; +} diff --git a/r2/r2/public/static/xray-snoo-body.png b/r2/r2/public/static/xray-snoo-body.png new file mode 100644 index 000000000..2b8b5acba Binary files /dev/null and b/r2/r2/public/static/xray-snoo-body.png differ diff --git a/r2/r2/public/static/xray-snoo-feet.png b/r2/r2/public/static/xray-snoo-feet.png new file mode 100644 index 000000000..d384355c3 Binary files /dev/null and b/r2/r2/public/static/xray-snoo-feet.png differ diff --git a/r2/r2/public/static/xray-snoo-head.png b/r2/r2/public/static/xray-snoo-head.png new file mode 100644 index 000000000..0c817033d Binary files /dev/null and b/r2/r2/public/static/xray-snoo-head.png differ diff --git a/r2/r2/templates/apihelp.html b/r2/r2/templates/apihelp.html index 4194c9683..31ce23112 100644 --- a/r2/r2/templates/apihelp.html +++ b/r2/r2/templates/apihelp.html @@ -1,78 +1,122 @@ <%! + import re from r2.lib.filters import safemarkdown + from r2.controllers.api_docs import section_info %> -
-
-

This is the automatically-generated documentation for the Reddit API.

-

It's gathered from the docstrings in the code.

+<%def name="api_method_id(uri, method)">${method}_${uri.replace('/', '_').strip('_')} +<%def name="api_uri(uri)">${unsafe(re.sub(r'{(\w+)}', r'\1', uri))} + +<% + api = thing.api_docs +%> + +
-

Contents

+
+

This is automatically-generated documentation for the reddit API. It's gathered from docstrings and annotations in the code.

+
+

The reddit API and code are open source. Found a mistake or interested in helping us improve? Have a gander at api.py and send us a pull request.

+
-
    -
  • - API methods -
      - %for uri in sorted(thing.api_methods.keys()): -
    • - ${uri} -   (${', '.join(sorted(thing.api_methods[uri].keys()))}) -
    • +
      + %for section in sorted(api): +

      ${section_info[section]['title']}

      + %if 'description' in section_info[section]: +
      + ${unsafe(safemarkdown(section_info[section]['description']))} +
      + %endif + %for uri in sorted(api[section]): + %for method in sorted(api[section][uri]): + <% + docs = api[section][uri][method] + # skip uri variants in the index + if docs['uri'] != uri: + continue + + extends = docs.get('extends') + %> +
      + +

      + ${method}  + ${api_uri(uri)} + %if 'extensions' in docs: + + [ ${' | '.join('.'+extension for extension in docs['extensions'])} ] + + %endif +

      + %if 'uri_variants' in docs: +
        + %for variant in docs['uri_variants']: +
      • → ${api_uri(variant)}
      • + %endfor +
      + %endif +
      + ${unsafe(safemarkdown(docs.get('doc')))} + <% + params = docs.get('parameters') + base_params = extends.get('parameters') if extends else None + %> + %if params or base_params: + + %if params: + %for param in sorted(params): + + + + + %endfor + %endif + %if base_params: + %for param in sorted(base_params): + + + + + %endfor + %endif +
      ${param}${params[param]}
      ${param}${base_params[param]}
      + %endif +
      +
      + %endfor %endfor -
    -
  • - OAuth scopes -
      - %for scope in sorted(thing.oauth2_scopes.keys()): -
    • ${scope}
    • - %endfor -
    -
  • -
-
- -
-

API methods

- -%for uri in sorted(thing.api_methods.keys()): -
- -

${uri}

- %for method in sorted(thing.api_methods[uri].keys()): -
-

${method}

- ${unsafe(safemarkdown(thing.api_methods[uri][method]))} -
%endfor
-%endfor -
- -
-

OAuth scopes

- - - - - - - - - - - %for scope in sorted(thing.oauth2_scopes.keys()): - - - - - - %endfor - -
idnamedescription
- - ${scope} - ${thing.oauth2_scopes[scope]['name']}${thing.oauth2_scopes[scope]['description']}
-