]>
Commit | Line | Data |
---|---|---|
11fdf7f2 TL |
1 | # -*- coding: utf-8 -*- |
2 | from __future__ import absolute_import | |
f6b5b4d7 | 3 | from typing import Any, Dict, Union |
11fdf7f2 | 4 | |
9f95a23c | 5 | import logging |
11fdf7f2 TL |
6 | import cherrypy |
7 | ||
f91f0fd5 TL |
8 | from . import Controller, BaseController, Endpoint, ENDPOINT_MAP, \ |
9 | allow_empty_body | |
9f95a23c | 10 | from .. import mgr |
11fdf7f2 TL |
11 | |
12 | from ..tools import str_to_bool | |
13 | ||
14 | ||
9f95a23c TL |
15 | logger = logging.getLogger('controllers.docs') |
16 | ||
17 | ||
11fdf7f2 TL |
18 | @Controller('/docs', secure=False) |
19 | class Docs(BaseController): | |
20 | ||
21 | @classmethod | |
22 | def _gen_tags(cls, all_endpoints): | |
23 | """ Generates a list of all tags and corresponding descriptions. """ | |
24 | # Scenarios to consider: | |
25 | # * Intentionally make up a new tag name at controller => New tag name displayed. | |
26 | # * Misspell or make up a new tag name at endpoint => Neither tag or endpoint displayed. | |
27 | # * Misspell tag name at controller (when referring to another controller) => | |
28 | # Tag displayed but no endpoints assigned | |
29 | # * Description for a tag added at multiple locations => Only one description displayed. | |
30 | list_of_ctrl = set() | |
31 | for endpoints in ENDPOINT_MAP.values(): | |
32 | for endpoint in endpoints: | |
33 | if endpoint.is_api or all_endpoints: | |
34 | list_of_ctrl.add(endpoint.ctrl) | |
35 | ||
f6b5b4d7 | 36 | tag_map: Dict[str, str] = {} |
11fdf7f2 TL |
37 | for ctrl in list_of_ctrl: |
38 | tag_name = ctrl.__name__ | |
39 | tag_descr = "" | |
40 | if hasattr(ctrl, 'doc_info'): | |
41 | if ctrl.doc_info['tag']: | |
42 | tag_name = ctrl.doc_info['tag'] | |
43 | tag_descr = ctrl.doc_info['tag_descr'] | |
9f95a23c TL |
44 | if tag_name not in tag_map or not tag_map[tag_name]: |
45 | tag_map[tag_name] = tag_descr | |
11fdf7f2 TL |
46 | |
47 | tags = [{'name': k, 'description': v if v else "*No description available*"} | |
9f95a23c | 48 | for k, v in tag_map.items()] |
11fdf7f2 TL |
49 | tags.sort(key=lambda e: e['name']) |
50 | return tags | |
51 | ||
52 | @classmethod | |
53 | def _get_tag(cls, endpoint): | |
54 | """ Returns the name of a tag to assign to a path. """ | |
55 | ctrl = endpoint.ctrl | |
56 | func = endpoint.func | |
57 | tag = ctrl.__name__ | |
58 | if hasattr(func, 'doc_info') and func.doc_info['tag']: | |
59 | tag = func.doc_info['tag'] | |
60 | elif hasattr(ctrl, 'doc_info') and ctrl.doc_info['tag']: | |
61 | tag = ctrl.doc_info['tag'] | |
62 | return tag | |
63 | ||
64 | @classmethod | |
65 | def _gen_type(cls, param): | |
66 | # pylint: disable=too-many-return-statements | |
67 | """ | |
68 | Generates the type of parameter based on its name and default value, | |
69 | using very simple heuristics. | |
70 | Used if type is not explicitly defined. | |
71 | """ | |
72 | param_name = param['name'] | |
73 | def_value = param['default'] if 'default' in param else None | |
74 | if param_name.startswith("is_"): | |
75 | return "boolean" | |
76 | if "size" in param_name: | |
77 | return "integer" | |
78 | if "count" in param_name: | |
79 | return "integer" | |
80 | if "num" in param_name: | |
81 | return "integer" | |
82 | if isinstance(def_value, bool): | |
83 | return "boolean" | |
84 | if isinstance(def_value, int): | |
85 | return "integer" | |
86 | return "string" | |
87 | ||
88 | @classmethod | |
89 | # isinstance doesn't work: input is always <type 'type'>. | |
90 | def _type_to_str(cls, type_as_type): | |
91 | """ Used if type is explicitly defined. """ | |
92 | if type_as_type is str: | |
93 | type_as_str = 'string' | |
94 | elif type_as_type is int: | |
95 | type_as_str = 'integer' | |
96 | elif type_as_type is bool: | |
97 | type_as_str = 'boolean' | |
98 | elif type_as_type is list or type_as_type is tuple: | |
99 | type_as_str = 'array' | |
100 | elif type_as_type is float: | |
101 | type_as_str = 'number' | |
102 | else: | |
103 | type_as_str = 'object' | |
104 | return type_as_str | |
105 | ||
106 | @classmethod | |
107 | def _add_param_info(cls, parameters, p_info): | |
108 | # Cases to consider: | |
109 | # * Parameter name (if not nested) misspelt in decorator => parameter not displayed | |
110 | # * Sometimes a parameter is used for several endpoints (e.g. fs_id in CephFS). | |
111 | # Currently, there is no possibility of reuse. Should there be? | |
112 | # But what if there are two parameters with same name but different functionality? | |
113 | """ | |
114 | Adds explicitly described information for parameters of an endpoint. | |
115 | ||
116 | There are two cases: | |
117 | * Either the parameter in p_info corresponds to an endpoint parameter. Implicit information | |
118 | has higher priority, so only information that doesn't already exist is added. | |
119 | * Or the parameter in p_info describes a nested parameter inside an endpoint parameter. | |
120 | In that case there is no implicit information at all so all explicitly described info needs | |
121 | to be added. | |
122 | """ | |
123 | for p in p_info: | |
124 | if not p['nested']: | |
125 | for parameter in parameters: | |
126 | if p['name'] == parameter['name']: | |
127 | parameter['type'] = p['type'] | |
128 | parameter['description'] = p['description'] | |
129 | if 'nested_params' in p: | |
130 | parameter['nested_params'] = cls._add_param_info([], p['nested_params']) | |
131 | else: | |
132 | nested_p = { | |
133 | 'name': p['name'], | |
134 | 'type': p['type'], | |
135 | 'description': p['description'], | |
136 | 'required': p['required'], | |
137 | } | |
138 | if 'default' in p: | |
139 | nested_p['default'] = p['default'] | |
140 | if 'nested_params' in p: | |
141 | nested_p['nested_params'] = cls._add_param_info([], p['nested_params']) | |
142 | parameters.append(nested_p) | |
143 | ||
144 | return parameters | |
145 | ||
146 | @classmethod | |
147 | def _gen_schema_for_content(cls, params): | |
148 | """ | |
149 | Generates information to the content-object in OpenAPI Spec. | |
150 | Used to for request body and responses. | |
151 | """ | |
152 | required_params = [] | |
153 | properties = {} | |
154 | ||
155 | for param in params: | |
156 | if param['required']: | |
157 | required_params.append(param['name']) | |
158 | ||
159 | props = {} | |
160 | if 'type' in param: | |
161 | props['type'] = cls._type_to_str(param['type']) | |
162 | if 'nested_params' in param: | |
163 | if props['type'] == 'array': # dict in array | |
164 | props['items'] = cls._gen_schema_for_content(param['nested_params']) | |
165 | else: # dict in dict | |
166 | props = cls._gen_schema_for_content(param['nested_params']) | |
167 | elif props['type'] == 'object': # e.g. [int] | |
168 | props['type'] = 'array' | |
169 | props['items'] = {'type': cls._type_to_str(param['type'][0])} | |
170 | else: | |
171 | props['type'] = cls._gen_type(param) | |
172 | if 'description' in param: | |
173 | props['description'] = param['description'] | |
174 | if 'default' in param: | |
175 | props['default'] = param['default'] | |
176 | properties[param['name']] = props | |
177 | ||
178 | schema = { | |
179 | 'type': 'object', | |
180 | 'properties': properties, | |
181 | } | |
182 | if required_params: | |
183 | schema['required'] = required_params | |
184 | return schema | |
185 | ||
186 | @classmethod | |
187 | def _gen_responses(cls, method, resp_object=None): | |
f6b5b4d7 | 188 | resp: Dict[str, Dict[str, Union[str, Any]]] = { |
11fdf7f2 TL |
189 | '400': { |
190 | "description": "Operation exception. Please check the " | |
191 | "response body for details." | |
192 | }, | |
193 | '401': { | |
194 | "description": "Unauthenticated access. Please login first." | |
195 | }, | |
196 | '403': { | |
197 | "description": "Unauthorized access. Please check your " | |
198 | "permissions." | |
199 | }, | |
200 | '500': { | |
201 | "description": "Unexpected error. Please check the " | |
202 | "response body for the stack trace." | |
203 | } | |
204 | } | |
205 | if method.lower() == 'get': | |
206 | resp['200'] = {'description': "OK"} | |
207 | if method.lower() == 'post': | |
208 | resp['201'] = {'description': "Resource created."} | |
209 | if method.lower() == 'put': | |
210 | resp['200'] = {'description': "Resource updated."} | |
211 | if method.lower() == 'delete': | |
212 | resp['204'] = {'description': "Resource deleted."} | |
213 | if method.lower() in ['post', 'put', 'delete']: | |
214 | resp['202'] = {'description': "Operation is still executing." | |
215 | " Please check the task queue."} | |
216 | ||
217 | if resp_object: | |
218 | for status_code, response_body in resp_object.items(): | |
219 | resp[status_code].update({ | |
220 | 'content': { | |
221 | 'application/json': { | |
222 | 'schema': cls._gen_schema_for_content(response_body)}}}) | |
223 | ||
224 | return resp | |
225 | ||
226 | @classmethod | |
227 | def _gen_params(cls, params, location): | |
228 | parameters = [] | |
229 | for param in params: | |
230 | if 'type' in param: | |
231 | _type = cls._type_to_str(param['type']) | |
232 | else: | |
233 | _type = cls._gen_type(param) | |
234 | if 'description' in param: | |
235 | descr = param['description'] | |
236 | else: | |
237 | descr = "*No description available*" | |
238 | res = { | |
239 | 'name': param['name'], | |
240 | 'in': location, | |
241 | 'schema': { | |
242 | 'type': _type | |
243 | }, | |
244 | 'description': descr | |
245 | } | |
246 | if param['required']: | |
247 | res['required'] = True | |
248 | elif param['default'] is None: | |
249 | res['allowEmptyValue'] = True | |
250 | else: | |
251 | res['default'] = param['default'] | |
252 | parameters.append(res) | |
253 | ||
254 | return parameters | |
255 | ||
256 | @classmethod | |
f6b5b4d7 | 257 | def _gen_paths(cls, all_endpoints): |
9f95a23c | 258 | method_order = ['get', 'post', 'put', 'delete'] |
11fdf7f2 TL |
259 | paths = {} |
260 | for path, endpoints in sorted(list(ENDPOINT_MAP.items()), | |
261 | key=lambda p: p[0]): | |
262 | methods = {} | |
263 | skip = False | |
264 | ||
265 | endpoint_list = sorted(endpoints, key=lambda e: | |
9f95a23c | 266 | method_order.index(e.method.lower())) |
11fdf7f2 TL |
267 | for endpoint in endpoint_list: |
268 | if not endpoint.is_api and not all_endpoints: | |
269 | skip = True | |
270 | break | |
271 | ||
272 | method = endpoint.method | |
273 | func = endpoint.func | |
274 | ||
275 | summary = "No description available" | |
276 | resp = {} | |
277 | p_info = [] | |
278 | if hasattr(func, 'doc_info'): | |
279 | if func.doc_info['summary']: | |
280 | summary = func.doc_info['summary'] | |
281 | resp = func.doc_info['response'] | |
282 | p_info = func.doc_info['parameters'] | |
283 | params = [] | |
284 | if endpoint.path_params: | |
285 | params.extend( | |
286 | cls._gen_params( | |
287 | cls._add_param_info(endpoint.path_params, p_info), 'path')) | |
288 | if endpoint.query_params: | |
289 | params.extend( | |
290 | cls._gen_params( | |
291 | cls._add_param_info(endpoint.query_params, p_info), 'query')) | |
292 | ||
293 | methods[method.lower()] = { | |
294 | 'tags': [cls._get_tag(endpoint)], | |
295 | 'summary': summary, | |
296 | 'description': func.__doc__, | |
297 | 'parameters': params, | |
298 | 'responses': cls._gen_responses(method, resp) | |
299 | } | |
300 | ||
301 | if method.lower() in ['post', 'put']: | |
302 | if endpoint.body_params: | |
303 | body_params = cls._add_param_info(endpoint.body_params, p_info) | |
304 | methods[method.lower()]['requestBody'] = { | |
305 | 'content': { | |
306 | 'application/json': { | |
307 | 'schema': cls._gen_schema_for_content(body_params)}}} | |
308 | ||
309 | if endpoint.is_secure: | |
310 | methods[method.lower()]['security'] = [{'jwt': []}] | |
311 | ||
312 | if not skip: | |
f6b5b4d7 | 313 | paths[path] = methods |
11fdf7f2 TL |
314 | |
315 | return paths | |
316 | ||
317 | def _gen_spec(self, all_endpoints=False, base_url=""): | |
318 | if all_endpoints: | |
319 | base_url = "" | |
320 | ||
321 | host = cherrypy.request.base | |
322 | host = host[host.index(':')+3:] | |
9f95a23c | 323 | logger.debug("Host: %s", host) |
11fdf7f2 | 324 | |
f6b5b4d7 | 325 | paths = self._gen_paths(all_endpoints) |
11fdf7f2 TL |
326 | |
327 | if not base_url: | |
328 | base_url = "/" | |
329 | ||
330 | scheme = 'https' | |
331 | ssl = str_to_bool(mgr.get_localized_module_option('ssl', True)) | |
332 | if not ssl: | |
333 | scheme = 'http' | |
334 | ||
335 | spec = { | |
336 | 'openapi': "3.0.0", | |
337 | 'info': { | |
338 | 'description': "Please note that this API is not an official " | |
339 | "Ceph REST API to be used by third-party " | |
340 | "applications. It's primary purpose is to serve" | |
341 | " the requirements of the Ceph Dashboard and is" | |
342 | " subject to change at any time. Use at your " | |
343 | "own risk.", | |
344 | 'version': "v1", | |
345 | 'title': "Ceph-Dashboard REST API" | |
346 | }, | |
347 | 'host': host, | |
348 | 'basePath': base_url, | |
349 | 'servers': [{'url': "{}{}".format(cherrypy.request.base, base_url)}], | |
350 | 'tags': self._gen_tags(all_endpoints), | |
351 | 'schemes': [scheme], | |
352 | 'paths': paths, | |
353 | 'components': { | |
354 | 'securitySchemes': { | |
355 | 'jwt': { | |
356 | 'type': 'http', | |
357 | 'scheme': 'bearer', | |
358 | 'bearerFormat': 'JWT' | |
359 | } | |
360 | } | |
361 | } | |
362 | } | |
363 | ||
364 | return spec | |
365 | ||
366 | @Endpoint(path="api.json") | |
367 | def api_json(self): | |
f6b5b4d7 | 368 | return self._gen_spec(False, "/") |
11fdf7f2 TL |
369 | |
370 | @Endpoint(path="api-all.json") | |
371 | def api_all_json(self): | |
f6b5b4d7 | 372 | return self._gen_spec(True, "/") |
11fdf7f2 TL |
373 | |
374 | def _swagger_ui_page(self, all_endpoints=False, token=None): | |
375 | base = cherrypy.request.base | |
376 | if all_endpoints: | |
377 | spec_url = "{}/docs/api-all.json".format(base) | |
378 | else: | |
379 | spec_url = "{}/docs/api.json".format(base) | |
380 | ||
381 | auth_header = cherrypy.request.headers.get('authorization') | |
adb31ebb | 382 | auth_cookie = cherrypy.request.cookie['token'] |
11fdf7f2 | 383 | jwt_token = "" |
adb31ebb TL |
384 | if auth_cookie is not None: |
385 | jwt_token = auth_cookie.value | |
386 | elif auth_header is not None: | |
11fdf7f2 TL |
387 | scheme, params = auth_header.split(' ', 1) |
388 | if scheme.lower() == 'bearer': | |
389 | jwt_token = params | |
390 | else: | |
391 | if token is not None: | |
392 | jwt_token = token | |
393 | ||
394 | api_key_callback = """, onComplete: () => {{ | |
395 | ui.preauthorizeApiKey('jwt', '{}'); | |
396 | }} | |
397 | """.format(jwt_token) | |
398 | ||
399 | page = """ | |
400 | <!DOCTYPE html> | |
401 | <html> | |
402 | <head> | |
403 | <meta charset="UTF-8"> | |
404 | <meta name="referrer" content="no-referrer" /> | |
11fdf7f2 | 405 | <link rel="stylesheet" type="text/css" |
9f95a23c | 406 | href="/swagger-ui.css" > |
11fdf7f2 TL |
407 | <style> |
408 | html | |
409 | {{ | |
410 | box-sizing: border-box; | |
411 | overflow: -moz-scrollbars-vertical; | |
412 | overflow-y: scroll; | |
413 | }} | |
414 | *, | |
415 | *:before, | |
416 | *:after | |
417 | {{ | |
418 | box-sizing: inherit; | |
419 | }} | |
420 | body {{ | |
421 | margin:0; | |
422 | background: #fafafa; | |
423 | }} | |
424 | </style> | |
425 | </head> | |
426 | <body> | |
427 | <div id="swagger-ui"></div> | |
9f95a23c | 428 | <script src="/swagger-ui-bundle.js"> |
11fdf7f2 TL |
429 | </script> |
430 | <script> | |
431 | window.onload = function() {{ | |
432 | const ui = SwaggerUIBundle({{ | |
433 | url: '{}', | |
434 | dom_id: '#swagger-ui', | |
435 | presets: [ | |
436 | SwaggerUIBundle.presets.apis | |
437 | ], | |
438 | layout: "BaseLayout" | |
439 | {} | |
440 | }}) | |
441 | window.ui = ui | |
442 | }} | |
443 | </script> | |
444 | </body> | |
445 | </html> | |
446 | """.format(spec_url, api_key_callback) | |
447 | ||
448 | return page | |
449 | ||
450 | @Endpoint(json_response=False) | |
451 | def __call__(self, all_endpoints=False): | |
452 | return self._swagger_ui_page(all_endpoints) | |
453 | ||
454 | @Endpoint('POST', path="/", json_response=False, | |
455 | query_params="{all_endpoints}") | |
f91f0fd5 | 456 | @allow_empty_body |
11fdf7f2 TL |
457 | def _with_token(self, token, all_endpoints=False): |
458 | return self._swagger_ui_page(all_endpoints, token) |