]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/controllers/docs.py
import ceph pacific 16.2.5
[ceph.git] / ceph / src / pybind / mgr / dashboard / controllers / docs.py
CommitLineData
11fdf7f2
TL
1# -*- coding: utf-8 -*-
2from __future__ import absolute_import
3
9f95a23c 4import logging
f67539c2 5from typing import Any, Dict, List, Union
11fdf7f2 6
f67539c2 7import cherrypy
11fdf7f2 8
f67539c2
TL
9from .. import DEFAULT_VERSION, mgr
10from ..api.doc import Schema, SchemaInput, SchemaType
18d92ca7 11from . import ENDPOINT_MAP, BaseController, Controller, Endpoint
11fdf7f2 12
f67539c2 13NO_DESCRIPTION_AVAILABLE = "*No description available*"
11fdf7f2 14
9f95a23c
TL
15logger = logging.getLogger('controllers.docs')
16
17
11fdf7f2
TL
18@Controller('/docs', secure=False)
19class 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 46
f67539c2 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_"):
f67539c2 75 return str(SchemaType.BOOLEAN)
11fdf7f2 76 if "size" in param_name:
f67539c2 77 return str(SchemaType.INTEGER)
11fdf7f2 78 if "count" in param_name:
f67539c2 79 return str(SchemaType.INTEGER)
11fdf7f2 80 if "num" in param_name:
f67539c2 81 return str(SchemaType.INTEGER)
11fdf7f2 82 if isinstance(def_value, bool):
f67539c2 83 return str(SchemaType.BOOLEAN)
11fdf7f2 84 if isinstance(def_value, int):
f67539c2
TL
85 return str(SchemaType.INTEGER)
86 return str(SchemaType.STRING)
11fdf7f2
TL
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:
f67539c2 93 type_as_str = str(SchemaType.STRING)
11fdf7f2 94 elif type_as_type is int:
f67539c2 95 type_as_str = str(SchemaType.INTEGER)
11fdf7f2 96 elif type_as_type is bool:
f67539c2 97 type_as_str = str(SchemaType.BOOLEAN)
11fdf7f2 98 elif type_as_type is list or type_as_type is tuple:
f67539c2 99 type_as_str = str(SchemaType.ARRAY)
11fdf7f2 100 elif type_as_type is float:
f67539c2 101 type_as_str = str(SchemaType.NUMBER)
11fdf7f2 102 else:
f67539c2 103 type_as_str = str(SchemaType.OBJECT)
11fdf7f2
TL
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
f67539c2 147 def _gen_schema_for_content(cls, params: List[Any]) -> Dict[str, Any]:
11fdf7f2
TL
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 = {}
f67539c2
TL
154 schema_type = SchemaType.OBJECT
155 if isinstance(params, SchemaInput):
156 schema_type = params.type
157 params = params.params
11fdf7f2
TL
158
159 for param in params:
160 if param['required']:
161 required_params.append(param['name'])
162
163 props = {}
164 if 'type' in param:
165 props['type'] = cls._type_to_str(param['type'])
166 if 'nested_params' in param:
f67539c2 167 if props['type'] == str(SchemaType.ARRAY): # dict in array
11fdf7f2
TL
168 props['items'] = cls._gen_schema_for_content(param['nested_params'])
169 else: # dict in dict
170 props = cls._gen_schema_for_content(param['nested_params'])
f67539c2
TL
171 elif props['type'] == str(SchemaType.OBJECT): # e.g. [int]
172 props['type'] = str(SchemaType.ARRAY)
11fdf7f2
TL
173 props['items'] = {'type': cls._type_to_str(param['type'][0])}
174 else:
175 props['type'] = cls._gen_type(param)
176 if 'description' in param:
177 props['description'] = param['description']
178 if 'default' in param:
179 props['default'] = param['default']
180 properties[param['name']] = props
181
f67539c2
TL
182 schema = Schema(schema_type=schema_type, properties=properties,
183 required=required_params)
184
185 return schema.as_dict()
11fdf7f2
TL
186
187 @classmethod
b3b6e05e 188 def _gen_responses(cls, method, resp_object=None, version=None):
f6b5b4d7 189 resp: Dict[str, Dict[str, Union[str, Any]]] = {
11fdf7f2
TL
190 '400': {
191 "description": "Operation exception. Please check the "
192 "response body for details."
193 },
194 '401': {
195 "description": "Unauthenticated access. Please login first."
196 },
197 '403': {
198 "description": "Unauthorized access. Please check your "
199 "permissions."
200 },
201 '500': {
202 "description": "Unexpected error. Please check the "
203 "response body for the stack trace."
204 }
205 }
b3b6e05e
TL
206
207 if not version:
208 version = DEFAULT_VERSION
209
11fdf7f2 210 if method.lower() == 'get':
f67539c2 211 resp['200'] = {'description': "OK",
b3b6e05e 212 'content': {'application/vnd.ceph.api.v{}+json'.format(version):
f67539c2 213 {'type': 'object'}}}
11fdf7f2 214 if method.lower() == 'post':
f67539c2 215 resp['201'] = {'description': "Resource created.",
b3b6e05e 216 'content': {'application/vnd.ceph.api.v{}+json'.format(version):
f67539c2 217 {'type': 'object'}}}
11fdf7f2 218 if method.lower() == 'put':
f67539c2 219 resp['200'] = {'description': "Resource updated.",
b3b6e05e 220 'content': {'application/vnd.ceph.api.v{}+json'.format(version):
f67539c2 221 {'type': 'object'}}}
11fdf7f2 222 if method.lower() == 'delete':
f67539c2 223 resp['204'] = {'description': "Resource deleted.",
b3b6e05e 224 'content': {'application/vnd.ceph.api.v{}+json'.format(version):
f67539c2 225 {'type': 'object'}}}
11fdf7f2
TL
226 if method.lower() in ['post', 'put', 'delete']:
227 resp['202'] = {'description': "Operation is still executing."
f67539c2 228 " Please check the task queue.",
b3b6e05e 229 'content': {'application/vnd.ceph.api.v{}+json'.format(version):
f67539c2 230 {'type': 'object'}}}
11fdf7f2
TL
231
232 if resp_object:
233 for status_code, response_body in resp_object.items():
f67539c2
TL
234 if status_code in resp:
235 resp[status_code].update({
236 'content': {
b3b6e05e 237 'application/vnd.ceph.api.v{}+json'.format(version): {
f67539c2 238 'schema': cls._gen_schema_for_content(response_body)}}})
11fdf7f2
TL
239
240 return resp
241
242 @classmethod
243 def _gen_params(cls, params, location):
244 parameters = []
245 for param in params:
246 if 'type' in param:
247 _type = cls._type_to_str(param['type'])
248 else:
249 _type = cls._gen_type(param)
11fdf7f2
TL
250 res = {
251 'name': param['name'],
252 'in': location,
253 'schema': {
254 'type': _type
255 },
11fdf7f2 256 }
f67539c2
TL
257 if param.get('description'):
258 res['description'] = param['description']
11fdf7f2
TL
259 if param['required']:
260 res['required'] = True
261 elif param['default'] is None:
262 res['allowEmptyValue'] = True
263 else:
264 res['default'] = param['default']
265 parameters.append(res)
266
267 return parameters
268
269 @classmethod
b3b6e05e 270 def gen_paths(cls, all_endpoints):
f67539c2 271 # pylint: disable=R0912
9f95a23c 272 method_order = ['get', 'post', 'put', 'delete']
11fdf7f2
TL
273 paths = {}
274 for path, endpoints in sorted(list(ENDPOINT_MAP.items()),
275 key=lambda p: p[0]):
276 methods = {}
277 skip = False
278
279 endpoint_list = sorted(endpoints, key=lambda e:
9f95a23c 280 method_order.index(e.method.lower()))
11fdf7f2
TL
281 for endpoint in endpoint_list:
282 if not endpoint.is_api and not all_endpoints:
283 skip = True
284 break
285
286 method = endpoint.method
287 func = endpoint.func
288
f67539c2 289 summary = ''
b3b6e05e 290 version = ''
11fdf7f2
TL
291 resp = {}
292 p_info = []
b3b6e05e
TL
293
294 if hasattr(func, '__method_map_method__'):
295 version = func.__method_map_method__['version']
296
297 elif hasattr(func, '__resource_method__'):
298 version = func.__resource_method__['version']
299
300 elif hasattr(func, '__collection_method__'):
301 version = func.__collection_method__['version']
302
11fdf7f2
TL
303 if hasattr(func, 'doc_info'):
304 if func.doc_info['summary']:
305 summary = func.doc_info['summary']
306 resp = func.doc_info['response']
307 p_info = func.doc_info['parameters']
308 params = []
309 if endpoint.path_params:
310 params.extend(
311 cls._gen_params(
312 cls._add_param_info(endpoint.path_params, p_info), 'path'))
313 if endpoint.query_params:
314 params.extend(
315 cls._gen_params(
316 cls._add_param_info(endpoint.query_params, p_info), 'query'))
317
318 methods[method.lower()] = {
319 'tags': [cls._get_tag(endpoint)],
11fdf7f2
TL
320 'description': func.__doc__,
321 'parameters': params,
b3b6e05e 322 'responses': cls._gen_responses(method, resp, version)
11fdf7f2 323 }
f67539c2
TL
324 if summary:
325 methods[method.lower()]['summary'] = summary
11fdf7f2
TL
326
327 if method.lower() in ['post', 'put']:
328 if endpoint.body_params:
329 body_params = cls._add_param_info(endpoint.body_params, p_info)
330 methods[method.lower()]['requestBody'] = {
331 'content': {
332 'application/json': {
333 'schema': cls._gen_schema_for_content(body_params)}}}
334
f67539c2
TL
335 if endpoint.query_params:
336 query_params = cls._add_param_info(endpoint.query_params, p_info)
337 methods[method.lower()]['requestBody'] = {
338 'content': {
339 'application/json': {
340 'schema': cls._gen_schema_for_content(query_params)}}}
341
11fdf7f2
TL
342 if endpoint.is_secure:
343 methods[method.lower()]['security'] = [{'jwt': []}]
344
345 if not skip:
f6b5b4d7 346 paths[path] = methods
11fdf7f2
TL
347
348 return paths
349
f67539c2
TL
350 @classmethod
351 def _gen_spec(cls, all_endpoints=False, base_url="", offline=False):
11fdf7f2
TL
352 if all_endpoints:
353 base_url = ""
354
f67539c2 355 host = cherrypy.request.base.split('://', 1)[1] if not offline else 'example.com'
9f95a23c 356 logger.debug("Host: %s", host)
11fdf7f2 357
b3b6e05e 358 paths = cls.gen_paths(all_endpoints)
11fdf7f2
TL
359
360 if not base_url:
361 base_url = "/"
362
f67539c2 363 scheme = 'https' if offline or mgr.get_localized_module_option('ssl') else 'http'
11fdf7f2
TL
364
365 spec = {
366 'openapi': "3.0.0",
367 'info': {
f67539c2 368 'description': "This is the official Ceph REST API",
11fdf7f2 369 'version': "v1",
f67539c2 370 'title': "Ceph REST API"
11fdf7f2
TL
371 },
372 'host': host,
373 'basePath': base_url,
f67539c2
TL
374 'servers': [{'url': "{}{}".format(
375 cherrypy.request.base if not offline else '',
376 base_url)}],
377 'tags': cls._gen_tags(all_endpoints),
11fdf7f2
TL
378 'schemes': [scheme],
379 'paths': paths,
380 'components': {
381 'securitySchemes': {
382 'jwt': {
383 'type': 'http',
384 'scheme': 'bearer',
385 'bearerFormat': 'JWT'
386 }
387 }
388 }
389 }
390
391 return spec
392
f67539c2 393 @Endpoint(path="api.json", version=None)
11fdf7f2 394 def api_json(self):
f6b5b4d7 395 return self._gen_spec(False, "/")
11fdf7f2 396
f67539c2 397 @Endpoint(path="api-all.json", version=None)
11fdf7f2 398 def api_all_json(self):
f6b5b4d7 399 return self._gen_spec(True, "/")
11fdf7f2 400
18d92ca7 401 def _swagger_ui_page(self, all_endpoints=False):
11fdf7f2
TL
402 base = cherrypy.request.base
403 if all_endpoints:
404 spec_url = "{}/docs/api-all.json".format(base)
405 else:
406 spec_url = "{}/docs/api.json".format(base)
407
11fdf7f2
TL
408 page = """
409 <!DOCTYPE html>
410 <html>
411 <head>
412 <meta charset="UTF-8">
413 <meta name="referrer" content="no-referrer" />
11fdf7f2 414 <link rel="stylesheet" type="text/css"
9f95a23c 415 href="/swagger-ui.css" >
11fdf7f2
TL
416 <style>
417 html
418 {{
419 box-sizing: border-box;
420 overflow: -moz-scrollbars-vertical;
421 overflow-y: scroll;
422 }}
423 *,
424 *:before,
425 *:after
426 {{
427 box-sizing: inherit;
428 }}
429 body {{
430 margin:0;
431 background: #fafafa;
432 }}
433 </style>
434 </head>
435 <body>
436 <div id="swagger-ui"></div>
9f95a23c 437 <script src="/swagger-ui-bundle.js">
11fdf7f2
TL
438 </script>
439 <script>
440 window.onload = function() {{
441 const ui = SwaggerUIBundle({{
442 url: '{}',
443 dom_id: '#swagger-ui',
444 presets: [
445 SwaggerUIBundle.presets.apis
446 ],
447 layout: "BaseLayout"
11fdf7f2
TL
448 }})
449 window.ui = ui
450 }}
451 </script>
452 </body>
453 </html>
18d92ca7 454 """.format(spec_url)
11fdf7f2
TL
455
456 return page
457
f67539c2 458 @Endpoint(json_response=False, version=None)
11fdf7f2
TL
459 def __call__(self, all_endpoints=False):
460 return self._swagger_ui_page(all_endpoints)
461
f67539c2
TL
462
463if __name__ == "__main__":
464 import sys
465
466 import yaml
467
468 from . import generate_routes
469
470 def fix_null_descr(obj):
471 """
472 A hot fix for errors caused by null description values when generating
473 static documentation: better fix would be default values in source
474 to be 'None' strings: however, decorator changes didn't resolve
475 """
476 return {k: fix_null_descr(v) for k, v in obj.items() if v is not None} \
477 if isinstance(obj, dict) else obj
478
479 generate_routes("/api")
480 try:
481 with open(sys.argv[1], 'w') as f:
482 # pylint: disable=protected-access
483 yaml.dump(
484 fix_null_descr(Docs._gen_spec(all_endpoints=False, base_url="/", offline=True)),
485 f)
486 except IndexError:
487 sys.exit("Output file name missing; correct syntax is: `cmd <file.yml>`")
488 except IsADirectoryError:
489 sys.exit("Specified output is a directory; correct syntax is: `cmd <file.yml>`")