-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathflask_classy_swagger.py
290 lines (228 loc) · 8.01 KB
/
flask_classy_swagger.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import ast
import inspect
import re
from collections import defaultdict
from flask import jsonify
from undecorated import undecorated
SWAGGER_VERSION = '2.0'
SWAGGER_PATH = '/swagger.json'
IGNORED_RULES = ['/static', SWAGGER_PATH]
# map from werkzeug url param converters to swagger (type, format)
WERKZEUG_SWAGGER_TYPES = {
'int': ('integer', 'int32'),
'float': ('number', 'float'),
'uuid': ('string', 'uuid'),
'string': ('string', None),
}
# anything else is the default type below:
DEFAULT_TYPE = ('string', None)
def func_doc(func):
try:
return func.func_doc
except AttributeError: # python3
return func.__doc__
def schema(title, version, base_path=None):
schema = {"swagger": SWAGGER_VERSION,
"paths": {},
"tags": [],
"info": {
"title": title,
"version": version}}
if base_path is not None:
schema['basePath'] = base_path
return schema
def http_verb(rule):
# trying to get over rule.methods like: set(['HEAD', 'OPTIONS', 'GET'])
for m in rule.methods:
if m in ['GET', 'POST', 'PUT', 'DELETE']:
return m.lower()
def is_ignored(rule):
# TODO app.static_url_path
for ignored in IGNORED_RULES:
if rule.rule.startswith(ignored):
return True
else:
return False
def get_path(rule):
# swagger spec is very clear about wanting paths to start with a slash
assert rule.rule.startswith('/')
if rule.rule == '/':
return '/'
# swagger prefers curly braces to angle brackets
# we also need to remove the type declaration e.g. '<int:'
path = re.sub('<(?:\w+:)?', '{', rule.rule)
path = path.replace('>', '}')
# and no ending slash
return path.rstrip('/')
def get_tag(rule):
return rule.endpoint.split(":")[0]
def get_docs(function):
"""Return (summary, description) tuple from the passed in function"""
try:
summary, description = re.match(
r"""
(.+?$) # first (summary) line, non-greedy MULTILINE $
\n? # maybe a newline
\s* # maybe indentation to the beginning of the next line
(.*) # maybe multiple other lines DOTALL
""",
func_doc(function).strip(),
re.MULTILINE | re.DOTALL | re.VERBOSE
).groups()
except (AttributeError, TypeError):
return '', ''
# swagger ignores single newlines, but if it sees two consecutive
# newline characters (a blank line) the swagger UI break out of the
# "Implementation Notes" paragraph. AFAICS this is not in the
# swagger spec?
description = re.sub(r'\n\n+', '\n', description)
return summary, description
def get_flask_classy_class(method):
if method is None:
return None
if not inspect.ismethod(method):
print(
"Couldn't determine to which class '{}' is bound. "
"Doesn't look like a method."
.format(method.__name__))
return None
try:
# python2
return method.im_class
except AttributeError:
# python3 does not have unbound methods, it just has bound
# methods and functions, so the im_class attribute also
# disappeared
for klass in inspect.getmro(method.__self__.__class__):
if klass.__dict__.get(method.__name__) is method.__func__:
return klass
else:
print(
"Couldn't determine to which class the method '{}' "
"is bound."
.format(method.__name__))
return None
def get_tag_description(func):
klass = get_flask_classy_class(func)
if klass:
return klass.__doc__ or ''
def get_parameter_types(rule):
"""Parse the werkzeug rule to get the parameter types"""
param_types = {}
for type_, param in re.findall(r'\/<(\w+):(.*)>\/', rule.rule):
param_types[param] = WERKZEUG_SWAGGER_TYPES.get(
type_, DEFAULT_TYPE)
return param_types
def get_parameters(rule, method):
"""Return parameters for the passed-in method
Currently only returns 'path' parameters i.e. there is no support
for 'body' params.
"""
if method is None:
return []
argspec = inspect.getargspec(method)
if argspec.defaults is None:
# all are required
optional = []
required = [
{'name': p, 'required': True}
for p in argspec.args
]
else:
optional = [
{'name': p, 'required': False}
for p in argspec.args[-len(argspec.defaults):]
]
required = [
{'name': p, 'required': True}
for p in argspec.args[:-len(argspec.defaults)]
]
if required and required[0]['name'] == 'self': # assert this?
required.pop(0)
param_types = get_parameter_types(rule)
parameters = []
for p in required + optional:
type_, format_ = param_types.get(p['name'], DEFAULT_TYPE)
p['type'] = type_
if format_:
p['format'] = format_
# they are all path arguments because flask-classy puts them
# there if they are method parameters
p['in'] = 'path'
parameters.append(p)
return parameters
def get_api_method(app, rule):
"""Return the original Flask-Classy method as the user first wrote it
This means without any decorators applied.
:app: a Flask app object
:rule: a werkzeug.routing.Rule object
"""
return undecorated(app.view_functions[rule.endpoint])
def unindented_method_code(method):
indented = inspect.getsource(method)
indentation = re.match('(\s+)', indented)
if indentation:
unindented = re.sub('(?m)^{}'.format(
indentation.groups()[0]), '', indented)
else:
unindented = indented
return unindented
def get_status_code(method):
class MyVisitor(ast.NodeVisitor):
status_code = 'unknown'
def visit_Return(self, node):
# assume that returning the result of a jsonify call (note:
# we don't even know if this is flask's jsonify or if it
# comes from a different module) means the status code is
# 200
if (
node.value and
# TODO fix the ugly
isinstance(node.value, ast.Call) and
node.value.func.id == 'jsonify'
):
self.status_code = '200'
visitor = MyVisitor()
code = unindented_method_code(method)
visitor.visit(ast.parse(code))
return visitor.status_code
def generate_everything(app, title, version, base_path=None):
"""Build the whole swagger JSON tree for this app"""
paths = defaultdict(dict)
tags = dict()
for rule in app.url_map.iter_rules():
if is_ignored(rule):
continue
path = get_path(rule)
method = get_api_method(app, rule)
status_code = get_status_code(method)
summary, description = get_docs(method)
parameters = get_parameters(rule, method)
tag = get_tag(rule)
if tag not in tags:
tags[tag] = get_tag_description(method)
path_item_object = {
"summary": summary,
"description": description,
"tags": [tag],
"parameters": parameters,
"responses": {
status_code: {
# TODO think of a better way to get descriptions
'description': 'Success'
}
}
}
path_item_name = http_verb(rule)
paths[path][path_item_name] = path_item_object
docs = schema(title, version, base_path)
docs['paths'] = paths
docs['tags'] = [{'name': k, 'description': v}
for k, v in tags.items()]
return docs
def swaggerify(
app, title, version, swagger_path=SWAGGER_PATH, base_path=None):
@app.route(swagger_path)
def swagger():
docs = generate_everything(app, title, version, base_path)
return jsonify(docs)