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
|
|
|
|