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