334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""Generate Markdown documents from an OpenAPI swagger file."""
|
|
|
|
from __future__ import print_function
|
|
|
|
import contextlib
|
|
import functools
|
|
import os
|
|
import os.path
|
|
import re
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
|
|
# JSON Reference helpers
|
|
|
|
class JRefable(object):
|
|
"""An object that can be indexed with JSON Pointers, and supports $ref."""
|
|
def __init__(self, data, doc=None, ref=None):
|
|
self.data = data
|
|
self.doc = doc or data
|
|
self.ref = ref or '/'
|
|
self.name = None
|
|
|
|
def __repr__(self):
|
|
return repr(self.data)
|
|
|
|
def wrap(self, data, ref):
|
|
if isinstance(data, dict):
|
|
if '$ref' in data:
|
|
ref = data['$ref']
|
|
ret = JRefableObject(self.doc)[ref]
|
|
ret.name = ref.split('/')[-1]
|
|
return ret
|
|
return JRefableObject(data, self.doc, ref)
|
|
if isinstance(data, list):
|
|
return JRefableArray(data, self.doc, ref)
|
|
return data
|
|
|
|
|
|
class JRefableObject(JRefable):
|
|
"""Make a dictionary into a JSON Reference-capable object."""
|
|
def __getitem__(self, jref):
|
|
if jref.startswith('#/'):
|
|
parts = jref[2:]
|
|
data = self.doc
|
|
ref = '/'
|
|
else:
|
|
parts = jref
|
|
data = self.data
|
|
ref = self.ref
|
|
for part in parts.split('/'):
|
|
try:
|
|
data = data[part]
|
|
except KeyError:
|
|
raise KeyError("{!r} not in {!r} then {!r}".format(part, self.ref, jref))
|
|
ref = ref + part + '/'
|
|
return self.wrap(data, ref=ref)
|
|
|
|
def get(self, key, default=None):
|
|
if key in self.data:
|
|
return self.wrap(self.data[key], self.ref + key + '/')
|
|
return default
|
|
|
|
def keys(self):
|
|
return self.data.keys()
|
|
|
|
def items(self):
|
|
for k, v in self.data.items():
|
|
yield k, self.wrap(v, self.ref + k.replace('/', ':') + '/')
|
|
|
|
def __contains__(self, val):
|
|
return val in self.data
|
|
|
|
|
|
class JRefableArray(JRefable):
|
|
"""Make a list into a JSON Reference-capable array."""
|
|
def __getitem__(self, index):
|
|
try:
|
|
data = self.data[index]
|
|
except IndexError:
|
|
raise IndexError("{!r} not in {!r}".format(index, self.ref))
|
|
return self.wrap(data, self.ref + str(index) + '/')
|
|
|
|
def __iter__(self):
|
|
for i, elt in enumerate(self.data):
|
|
yield self.wrap(elt, self.ref + str(i) + '/')
|
|
|
|
|
|
class OutputFiles(object):
|
|
"""A context manager to manage a series of files.
|
|
|
|
Use like this::
|
|
|
|
with OutputFiles() as outfiles:
|
|
...
|
|
if some_condition():
|
|
f = outfiles.open("filename.txt", "w")
|
|
|
|
Each open will close the previously opened file, and the end of the with
|
|
statement will close the last one.
|
|
|
|
"""
|
|
def __init__(self):
|
|
self.file = None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args, **kwargs):
|
|
if self.file:
|
|
self.file.close()
|
|
return False
|
|
|
|
def open(self, *args, **kwargs):
|
|
if self.file:
|
|
self.file.close()
|
|
self.file = open(*args, **kwargs)
|
|
return self.file
|
|
|
|
|
|
sluggers = [
|
|
r"^.*?/v\d+/[\w_-]+",
|
|
r"^(/[\w_-]+){,3}",
|
|
]
|
|
|
|
method_order = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
|
|
|
|
|
|
def method_ordered_items(method_data):
|
|
keys = [k for k in method_order if k in method_data]
|
|
for key in keys:
|
|
yield key, method_data[key]
|
|
|
|
|
|
class MarkdownWriter(object):
|
|
"""Help write markdown, managing indentation and header nesting."""
|
|
|
|
def __init__(self, outfile):
|
|
self.outfile = outfile
|
|
self.cur_indent = 0
|
|
|
|
def print(self, text='', increase_headers=0):
|
|
if increase_headers:
|
|
text = re.sub(r"^#", "#" * (increase_headers + 1), text, flags=re.MULTILINE)
|
|
if self.cur_indent:
|
|
text = re.sub(r"^", " " * self.cur_indent, text, flags=re.MULTILINE)
|
|
print(text, file=self.outfile)
|
|
|
|
@contextlib.contextmanager
|
|
def indent(self, spaces):
|
|
old_indent = self.cur_indent
|
|
self.cur_indent += spaces
|
|
try:
|
|
yield
|
|
finally:
|
|
self.cur_indent = old_indent
|
|
|
|
|
|
def convert_swagger_to_markdown(swagger_data, output_dir):
|
|
"""Convert a swagger.yaml file to a series of markdown documents."""
|
|
sw = JRefableObject(swagger_data)
|
|
|
|
if not os.path.exists(output_dir):
|
|
os.makedirs(output_dir)
|
|
|
|
with open(os.path.join(output_dir, 'index.md'), 'w') as index:
|
|
indexmd = MarkdownWriter(index)
|
|
indexmd.print("# {}\n".format(sw['info/title']))
|
|
indexmd.print(sw['info/description'])
|
|
indexmd.print()
|
|
|
|
with OutputFiles() as outfiles:
|
|
slug = None
|
|
|
|
for uri, methods in sorted(sw['paths'].items()):
|
|
for slugger in sluggers:
|
|
m = re.search(slugger, uri)
|
|
if m:
|
|
new_slug = m.group()
|
|
if new_slug != slug:
|
|
slug = new_slug
|
|
outfile = slug.strip('/').replace('/', '_') + '.md'
|
|
outf = outfiles.open(os.path.join(output_dir, outfile), 'w')
|
|
outmd = MarkdownWriter(outf)
|
|
outmd.print("# {}\n".format(slug))
|
|
indexmd.print("## {}\n".format(slug))
|
|
break
|
|
|
|
common_params = methods.get('parameters', [])
|
|
for method, op_data in method_ordered_items(methods):
|
|
summary = ''
|
|
if 'summary' in op_data:
|
|
summary = " --- {}".format(op_data['summary'])
|
|
indexmd.print("[{} {}]({}){}\n".format(method.upper(), uri, outfile, summary))
|
|
write_one_method(outmd, method, uri, op_data, common_params)
|
|
|
|
|
|
def write_one_method(outmd, method, uri, op_data, common_params):
|
|
"""Write one entry (uri and method) to the markdown output."""
|
|
outmd.print("\n## {} {}\n".format(method.upper(), uri))
|
|
if 'summary' in op_data:
|
|
outmd.print(op_data['summary'])
|
|
outmd.print()
|
|
outmd.print(op_data['description'], increase_headers=2)
|
|
|
|
params = list(op_data.get('parameters', []))
|
|
params.extend(common_params)
|
|
if params:
|
|
outmd.print("\n### Parameters\n")
|
|
for param in params:
|
|
description = param.get('description', '').strip()
|
|
if description:
|
|
description = ": " + description
|
|
where = param['in']
|
|
required = param.get('required', False)
|
|
required = "required" if required else "optional"
|
|
if where == 'body':
|
|
schema = param['schema']
|
|
outmd.print("- **{}** (body, {}){}".format(
|
|
param['name'],
|
|
schema.name or schema['type'],
|
|
description,
|
|
))
|
|
with outmd.indent(2):
|
|
write_schema(outmd, schema)
|
|
else:
|
|
outmd.print("- **{}** ({}, {}, {}){}".format(
|
|
param['name'],
|
|
where,
|
|
param['type'],
|
|
required,
|
|
description,
|
|
))
|
|
|
|
responses = op_data.get('responses', [])
|
|
if responses:
|
|
outmd.print("\n### Responses\n")
|
|
for status, response in sorted(responses.items()):
|
|
description = response.get('description', '').strip()
|
|
if description:
|
|
description = ": " + description
|
|
schema = response.get('schema')
|
|
if schema:
|
|
type_note = " ({})".format(type_name(schema))
|
|
else:
|
|
type_note = ""
|
|
outmd.print("- **{}**{}{}".format(
|
|
status,
|
|
type_note,
|
|
description,
|
|
))
|
|
if schema:
|
|
with outmd.indent(2):
|
|
write_schema(outmd, schema)
|
|
|
|
|
|
def type_name(schema):
|
|
"""What is the short type name for `schema`?"""
|
|
if schema['type'] == 'object':
|
|
return schema.name or schema.get('type') or "object"
|
|
elif schema['type'] == 'array':
|
|
item_type = type_name(schema['items'])
|
|
return "array of " + item_type
|
|
else:
|
|
return schema['type']
|
|
|
|
|
|
def write_schema(outmd, schema):
|
|
"""Write a schema to the markdown output."""
|
|
if schema['type'] == 'object':
|
|
required = set(schema.get('required', ()))
|
|
for prop_name, prop in sorted(schema['properties'].items()):
|
|
attrs = []
|
|
type = type_name(prop)
|
|
if prop['type'] == 'array':
|
|
item_type = prop['items']
|
|
else:
|
|
item_type = None
|
|
attrs.append(type)
|
|
if prop_name in required:
|
|
attrs.append("required")
|
|
else:
|
|
attrs.append("optional")
|
|
if 'format' in prop:
|
|
attrs.append("format {}".format(prop["format"]))
|
|
if 'pattern' in prop:
|
|
attrs.append("pattern `{}`".format(prop["pattern"]))
|
|
if 'minLength' in prop:
|
|
attrs.append("min length {}".format(prop["minLength"]))
|
|
if 'maxLength' in prop:
|
|
attrs.append("max length {}".format(prop["maxLength"]))
|
|
if 'minimum' in prop:
|
|
attrs.append("minimum {}".format(prop["minimum"]))
|
|
if 'maximum' in prop:
|
|
attrs.append("maximum {}".format(prop["maximum"]))
|
|
if prop.get('readOnly', False):
|
|
attrs.append("read only")
|
|
# TODO: enum
|
|
# TODO: x-nullable
|
|
|
|
title = prop.get('title', '').strip()
|
|
if title:
|
|
title = ": " + title
|
|
description = prop.get('description', '').strip()
|
|
if description:
|
|
if title:
|
|
title = title + ". " + description
|
|
else:
|
|
title = ": " + description
|
|
|
|
outmd.print("- **{name}** ({attrs}){title}".format(
|
|
name=prop_name,
|
|
attrs=", ".join(attrs),
|
|
title=title,
|
|
))
|
|
if item_type and item_type['type'] in ['object', 'array']:
|
|
with outmd.indent(2):
|
|
write_schema(outmd, item_type)
|
|
elif schema['type'] == 'array':
|
|
write_schema(outmd, schema['items'])
|
|
else:
|
|
raise ValueError("Don't understand schema type {!r} at {}".format(schema['type'], schema.ref))
|
|
|
|
|
|
def main(args):
|
|
with open(args[0]) as swyaml:
|
|
swagger_data = yaml.safe_load(swyaml)
|
|
convert_swagger_to_markdown(swagger_data, output_dir=args[1])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main(sys.argv[1:]))
|