import json
import inspect
import re
try:
from itertools import imap as map # PY2
except ImportError:
pass
import tornado.web
from jsonschema import ValidationError, validate
from tornado_json.utils import extract_method, is_method
from tornado_json.constants import HTTP_METHODS
from tornado_json.requesthandlers import APIHandler
def _validate_example(rh, method, example_type):
"""Validates example against schema
:returns: Formatted example if example exists and validates, otherwise None
:raises ValidationError: If example does not validate against the schema
"""
example = getattr(method, example_type + "_example")
schema = getattr(method, example_type + "_schema")
if example is None:
return None
try:
validate(example, schema)
except ValidationError as e:
raise ValidationError(
"{}_example for {}.{} could not be validated.\n{}".format(
example_type, rh.__name__, method.__name__, str(e)
)
)
return json.dumps(example, indent=4, sort_keys=True)
def _get_rh_methods(rh):
"""Yield all HTTP methods in ``rh`` that are decorated
with schema.validate"""
for k, v in vars(rh).items():
if all([
k in HTTP_METHODS,
is_method(v),
hasattr(v, "input_schema")
]):
yield (k, v)
def _get_tuple_from_route(route):
"""Return (pattern, handler_class, methods) tuple from ``route``
:type route: tuple|tornado.web.URLSpec
:rtype: tuple
:raises TypeError: If ``route`` is not a tuple or URLSpec
"""
if isinstance(route, tuple):
assert len(route) >= 2
pattern, handler_class = route[:2]
elif isinstance(route, tornado.web.URLSpec):
pattern, handler_class = route.regex.pattern, route.handler_class
else:
raise TypeError("Unknown route type '{}'"
.format(type(route).__name__))
methods = []
route_re = re.compile(pattern)
route_params = set(list(route_re.groupindex.keys()) + ['self'])
for http_method in HTTP_METHODS:
method = getattr(handler_class, http_method, None)
if method:
method = extract_method(method)
method_params = set(getattr(method, "__argspec_args",
inspect.getargspec(method).args))
if route_params.issubset(method_params) and \
method_params.issubset(route_params):
methods.append(http_method)
return pattern, handler_class, methods
def _escape_markdown_literals(string):
"""Escape any markdown literals in ``string`` by prepending with \\
:type string: str
:rtype: str
"""
literals = list("\\`*_{}[]()<>#+-.!:|")
escape = lambda c: '\\' + c if c in literals else c
return "".join(map(escape, string))
def _cleandoc(doc):
"""Remove uniform indents from ``doc`` lines that are not empty
:returns: Cleaned ``doc``
"""
indent_length = lambda s: len(s) - len(s.lstrip(" "))
not_empty = lambda s: s != ""
lines = doc.split("\n")
indent = min(map(indent_length, filter(not_empty, lines)))
return "\n".join(s[indent:] for s in lines)
def _add_indent(string, indent):
"""Add indent of ``indent`` spaces to ``string.split("\n")[1:]``
Useful for formatting in strings to already indented blocks
"""
lines = string.split("\n")
first, lines = lines[0], lines[1:]
lines = ["{indent}{s}".format(indent=" " * indent, s=s)
for s in lines]
lines = [first] + lines
return "\n".join(lines)
def _get_example_doc(rh, method, type):
assert type in ("input", "output")
example = _validate_example(rh, method, type)
if not example:
return ""
res = """
**{type} Example**
```json
{example}
```
""".format(
type=type.capitalize(),
example=_add_indent(example, 4)
)
return _cleandoc(res)
def _get_input_example(rh, method):
return _get_example_doc(rh, method, "input")
def _get_output_example(rh, method):
return _get_example_doc(rh, method, "output")
def _get_schema_doc(schema, type):
res = """
**{type} Schema**
```json
{schema}
```
""".format(
schema=_add_indent(json.dumps(schema, indent=4, sort_keys=True), 4),
type=type.capitalize()
)
return _cleandoc(res)
def _get_input_schema_doc(method):
return _get_schema_doc(method.input_schema, "input")
def _get_output_schema_doc(method):
return _get_schema_doc(method.output_schema, "output")
def _get_notes(method):
doc = inspect.getdoc(method)
if doc is None:
return None
res = """
**Notes**
{}
""".format(_add_indent(doc, 4))
return _cleandoc(res)
def _get_method_doc(rh, method_name, method):
res = """## {method_name}
{input_schema}
{input_example}
{output_schema}
{output_example}
{notes}
""".format(
method_name=method_name.upper(),
input_schema=_get_input_schema_doc(method),
output_schema=_get_output_schema_doc(method),
notes=_get_notes(method) or "",
input_example=_get_input_example(rh, method),
output_example=_get_output_example(rh, method),
)
return _cleandoc("\n".join([l.rstrip() for l in res.splitlines()]))
def _get_rh_doc(rh, methods):
res = "\n\n".join([_get_method_doc(rh, method_name, method)
for method_name, method in _get_rh_methods(rh)
if method_name in methods])
return res
def _get_content_type(rh):
# XXX: Content-type is hard-coded but ideally should be retrieved;
# the hard part is, we don't know what it is without initializing
# an instance, so just leave as-is for now
return "Content-Type: application/json"
def _get_route_doc(url, rh, methods):
route_doc = """
# {route_pattern}
{content_type}
{rh_doc}
""".format(
route_pattern=_escape_markdown_literals(url),
content_type=_get_content_type(rh),
rh_doc=_add_indent(_get_rh_doc(rh, methods), 4)
)
return _cleandoc(route_doc)
def _write_docs_to_file(documentation):
# Documentation is written to the root folder
with open("API_Documentation.md", "w+") as f:
f.write(documentation)
[docs]def get_api_docs(routes):
"""
Generates GitHub Markdown formatted API documentation using
provided schemas in RequestHandler methods and their docstrings.
:type routes: [(url, RequestHandler), ...]
:param routes: List of routes (this is ideally all possible routes of the
app)
:rtype: str
:returns: generated GFM-formatted documentation
"""
routes = map(_get_tuple_from_route, routes)
documentation = []
for url, rh, methods in sorted(routes, key=lambda a: a[0]):
if issubclass(rh, APIHandler):
documentation.append(_get_route_doc(url, rh, methods))
documentation = (
"**This documentation is automatically generated.**\n\n" +
"**Output schemas only represent `data` and not the full output; " +
"see output examples and the JSend specification.**\n" +
"\n<br>\n<br>\n".join(documentation)
)
return documentation
[docs]def api_doc_gen(routes):
"""Get and write API documentation for ``routes`` to file"""
documentation = get_api_docs(routes)
_write_docs_to_file(documentation)