1
|
|
|
"""Deal with OpenAPI v3.""" |
2
|
1 |
|
import json |
3
|
1 |
|
import re |
4
|
|
|
|
5
|
1 |
|
from jinja2 import Environment, FileSystemLoader |
6
|
|
|
|
7
|
|
|
|
8
|
1 |
|
class OpenAPI: # pylint: disable=too-few-public-methods |
9
|
|
|
"""Create OpenAPI skeleton.""" |
10
|
|
|
|
11
|
1 |
|
def __init__(self, napp_path, tpl_path): |
12
|
|
|
"""Instantiate an OpenAPI object. |
13
|
|
|
|
14
|
|
|
Args: |
15
|
|
|
napp_path (string): Napp directory |
16
|
|
|
tlp_path (string): File name from template |
17
|
|
|
|
18
|
|
|
""" |
19
|
1 |
|
self._napp_path = napp_path |
20
|
1 |
|
self._template = tpl_path / 'openapi.yml.template' |
21
|
1 |
|
self._api_file = napp_path / 'openapi.yml' |
22
|
|
|
|
23
|
1 |
|
self._napp_dict = self._parse_napp_metadata() |
24
|
|
|
|
25
|
|
|
# Data for a path |
26
|
1 |
|
self._summary = None |
27
|
1 |
|
self._description = None |
28
|
|
|
|
29
|
|
|
# Part of template context |
30
|
1 |
|
self._paths = {} |
31
|
|
|
|
32
|
1 |
|
def render_template(self): |
33
|
|
|
"""Render and save API doc in openapi.yml.""" |
34
|
1 |
|
self._parse_paths() |
35
|
1 |
|
context = dict(napp=self._napp_dict, paths=self._paths) |
36
|
1 |
|
self._save(context) |
37
|
|
|
|
38
|
1 |
|
def _parse_napp_metadata(self): |
39
|
|
|
"""Return a NApp metadata file.""" |
40
|
1 |
|
filename = self._napp_path / 'kytos.json' |
41
|
1 |
|
with open(filename, encoding='utf-8') as data_file: |
42
|
1 |
|
data = json.loads(data_file.read()) |
43
|
1 |
|
return data |
44
|
|
|
|
45
|
1 |
|
def _parse_paths(self): |
46
|
1 |
|
main_file = self._napp_path / 'main.py' |
47
|
1 |
|
code = main_file.open().read() |
48
|
1 |
|
return self._parse_decorated_functions(code) |
49
|
|
|
|
50
|
1 |
|
def _parse_decorated_functions(self, code): |
51
|
|
|
"""Return URL rule, HTTP methods and docstring.""" |
52
|
1 |
|
matches = re.finditer(r""" |
53
|
|
|
# @rest decorators |
54
|
|
|
(?P<decorators> |
55
|
|
|
(?:@rest\(.+?\)\n)+ # one or more @rest decorators inside |
56
|
|
|
) |
57
|
|
|
# docstring delimited by 3 double quotes |
58
|
|
|
.+?"{3}(?P<docstring>.+?)"{3} |
59
|
|
|
""", code, re.VERBOSE | re.DOTALL) |
60
|
|
|
|
61
|
1 |
|
for function_match in matches: |
62
|
1 |
|
m_dict = function_match.groupdict() |
63
|
1 |
|
self._parse_docstring(m_dict['docstring']) |
64
|
1 |
|
self._add_function_paths(m_dict['decorators']) |
65
|
|
|
|
66
|
1 |
|
def _get_absolute_rule(self, rule): |
67
|
1 |
|
napp_prefix = "/api/{username}/{name}/" |
68
|
1 |
|
relative_rule = rule[1:] if rule.startswith('/') else rule |
69
|
1 |
|
return napp_prefix.format_map(self._napp_dict) + relative_rule |
70
|
|
|
|
71
|
1 |
|
def _add_function_paths(self, decorators_str): |
72
|
1 |
|
for rule, parsed_methods in self._parse_decorators(decorators_str): |
73
|
1 |
|
absolute_rule = self._get_absolute_rule(rule) |
74
|
1 |
|
path_url = self._rule2path(absolute_rule) |
75
|
1 |
|
path_methods = self._paths.setdefault(path_url, {}) |
76
|
1 |
|
self._add_methods(parsed_methods, path_methods) |
77
|
|
|
|
78
|
1 |
|
def _parse_docstring(self, docstring): |
79
|
|
|
"""Parse the method docstring.""" |
80
|
1 |
|
match = re.match(r""" |
81
|
|
|
# Following PEP 257 |
82
|
|
|
\s* (?P<summary>[^\n]+?) \s* # First line |
83
|
|
|
|
84
|
|
|
( # Description and YAML are optional |
85
|
|
|
(\n \s*){2} # Blank line |
86
|
|
|
|
87
|
|
|
# Description (optional) |
88
|
|
|
( |
89
|
|
|
(?!-{3,}) # Don't use YAML as description |
90
|
|
|
\s* (?P<description>.+?) \s* # Third line and maybe others |
91
|
|
|
(?=-{3,})? # Stop if "---" is found |
92
|
|
|
)? |
93
|
|
|
|
94
|
|
|
# YAML spec (optional) **currently not used** |
95
|
|
|
( |
96
|
|
|
-{3,}\n # "---" begins yaml spec |
97
|
|
|
(?P<open_api>.+) |
98
|
|
|
)? |
99
|
|
|
)? |
100
|
|
|
$""", docstring, re.VERBOSE | re.DOTALL) |
101
|
|
|
|
102
|
1 |
|
summary = 'TODO write the summary.' |
103
|
1 |
|
description = 'TODO write/remove the description' |
104
|
1 |
|
if match: |
105
|
1 |
|
m_dict = match.groupdict() |
106
|
1 |
|
summary = m_dict['summary'] |
107
|
1 |
|
if m_dict['description']: |
108
|
|
|
description = re.sub(r'(\s|\n){2,}', ' ', |
109
|
|
|
m_dict['description']) |
110
|
1 |
|
self._summary = summary |
111
|
1 |
|
self._description = description |
112
|
|
|
|
113
|
1 |
|
def _parse_decorators(self, decorators_str): |
114
|
1 |
|
matches = re.finditer(r""" |
115
|
|
|
@rest\( |
116
|
|
|
|
117
|
|
|
## Endpoint rule |
118
|
|
|
(?P<quote>['"]) # inside single or double quotes |
119
|
|
|
(?P<rule>.+?) |
120
|
|
|
(?P=quote) |
121
|
|
|
|
122
|
|
|
## HTTP methods (optional) |
123
|
|
|
(\s*,\s* |
124
|
|
|
methods=(?P<methods>\[.+?\]) |
125
|
|
|
)? |
126
|
|
|
|
127
|
|
|
.*?\)\s*$ |
128
|
|
|
""", decorators_str, re.VERBOSE) |
129
|
|
|
|
130
|
1 |
|
for match in matches: |
131
|
1 |
|
rule = match.group('rule') |
132
|
1 |
|
methods = self._parse_methods(match.group('methods')) |
133
|
1 |
|
yield rule, methods |
134
|
|
|
|
135
|
1 |
|
@classmethod |
136
|
|
|
def _parse_methods(cls, list_string): |
137
|
|
|
"""Return HTTP method list. Use json for security reasons.""" |
138
|
1 |
|
if list_string is None: |
139
|
|
|
return ('GET',) |
140
|
|
|
# json requires double quotes |
141
|
1 |
|
json_list = list_string.replace("'", '"') |
142
|
1 |
|
return json.loads(json_list) |
143
|
|
|
|
144
|
1 |
|
def _add_methods(self, methods, path_methods): |
145
|
1 |
|
for method in methods: |
146
|
1 |
|
path_method = dict(summary=self._summary, |
147
|
|
|
description=self._description) |
148
|
1 |
|
path_methods[method.lower()] = path_method |
149
|
|
|
|
150
|
1 |
|
@classmethod |
151
|
|
|
def _rule2path(cls, rule): |
152
|
|
|
"""Convert relative Flask rule to absolute OpenAPI path.""" |
153
|
1 |
|
typeless = re.sub(r'<\w+?:', '<', rule) # remove Flask types |
154
|
1 |
|
return typeless.replace('<', '{').replace('>', '}') # <> -> {} |
155
|
|
|
|
156
|
1 |
|
def _read_napp_info(self): |
157
|
1 |
|
filename = self._napp_path / 'kytos.json' |
158
|
1 |
|
return json.load(filename.open()) |
159
|
|
|
|
160
|
1 |
|
def _save(self, context): |
161
|
1 |
|
tpl_env = Environment( |
162
|
|
|
loader=FileSystemLoader(str(self._template.parent)), |
163
|
|
|
trim_blocks=True) |
164
|
1 |
|
content = tpl_env.get_template( |
165
|
|
|
'openapi.yml.template').render(context) |
166
|
1 |
|
with self._api_file.open('w') as openapi: |
167
|
|
|
openapi.write(content) |
168
|
|
|
|